mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
82db9e2f9f
Three issues, one theme: tests and the shared system temp directory. - **#858 / #854** were live leaks — three call sites captured a real temp path and never removed it. One reaches the cleanup its file already had; the other two get a cleanup the file lacked. - **#836** turned out to be **already fixed on `main`** by #714, which scoped those three tests' `TMPDIR` to a directory they own. This PR verifies that rather than changing it — reproduction and mutation evidence below. - Sweeping the same shape across the two packages turned up **six more test files** doing it, worth **44** leftover entries per run. - A third shape, in **two** more files: `resolveFileInputs` hands its caller a `cleanup` for whatever it materialized — the dispatcher's job in production. Five call sites kept the result and never called it, stranding 5 dirs per run. A scoped-`TMPDIR` run of all 12 affected files leaves **one** entry behind: an empty `argent-chromium-media/`, production's own fixed staging dir. 173 tests, no other file or directory. Fixes #858 Fixes #854 Closes #836 <details> <summary>#858 — before/after temp scope</summary> ``` rm -rf /tmp/scope && mkdir -p /tmp/scope TMPDIR=/tmp/scope TMP=/tmp/scope TEMP=/tmp/scope \ npx vitest run --root packages/tool-server test/chromium-server-screenshot.test.ts ``` | site | before | after | |---|---|---| | `chromium-server-screenshot.test.ts` | `argent-chromium-media/argent-screenshot-test-1788803062089-38318.png` | *(no PNG)* | | `chromium-cdp-blueprint.test.ts` | `argent-chromium-media/argent-screenshot-chromium-cdp-52204-1788803091335-39401.png` | *(no PNG)* | Both files keep an empty `argent-chromium-media/` — that is production's fixed staging dir (`src/chromium-server/cdp-session.ts:218`), shared with any live chromium-server on the machine and not per-run garbage. A test `rm`ing it would be the machine-global-resource defect #714 removed elsewhere, so it is left alone deliberately. </details> <details> <summary>#854 — before/after temp scope</summary> ``` TMPDIR=/tmp/scope … npx vitest run --root packages/argent-mcp test/content.test.ts ``` Before (`34 passed`, two directories left): ``` argent-mcp-content-kPfgYa/context.diff.png argent-artifacts/test-temp-dir-hygiene-037c31/20260907-174453-39458/home-diff.png ``` After: **empty**. The `mkdtemp` moved onto a `beforeEach`/`afterEach` pair mirroring the sibling describe, and `flowRunToMcpContent` now pins `ARGENT_ARTIFACTS_DIR` the way the artifact describe above it already did. The unconditional `delete process.env.ARGENT_ARTIFACTS_DIR` is kept, not converted to a save/restore: it is what keeps any future unpinned write landing in `tmpdir` rather than a developer's configured artifacts directory. The pin is not hygiene-only — it is asserted. Removing `process.env.ARGENT_ARTIFACTS_DIR = root` fails the test: ``` - diff: /tmp/scopeM4/content-flow-artifacts-LZKPAl + diff: /tmp/scopeM4/argent-artifacts/test-temp-dir-hygiene-037c31/20260908-100837-89701/home-diff.png ❯ test/content.test.ts:627 expect(artifactText?.text).toContain(`diff: ${root}`) ``` </details> <details> <summary>#836 — why no code changed, and the evidence</summary> (This PR does change `file-inputs.test.ts`, but for the unrelated third shape above — not for the sites #836 cites.) The issue cites `readdir(os.tmpdir())` at `file-inputs.test.ts:180-197` and `flow-visual.test.ts:586-602`. On current `main` neither exists: #714 replaced both (and the `http-upload.test.ts:154-158` sibling the issue also flags) with a per-test scratch directory the test creates, points `os.tmpdir()` at via `redirectTmpdir`, and removes. There is now **no** `readdir` of `os.tmpdir()` anywhere in either package. The per-path assertion the issue asks for is not reachable at these three sites — every name is a `mkdtemp` random suffix or a server-side `randomUUID`, so the test cannot know the path. Scoping the tmpdir to a directory the test owns is the same guarantee, and is what landed. **Reproduced the reported failure** by reverting each site to the global-listing shape, then running two simultaneous instances in a loop: | file | pre-#714 shape | current | |---|---|---| | `file-inputs.test.ts` | **1 / 12** — `expected [ 'argent-file-input-00mPU7', …(1371) ] to deeply equal [ …(1370) ]` | **0 / 12** | | `flows/flow-visual.test.ts` | **12 / 12** — `expected [ 'argent-flow-crop-00R9XZ', …(4853) ] to deeply equal []` | **0 / 12** | | `http-upload.test.ts` | — | **0 / 12** | **Mutation-tested all three assertions** — each still catches a real leak; production cleanup broken, test run, source restored: | assertion | mutation | result | |---|---|---| | `file-inputs.test.ts:249` | drop `await cleanup()` from `resolveFileInputs`' catch path | ✗ `+ [ "argent-file-input-amPJuK" ]` | | `flow-visual.test.ts:610` | drop `cleanupDiffDir(cropDir, …)` from the outer `finally` | ✗ `+ [ "argent-flow-crop-rWT9Bd" ]` | | `http-upload.test.ts:187` | drop `rm(tarPath)` from the `req.on("close")` handler | ✗ `+ [ "argent-upload-43d82021-….tar.gz" ]` | </details> <details> <summary>Sibling sweep — the same two shapes across both packages</summary> **Shape "assertion diffing a listing of `os.tmpdir()`": zero remaining.** No `readdir`/`readdirSync`/`glob` over `os.tmpdir()`, `tmpdir()` or a literal `/tmp` in either test tree. Every literal `/tmp` string left is a fixture handed to a mocked API; the three that do touch disk were checked by hand and are clean (`native-devtools-socket-bind.test.ts` mkdtemps and removes, `flow-utils.test.ts` is pure path math, `native-profiler-external-device.test.ts` writes only inside its own per-test `mkdtemp`). **Shape "a captured temp path never cleaned up": six files fixed.** Each gets one recording choke point plus an `afterEach`. Running those six under a scoped `TMPDIR` (`48 passed` both times): ``` before: 44 entries after: 1 entry ``` | file | sites | |---|---| | `screenshot-diff-tool.test.ts` | 9 | | `react-profiler/ast-index.test.ts` | 6 | | `event-log.test.ts` | 1 helper (4 dirs/run) | | `screenshot-diff.test.ts` | 1 helper | | `react-profiler/component-source-duplicates.test.ts` | 2 | | `ios-instruments/combined-report-frozen-anchor.test.ts` | 1 | | `flows/flow-visual.test.ts` | production's crop + diff dirs (5/run) | **Left deliberately (1 entry):** an **empty** `argent-chromium-media/` — production's fixed staging dir (`src/chromium-server/cdp-session.ts:218`), shared with any live chromium-server on the machine. A test `rm`ing it would be the machine-global-resource defect #714 removed elsewhere. Two further shapes turned up while checking that claim, both *production* dirs a test drove and then abandoned: | site | per run | why the test could not just `rm` it | |---|---|---| | `screenshot-diff-tool.test.ts` | 2 dirs + 2 PNGs | `resolveOutputDir` mints `argent-screenshot-diff/<hex>` before anything validates the call, so the path exists before the test can learn it — and on a throw it never learns it at all | | `flows/flow-visual.test.ts` | 5 dirs + 5 PNGs | `runSnapshot` leaves the file it registered as an artifact inside its `argent-flow-crop-*` / `argent-flow-diff-*` scratch dir; from there the dir belongs to whoever consumes the artifact, which here is the test | Both are fixed the same way, with `redirectTmpdir` (already used by seven other test files): point `os.tmpdir()` at a directory the test already owns and removes, so whatever production mints lands inside it whether the call returns or throws. The `<hex>` and `mkdtemp` suffixes are per-call and never shared, so nothing a concurrent tool-server owns is touched — verified with a decoy file planted under the shared root, which survives the run. Measured under a scoped `TMPDIR`, three consecutive runs each: `screenshot-diff-tool` 6 dirs + 6 PNGs → **0 + 0**; `flow-visual` 15 + 15 → **0 + 0**. The underlying production leak behind the first is still #790. `argent-mcp` is now clean end to end: every `mkdtemp` in the package has a matching `rm`. </details> <details> <summary>Checks</summary> Run from the worktree root, exit codes read unpiped: - `npm run format` → 0, no files rewritten - `npx tsc --noEmit -p packages/tool-server/tsconfig.test.json` → 0 - `npx tsc --noEmit -p packages/argent-mcp/tsconfig.test.json` → 0 - `npm run lint` → 0 - `npm run knip` → 0 - no `it.only` / `describe.only` in the diff Suites: `argent-mcp` **90 passed**; `tool-server` run as six shards under a scoped `TMPDIR`. `lens-tools-platform-gate.test.ts` times out at the 5 s default under full-suite load and passes in isolation (`2 passed`, 3.14 s). It is not in this diff and #714 recorded the same behaviour before it. </details> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Tests** - Improved test isolation by consistently cleaning up temporary files and directories after each test. - Added coverage verifying generated artifacts are stored under the configured artifacts directory. - Ensured screenshot, upload, event-log, profiling, visual-flow, and diff-test artifacts are removed reliably after execution. - Standardized temporary-directory handling and cleanup across test scenarios to reduce leftover test data and improve repeatability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
import fs from "fs/promises";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { PNG } from "pngjs";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { diffPngFiles, type Rgb } from "../src/tools/screenshot-diff/screenshot-diff";
|
|
|
|
const analyzeScreenshotTextChangesMock = vi.hoisted(() =>
|
|
vi.fn(async () => ({
|
|
status: "ok" as const,
|
|
provider: "ocr" as const,
|
|
changes: [],
|
|
}))
|
|
);
|
|
|
|
vi.mock("../src/tools/screenshot-diff/text-diff", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/tools/screenshot-diff/text-diff")>();
|
|
return {
|
|
...actual,
|
|
analyzeScreenshotTextChanges: analyzeScreenshotTextChangesMock,
|
|
};
|
|
});
|
|
|
|
interface Pixel {
|
|
x: number;
|
|
y: number;
|
|
rgb: Rgb;
|
|
}
|
|
|
|
describe("diffPngFiles", () => {
|
|
beforeEach(() => {
|
|
analyzeScreenshotTextChangesMock.mockReset();
|
|
analyzeScreenshotTextChangesMock.mockResolvedValue({
|
|
status: "ok",
|
|
provider: "ocr",
|
|
changes: [],
|
|
});
|
|
});
|
|
|
|
it("reports matching PNGs and writes full-size and context diff artifacts", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 10, 10, { r: 100, g: 120, b: 140 });
|
|
await writePng(currentPath, 10, 10, { r: 100, g: 120, b: 140 });
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result).toMatchObject({
|
|
totalPixels: 100,
|
|
differentPixels: 0,
|
|
mismatchPercentage: 0,
|
|
summary: expect.stringContaining("Screenshot diff summary"),
|
|
imageSize: { width: 10, height: 10 },
|
|
regions: [],
|
|
});
|
|
expect(result.diffPath).toBe(path.join(dir, "current-diff.png"));
|
|
expect(result.contextDiffPath).toBe(path.join(dir, "current-context-diff.png"));
|
|
|
|
const diff = PNG.sync.read(await fs.readFile(result.diffPath!));
|
|
expect(diff.width).toBe(10);
|
|
expect(diff.height).toBe(10);
|
|
expect(readRgb(diff, 0, 0)).toEqual({ r: 170, g: 181, b: 192 });
|
|
|
|
const contextDiff = PNG.sync.read(await fs.readFile(result.contextDiffPath!));
|
|
expect(contextDiff.width).toBe(3);
|
|
expect(contextDiff.height).toBe(3);
|
|
});
|
|
|
|
it("uses the hardcoded normalized threshold", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const belowThresholdPath = path.join(dir, "below.png");
|
|
const aboveThresholdPath = path.join(dir, "above.png");
|
|
await writePng(baselinePath, 1, 20, { r: 0, g: 0, b: 0 });
|
|
await writePng(belowThresholdPath, 1, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 0, y: 10, rgb: { r: 25, g: 25, b: 25 } },
|
|
]);
|
|
await writePng(aboveThresholdPath, 1, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 0, y: 10, rgb: { r: 26, g: 26, b: 26 } },
|
|
]);
|
|
|
|
await expect(
|
|
diffPngFiles({ baselinePath, currentPath: belowThresholdPath, outputDir: dir })
|
|
).resolves.toMatchObject({ differentPixels: 0 });
|
|
await expect(
|
|
diffPngFiles({ baselinePath, currentPath: aboveThresholdPath, outputDir: dir })
|
|
).resolves.toMatchObject({ differentPixels: 1 });
|
|
});
|
|
|
|
it("returns dimension mismatch details without writing diff artifacts", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 1, { r: 0, g: 0, b: 0 });
|
|
await writePng(currentPath, 1, 2, { r: 0, g: 0, b: 0 });
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result).toMatchObject({
|
|
totalPixels: 2,
|
|
differentPixels: 0,
|
|
mismatchPercentage: 0,
|
|
summary: expect.stringContaining("dimension_mismatch: expected=2x1 actual=1x2"),
|
|
dimensionMismatch: {
|
|
expected: { width: 2, height: 1 },
|
|
actual: { width: 1, height: 2 },
|
|
},
|
|
regions: [],
|
|
textAnalysis: { status: "skipped", provider: "ocr", changes: [] },
|
|
});
|
|
expect(result.diffPath).toBeUndefined();
|
|
expect(result.contextDiffPath).toBeUndefined();
|
|
});
|
|
|
|
it("compares same-aspect screenshots of different resolutions instead of failing", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
// Same aspect ratio (0.5), different scale: a 0.3x-style saved baseline vs a
|
|
// full-res live capture. These must be normalized and compared, not rejected.
|
|
await writePng(baselinePath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
await writePng(currentPath, 20, 40, { r: 30, g: 60, b: 90 });
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result.dimensionMismatch).toBeUndefined();
|
|
expect(result.summary).not.toContain("dimension_mismatch");
|
|
// …but the rescale is now disclosed rather than absorbed silently.
|
|
expect(result.sizeNormalization).toBeDefined();
|
|
expect(result.imageSize).toEqual({ width: 10, height: 20 });
|
|
expect(result.diffPath).toBe(path.join(dir, "current-diff.png"));
|
|
const diff = PNG.sync.read(await fs.readFile(result.diffPath!));
|
|
expect(diff.width).toBe(10);
|
|
expect(diff.height).toBe(20);
|
|
});
|
|
|
|
it("does not call a rescaled comparison `unchanged` (issue #617)", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
// A solid fill downscales to the identical colour under Lanczos3, so this is
|
|
// a genuine zero-diff — the false green exactly as reported: same aspect,
|
|
// different resolution, nothing said about it.
|
|
await writePng(baselinePath, 20, 40, { r: 30, g: 60, b: 90 });
|
|
await writePng(currentPath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result.differentPixels).toBe(0);
|
|
expect(result.dimensionMismatch).toBeUndefined();
|
|
expect(result.sizeNormalization).toEqual({
|
|
baseline: { width: 20, height: 40 },
|
|
current: { width: 10, height: 20 },
|
|
comparedAt: { width: 10, height: 20 },
|
|
});
|
|
// A gate reading the status must not see a plain pass.
|
|
expect(result.summary).toContain("- status: resized_no_change");
|
|
expect(result.summary).not.toContain("- status: unchanged");
|
|
expect(result.summary).toContain(
|
|
"- size_normalized: baseline=20x40 current=10x20 compared_at=10x20"
|
|
);
|
|
});
|
|
|
|
it("still reports a real difference as `changed` when sizes were normalized", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
// The caveat must never soften an actual regression into a caveat status —
|
|
// this pins the ordering inside screenshotDiffStatus.
|
|
await writePng(baselinePath, 20, 40, { r: 20, g: 20, b: 20 });
|
|
await writePng(currentPath, 10, 20, { r: 20, g: 20, b: 20 }, [
|
|
// Clear of the ignored top band (ceil(20 * 0.06) = 2 rows).
|
|
...rectPixels(2, 12, 4, 4, { r: 255, g: 0, b: 0 }),
|
|
]);
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result.differentPixels).toBeGreaterThan(0);
|
|
expect(result.summary).toContain("- status: changed");
|
|
// …and the rescale is still disclosed, so the figures can be read correctly.
|
|
expect(result.summary).toContain("- size_normalized:");
|
|
});
|
|
|
|
it("says nothing about normalization when the sizes already matched", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
await writePng(currentPath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result.sizeNormalization).toBeUndefined();
|
|
expect(result.summary).not.toContain("size_normalized");
|
|
expect(result.summary).toContain("- status: unchanged");
|
|
});
|
|
|
|
it("hard-fails same-aspect resolution differences when normalizeSizes is false", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
// Exactly 2x with the same aspect ratio — the default path (test above)
|
|
// resamples and compares; normalizeSizes: false must bail instead, since
|
|
// for images whose dimensions carry meaning the drift IS the failure.
|
|
await writePng(baselinePath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
await writePng(currentPath, 20, 40, { r: 30, g: 60, b: 90 });
|
|
|
|
const result = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath,
|
|
outputDir: dir,
|
|
normalizeSizes: false,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
mismatchPercentage: 0,
|
|
dimensionMismatch: {
|
|
expected: { width: 10, height: 20 },
|
|
actual: { width: 20, height: 40 },
|
|
},
|
|
});
|
|
expect(result.diffPath).toBeUndefined();
|
|
expect(result.contextDiffPath).toBeUndefined();
|
|
});
|
|
|
|
it("compares equal-dimension images normally under normalizeSizes: false", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
// The pass path every matching cropOn comparison takes: identical
|
|
// dimensions must flow through to a normal pixel compare, not a bail.
|
|
await writePng(baselinePath, 10, 20, { r: 30, g: 60, b: 90 });
|
|
await writePng(currentPath, 10, 20, { r: 30, g: 60, b: 90 }, [
|
|
...rectPixels(2, 10, 3, 2, { r: 255, g: 0, b: 0 }),
|
|
]);
|
|
|
|
const result = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath,
|
|
outputDir: dir,
|
|
topMask: "none",
|
|
normalizeSizes: false,
|
|
});
|
|
|
|
expect(result.dimensionMismatch).toBeUndefined();
|
|
expect(result.differentPixels).toBe(6);
|
|
expect(result.diffPath).toBe(path.join(dir, "current-diff.png"));
|
|
});
|
|
|
|
it("merges same-line fragments but keeps separate rows distinct", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const sameLinePath = path.join(dir, "same-line.png");
|
|
const rowsPath = path.join(dir, "rows.png");
|
|
await writePng(baselinePath, 80, 40, { r: 0, g: 0, b: 0 });
|
|
await writePng(sameLinePath, 80, 40, { r: 0, g: 0, b: 0 }, [
|
|
...rectPixels(4, 12, 10, 8, { r: 255, g: 0, b: 0 }),
|
|
...rectPixels(30, 12, 10, 8, { r: 255, g: 0, b: 0 }),
|
|
...rectPixels(55, 12, 10, 8, { r: 255, g: 0, b: 0 }),
|
|
]);
|
|
await writePng(rowsPath, 80, 40, { r: 0, g: 0, b: 0 }, [
|
|
...rectPixels(4, 4, 10, 8, { r: 255, g: 0, b: 0 }),
|
|
...rectPixels(24, 26, 10, 8, { r: 255, g: 0, b: 0 }),
|
|
]);
|
|
|
|
const sameLine = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath: sameLinePath,
|
|
outputDir: dir,
|
|
});
|
|
const rows = await diffPngFiles({ baselinePath, currentPath: rowsPath, outputDir: dir });
|
|
|
|
expect(sameLine.regions).toHaveLength(1);
|
|
expect(sameLine.regions[0]).toMatchObject({
|
|
bounds: { x: 4, y: 12, width: 61, height: 8 },
|
|
pixelCount: 240,
|
|
});
|
|
expect(rows.regions).toHaveLength(2);
|
|
});
|
|
|
|
it("reports average colors and dominant luminance shifts per region", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 0, y: 10, rgb: { r: 20, g: 40, b: 60 } },
|
|
{ x: 1, y: 10, rgb: { r: 20, g: 40, b: 60 } },
|
|
]);
|
|
await writePng(currentPath, 2, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 0, y: 10, rgb: { r: 80, g: 100, b: 120 } },
|
|
{ x: 1, y: 10, rgb: { r: 80, g: 100, b: 120 } },
|
|
]);
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result).toMatchObject({
|
|
differentPixels: 2,
|
|
regions: [
|
|
{
|
|
bounds: { x: 0, y: 10, width: 2, height: 1 },
|
|
pixelCount: 2,
|
|
averageColor: {
|
|
delta: { r: 60, g: 60, b: 60 },
|
|
dominantChange: { channel: "luminance", direction: "increase", magnitude: 60 },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("draws region rectangles and ignores changed pixels in the status-bar band", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 12, 100, { r: 20, g: 20, b: 20 });
|
|
await writePng(currentPath, 12, 100, { r: 20, g: 20, b: 20 }, [
|
|
...rectPixels(0, 0, 12, 6, { r: 240, g: 240, b: 240 }),
|
|
...rectPixels(4, 20, 4, 4, { r: 255, g: 0, b: 0 }),
|
|
]);
|
|
|
|
const result = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath,
|
|
outputDir: dir,
|
|
topMask: "status-bar",
|
|
});
|
|
|
|
expect(result.regions).toEqual([
|
|
expect.objectContaining({
|
|
bounds: { x: 4, y: 20, width: 4, height: 4 },
|
|
pixelCount: 16,
|
|
}),
|
|
]);
|
|
const diff = PNG.sync.read(await fs.readFile(result.diffPath!));
|
|
expect(readRgb(diff, 0, 0)).toEqual({ r: 247, g: 247, b: 247 });
|
|
expect(readRgb(diff, 4, 17)).toEqual({ r: 255, g: 220, b: 0 });
|
|
expect(readRgb(diff, 5, 21)).toEqual({ r: 0, g: 200, b: 0 });
|
|
});
|
|
|
|
it("compares the full image when the top mask is disabled", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 12, 100, { r: 20, g: 20, b: 20 });
|
|
await writePng(
|
|
currentPath,
|
|
12,
|
|
100,
|
|
{ r: 20, g: 20, b: 20 },
|
|
rectPixels(0, 0, 12, 6, { r: 240, g: 240, b: 240 })
|
|
);
|
|
|
|
const result = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath,
|
|
outputDir: dir,
|
|
topMask: "none",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
differentPixels: 72,
|
|
mismatchPercentage: 6,
|
|
regions: [
|
|
expect.objectContaining({
|
|
bounds: { x: 0, y: 0, width: 12, height: 6 },
|
|
pixelCount: 72,
|
|
}),
|
|
],
|
|
});
|
|
expect(analyzeScreenshotTextChangesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ ignoreTopPixels: 0 })
|
|
);
|
|
});
|
|
|
|
it("does not mask an entire one-row image when the top mask is disabled", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 1, { r: 0, g: 0, b: 0 });
|
|
await writePng(currentPath, 2, 1, { r: 255, g: 255, b: 255 });
|
|
|
|
const masked = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
const unmasked = await diffPngFiles({
|
|
baselinePath,
|
|
currentPath,
|
|
outputDir: dir,
|
|
topMask: "none",
|
|
});
|
|
|
|
expect(masked).toMatchObject({ differentPixels: 0, mismatchPercentage: 0 });
|
|
expect(unmasked).toMatchObject({ differentPixels: 2, mismatchPercentage: 100 });
|
|
});
|
|
|
|
it("colors changed pixels green when they brightened and red when they darkened", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 30, 60, { r: 20, g: 20, b: 20 }, [
|
|
...rectPixels(2, 20, 10, 10, { r: 240, g: 240, b: 240 }),
|
|
]);
|
|
await writePng(currentPath, 30, 60, { r: 20, g: 20, b: 20 }, [
|
|
...rectPixels(18, 20, 10, 10, { r: 240, g: 240, b: 240 }),
|
|
]);
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
const diff = PNG.sync.read(await fs.readFile(result.diffPath!));
|
|
|
|
expect(readRgb(diff, 6, 24)).toEqual({ r: 255, g: 0, b: 0 });
|
|
expect(readRgb(diff, 22, 24)).toEqual({ r: 0, g: 200, b: 0 });
|
|
});
|
|
|
|
it("keeps pixel diff artifacts when text analysis fails unexpectedly", async () => {
|
|
analyzeScreenshotTextChangesMock.mockRejectedValueOnce(new Error("ocr crashed"));
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 20, { r: 0, g: 0, b: 0 });
|
|
await writePng(currentPath, 2, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 1, y: 10, rgb: { r: 255, g: 0, b: 0 } },
|
|
]);
|
|
|
|
const result = await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(result).toMatchObject({
|
|
differentPixels: 1,
|
|
textAnalysis: { status: "unavailable", provider: "ocr", changes: [] },
|
|
});
|
|
await expect(fs.stat(result.diffPath!)).resolves.toMatchObject({ size: expect.any(Number) });
|
|
await expect(fs.stat(result.contextDiffPath!)).resolves.toMatchObject({
|
|
size: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("passes the default text-change minimum confidence to OCR analysis", async () => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 20, { r: 0, g: 0, b: 0 });
|
|
await writePng(currentPath, 2, 20, { r: 0, g: 0, b: 0 }, [
|
|
{ x: 1, y: 10, rgb: { r: 255, g: 0, b: 0 } },
|
|
]);
|
|
|
|
await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(analyzeScreenshotTextChangesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ textChangeMinConfidence: 0.7, ignoreTopPixels: 2 })
|
|
);
|
|
});
|
|
|
|
// diffPngFiles normalizes two same-aspect, different-resolution screenshots to a
|
|
// common size, then hands the OCR/font pass BOTH the normalized images and each
|
|
// image's decoded->normalized region scale, so the rescaled text bounds and the
|
|
// pixels they crop share the pixel-diff coordinate space. normalizeToCommonSize
|
|
// only ever downscales the LARGER image and returns the smaller one untouched, so
|
|
// for any one fixture only the downscaled side's wiring is load-bearing: its
|
|
// normalized image and region scale differ from the raw decoded ones, so reverting
|
|
// baselineImage/currentImage (or the matching region scale) to the decoded value
|
|
// would surface the raw size and fail here. The untouched side's assertions hold
|
|
// for both the decoded and normalized value, so we exercise BOTH directions to pin
|
|
// both sides. Without this wiring the OCR pass would be handed the raw images and
|
|
// no region scales -- the mismatch #442 fixed.
|
|
it.each([
|
|
{
|
|
larger: "baseline",
|
|
baseline: { width: 480, height: 240 },
|
|
current: { width: 240, height: 120 },
|
|
baselineRegionScale: { x: 0.5, y: 0.5 },
|
|
currentRegionScale: { x: 1, y: 1 },
|
|
},
|
|
{
|
|
larger: "current",
|
|
baseline: { width: 240, height: 120 },
|
|
current: { width: 480, height: 240 },
|
|
baselineRegionScale: { x: 1, y: 1 },
|
|
currentRegionScale: { x: 0.5, y: 0.5 },
|
|
},
|
|
])(
|
|
"hands OCR the normalized images and decoded->normalized region scales when the $larger image is downscaled (diffPngFiles wiring)",
|
|
async ({ baseline, current, baselineRegionScale, currentRegionScale }) => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, baseline.width, baseline.height, { r: 0, g: 0, b: 0 });
|
|
await writePng(currentPath, current.width, current.height, { r: 0, g: 0, b: 0 });
|
|
|
|
await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
// Both images are normalized to the common 240x120 size; each region scale
|
|
// maps that image's OCR bounds from its decoded size into the shared space.
|
|
expect(analyzeScreenshotTextChangesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baselineImage: expect.objectContaining({ width: 240, height: 120 }),
|
|
currentImage: expect.objectContaining({ width: 240, height: 120 }),
|
|
baselineRegionScale,
|
|
currentRegionScale,
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
// diffPngFiles always calls the OCR/font pass, but tells it whether the pixel diff
|
|
// found any changes via hasPixelDiff; the pass uses that to skip text analysis when
|
|
// nothing moved. Pin the wiring in both directions -- identical images -> false, a
|
|
// real pixel change -> true -- so hardcoding the source (e.g. to false, which would
|
|
// silently disable text analysis for every real diff) fails here instead of staying
|
|
// green.
|
|
it.each([
|
|
{ name: "no pixel diff", changePixel: false, expected: false },
|
|
{ name: "a pixel diff", changePixel: true, expected: true },
|
|
])(
|
|
"tells OCR whether the pixel diff found changes ($name -> hasPixelDiff=$expected)",
|
|
async ({ changePixel, expected }) => {
|
|
const dir = await makeTempDir();
|
|
const baselinePath = path.join(dir, "baseline.png");
|
|
const currentPath = path.join(dir, "current.png");
|
|
await writePng(baselinePath, 2, 20, { r: 0, g: 0, b: 0 });
|
|
await writePng(
|
|
currentPath,
|
|
2,
|
|
20,
|
|
{ r: 0, g: 0, b: 0 },
|
|
changePixel ? [{ x: 1, y: 10, rgb: { r: 255, g: 0, b: 0 } }] : []
|
|
);
|
|
|
|
await diffPngFiles({ baselinePath, currentPath, outputDir: dir });
|
|
|
|
expect(analyzeScreenshotTextChangesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ hasPixelDiff: expected })
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs.splice(0)) await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function makeTempDir(): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "argent-screenshot-diff-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function writePng(
|
|
filePath: string,
|
|
width: number,
|
|
height: number,
|
|
fill: Rgb,
|
|
pixels: Pixel[] = []
|
|
): Promise<void> {
|
|
const png = new PNG({ width, height });
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
writeRgb(png, x, y, fill);
|
|
}
|
|
}
|
|
|
|
for (const pixel of pixels) {
|
|
writeRgb(png, pixel.x, pixel.y, pixel.rgb);
|
|
}
|
|
|
|
await fs.writeFile(filePath, PNG.sync.write(png));
|
|
}
|
|
|
|
function rectPixels(x: number, y: number, width: number, height: number, rgb: Rgb): Pixel[] {
|
|
const pixels: Pixel[] = [];
|
|
for (let py = y; py < y + height; py++) {
|
|
for (let px = x; px < x + width; px++) {
|
|
pixels.push({ x: px, y: py, rgb });
|
|
}
|
|
}
|
|
return pixels;
|
|
}
|
|
|
|
function writeRgb(png: PNG, x: number, y: number, rgb: Rgb): void {
|
|
const offset = (png.width * y + x) * 4;
|
|
png.data[offset] = rgb.r;
|
|
png.data[offset + 1] = rgb.g;
|
|
png.data[offset + 2] = rgb.b;
|
|
png.data[offset + 3] = 255;
|
|
}
|
|
|
|
function readRgb(png: PNG, x: number, y: number): Rgb {
|
|
const offset = (png.width * y + x) * 4;
|
|
return {
|
|
r: png.data[offset],
|
|
g: png.data[offset + 1],
|
|
b: png.data[offset + 2],
|
|
};
|
|
}
|