import { EventEmitter } from "node:events"; import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HelperProcess, SpawnHelper } from "./media-photos-helper.js"; import { changesArgs, createPhotosHelper, exportArgs, helperEnv, listArgs, PhotosHelperError, parseAssetOutput, parseAuthorizeOutput, parseChangesOutput, parseExportOutput, parseHelperError, parseListOutput, parseProgressLine, photosHelperCandidates, resolvePhotosHelperBinary, runPhotosHelper, } from "./media-photos-helper.js"; const item = (overrides: Record = {}) => ({ externalId: "9F2A61B0-0000-4000-8000-000000000001/L0/001", kind: "video", capturedAt: "2024-01-02T03:04:05.500Z", durationMs: 12_346, width: 1920, height: 1080, fps: null, gpsLat: 52.52, gpsLon: 13.4, deviceModel: null, albumNames: ["Trip"], isFavorite: true, fileName: "IMG_0001.MOV", ...overrides, }); const listJson = (items: unknown[], nextCursor: unknown = null, changeToken: unknown = "tok") => JSON.stringify({ items, nextCursor, changeToken }); function expectCode(run: () => unknown, code: string) { try { run(); } catch (error) { expect(error).toBeInstanceOf(PhotosHelperError); expect((error as PhotosHelperError).code).toBe(code); return; } throw new Error("expected a PhotosHelperError"); } describe("photos helper output parsing", () => { it("accepts a list page that matches the shared item contract", () => { const page = parseListOutput( listJson([item(), item({ kind: "photo", durationMs: null })], "c1"), 2, ); expect(page.nextCursor).toBe("c1"); expect(page.changeToken).toBe("tok"); expect(page.items).toHaveLength(2); expect(page.items[0]).toEqual(item()); }); it("accepts the last page of an empty library", () => { expect(parseListOutput(listJson([], null, null), 500)).toEqual({ items: [], nextCursor: null, changeToken: null, }); }); it.each([ ["not JSON", "items: []"], ["an array document", "[]"], ["truncated JSON", '{"items":[{"externalId":'], ["missing items", JSON.stringify({ nextCursor: null, changeToken: null })], ["an item missing a key", listJson([{ ...item(), width: undefined }])], ["an unknown kind", listJson([item({ kind: "audio" })])], ["out-of-range GPS", listJson([item({ gpsLat: 91 })])], ["a non-UTC-parsable date", listJson([item({ capturedAt: "yesterday" })])], ["a negative duration", listJson([item({ durationMs: -1 })])], ["an empty external id", listJson([item({ externalId: "" })])], ["an empty cursor", listJson([item()], "")], ["a numeric cursor", listJson([item()], 3)], ["too many items", listJson([item(), item(), item()])], ])("rejects %s", (_name, text) => { expectCode(() => parseListOutput(text, 2), "invalid_output"); }); it("parses authorization states and rejects unknown ones", () => { expect(parseAuthorizeOutput('{"status":"limited"}')).toBe("limited"); expect(parseAuthorizeOutput('{"status":"notDetermined"}')).toBe("notDetermined"); expectCode(() => parseAuthorizeOutput('{"status":"maybe"}'), "invalid_output"); expectCode(() => parseAuthorizeOutput(""), "invalid_output"); }); it("parses asset details", () => { const detail = parseAssetOutput( JSON.stringify({ item: item(), modifiedAt: "2024-02-01T00:00:00.000Z", hidden: false, resources: [{ type: "video", fileName: "IMG_0001.MOV", uti: "com.apple.quicktime-movie" }], }), ); expect(detail.resources[0]?.type).toBe("video"); expectCode( () => parseAssetOutput(JSON.stringify({ item: item(), modifiedAt: null, resources: [] })), "invalid_output", ); }); it("parses changes and requires a token unless a full relisting is needed", () => { expect( parseChangesOutput( JSON.stringify({ token: "t2", changed: [item()], deleted: ["x/L0/001"], fullResyncRequired: false, }), ), ).toMatchObject({ token: "t2", deleted: ["x/L0/001"], fullResyncRequired: false }); expect( parseChangesOutput( JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: true }), ).fullResyncRequired, ).toBe(true); expectCode( () => parseChangesOutput( JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: false }), ), "invalid_output", ); expectCode( () => parseChangesOutput( JSON.stringify({ token: "t", changed: [], deleted: [7], fullResyncRequired: false }), ), "invalid_output", ); }); it("maps helper error lines to codes", () => { const stderr = '{"progress":0.5}\n{"error":{"code":"not_authorized","message":"Photos access has not been granted."}}\n'; const error = parseHelperError(stderr, 77); expect(error.code).toBe("not_authorized"); expect(error.message).toBe("Photos access has not been granted."); expect(parseHelperError('{"error":{"code":"internal","message":"x"}}', 70).code).toBe("failed"); expect(parseHelperError("Segmentation fault", 139).message).toContain("139"); expect(parseHelperError('{"error":"oops"}', 1).code).toBe("failed"); }); it("reads progress lines only", () => { expect(parseProgressLine('{"progress":0.42}')).toBe(0.42); expect(parseProgressLine('{"progress":2}')).toBeNull(); expect(parseProgressLine('{"error":{"code":"usage"}}')).toBeNull(); expect(parseProgressLine('{"progress":')).toBeNull(); }); }); describe("photos helper export result", () => { let dir: string; let other: string; beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-export-")); other = await mkdtemp(path.join(tmpdir(), "rakazo-photos-other-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); await rm(other, { recursive: true, force: true }); }); const result = (file: string, fileName = path.basename(file)) => JSON.stringify({ path: file, fileName, sizeBytes: 3, sha256: "a".repeat(64) }); it("accepts a file directly inside the export folder", async () => { const file = path.join(dir, "IMG_0001.MOV"); await writeFile(file, "abc"); await expect(parseExportOutput(result(file), dir)).resolves.toMatchObject({ fileName: "IMG_0001.MOV", sizeBytes: 3, }); }); it("rejects files outside the export folder, including through symlinks", async () => { const outside = path.join(other, "secret.txt"); await writeFile(outside, "abc"); await expect(parseExportOutput(result(outside), dir)).rejects.toMatchObject({ code: "path_rejected", }); const link = path.join(dir, "link.MOV"); await symlink(outside, link); await expect(parseExportOutput(result(link), dir)).rejects.toMatchObject({ code: "path_rejected", }); const nested = path.join(dir, "..", path.basename(other), "secret.txt"); await expect(parseExportOutput(result(nested), dir)).rejects.toMatchObject({ code: "path_rejected", }); }); it("rejects malformed results", async () => { const file = path.join(dir, "a.jpg"); await writeFile(file, "abc"); for (const text of [ JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: 3, sha256: "XYZ" }), JSON.stringify({ path: "a.jpg", fileName: "a.jpg", sizeBytes: 3, sha256: "a".repeat(64) }), JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: -1, sha256: "a".repeat(64) }), JSON.stringify({ path: path.join(dir, "missing.jpg"), fileName: "missing.jpg", sizeBytes: 3, sha256: "a".repeat(64), }), ]) { await expect(parseExportOutput(text, dir)).rejects.toMatchObject({ code: "invalid_output" }); } await expect(parseExportOutput(result(file, "b.jpg"), dir)).rejects.toMatchObject({ code: "path_rejected", }); }); }); describe("photos helper argv", () => { it("builds fixed argv arrays", () => { expect(listArgs({ cursor: null, limit: 500 })).toEqual(["list", "--limit", "500"]); expect(listArgs({ cursor: "abc_-", limit: 10, since: "2024-01-01T00:00:00Z" })).toEqual([ "list", "--limit", "10", "--cursor", "abc_-", "--since", "2024-01-01T00:00:00Z", ]); expect(exportArgs("ID/L0/001", "/tmp/rakazo-photos-1")).toEqual([ "export", "ID/L0/001", "--dest", "/tmp/rakazo-photos-1", "--original", ]); expect(changesArgs("tok")).toEqual(["changes", "--token", "tok"]); }); it("keeps shell syntax as inert data and rejects option-like values", () => { // No shell runs, so metacharacters stay one literal argument. expect(exportArgs("$(rm -rf ~);`x`", "/tmp/a b")[1]).toBe("$(rm -rf ~);`x`"); expectCode(() => exportArgs("--dest", "/tmp"), "usage"); expectCode(() => listArgs({ cursor: "--limit", limit: 1 }), "usage"); expectCode(() => changesArgs("a\nb"), "usage"); expectCode(() => listArgs({ cursor: null, limit: 501 }), "usage"); expectCode(() => exportArgs("ID", "relative/dir"), "path_rejected"); expectCode(() => exportArgs("ID", "/tmp/../etc"), "path_rejected"); }); it("passes only the environment PhotoKit needs", () => { expect( helperEnv({ HOME: "/Users/example", TMPDIR: "/tmp", OPENAI_API_KEY: "secret", PATH: "/bin" }), ).toEqual({ HOME: "/Users/example", TMPDIR: "/tmp" }); }); }); describe("photos helper binary", () => { it("is only available on macOS", () => { const options = { packaged: true, resourcesPath: "/App/Resources", appPath: "/app", exists: () => true, }; expect(resolvePhotosHelperBinary({ ...options, platform: "linux" })).toBeNull(); expect(resolvePhotosHelperBinary({ ...options, platform: "win32" })).toBeNull(); expect(resolvePhotosHelperBinary({ ...options, platform: "darwin" })).toBe( "/App/Resources/bin/photos-helper", ); }); it("falls back to the development build and reports a missing build", () => { const options = { platform: "darwin", packaged: false, resourcesPath: "/r", appPath: "/repo/apps/desktop", }; expect(photosHelperCandidates(options)).toEqual([ "/repo/apps/desktop/native/photos-helper/.build/apple/Products/Release/photos-helper", "/repo/apps/desktop/native/photos-helper/.build/release/photos-helper", ]); expect( resolvePhotosHelperBinary({ ...options, exists: (file) => file.endsWith(".build/release/photos-helper"), }), ).toBe("/repo/apps/desktop/native/photos-helper/.build/release/photos-helper"); expect(resolvePhotosHelperBinary({ ...options, exists: () => false })).toBeNull(); }); }); class FakeProcess extends EventEmitter implements HelperProcess { stdout = new PassThrough(); stderr = new PassThrough(); kill = vi.fn(() => { queueMicrotask(() => this.emit("close", null)); return true; }); exit(code: number, stdout = "", stderr = "") { if (stdout) this.stdout.write(stdout); if (stderr) this.stderr.write(stderr); setImmediate(() => this.emit("close", code)); } } function fakeSpawn() { const processes: FakeProcess[] = []; const spawn = vi.fn(() => { const child = new FakeProcess(); processes.push(child); return child; }); return { spawn, processes }; } describe("photos helper process", () => { it("spawns the binary with argv and no shell", async () => { const { spawn, processes } = fakeSpawn(); const helper = createPhotosHelper("/App/Resources/bin/photos-helper", spawn); const pending = helper.list({ cursor: null, limit: 2 }); processes[0]!.exit(0, listJson([item()])); await expect(pending).resolves.toMatchObject({ items: [item()] }); expect(spawn).toHaveBeenCalledWith( "/App/Resources/bin/photos-helper", ["list", "--limit", "2"], expect.objectContaining({ shell: false, stdio: ["ignore", "pipe", "pipe"] }), ); }); it("maps a failing exit to the helper's error code", async () => { const { spawn, processes } = fakeSpawn(); const pending = createPhotosHelper("/bin/photos-helper", spawn).asset("ID/L0/001"); processes[0]!.exit( 66, "", '{"error":{"code":"not_found","message":"No photo or video with that identifier."}}\n', ); await expect(pending).rejects.toMatchObject({ code: "not_found" }); }); it("reports export progress and validates the result", async () => { const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-run-")); try { const file = path.join(dir, "IMG_0001.MOV"); await writeFile(file, "abc"); const { spawn, processes } = fakeSpawn(); const progress: number[] = []; const pending = createPhotosHelper("/bin/photos-helper", spawn).exportOriginal("ID", dir, { onProgress: (value) => progress.push(value), }); const child = processes[0]!; child.stderr.write('{"progress":0}\n{"progress":0.5'); child.stderr.write('}\nnoise\n{"progress":1}\n'); child.exit( 0, JSON.stringify({ path: file, fileName: "IMG_0001.MOV", sizeBytes: 3, sha256: "b".repeat(64), }), ); await expect(pending).resolves.toMatchObject({ sizeBytes: 3 }); expect(progress).toEqual([0, 0.5, 1]); } finally { await rm(dir, { recursive: true, force: true }); } }); it("kills the helper on abort and on timeout", async () => { const { spawn, processes } = fakeSpawn(); const controller = new AbortController(); const aborted = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 60_000, signal: controller.signal, }); controller.abort(); await expect(aborted).rejects.toMatchObject({ code: "cancelled" }); expect(processes[0]!.kill).toHaveBeenCalledWith("SIGTERM"); const timedOut = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 5 }); await expect(timedOut).rejects.toMatchObject({ code: "timeout" }); expect(processes[1]!.kill).toHaveBeenCalled(); }); it("fails a silent export through the idle limit", async () => { const { spawn, processes } = fakeSpawn(); const pending = runPhotosHelper("/bin/h", ["export"], { spawn, timeoutMs: 60_000, idleTimeoutMs: 5, }); await expect(pending).rejects.toMatchObject({ code: "timeout" }); expect(processes[0]!.kill).toHaveBeenCalled(); }); it("does not start when already aborted and reports spawn failures", async () => { const { spawn, processes } = fakeSpawn(); const controller = new AbortController(); controller.abort(); await expect( runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000, signal: controller.signal }), ).rejects.toMatchObject({ code: "cancelled" }); expect(spawn).not.toHaveBeenCalled(); const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 }); processes[0]!.emit("error", new Error("ENOENT")); await expect(pending).rejects.toMatchObject({ code: "unavailable" }); }); it("rejects oversized output", async () => { const { spawn, processes } = fakeSpawn(); const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 }); processes[0]!.stdout.write(Buffer.alloc(33 * 1024 * 1024)); await expect(pending).rejects.toMatchObject({ code: "invalid_output" }); }); });