scripts/desktop-window-capture.test.mjs
Rama main · réplica de lectura del repositorio original.
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/);
});