dungbeetle 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/NOTICE +19 -0
- package/README.md +139 -0
- package/dist/api/capture.d.ts +24 -0
- package/dist/api/capture.js +61 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +38 -0
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +9 -0
- package/dist/capture.d.ts +15 -0
- package/dist/capture.js +7 -0
- package/dist/captures/api.d.ts +2 -0
- package/dist/captures/api.js +114 -0
- package/dist/captures/check.d.ts +2 -0
- package/dist/captures/check.js +116 -0
- package/dist/captures/desktop.d.ts +2 -0
- package/dist/captures/desktop.js +97 -0
- package/dist/captures/game.d.ts +4 -0
- package/dist/captures/game.js +266 -0
- package/dist/captures/performance.d.ts +2 -0
- package/dist/captures/performance.js +47 -0
- package/dist/captures/registry.d.ts +4 -0
- package/dist/captures/registry.js +23 -0
- package/dist/captures/terminal.d.ts +2 -0
- package/dist/captures/terminal.js +65 -0
- package/dist/captures/types.d.ts +18 -0
- package/dist/captures/types.js +1 -0
- package/dist/captures/web.d.ts +3 -0
- package/dist/captures/web.js +248 -0
- package/dist/check/capture.d.ts +15 -0
- package/dist/check/capture.js +76 -0
- package/dist/check/junit.d.ts +9 -0
- package/dist/check/junit.js +51 -0
- package/dist/check/laravel.d.ts +2 -0
- package/dist/check/laravel.js +44 -0
- package/dist/check/parsers.d.ts +12 -0
- package/dist/check/parsers.js +278 -0
- package/dist/check/schema.d.ts +2 -0
- package/dist/check/schema.js +114 -0
- package/dist/cloud.d.ts +42 -0
- package/dist/cloud.js +334 -0
- package/dist/compare/shared.d.ts +42 -0
- package/dist/compare/shared.js +115 -0
- package/dist/compare.d.ts +3 -0
- package/dist/compare.js +33 -0
- package/dist/config.d.ts +146 -0
- package/dist/config.js +382 -0
- package/dist/desktop/a11y.d.ts +18 -0
- package/dist/desktop/a11y.js +74 -0
- package/dist/desktop/capture.d.ts +13 -0
- package/dist/desktop/capture.js +80 -0
- package/dist/desktop/macos.d.ts +8 -0
- package/dist/desktop/macos.js +98 -0
- package/dist/desktop/ocr.d.ts +17 -0
- package/dist/desktop/ocr.js +99 -0
- package/dist/diff/lcs.d.ts +5 -0
- package/dist/diff/lcs.js +42 -0
- package/dist/diff/numeric.d.ts +6 -0
- package/dist/diff/numeric.js +24 -0
- package/dist/diff/pixel.d.ts +23 -0
- package/dist/diff/pixel.js +97 -0
- package/dist/diff/structural.d.ts +11 -0
- package/dist/diff/structural.js +38 -0
- package/dist/diff/text.d.ts +7 -0
- package/dist/diff/text.js +64 -0
- package/dist/diff/tree.d.ts +46 -0
- package/dist/diff/tree.js +188 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.js +57 -0
- package/dist/game/capture.d.ts +24 -0
- package/dist/game/capture.js +51 -0
- package/dist/game/protocol.d.ts +30 -0
- package/dist/game/protocol.js +146 -0
- package/dist/game/walkthrough.d.ts +45 -0
- package/dist/game/walkthrough.js +85 -0
- package/dist/guards.d.ts +2 -0
- package/dist/guards.js +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +504 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +40 -0
- package/dist/lifecycle.d.ts +14 -0
- package/dist/lifecycle.js +190 -0
- package/dist/normalization.d.ts +4 -0
- package/dist/normalization.js +27 -0
- package/dist/perf/ab.d.ts +6 -0
- package/dist/perf/ab.js +89 -0
- package/dist/perf/autocannon.d.ts +6 -0
- package/dist/perf/autocannon.js +101 -0
- package/dist/perf/capture.d.ts +7 -0
- package/dist/perf/capture.js +6 -0
- package/dist/perf/k6.d.ts +9 -0
- package/dist/perf/k6.js +44 -0
- package/dist/perf/parsers.d.ts +15 -0
- package/dist/perf/parsers.js +69 -0
- package/dist/perf/run.d.ts +8 -0
- package/dist/perf/run.js +45 -0
- package/dist/perf/toolOutput.d.ts +3 -0
- package/dist/perf/toolOutput.js +24 -0
- package/dist/reporters.d.ts +11 -0
- package/dist/reporters.js +314 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +352 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.js +37 -0
- package/dist/terminal/ansi.d.ts +21 -0
- package/dist/terminal/ansi.js +144 -0
- package/dist/terminal/capture.d.ts +30 -0
- package/dist/terminal/capture.js +91 -0
- package/dist/tty.d.ts +72 -0
- package/dist/tty.js +175 -0
- package/dist/web/domSnapshot.d.ts +27 -0
- package/dist/web/domSnapshot.js +55 -0
- package/dist/web/playwrightCapture.d.ts +16 -0
- package/dist/web/playwrightCapture.js +64 -0
- package/package.json +79 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MaskRule } from "../config.js";
|
|
2
|
+
import { type DesktopSnapshot } from "./a11y.js";
|
|
3
|
+
export type ShellRunner = (command: string, options: {
|
|
4
|
+
cwd: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
}) => Promise<string>;
|
|
7
|
+
export type DesktopOcrOptions = {
|
|
8
|
+
app?: string;
|
|
9
|
+
screenshot?: string;
|
|
10
|
+
screenshotCommand?: string;
|
|
11
|
+
ocrCommand?: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
maskRules: MaskRule[];
|
|
15
|
+
run?: ShellRunner;
|
|
16
|
+
};
|
|
17
|
+
export declare function captureDesktopOcr(options: DesktopOcrOptions): Promise<DesktopSnapshot>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { normalizeA11yTree } from "./a11y.js";
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// Screenshot + OCR fallback for desktop targets without reliable structured
|
|
9
|
+
// (accessibility) access. Following the same shell-out philosophy as the
|
|
10
|
+
// macos-ax and `command` drivers, the pixels never become a bundled dependency:
|
|
11
|
+
// a screenshot command captures an image and an OCR command turns it into text.
|
|
12
|
+
// The recognized lines are normalized into the *same* desktop a11y snapshot the
|
|
13
|
+
// structured path produces — one AXStaticText node per line — so the existing
|
|
14
|
+
// desktop comparer diffs OCR output with no special-casing.
|
|
15
|
+
// Default OCR command. Tesseract is the de-facto open-source OCR CLI; `{image}`
|
|
16
|
+
// is replaced with the screenshot path. Override via the target's `ocrCommand`.
|
|
17
|
+
const DEFAULT_OCR_COMMAND = "tesseract {image} stdout";
|
|
18
|
+
export async function captureDesktopOcr(options) {
|
|
19
|
+
const run = options.run ?? defaultRun;
|
|
20
|
+
let tempDir;
|
|
21
|
+
let imagePath;
|
|
22
|
+
if (options.screenshot) {
|
|
23
|
+
imagePath = path.resolve(options.cwd, options.screenshot);
|
|
24
|
+
}
|
|
25
|
+
else if (options.screenshotCommand) {
|
|
26
|
+
tempDir = await mkdtemp(path.join(os.tmpdir(), "dungbeetle-ocr-"));
|
|
27
|
+
imagePath = path.join(tempDir, "screenshot.png");
|
|
28
|
+
const command = options.screenshotCommand.replace(/\{out\}/g, shellQuote(imagePath));
|
|
29
|
+
try {
|
|
30
|
+
await run(command, { cwd: options.cwd, timeoutMs: options.timeoutMs });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
await cleanup(tempDir);
|
|
34
|
+
throw new Error(`Desktop screenshot command failed: ${messageOf(error)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
throw new Error('Desktop OCR needs a "screenshot" file or a "screenshotCommand".');
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const ocrCommand = (options.ocrCommand ?? DEFAULT_OCR_COMMAND).replace(/\{image\}/g, shellQuote(imagePath));
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = await run(ocrCommand, { cwd: options.cwd, timeoutMs: options.timeoutMs });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new Error(translateOcrError(error, options.ocrCommand));
|
|
48
|
+
}
|
|
49
|
+
return ocrTextToSnapshot(text, options.app, options.maskRules);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (tempDir) {
|
|
53
|
+
await cleanup(tempDir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Build a desktop snapshot from recognized text: collapse whitespace, drop blank
|
|
58
|
+
// lines, and emit one AXStaticText leaf per line under a synthetic app root.
|
|
59
|
+
// Routing the raw tree through normalizeA11yTree reuses masking + normalization,
|
|
60
|
+
// and tags `tool: "ocr"` so a reader can tell pixels-derived snapshots apart.
|
|
61
|
+
function ocrTextToSnapshot(text, app, maskRules) {
|
|
62
|
+
const lines = text
|
|
63
|
+
.split(/\r?\n/)
|
|
64
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
65
|
+
.filter((line) => line.length > 0);
|
|
66
|
+
const root = {
|
|
67
|
+
role: "AXApplication",
|
|
68
|
+
children: lines.map((line) => ({ role: "AXStaticText", value: line }))
|
|
69
|
+
};
|
|
70
|
+
if (app) {
|
|
71
|
+
root.name = app;
|
|
72
|
+
}
|
|
73
|
+
return normalizeA11yTree({ tool: "ocr", root }, { maskRules });
|
|
74
|
+
}
|
|
75
|
+
async function defaultRun(command, options) {
|
|
76
|
+
const { stdout } = await execAsync(command, {
|
|
77
|
+
cwd: options.cwd,
|
|
78
|
+
timeout: options.timeoutMs,
|
|
79
|
+
maxBuffer: 32 * 1024 * 1024
|
|
80
|
+
});
|
|
81
|
+
return stdout;
|
|
82
|
+
}
|
|
83
|
+
// POSIX single-quote quoting so a screenshot path with spaces survives the shell.
|
|
84
|
+
function shellQuote(value) {
|
|
85
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
86
|
+
}
|
|
87
|
+
async function cleanup(dir) {
|
|
88
|
+
await rm(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
function translateOcrError(error, ocrCommand) {
|
|
91
|
+
const message = messageOf(error);
|
|
92
|
+
if (!ocrCommand && /ENOENT|not found|not recognized/i.test(message)) {
|
|
93
|
+
return 'OCR failed: "tesseract" was not found. Install Tesseract OCR, or set "ocrCommand" to your own OCR tool (use {image} for the screenshot path).';
|
|
94
|
+
}
|
|
95
|
+
return `OCR command failed: ${message}`;
|
|
96
|
+
}
|
|
97
|
+
function messageOf(error) {
|
|
98
|
+
return error instanceof Error ? error.message : String(error);
|
|
99
|
+
}
|
package/dist/diff/lcs.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single longest-common-subsequence implementation shared by the text (token)
|
|
2
|
+
// and DOM-tree diffs. Previously this table was hand-rolled in three places
|
|
3
|
+
// (the line, token, and tree diffs) with copies that had already drifted.
|
|
4
|
+
// Bottom-up LCS length table: table[i][j] is the LCS length of left[i:] and
|
|
5
|
+
// right[j:]. `eq` defaults to strict equality (used for string tokens/keys).
|
|
6
|
+
export function buildLcsTable(left, right, eq = (a, b) => a === b) {
|
|
7
|
+
const table = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
|
|
8
|
+
for (let i = left.length - 1; i >= 0; i -= 1) {
|
|
9
|
+
for (let j = right.length - 1; j >= 0; j -= 1) {
|
|
10
|
+
const row = table[i];
|
|
11
|
+
if (eq(left[i], right[j])) {
|
|
12
|
+
row[j] = (table[i + 1]?.[j + 1] ?? 0) + 1;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
row[j] = Math.max(table[i + 1]?.[j] ?? 0, table[i]?.[j + 1] ?? 0);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return table;
|
|
20
|
+
}
|
|
21
|
+
// The index pairs (i in left, j in right) of a longest common subsequence, in
|
|
22
|
+
// order — what the tree diff aligns on.
|
|
23
|
+
export function lcsPairs(left, right, eq = (a, b) => a === b) {
|
|
24
|
+
const table = buildLcsTable(left, right, eq);
|
|
25
|
+
const pairs = [];
|
|
26
|
+
let i = 0;
|
|
27
|
+
let j = 0;
|
|
28
|
+
while (i < left.length && j < right.length) {
|
|
29
|
+
if (eq(left[i], right[j])) {
|
|
30
|
+
pairs.push({ i, j });
|
|
31
|
+
i += 1;
|
|
32
|
+
j += 1;
|
|
33
|
+
}
|
|
34
|
+
else if ((table[i + 1]?.[j] ?? 0) >= (table[i]?.[j + 1] ?? 0)) {
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
j += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return pairs;
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type NumericTolerance = {
|
|
2
|
+
absolute?: number;
|
|
3
|
+
relative?: number;
|
|
4
|
+
};
|
|
5
|
+
export declare function numbersWithinTolerance(left: number, right: number, tolerance?: NumericTolerance): boolean;
|
|
6
|
+
export declare function isNumericTolerant(tolerance: NumericTolerance | undefined): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Two numbers are "equal enough" when they are exactly equal, or within an
|
|
2
|
+
// absolute and/or relative epsilon. With no tolerance configured (both 0) this
|
|
3
|
+
// collapses to strict equality, keeping default behavior exact.
|
|
4
|
+
export function numbersWithinTolerance(left, right, tolerance = {}) {
|
|
5
|
+
if (left === right) {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
if (!Number.isFinite(left) || !Number.isFinite(right)) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const absolute = tolerance.absolute ?? 0;
|
|
12
|
+
const relative = tolerance.relative ?? 0;
|
|
13
|
+
const delta = Math.abs(left - right);
|
|
14
|
+
if (absolute > 0 && delta <= absolute) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (relative > 0 && delta <= relative * Math.max(Math.abs(left), Math.abs(right))) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export function isNumericTolerant(tolerance) {
|
|
23
|
+
return Boolean(tolerance && ((tolerance.absolute ?? 0) > 0 || (tolerance.relative ?? 0) > 0));
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type PixelTolerance = {
|
|
2
|
+
maxChangedRatio?: number;
|
|
3
|
+
perChannelThreshold?: number;
|
|
4
|
+
};
|
|
5
|
+
export type PixelDiffResult = {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
dimensionsMatch: boolean;
|
|
9
|
+
totalPixels: number;
|
|
10
|
+
changedPixels: number;
|
|
11
|
+
changedRatio: number;
|
|
12
|
+
withinTolerance: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type DecodedImage = {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
data: Uint8Array;
|
|
18
|
+
};
|
|
19
|
+
export declare function decodePng(buffer: Buffer): DecodedImage;
|
|
20
|
+
export declare function encodePng(image: DecodedImage): Buffer;
|
|
21
|
+
export declare function diffPng(before: Buffer, after: Buffer, tolerance?: PixelTolerance): PixelDiffResult;
|
|
22
|
+
export declare function diffImages(before: DecodedImage, after: DecodedImage, tolerance?: PixelTolerance): PixelDiffResult;
|
|
23
|
+
export declare function renderDiffImage(before: DecodedImage, after: DecodedImage, tolerance?: PixelTolerance): Buffer | undefined;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
// PNG decode/encode is delegated to `pngjs` (a vetted dependency) rather than
|
|
3
|
+
// hand-rolled: it handles interlacing, 16-bit, and palette images, and rejects
|
|
4
|
+
// malformed input loudly. The diff and overlay logic below works on the decoded
|
|
5
|
+
// RGBA buffer and is independent of the codec.
|
|
6
|
+
export function decodePng(buffer) {
|
|
7
|
+
const png = PNG.sync.read(buffer);
|
|
8
|
+
return { width: png.width, height: png.height, data: png.data };
|
|
9
|
+
}
|
|
10
|
+
export function encodePng(image) {
|
|
11
|
+
const png = new PNG({ width: image.width, height: image.height });
|
|
12
|
+
png.data = Buffer.from(image.data);
|
|
13
|
+
return PNG.sync.write(png);
|
|
14
|
+
}
|
|
15
|
+
export function diffPng(before, after, tolerance = {}) {
|
|
16
|
+
return diffImages(decodePng(before), decodePng(after), tolerance);
|
|
17
|
+
}
|
|
18
|
+
export function diffImages(before, after, tolerance = {}) {
|
|
19
|
+
const perChannelThreshold = tolerance.perChannelThreshold ?? 0;
|
|
20
|
+
const maxChangedRatio = tolerance.maxChangedRatio ?? 0;
|
|
21
|
+
const dimensionsMatch = before.width === after.width && before.height === after.height;
|
|
22
|
+
if (!dimensionsMatch) {
|
|
23
|
+
const totalPixels = Math.max(before.width * before.height, after.width * after.height);
|
|
24
|
+
return {
|
|
25
|
+
width: after.width,
|
|
26
|
+
height: after.height,
|
|
27
|
+
dimensionsMatch: false,
|
|
28
|
+
totalPixels,
|
|
29
|
+
changedPixels: totalPixels,
|
|
30
|
+
changedRatio: 1,
|
|
31
|
+
withinTolerance: false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const totalPixels = before.width * before.height;
|
|
35
|
+
let changedPixels = 0;
|
|
36
|
+
for (let pixel = 0; pixel < totalPixels; pixel += 1) {
|
|
37
|
+
const offset = pixel * 4;
|
|
38
|
+
if (channelDelta(before.data, after.data, offset) > perChannelThreshold ||
|
|
39
|
+
channelDelta(before.data, after.data, offset + 1) > perChannelThreshold ||
|
|
40
|
+
channelDelta(before.data, after.data, offset + 2) > perChannelThreshold ||
|
|
41
|
+
channelDelta(before.data, after.data, offset + 3) > perChannelThreshold) {
|
|
42
|
+
changedPixels += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const changedRatio = totalPixels === 0 ? 0 : changedPixels / totalPixels;
|
|
46
|
+
return {
|
|
47
|
+
width: before.width,
|
|
48
|
+
height: before.height,
|
|
49
|
+
dimensionsMatch: true,
|
|
50
|
+
totalPixels,
|
|
51
|
+
changedPixels,
|
|
52
|
+
changedRatio,
|
|
53
|
+
withinTolerance: changedRatio <= maxChangedRatio
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Render a human-facing diff overlay: changed pixels are painted solid red on
|
|
57
|
+
// top of a faint ghost of the "after" image, so the regression is obvious at a
|
|
58
|
+
// glance. Takes already-decoded images so the diff path decodes each PNG once.
|
|
59
|
+
// Returns undefined when the two images differ in size (there is no meaningful
|
|
60
|
+
// per-pixel overlay), in which case callers fall back to showing the
|
|
61
|
+
// before/after images alone.
|
|
62
|
+
export function renderDiffImage(before, after, tolerance = {}) {
|
|
63
|
+
if (before.width !== after.width || before.height !== after.height) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const threshold = tolerance.perChannelThreshold ?? 0;
|
|
67
|
+
const { width, height } = before;
|
|
68
|
+
const out = new Uint8Array(width * height * 4);
|
|
69
|
+
for (let pixel = 0; pixel < width * height; pixel += 1) {
|
|
70
|
+
const offset = pixel * 4;
|
|
71
|
+
const changed = channelDelta(before.data, after.data, offset) > threshold ||
|
|
72
|
+
channelDelta(before.data, after.data, offset + 1) > threshold ||
|
|
73
|
+
channelDelta(before.data, after.data, offset + 2) > threshold ||
|
|
74
|
+
channelDelta(before.data, after.data, offset + 3) > threshold;
|
|
75
|
+
if (changed) {
|
|
76
|
+
out[offset] = 255;
|
|
77
|
+
out[offset + 1] = 0;
|
|
78
|
+
out[offset + 2] = 0;
|
|
79
|
+
out[offset + 3] = 255;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const gray = ((after.data[offset] ?? 0) +
|
|
83
|
+
(after.data[offset + 1] ?? 0) +
|
|
84
|
+
(after.data[offset + 2] ?? 0)) /
|
|
85
|
+
3;
|
|
86
|
+
const ghost = Math.round(gray * 0.25 + 191);
|
|
87
|
+
out[offset] = ghost;
|
|
88
|
+
out[offset + 1] = ghost;
|
|
89
|
+
out[offset + 2] = ghost;
|
|
90
|
+
out[offset + 3] = 255;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return encodePng({ width, height, data: out });
|
|
94
|
+
}
|
|
95
|
+
function channelDelta(left, right, index) {
|
|
96
|
+
return Math.abs((left[index] ?? 0) - (right[index] ?? 0));
|
|
97
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type NumericTolerance } from "./numeric.js";
|
|
2
|
+
export type StructuralChange = {
|
|
3
|
+
path: string;
|
|
4
|
+
before: unknown;
|
|
5
|
+
after: unknown;
|
|
6
|
+
};
|
|
7
|
+
export type StructuralOptions = {
|
|
8
|
+
numericTolerance?: NumericTolerance;
|
|
9
|
+
};
|
|
10
|
+
export declare function structuralChanges(left: unknown, right: unknown, options?: StructuralOptions): StructuralChange[];
|
|
11
|
+
export declare function structuralEqual(left: unknown, right: unknown, options?: StructuralOptions): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { numbersWithinTolerance } from "./numeric.js";
|
|
2
|
+
import { isRecord } from "../guards.js";
|
|
3
|
+
// Deep structural comparison that understands numeric tolerance: two snapshots
|
|
4
|
+
// are equal when they have the same shape and every leaf matches (numbers within
|
|
5
|
+
// tolerance, everything else strictly). Returns the leaf paths that differ.
|
|
6
|
+
export function structuralChanges(left, right, options = {}) {
|
|
7
|
+
const changes = [];
|
|
8
|
+
walk(left, right, "$", options, changes);
|
|
9
|
+
return changes;
|
|
10
|
+
}
|
|
11
|
+
export function structuralEqual(left, right, options = {}) {
|
|
12
|
+
return structuralChanges(left, right, options).length === 0;
|
|
13
|
+
}
|
|
14
|
+
function walk(left, right, path, options, changes) {
|
|
15
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
16
|
+
if (!numbersWithinTolerance(left, right, options.numericTolerance)) {
|
|
17
|
+
changes.push({ path, before: left, after: right });
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
22
|
+
const length = Math.max(left.length, right.length);
|
|
23
|
+
for (let index = 0; index < length; index += 1) {
|
|
24
|
+
walk(left[index], right[index], `${path}[${index}]`, options, changes);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (isRecord(left) && isRecord(right)) {
|
|
29
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
30
|
+
for (const key of [...keys].sort()) {
|
|
31
|
+
walk(left[key], right[key], `${path}.${key}`, options, changes);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!Object.is(left, right)) {
|
|
36
|
+
changes.push({ path, before: left, after: right });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type InlineOp = {
|
|
2
|
+
kind: "equal" | "insert" | "delete";
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function diffTokens(left: string[], right: string[]): InlineOp[];
|
|
6
|
+
export declare function diffWords(left: string, right: string): InlineOp[];
|
|
7
|
+
export declare function renderInline(ops: InlineOp[]): string;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { buildLcsTable } from "./lcs.js";
|
|
2
|
+
// Token-level LCS diff. Shared by the word and character diffs below — the only
|
|
3
|
+
// difference is how the input strings are split into tokens.
|
|
4
|
+
export function diffTokens(left, right) {
|
|
5
|
+
const table = buildLcsTable(left, right);
|
|
6
|
+
const ops = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
let j = 0;
|
|
9
|
+
while (i < left.length && j < right.length) {
|
|
10
|
+
if (left[i] === right[j]) {
|
|
11
|
+
pushOp(ops, "equal", left[i]);
|
|
12
|
+
i += 1;
|
|
13
|
+
j += 1;
|
|
14
|
+
}
|
|
15
|
+
else if ((table[i + 1]?.[j] ?? 0) >= (table[i]?.[j + 1] ?? 0)) {
|
|
16
|
+
pushOp(ops, "delete", left[i]);
|
|
17
|
+
i += 1;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
pushOp(ops, "insert", right[j]);
|
|
21
|
+
j += 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
while (i < left.length) {
|
|
25
|
+
pushOp(ops, "delete", left[i]);
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
while (j < right.length) {
|
|
29
|
+
pushOp(ops, "insert", right[j]);
|
|
30
|
+
j += 1;
|
|
31
|
+
}
|
|
32
|
+
return ops;
|
|
33
|
+
}
|
|
34
|
+
export function diffWords(left, right) {
|
|
35
|
+
return diffTokens(tokenizeWords(left), tokenizeWords(right));
|
|
36
|
+
}
|
|
37
|
+
// Renders inline ops with readable markers: insertions in {+ +}, deletions in
|
|
38
|
+
// [- -]. Adjacent same-kind tokens are already merged by pushOp.
|
|
39
|
+
export function renderInline(ops) {
|
|
40
|
+
return ops
|
|
41
|
+
.map((op) => {
|
|
42
|
+
if (op.kind === "insert") {
|
|
43
|
+
return `{+${op.value}+}`;
|
|
44
|
+
}
|
|
45
|
+
if (op.kind === "delete") {
|
|
46
|
+
return `[-${op.value}-]`;
|
|
47
|
+
}
|
|
48
|
+
return op.value;
|
|
49
|
+
})
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
// Splits into a sequence of word and whitespace tokens, preserving whitespace so
|
|
53
|
+
// the diff can be rendered back into readable text.
|
|
54
|
+
function tokenizeWords(value) {
|
|
55
|
+
return value.match(/\s+|\S+/g) ?? [];
|
|
56
|
+
}
|
|
57
|
+
function pushOp(ops, kind, value) {
|
|
58
|
+
const last = ops[ops.length - 1];
|
|
59
|
+
if (last && last.kind === kind) {
|
|
60
|
+
last.value += value;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
ops.push({ kind, value });
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DomSnapshotNode } from "../web/domSnapshot.js";
|
|
2
|
+
export type TreeChange = {
|
|
3
|
+
type: "added";
|
|
4
|
+
path: string;
|
|
5
|
+
summary: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: "removed";
|
|
8
|
+
path: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "moved";
|
|
12
|
+
path: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: "text-changed";
|
|
16
|
+
path: string;
|
|
17
|
+
before: string;
|
|
18
|
+
after: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: "tag-changed";
|
|
21
|
+
path: string;
|
|
22
|
+
before: string;
|
|
23
|
+
after: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "attribute-added";
|
|
26
|
+
path: string;
|
|
27
|
+
name: string;
|
|
28
|
+
value: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "attribute-removed";
|
|
31
|
+
path: string;
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
} | {
|
|
35
|
+
type: "attribute-changed";
|
|
36
|
+
path: string;
|
|
37
|
+
name: string;
|
|
38
|
+
before: string;
|
|
39
|
+
after: string;
|
|
40
|
+
};
|
|
41
|
+
export type TreeDiff = {
|
|
42
|
+
equal: boolean;
|
|
43
|
+
changes: TreeChange[];
|
|
44
|
+
};
|
|
45
|
+
export declare function diffDomTrees(before: DomSnapshotNode[], after: DomSnapshotNode[]): TreeDiff;
|
|
46
|
+
export declare function renderTreeChanges(changes: TreeChange[]): string;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { lcsPairs } from "./lcs.js";
|
|
2
|
+
// Structural DOM diff: aligns children by a stable signature (tag + id, or
|
|
3
|
+
// "#text") so insertions/removals don't cascade, recurses matched pairs to find
|
|
4
|
+
// attribute/text/tag changes, and re-classifies a removed+added pair with an
|
|
5
|
+
// identical subtree as a "moved" node.
|
|
6
|
+
export function diffDomTrees(before, after) {
|
|
7
|
+
const changes = [];
|
|
8
|
+
diffChildren(before, after, "", changes);
|
|
9
|
+
return { equal: changes.length === 0, changes };
|
|
10
|
+
}
|
|
11
|
+
// Two-phase alignment. Phase 1 anchors byte-identical subtrees (LCS over deep
|
|
12
|
+
// signatures) so a pure insertion or a reorder doesn't cascade into spurious
|
|
13
|
+
// changes. Phase 2 pairs the leftover nodes in each gap by shallow signature
|
|
14
|
+
// (tag + id) so a container whose descendant changed is still matched and
|
|
15
|
+
// recursed, surfacing attribute/text/tag changes rather than remove + add.
|
|
16
|
+
function diffChildren(before, after, basePath, changes) {
|
|
17
|
+
const anchors = lcsPairs(before.map(subtreeSignature), after.map(subtreeSignature));
|
|
18
|
+
const removed = [];
|
|
19
|
+
const added = [];
|
|
20
|
+
let beforeCursor = 0;
|
|
21
|
+
let afterCursor = 0;
|
|
22
|
+
for (const anchor of [...anchors, { i: before.length, j: after.length }]) {
|
|
23
|
+
pairGap(before, after, beforeCursor, anchor.i, afterCursor, anchor.j, basePath, changes, removed, added);
|
|
24
|
+
beforeCursor = anchor.i + 1;
|
|
25
|
+
afterCursor = anchor.j + 1;
|
|
26
|
+
}
|
|
27
|
+
reconcileMoves(removed, added, basePath, changes);
|
|
28
|
+
}
|
|
29
|
+
// Aligns the leftover nodes between two anchors by shallow signature, recursing
|
|
30
|
+
// matched pairs and collecting the rest as candidate removals/additions.
|
|
31
|
+
function pairGap(before, after, beforeStart, beforeEnd, afterStart, afterEnd, basePath, changes, removed, added) {
|
|
32
|
+
const leftKeys = [];
|
|
33
|
+
for (let k = beforeStart; k < beforeEnd; k += 1) {
|
|
34
|
+
leftKeys.push(alignKey(before[k]));
|
|
35
|
+
}
|
|
36
|
+
const rightKeys = [];
|
|
37
|
+
for (let k = afterStart; k < afterEnd; k += 1) {
|
|
38
|
+
rightKeys.push(alignKey(after[k]));
|
|
39
|
+
}
|
|
40
|
+
const pairs = lcsPairs(leftKeys, rightKeys);
|
|
41
|
+
const matchedLeft = new Set();
|
|
42
|
+
const matchedRight = new Set();
|
|
43
|
+
for (const pair of pairs) {
|
|
44
|
+
const beforeIndex = beforeStart + pair.i;
|
|
45
|
+
const afterIndex = afterStart + pair.j;
|
|
46
|
+
matchedLeft.add(beforeIndex);
|
|
47
|
+
matchedRight.add(afterIndex);
|
|
48
|
+
diffNode(before[beforeIndex], after[afterIndex], childPath(basePath, after[afterIndex], afterIndex), changes);
|
|
49
|
+
}
|
|
50
|
+
for (let k = beforeStart; k < beforeEnd; k += 1) {
|
|
51
|
+
if (!matchedLeft.has(k)) {
|
|
52
|
+
removed.push({ node: before[k], index: k });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (let k = afterStart; k < afterEnd; k += 1) {
|
|
56
|
+
if (!matchedRight.has(k)) {
|
|
57
|
+
added.push({ node: after[k], index: k });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// A removed node and an added node with byte-identical subtrees are a move, not
|
|
62
|
+
// an unrelated delete + insert.
|
|
63
|
+
function reconcileMoves(removed, added, basePath, changes) {
|
|
64
|
+
const remaining = [...added];
|
|
65
|
+
for (const drop of removed) {
|
|
66
|
+
const signature = subtreeSignature(drop.node);
|
|
67
|
+
const moveIndex = remaining.findIndex((candidate) => subtreeSignature(candidate.node) === signature);
|
|
68
|
+
if (moveIndex >= 0) {
|
|
69
|
+
const moved = remaining.splice(moveIndex, 1)[0];
|
|
70
|
+
changes.push({
|
|
71
|
+
type: "moved",
|
|
72
|
+
path: childPath(basePath, moved.node, moved.index),
|
|
73
|
+
summary: `${describe(drop.node)} moved`
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
changes.push({
|
|
78
|
+
type: "removed",
|
|
79
|
+
path: childPath(basePath, drop.node, drop.index),
|
|
80
|
+
summary: describe(drop.node)
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const insert of remaining) {
|
|
84
|
+
changes.push({
|
|
85
|
+
type: "added",
|
|
86
|
+
path: childPath(basePath, insert.node, insert.index),
|
|
87
|
+
summary: describe(insert.node)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function diffNode(before, after, path, changes) {
|
|
92
|
+
if (before.type === "text" && after.type === "text") {
|
|
93
|
+
if (before.value !== after.value) {
|
|
94
|
+
changes.push({
|
|
95
|
+
type: "text-changed",
|
|
96
|
+
path,
|
|
97
|
+
before: before.value,
|
|
98
|
+
after: after.value
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (before.type === "element" && after.type === "element") {
|
|
104
|
+
if (before.tagName !== after.tagName) {
|
|
105
|
+
changes.push({
|
|
106
|
+
type: "tag-changed",
|
|
107
|
+
path,
|
|
108
|
+
before: before.tagName,
|
|
109
|
+
after: after.tagName
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
diffAttributes(before.attributes, after.attributes, path, changes);
|
|
113
|
+
diffChildren(before.children, after.children, path, changes);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function diffAttributes(before, after, path, changes) {
|
|
117
|
+
const names = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
118
|
+
for (const name of [...names].sort()) {
|
|
119
|
+
const beforeValue = before[name];
|
|
120
|
+
const afterValue = after[name];
|
|
121
|
+
if (beforeValue === undefined) {
|
|
122
|
+
changes.push({ type: "attribute-added", path, name, value: afterValue });
|
|
123
|
+
}
|
|
124
|
+
else if (afterValue === undefined) {
|
|
125
|
+
changes.push({ type: "attribute-removed", path, name, value: beforeValue });
|
|
126
|
+
}
|
|
127
|
+
else if (beforeValue !== afterValue) {
|
|
128
|
+
changes.push({
|
|
129
|
+
type: "attribute-changed",
|
|
130
|
+
path,
|
|
131
|
+
name,
|
|
132
|
+
before: beforeValue,
|
|
133
|
+
after: afterValue
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function renderTreeChanges(changes) {
|
|
139
|
+
return changes.map(renderTreeChange).join("\n");
|
|
140
|
+
}
|
|
141
|
+
function renderTreeChange(change) {
|
|
142
|
+
switch (change.type) {
|
|
143
|
+
case "added":
|
|
144
|
+
return `+ ${change.path} (${change.summary})`;
|
|
145
|
+
case "removed":
|
|
146
|
+
return `- ${change.path} (${change.summary})`;
|
|
147
|
+
case "moved":
|
|
148
|
+
return `~ ${change.path} (${change.summary})`;
|
|
149
|
+
case "text-changed":
|
|
150
|
+
return `~ ${change.path} text: "${change.before}" → "${change.after}"`;
|
|
151
|
+
case "tag-changed":
|
|
152
|
+
return `~ ${change.path} tag: <${change.before}> → <${change.after}>`;
|
|
153
|
+
case "attribute-added":
|
|
154
|
+
return `+ ${change.path} @${change.name}="${change.value}"`;
|
|
155
|
+
case "attribute-removed":
|
|
156
|
+
return `- ${change.path} @${change.name}="${change.value}"`;
|
|
157
|
+
case "attribute-changed":
|
|
158
|
+
return `~ ${change.path} @${change.name}: "${change.before}" → "${change.after}"`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function alignKey(node) {
|
|
162
|
+
if (node.type === "text") {
|
|
163
|
+
return "#text";
|
|
164
|
+
}
|
|
165
|
+
const id = node.attributes.id ? `#${node.attributes.id}` : "";
|
|
166
|
+
return `${node.tagName}${id}`;
|
|
167
|
+
}
|
|
168
|
+
function childPath(basePath, node, index) {
|
|
169
|
+
const segment = node.type === "text" ? `#text[${index}]` : `${node.tagName}[${index}]`;
|
|
170
|
+
return basePath ? `${basePath} > ${segment}` : segment;
|
|
171
|
+
}
|
|
172
|
+
function describe(node) {
|
|
173
|
+
return node.type === "text" ? `text "${node.value}"` : `<${node.tagName}>`;
|
|
174
|
+
}
|
|
175
|
+
// Signature of a subtree, used both to anchor the LCS alignment and to match a
|
|
176
|
+
// removed subtree to an identical added one (a move). Memoized per node so the
|
|
177
|
+
// `O(removed × added)` move reconciliation doesn't re-stringify the same
|
|
178
|
+
// subtrees repeatedly; keyed by node identity, so distinct nodes never collide.
|
|
179
|
+
const signatureCache = new WeakMap();
|
|
180
|
+
function subtreeSignature(node) {
|
|
181
|
+
const cached = signatureCache.get(node);
|
|
182
|
+
if (cached !== undefined) {
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
const signature = JSON.stringify(node);
|
|
186
|
+
signatureCache.set(node, signature);
|
|
187
|
+
return signature;
|
|
188
|
+
}
|