import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { deflateSync } from "node:zlib"; import { changedFiles, decodePng, flattenPng, parseCli, pickWindow, swapToBase } from "./desktop-window-capture.mjs"; const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function chunk(type, data) { const body = Buffer.concat([Buffer.from(type, "latin1"), data]); let c = 0xffffffff; for (const byte of body) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); body.copy(out, 4); out.writeUInt32BE((c ^ 0xffffffff) >>> 0, 8 + data.length); return out; } // Encodes RGBA rows, using a different PNG filter on each row so the decoder's // five filter paths are all exercised. function rgbaPng(width, rows, extraChunks = []) { const stride = width * 4; const raw = []; let previous = Buffer.alloc(stride); rows.forEach((pixels, y) => { const row = Buffer.from(pixels.flat()); const filter = y % 5; const filtered = Buffer.alloc(stride); for (let x = 0; x < stride; x++) { const left = x >= 4 ? row[x - 4] : 0; const up = previous[x]; const upLeft = x >= 4 ? previous[x - 4] : 0; const p = left + up - upLeft; const [pa, pb, pc] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)]; const predictor = [0, left, up, (left + up) >> 1, pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft][filter]; filtered[x] = (row[x] - predictor) & 0xff; } raw.push(Buffer.from([filter]), filtered); previous = row; }); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(rows.length, 4); ihdr[8] = 8; ihdr[9] = 6; return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), ...extraChunks, chunk("IDAT", deflateSync(Buffer.concat(raw))), chunk("IEND", Buffer.alloc(0)), ]); } test("flattening puts transparent corners on white and keeps opaque pixels exact", () => { const clear = [0, 0, 0, 0]; const red = [200, 30, 40, 255]; const halfBlack = [0, 0, 0, 128]; const rows = Array.from({ length: 6 }, (_, y) => [y === 0 ? clear : red, halfBlack, [10 * y, 20, 30, 255]]); const profile = chunk("iCCP", Buffer.from("Display P3\0\0profile-bytes")); const appleIndex = chunk("iDOT", Buffer.alloc(28)); const { png, width, height } = flattenPng(rgbaPng(3, rows, [profile, appleIndex])); assert.deepEqual([width, height], [3, 6]); const flat = decodePng(png); assert.equal(flat.channels, 3); const pixel = (x, y) => [...flat.pixels.subarray((y * 3 + x) * 3, (y * 3 + x) * 3 + 3)]; assert.deepEqual(pixel(0, 0), [255, 255, 255]); assert.deepEqual(pixel(0, 1), [200, 30, 40]); assert.deepEqual(pixel(1, 3), [127, 127, 127]); for (let y = 0; y < 6; y++) assert.deepEqual(pixel(2, y), [10 * y, 20, 30]); const types = flat.chunks.map((c) => c.type); assert.ok(types.includes("iCCP"), "the colour profile survives"); assert.ok(!types.includes("iDOT"), "Apple's IDAT index is dropped because it points into the old IDAT"); }); test("an already opaque PNG is passed through unchanged", () => { const { png } = flattenPng(rgbaPng(2, [[[1, 2, 3, 255], [4, 5, 6, 255]]])); assert.deepEqual(flattenPng(png).png, png); }); test("only the CDP owner's visible, full-size windows are candidates", () => { const window = (id, overrides = {}) => ({ id, pid: 100, layer: 0, onscreen: true, alpha: 1, title: "Issues", bounds: { X: 0, Y: 0, Width: 1280, Height: 800 }, ...overrides, }); const windows = [ window(1, { pid: 200, title: "Issues" }), window(2, { bounds: { X: 0, Y: 0, Width: 2880, Height: 30 }, title: "" }), window(3, { onscreen: false }), window(4, { layer: 25 }), window(5, { title: "DEV-2" }), window(6, { title: "Issues" }), ]; const picked = pickWindow(windows, 100); assert.equal(picked.window.id, 5); assert.deepEqual(picked.others.map((w) => w.id), [6]); assert.equal(pickWindow(windows, 100, "Issues").window.id, 6); assert.equal(pickWindow(windows, 300).window, null); }); test("compare swaps changed files to BASE without touching the index, then restores them exactly", (t) => { const repo = mkdtempSync(join(tmpdir(), "desktop-window-repo-")); const backup = mkdtempSync(join(tmpdir(), "desktop-window-backup-")); t.after(() => { rmSync(repo, { recursive: true, force: true }); rmSync(backup, { recursive: true, force: true }); }); const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_AUTHOR_NAME: "CI test", GIT_AUTHOR_EMAIL: "ci@example.invalid", GIT_COMMITTER_NAME: "CI test", GIT_COMMITTER_EMAIL: "ci@example.invalid", }; const git = (...args) => execFileSync("git", args, { cwd: repo, env, encoding: "utf8" }).trim(); git("init", "-b", "main"); writeFileSync(join(repo, "modified.ts"), "base\n"); writeFileSync(join(repo, "deleted.ts"), "only at base\n"); writeFileSync(join(repo, "untouched.ts"), "same\n"); writeFileSync(join(repo, "unindexed.ts"), "base copy\n"); git("add", "."); git("commit", "-m", "base"); const base = git("rev-parse", "HEAD"); writeFileSync(join(repo, "modified.ts"), "committed change\n"); git("rm", "-q", "deleted.ts"); writeFileSync(join(repo, "added.ts"), "new\n"); git("add", "."); git("commit", "-m", "head"); writeFileSync(join(repo, "modified.ts"), "uncommitted change\n"); chmodSync(join(repo, "modified.ts"), 0o755); writeFileSync(join(repo, "untracked.ts"), "scratch\n"); writeFileSync(join(repo, "added.ts"), "staged\n"); git("add", "added.ts"); // Git reports this path as deleted, but a local file still sits on disk. git("rm", "-q", "--cached", "unindexed.ts"); writeFileSync(join(repo, "unindexed.ts"), "local edit\n"); const indexBefore = git("diff", "--cached", "--name-status"); const files = changedFiles(repo, base); assert.deepEqual( files.sort((a, b) => a.path.localeCompare(b.path)), [ { status: "A", path: "added.ts" }, { status: "D", path: "deleted.ts" }, { status: "M", path: "modified.ts" }, { status: "D", path: "unindexed.ts" }, ], ); assert.deepEqual(changedFiles(repo, base, ["modified.ts"]), [{ status: "M", path: "modified.ts" }]); const restore = swapToBase(repo, base, files, backup); assert.equal(readFileSync(join(repo, "modified.ts"), "utf8"), "base\n"); assert.equal(readFileSync(join(repo, "deleted.ts"), "utf8"), "only at base\n"); assert.equal(readFileSync(join(repo, "unindexed.ts"), "utf8"), "base copy\n"); assert.equal(existsSync(join(repo, "added.ts")), false); assert.equal(readFileSync(join(repo, "untracked.ts"), "utf8"), "scratch\n"); assert.equal(git("diff", "--cached", "--name-status"), indexBefore); assert.ok(existsSync(join(backup, "files.json")), "the backup can be recovered by hand"); restore(); assert.equal(readFileSync(join(repo, "modified.ts"), "utf8"), "uncommitted change\n"); assert.equal(statSync(join(repo, "modified.ts")).mode & 0o777, 0o755); assert.equal(readFileSync(join(repo, "added.ts"), "utf8"), "staged\n"); assert.equal(readFileSync(join(repo, "unindexed.ts"), "utf8"), "local edit\n"); assert.equal(existsSync(join(repo, "deleted.ts")), false); assert.equal(git("diff", "--cached", "--name-status"), indexBefore); assert.deepEqual(changedFiles(repo, base), files); // A symlink or directory in place of a file is refused before anything moves. rmSync(join(repo, "unindexed.ts")); symlinkSync("untracked.ts", join(repo, "unindexed.ts")); assert.throws(() => swapToBase(repo, base, changedFiles(repo, base), backup), /not a regular file/); assert.equal(readFileSync(join(repo, "modified.ts"), "utf8"), "uncommitted change\n"); assert.equal(readFileSync(join(repo, "untracked.ts"), "utf8"), "scratch\n"); }); test("a single capture runs --prepare first and stops when it fails", (t) => { // REPO_ROOT is the script's parent, so a copy next to a stub dev-env.sh // exercises the real entrypoint without a running Desktop. const root = mkdtempSync(join(tmpdir(), "desktop-window-entry-")); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, "scripts")); copyFileSync(fileURLToPath(new URL("./desktop-window-capture.mjs", import.meta.url)), join(root, "scripts", "desktop-window-capture.mjs")); writeFileSync( join(root, "scripts", "dev-env.sh"), `echo '{"name":"stub","desktop_cdp_port":9999,"components":{"desktop":{"state":"running"}}}'\n`, ); const result = spawnSync( process.execPath, [join(root, "scripts", "desktop-window-capture.mjs"), "--prepare", 'echo "$DESKTOP_CAPTURE_STATE" > prepared; exit 23', join(root, "out.png")], { cwd: root, encoding: "utf8" }, ); assert.equal(result.status, 1); assert.match(result.stderr, /--prepare exited with 23 for current/); assert.equal(readFileSync(join(root, "prepared"), "utf8"), "current\n"); assert.equal(existsSync(join(root, "out.png")), false); }); test("pathspecs only make sense with --compare", () => { assert.throws(() => parseCli(["out.png", "--", "apps/desktop"]), /only applies to --compare/); assert.deepEqual(parseCli(["--compare", "main", "shots", "--", "apps/desktop"]).pathspecs, ["apps/desktop"]); assert.throws(() => parseCli(["--settle", "-1", "out.png"]), /--settle/); });