Skip to content

Commit 97139bc

Browse files
committed
Expose visual explanation controls
1 parent 7e45126 commit 97139bc

4 files changed

Lines changed: 166 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"test:browser-visual-compare-fair-ratio": "tsx tests/browser-visual-compare-fair-ratio.test.ts",
111111
"test:browser-visual-compare-layout-drift": "tsx tests/browser-visual-compare-layout-drift.test.ts",
112112
"test:browser-visual-compare-capture-reliability": "tsx tests/browser-visual-compare-capture-reliability.test.ts",
113+
"test:browser-visual-compare-contract": "npm run build && tsx tests/browser-visual-compare-contract.test.ts",
113114
"test:browser-visual-compare-dom-snapshots": "npm run build && tsx tests/browser-visual-compare-dom-snapshots.test.ts",
114115
"test:browser-session-public-dto": "tsx tests/browser-session-public-dto.test.ts",
115116
"test:browser-playground-session-run": "tsx tests/browser-playground-session-run.test.ts",

packages/runtime-core/src/command-registry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1217,7 +1217,7 @@ export const commandRegistry = [
12171217
acceptedArgs: [
12181218
{ name: "source-url", description: "Source browser target path or absolute URL.", format: "path or URL" },
12191219
{ name: "candidate-url", description: "Candidate browser target path or absolute URL.", format: "path or URL" },
1220-
{ name: "matrix-json", description: "Optional comparison matrix object with comparisons and optional viewports arrays; each comparison may provide source/candidate targets, labels, viewport, and wait settings.", format: "JSON object" },
1220+
{ name: "matrix-json", description: "Optional comparison matrix object with comparisons and optional viewports arrays; each comparison may provide source/candidate targets, labels, viewport, wait settings, maxExplanationElements/maxExplanationCandidates/explainSelectors, or their kebab-case max-explanation-elements/max-explanation-candidates/explain-selector aliases.", format: "JSON object" },
12211221
{ name: "source-screenshot", description: "Existing source PNG screenshot path on the host.", format: "path" },
12221222
{ name: "candidate-screenshot", description: "Existing candidate PNG screenshot path on the host.", format: "path" },
12231223
{ name: "source-dom-snapshot", description: "Optional source DOM/style sidecar snapshot path for screenshot-backed visual explanations.", format: "path" },
@@ -1232,6 +1232,9 @@ export const commandRegistry = [
12321232
{ name: "threshold", description: "Pixelmatch color threshold; defaults to 0.1.", format: "number between 0 and 1" },
12331233
{ name: "include-aa", description: "Include anti-aliased pixels in mismatch count; defaults to false.", format: "boolean" },
12341234
{ name: "max-regions", description: "Maximum mismatch regions to report; defaults to 8.", format: "positive integer" },
1235+
{ name: "max-explanation-elements", description: "Maximum DOM elements included in visual explanation attribution; defaults to 25.", format: "positive integer" },
1236+
{ name: "max-explanation-candidates", description: "Maximum visible DOM elements captured from each target before visual explanation attribution; defaults to 160.", format: "positive integer" },
1237+
{ name: "explain-selector", description: "Targeted selector included in visual explanation attribution. Supply once per selector.", repeatable: true, format: "CSS selector" },
12351238
],
12361239
outputShape: "wp-codebox/visual-compare/v1 JSON summary plus files/browser/visual-compare/source.png, candidate.png, diff.png, visual-diff.json, visual-explanation.json when DOM/style context is available, and optional baseline delta evidence when a previous visual comparison artifact is supplied. Matrix runs emit wp-codebox/visual-compare-matrix/v1 in files/browser/visual-compare/matrix-summary.json plus per-comparison subdirectories.",
12371240
outputSchema: objectEnvelopeSchema("wp-codebox/visual-compare/v1", {

packages/runtime-playground/src/browser-visual-compare.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,7 @@ function visualCompareMatrixEndpoint(args: string[], role: "source" | "candidate
12331233

12341234
function visualCompareMatrixOptions(args: string[]): Record<string, unknown> {
12351235
const requestedViewport = viewportArg(args, "viewport")
1236+
const explainSelectors = visualCompareExplainSelectors(args)
12361237
return {
12371238
waitFor: argValue(args, "wait-for")?.trim() || "domcontentloaded",
12381239
durationMs: durationArg(args, "duration", 0),
@@ -1243,6 +1244,7 @@ function visualCompareMatrixOptions(args: string[]): Record<string, unknown> {
12431244
maxRegions: positiveIntegerArg(args, "max-regions", 8),
12441245
maxExplanationElements: positiveIntegerArg(args, "max-explanation-elements", 25),
12451246
maxExplanationCandidates: positiveIntegerArg(args, "max-explanation-candidates", 160),
1247+
...(explainSelectors.length > 0 ? { explainSelectors } : {}),
12461248
}
12471249
}
12481250

@@ -1420,10 +1422,31 @@ function visualCompareMatrixArgs(record: Record<string, unknown>): string[] {
14201422
["max-explanation-elements", ["max-explanation-elements", "maxExplanationElements"]],
14211423
["max-explanation-candidates", ["max-explanation-candidates", "maxExplanationCandidates"]],
14221424
]
1423-
return fields.flatMap(([argName, keys]) => {
1425+
const args = fields.flatMap(([argName, keys]) => {
14241426
const value = visualCompareMatrixValue(record, keys)
14251427
return value === undefined ? [] : [`${argName}=${String(value)}`]
14261428
})
1429+
const selectors = visualCompareMatrixSelectors(record)
1430+
return [...args, ...selectors.map((selector) => `explain-selector=${selector}`)]
1431+
}
1432+
1433+
function visualCompareMatrixSelectors(record: Record<string, unknown>): string[] {
1434+
const raw = visualCompareMatrixValue(record, ["explain-selector", "explainSelector", "explain-selectors", "explainSelectors"])
1435+
if (raw === undefined) {
1436+
return []
1437+
}
1438+
const values = Array.isArray(raw) ? raw : [raw]
1439+
const selectors = new Set<string>()
1440+
for (const value of values) {
1441+
if (typeof value !== "string") {
1442+
throw new Error("matrix-json explainSelectors must be a string or an array of strings")
1443+
}
1444+
const selector = value.trim()
1445+
if (selector) {
1446+
selectors.add(selector)
1447+
}
1448+
}
1449+
return [...selectors]
14271450
}
14281451

14291452
function visualCompareMatrixString(record: Record<string, unknown>, keys: string[]): string | undefined {
@@ -1445,7 +1468,7 @@ function sanitizeVisualCompareMatrixName(name: string): string {
14451468
}
14461469

14471470
function mergeVisualCompareMatrixArgs(baseArgs: string[], entryArgs: string[]): string[] {
1448-
const merged = baseArgs.filter((arg) => !entryArgs.some((entryArg) => arg.slice(0, arg.indexOf("=") + 1) === entryArg.slice(0, entryArg.indexOf("=") + 1)))
1471+
const merged = baseArgs.filter((arg) => arg.startsWith("explain-selector=") || !entryArgs.some((entryArg) => arg.slice(0, arg.indexOf("=") + 1) === entryArg.slice(0, entryArg.indexOf("=") + 1)))
14491472
merged.push(...entryArgs)
14501473
return merged
14511474
}
@@ -2914,9 +2937,9 @@ function positiveIntegerArg(args: string[], name: string, fallback: number): num
29142937
if (!raw) {
29152938
return fallback
29162939
}
2917-
const parsed = Number.parseInt(raw, 10)
2918-
if (!Number.isFinite(parsed) || parsed <= 0) {
2940+
if (!/^[1-9]\d*$/.test(raw)) {
29192941
throw new Error(`${name} must be a positive integer`)
29202942
}
2943+
const parsed = Number.parseInt(raw, 10)
29212944
return parsed
29222945
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import assert from "node:assert/strict"
2+
import { readFile, writeFile } from "node:fs/promises"
3+
import { join } from "node:path"
4+
5+
import { PNG } from "pngjs"
6+
import { commandRegistry } from "../packages/runtime-core/src/command-registry.js"
7+
import { runVisualCompareCommand } from "../packages/runtime-playground/dist/browser-visual-compare.js"
8+
import { withTempDir } from "../scripts/test-kit.js"
9+
10+
const visualCompare = commandRegistry.find((definition) => definition.id === "wordpress.visual-compare")
11+
assert.ok(visualCompare, "wordpress.visual-compare is registered")
12+
13+
const acceptedArgs = visualCompare.acceptedArgs
14+
const maxElements = acceptedArgs.find((arg) => arg.name === "max-explanation-elements")
15+
const maxCandidates = acceptedArgs.find((arg) => arg.name === "max-explanation-candidates")
16+
const selector = acceptedArgs.find((arg) => arg.name === "explain-selector")
17+
assert.deepEqual(maxElements && { format: maxElements.format }, { format: "positive integer" })
18+
assert.deepEqual(maxCandidates && { format: maxCandidates.format }, { format: "positive integer" })
19+
assert.deepEqual(selector && { repeatable: selector.repeatable, format: selector.format }, { repeatable: true, format: "CSS selector" })
20+
const matrixDescription = acceptedArgs.find((arg) => arg.name === "matrix-json")?.description ?? ""
21+
for (const field of ["maxExplanationElements", "maxExplanationCandidates", "explainSelectors", "max-explanation-elements", "max-explanation-candidates", "explain-selector"]) {
22+
assert.match(matrixDescription, new RegExp(field))
23+
}
24+
25+
async function writePng(path: string): Promise<void> {
26+
const png = new PNG({ width: 1, height: 1 })
27+
png.data.set([255, 255, 255, 255])
28+
await writeFile(path, PNG.sync.write(png))
29+
}
30+
31+
async function visualCompareRun(artifactRoot: string, args: string[]) {
32+
return runVisualCompareCommand({
33+
artifactRoot,
34+
server: {
35+
serverUrl: "http://127.0.0.1:1",
36+
playground: { run: async () => ({ text: "" }) },
37+
async [Symbol.asyncDispose]() {},
38+
},
39+
spec: { command: "wordpress.visual-compare", args },
40+
})
41+
}
42+
43+
const expectedOptions = (maxExplanationElements: number, maxExplanationCandidates: number, explainSelectors?: string[]) => ({
44+
waitFor: "domcontentloaded",
45+
durationMs: 0,
46+
timeoutMs: 120_000,
47+
fullPage: true,
48+
maxFullPageHeight: 20_000,
49+
threshold: 0.1,
50+
includeAA: false,
51+
maxRegions: 8,
52+
maxExplanationElements,
53+
maxExplanationCandidates,
54+
...(explainSelectors ? { explainSelectors } : {}),
55+
})
56+
57+
await withTempDir("wp-codebox-visual-compare-contract-", async (artifactRoot) => {
58+
const sourceScreenshot = join(artifactRoot, "source.png")
59+
const candidateScreenshot = join(artifactRoot, "candidate.png")
60+
await Promise.all([writePng(sourceScreenshot), writePng(candidateScreenshot)])
61+
62+
const defaults = JSON.parse((await visualCompareRun(artifactRoot, [`source-screenshot=${sourceScreenshot}`, `candidate-screenshot=${candidateScreenshot}`])).output)
63+
assert.deepEqual(defaults.options, expectedOptions(25, 160))
64+
65+
const pair = JSON.parse((await visualCompareRun(artifactRoot, [
66+
`source-screenshot=${sourceScreenshot}`,
67+
`candidate-screenshot=${candidateScreenshot}`,
68+
"max-explanation-elements=40",
69+
"max-explanation-candidates=240",
70+
"explain-selector=main",
71+
"explain-selector=body",
72+
])).output)
73+
assert.deepEqual(pair.options, expectedOptions(40, 240, ["main", "body"]))
74+
assert.equal(pair.schema, "wp-codebox/visual-compare/v1")
75+
assert.equal(pair.command, "wordpress.visual-compare")
76+
assert.equal(pair.status, "identical")
77+
assert.deepEqual(pair.files, {
78+
sourceScreenshot: "files/browser/visual-compare/source.png",
79+
candidateScreenshot: "files/browser/visual-compare/candidate.png",
80+
diffScreenshot: "files/browser/visual-compare/diff.png",
81+
visualDiff: "files/browser/visual-compare/visual-diff.json",
82+
blocksEngineVisualParity: "files/browser/visual-compare/blocks-engine-visual-parity-report.json",
83+
summary: "files/browser/visual-compare/summary.json",
84+
})
85+
assert.deepEqual(pair.comparison, {
86+
source: { width: 1, height: 1 },
87+
candidate: { width: 1, height: 1 },
88+
diff: { width: 1, height: 1 },
89+
mismatchPixels: 0,
90+
totalPixels: 1,
91+
mismatchRatio: 0,
92+
overlapMismatchPixels: 0,
93+
overlapPixels: 1,
94+
overlapMismatchRatio: 0,
95+
dimensionMismatch: false,
96+
dimensionDeltaPixels: 0,
97+
dimensionDeltaRatio: 0,
98+
regions: [],
99+
})
100+
for (const hash of Object.values(pair.hashes) as Array<{ algorithm: string; value: string }>) {
101+
assert.equal(hash.algorithm, "sha256")
102+
assert.match(hash.value, /^[a-f0-9]{64}$/)
103+
}
104+
const persistedPair = JSON.parse(await readFile(join(artifactRoot, pair.files.summary), "utf8"))
105+
assert.deepEqual(persistedPair.options, pair.options)
106+
assert.deepEqual(persistedPair.files, pair.files)
107+
108+
for (const [arg, message] of [["max-explanation-elements=0", "max-explanation-elements"], ["max-explanation-candidates=0", "max-explanation-candidates"], ["max-explanation-elements=1.5", "max-explanation-elements"], ["max-explanation-candidates=160px", "max-explanation-candidates"]]) {
109+
await assert.rejects(visualCompareRun(artifactRoot, [`source-screenshot=${sourceScreenshot}`, `candidate-screenshot=${candidateScreenshot}`, arg]), new RegExp(`${message} must be a positive integer`))
110+
}
111+
112+
const matrix = JSON.parse((await visualCompareRun(artifactRoot, [
113+
"explain-selector=main",
114+
"explain-selector=body",
115+
`matrix-json=${JSON.stringify({ comparisons: [{ name: "camel-case", sourceScreenshot, candidateScreenshot, maxExplanationElements: 50, maxExplanationCandidates: 300, explainSelectors: ["body", "article"] }, { name: "kebab-case", "source-screenshot": sourceScreenshot, "candidate-screenshot": candidateScreenshot, "max-explanation-elements": 60, "max-explanation-candidates": 320, "explain-selector": "article" }] })}`,
116+
])).output)
117+
assert.equal(matrix.schema, "wp-codebox/visual-compare-matrix/v1")
118+
assert.equal(matrix.command, "wordpress.visual-compare")
119+
assert.equal(matrix.complete, true)
120+
assert.deepEqual(matrix.metrics, { expectedComparisons: 2, comparisons: 2, missing: 0, failed: 0, identical: 2, different: 0, maxMismatchRatio: 0, meanMismatchRatio: 0, maxOverlapMismatchRatio: 0, meanOverlapMismatchRatio: 0, maxMismatchPixels: 0, meanMismatchPixels: 0 })
121+
assert.deepEqual(matrix.comparisons[0].options, expectedOptions(50, 300, ["main", "body", "article"]))
122+
assert.deepEqual(matrix.comparisons[1].options, expectedOptions(60, 320, ["main", "body", "article"]))
123+
const persistedMatrix = JSON.parse(await readFile(join(artifactRoot, matrix.files.summary), "utf8"))
124+
assert.deepEqual(persistedMatrix.comparisons.map((comparison: { options: unknown }) => comparison.options), matrix.comparisons.map((comparison: { options: unknown }) => comparison.options))
125+
126+
for (const [field, value] of [["maxExplanationElements", "1.5"], ["maxExplanationCandidates", 0]]) {
127+
await assert.rejects(
128+
visualCompareRun(artifactRoot, [`matrix-json=${JSON.stringify({ comparisons: [{ sourceScreenshot, candidateScreenshot, [field]: value }] })}`]),
129+
new RegExp(`${field === "maxExplanationElements" ? "max-explanation-elements" : "max-explanation-candidates"} must be a positive integer`),
130+
)
131+
}
132+
})
133+
134+
console.log("browser visual compare contract passed")

0 commit comments

Comments
 (0)