@broberg/lens-daemon 0.0.0-stage → 0.1.5
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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +239 -0
- package/src/artifact-uploads.ts +397 -0
- package/src/auth/index.ts +301 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2953 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/secret-fill.ts +80 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1493 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Vision-model validation — runs each candidate against TWO cases:
|
|
2
|
+
// 1. spatial-overlap — the ✕ over the version badge (geometry's home turf;
|
|
3
|
+
// geometry nails it free — here we see if vision can even see it).
|
|
4
|
+
// 2. fuzzy-aesthetic — inverted hierarchy + low-contrast-on-gradient +
|
|
5
|
+
// clashing illegible button (NO overlap; geometry is blind here — THIS is
|
|
6
|
+
// vision's real job). A gradient bg means the geometry contrast check skips.
|
|
7
|
+
//
|
|
8
|
+
// Pick cheapest-that-DUER for vision's actual domain. Add Pixtral to CANDIDATES
|
|
9
|
+
// when it lands in the SDK. EU/Mistral only — no paid-Anthropic route (house rule).
|
|
10
|
+
//
|
|
11
|
+
// MISTRAL_API_KEY auto-loaded from .env:
|
|
12
|
+
// bun run apps/agent/src/lens/validate-vision.ts
|
|
13
|
+
|
|
14
|
+
import { withPage } from './browser.ts';
|
|
15
|
+
|
|
16
|
+
const FIXTURES = [
|
|
17
|
+
{
|
|
18
|
+
name: 'spatial-overlap',
|
|
19
|
+
html: `<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;margin:0;background:#fff">
|
|
20
|
+
<div style="position:relative;width:420px;height:160px;margin:48px;background:#f6f7f9;border:1px solid #ccc;border-radius:12px;padding:20px">
|
|
21
|
+
<h2 style="margin:0 0 8px">Release notes</h2>
|
|
22
|
+
<span style="position:absolute;left:24px;top:74px;width:160px;height:30px;line-height:30px;background:#e7ecff;border-radius:8px;padding:0 10px;color:#1a2b4a">shipped v0.2.0</span>
|
|
23
|
+
<button style="position:absolute;left:78px;top:68px;width:120px;height:42px;background:#fff;border:1px solid #ddd;font-size:18px">✕ close</button>
|
|
24
|
+
</div></body></html>`,
|
|
25
|
+
looksFor: /overlap|on top|cover|hidden|behind|close button|truncat|cut off|clip|awkward/i,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'fuzzy-aesthetic',
|
|
29
|
+
html: `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;background:#fff">
|
|
30
|
+
<div style="width:460px;margin:40px;background:linear-gradient(135deg,#7b2ff7,#f107a3);padding:28px;border-radius:14px;font-family:system-ui">
|
|
31
|
+
<p style="font-size:11px;color:#d8b8ff;margin:0 0 4px">UPGRADE</p>
|
|
32
|
+
<h1 style="font-size:13px;color:#e3c0ff;margin:0 0 18px;font-weight:400">Pro plan</h1>
|
|
33
|
+
<p style="font-size:23px;color:#c79fe6;margin:0 0 6px;line-height:1.15">Everything in Free, plus unlimited projects, priority support, and advanced analytics for your whole team.</p>
|
|
34
|
+
<button style="background:#e8ff00;color:#fbff66;border:none;padding:12px 20px;border-radius:8px;font-size:16px;margin-top:14px">Get Pro</button>
|
|
35
|
+
</div></body></html>`,
|
|
36
|
+
looksFor: /hierarch|contrast|legib|readab|illegible|cramp|spacing|clash|color|heading|small|tiny|button/i,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const SYSTEM =
|
|
41
|
+
'You are a UI composition critic. You are shown ONE screenshot of a single app screen. ' +
|
|
42
|
+
'Report ONLY genuine VISUAL composition problems a careful designer would flag: overlapping ' +
|
|
43
|
+
'or colliding elements, a control sitting on top of text, text that is clipped/truncated/' +
|
|
44
|
+
'unreadable, content cut off or off-screen, low contrast, broken hierarchy, clashing/illegible ' +
|
|
45
|
+
'colours, anything that plainly looks broken. If the screen looks coherent, return an empty list. Be terse.';
|
|
46
|
+
const PROMPT =
|
|
47
|
+
'Return ONLY a JSON object: {"findings":[{"severity":"high|medium|low","region":"<where>","issue":"<what>"}]}. ' +
|
|
48
|
+
'Empty findings array if the composition holds together.';
|
|
49
|
+
|
|
50
|
+
const CANDIDATES = [
|
|
51
|
+
// ai-sdk: Pixtral is retired; magistral-medium (reasoning + vision) is the
|
|
52
|
+
// strongest GDPR-safe bet for a critic that must judge aesthetics — try first.
|
|
53
|
+
{ key: 'magistral-medium ($2/$5, EU, reasoning)', provider: 'mistral', model: 'magistral-medium-latest', envKey: 'MISTRAL_API_KEY' },
|
|
54
|
+
{ key: 'mistral-large ($0.5/$1.5, EU)', provider: 'mistral', model: 'mistral-large-2512', envKey: 'MISTRAL_API_KEY' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const sdk = await import('@broberg/ai-sdk');
|
|
58
|
+
const providers: Record<string, unknown> = {};
|
|
59
|
+
if (process.env.MISTRAL_API_KEY) providers.mistral = sdk.mistralAdapter({ apiKey: process.env.MISTRAL_API_KEY });
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
const ai = sdk.createAI({ providers: providers as any }) as any;
|
|
62
|
+
|
|
63
|
+
for (const fx of FIXTURES) {
|
|
64
|
+
const url = 'data:text/html;charset=utf-8,' + encodeURIComponent(fx.html);
|
|
65
|
+
const shot = await withPage({ viewport: { width: 560, height: 320 } }, async (page) => {
|
|
66
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
67
|
+
await page.waitForTimeout(200);
|
|
68
|
+
return Buffer.from(await page.screenshot());
|
|
69
|
+
});
|
|
70
|
+
console.log(`\n\n######## CASE: ${fx.name} ########`);
|
|
71
|
+
for (const c of CANDIDATES) {
|
|
72
|
+
if (!process.env[c.envKey]) {
|
|
73
|
+
console.log(`\n=== ${c.key} — SKIPPED (no ${c.envKey}) ===`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
console.log(`\n=== ${c.key} ===`);
|
|
77
|
+
try {
|
|
78
|
+
const res = await ai.vision({
|
|
79
|
+
image: shot,
|
|
80
|
+
mimeType: 'image/png',
|
|
81
|
+
prompt: SYSTEM + '\n\n' + PROMPT,
|
|
82
|
+
override: { provider: c.provider, model: c.model, transport: 'http' },
|
|
83
|
+
purpose: 'vision-validation',
|
|
84
|
+
});
|
|
85
|
+
const text = typeof res.text === 'string' ? res.text : JSON.stringify(res.text ?? '');
|
|
86
|
+
const parsed = sdk.parseJsonLoose(text) as { findings?: unknown };
|
|
87
|
+
const findings = Array.isArray(parsed?.findings) ? parsed.findings : [];
|
|
88
|
+
const onTarget = fx.looksFor.test(JSON.stringify(findings));
|
|
89
|
+
console.log('cost :', res.usage?.costUsd != null ? `$${res.usage.costUsd.toFixed(5)}` : '—');
|
|
90
|
+
console.log('on-target finding?', onTarget ? 'YES ✓' : 'NO ✗', `· ${findings.length} finding(s)`);
|
|
91
|
+
console.log(JSON.stringify(findings, null, 2));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log('ERROR:', err instanceof Error ? err.message : String(err));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
process.exit(0);
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// F074.2 — Lens verify primitives.
|
|
2
|
+
//
|
|
3
|
+
// Verification = diff AND assertion, not just a raw screenshot. One page
|
|
4
|
+
// visit yields: a screenshot, an optional pixel-diff against a baseline, an
|
|
5
|
+
// optional DOM measurement (assert.js via page.evaluate), a structural
|
|
6
|
+
// domHash, and console/network capture (reported, never auto-fatal).
|
|
7
|
+
//
|
|
8
|
+
// Status logic (prioritised — see F074 plan):
|
|
9
|
+
// 1. assert set and assertResult.pass === false → fail
|
|
10
|
+
// 2. else baselineKey set + baseline exists → pixelmatch:
|
|
11
|
+
// diffRatio < threshold → pass, else → fail
|
|
12
|
+
// 3. else baselineKey set, no baseline → no-baseline
|
|
13
|
+
// 4. else (plain capture) page loaded w/o uncaught error → pass
|
|
14
|
+
//
|
|
15
|
+
// Console/network errors are reported but never downgrade status on their
|
|
16
|
+
// own — "assertion failed" is separated from "page logged a warning". The
|
|
17
|
+
// caller (cc) decides what to do with the console/network signal.
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { PNG } from 'pngjs';
|
|
21
|
+
import pixelmatch from 'pixelmatch';
|
|
22
|
+
import type { Page } from 'playwright';
|
|
23
|
+
import { withPage } from './browser.ts';
|
|
24
|
+
import { navigateAndAct, resolveExtensionUrl, resolvePrepareContext, takeShot, type CaptureMode, type CaptureOptions } from './capture.ts';
|
|
25
|
+
import { storagePayload } from './seed-storage.ts';
|
|
26
|
+
import { preflight, type PreflightResult } from './preflight.ts';
|
|
27
|
+
import { runCritic, topFindingMessage, type CriticReport } from './critic.ts';
|
|
28
|
+
import { checkVideo, type VideoCheckOptions, type VideoResult } from './video.ts';
|
|
29
|
+
// F074.39 — ONE assert evaluator, shared with the flow surface (see runAssert).
|
|
30
|
+
import { noVerdictMessage } from '@broberg/lens-engine';
|
|
31
|
+
import { evalAssertBody } from './flow.ts';
|
|
32
|
+
import { observeBuild } from './build-marker.ts';
|
|
33
|
+
|
|
34
|
+
export type LensStatus = 'pass' | 'fail' | 'no-baseline' | 'error';
|
|
35
|
+
|
|
36
|
+
export interface VerifyOptions extends CaptureOptions {
|
|
37
|
+
/** A JS statement-body evaluated in the page. Should `return` either a
|
|
38
|
+
* boolean or `{ pass: boolean, detail?: any }`. Runs in the browser
|
|
39
|
+
* context (sandboxed to the page), e.g.:
|
|
40
|
+
* "const el = document.querySelector('[data-testid=cta]');
|
|
41
|
+
* return { pass: el.getBoundingClientRect().width > 100, detail: el.offsetWidth }" */
|
|
42
|
+
assert?: string;
|
|
43
|
+
/** PNG bytes of the approved baseline for this surface, if one exists. */
|
|
44
|
+
baseline?: Buffer | null;
|
|
45
|
+
/** A key naming the baseline slot. When set without `baseline`, status is
|
|
46
|
+
* `no-baseline` (the shot becomes promotable). */
|
|
47
|
+
baselineKey?: string;
|
|
48
|
+
/** Pixel-diff pass threshold as a fraction of total pixels. Default 0.01. */
|
|
49
|
+
diffThreshold?: number;
|
|
50
|
+
/** F074.57 — BCP-47 locale + IANA timezone for the browser context. Unset
|
|
51
|
+
* passes nothing to Playwright (existing baselines must not move). */
|
|
52
|
+
locale?: string;
|
|
53
|
+
timezone?: string;
|
|
54
|
+
/** F074.59 — prefers-reduced-motion for this capture. Default 'reduce', which is
|
|
55
|
+
* what every capture did before this option existed, so no baseline can move. */
|
|
56
|
+
reducedMotion?: 'reduce' | 'no-preference';
|
|
57
|
+
/** Collect console messages (default true). */
|
|
58
|
+
collectConsole?: boolean;
|
|
59
|
+
/** Collect failed/non-2xx network responses (default true). */
|
|
60
|
+
collectNetwork?: boolean;
|
|
61
|
+
/** Skip the pre-flight stage (used when the caller already proved reachability). */
|
|
62
|
+
skipPreflight?: boolean;
|
|
63
|
+
/** Video-liveness: drive a <video> and assert it actually plays (currentTime
|
|
64
|
+
* advances + frames move + no MediaError). A not-playing verdict fails the
|
|
65
|
+
* verify. Presence of this object turns the check on. */
|
|
66
|
+
video?: VideoCheckOptions;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DiffResult {
|
|
70
|
+
diffRatio: number;
|
|
71
|
+
diffPixels: number;
|
|
72
|
+
totalPixels: number;
|
|
73
|
+
/** PNG bytes highlighting changed pixels, or null on a dimension mismatch. */
|
|
74
|
+
diffPng: Buffer | null;
|
|
75
|
+
/** True when baseline + current differ in size (an automatic fail). */
|
|
76
|
+
dimensionMismatch: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ConsoleEntry {
|
|
80
|
+
type: string;
|
|
81
|
+
text: string;
|
|
82
|
+
}
|
|
83
|
+
export interface NetworkEntry {
|
|
84
|
+
url: string;
|
|
85
|
+
status: number;
|
|
86
|
+
ok: boolean;
|
|
87
|
+
failure?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface VerifyResult {
|
|
91
|
+
status: LensStatus;
|
|
92
|
+
reason: string;
|
|
93
|
+
mode: CaptureMode;
|
|
94
|
+
selector: string | null;
|
|
95
|
+
url: string;
|
|
96
|
+
finalUrl: string | null;
|
|
97
|
+
title: string | null;
|
|
98
|
+
screenshot: Buffer | null;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
elementBox: { x: number; y: number; width: number; height: number } | null;
|
|
102
|
+
diff: DiffResult | null;
|
|
103
|
+
assertResult: { pass: boolean; detail: unknown } | null;
|
|
104
|
+
domHash: string | null;
|
|
105
|
+
console: ConsoleEntry[];
|
|
106
|
+
network: NetworkEntry[];
|
|
107
|
+
pageErrors: string[];
|
|
108
|
+
preflight: PreflightResult | null;
|
|
109
|
+
/** F074.69 — the extension this run loaded and the id Chromium generated for
|
|
110
|
+
* it, so a caller can cite the exact surface photographed. Null otherwise. */
|
|
111
|
+
extension?: { id: string; path: string } | null;
|
|
112
|
+
durationMs: number;
|
|
113
|
+
/** F126.4 — composition-critic findings, when a critic was requested. A
|
|
114
|
+
* high-severity finding folds the status to `fail` (it blocks Review→Done). */
|
|
115
|
+
critic: CriticReport | null;
|
|
116
|
+
/** Video-liveness result, when a `video` check was requested. A not-playing
|
|
117
|
+
* verdict folds the status to `fail`. */
|
|
118
|
+
video: VideoResult | null;
|
|
119
|
+
/** F074.30 — the build sha this capture was looking at, read at capture time.
|
|
120
|
+
* NULL when the target exposes no marker: "not measured", never "same build
|
|
121
|
+
* as the other run". */
|
|
122
|
+
build: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** sha256 over the target's normalized outerHTML — a cheap structural
|
|
126
|
+
* fingerprint that changes when the DOM changes but is stable across
|
|
127
|
+
* re-renders of identical markup. */
|
|
128
|
+
async function computeDomHash(page: Page, selector: string | null): Promise<string> {
|
|
129
|
+
const html = await page.evaluate((sel) => {
|
|
130
|
+
const node = sel ? document.querySelector(sel) : document.documentElement;
|
|
131
|
+
return node ? (node as HTMLElement).outerHTML : '';
|
|
132
|
+
}, selector);
|
|
133
|
+
const normalized = html.replace(/\s+/g, ' ').trim();
|
|
134
|
+
return createHash('sha256').update(normalized, 'utf-8').digest('hex');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Evaluate the assert body in the page; normalize boolean → {pass, detail}.
|
|
139
|
+
*
|
|
140
|
+
* F074.39 — this runs the SAME `evalAssertBody` the flow assert runs, because
|
|
141
|
+
* an assert contract that differs per surface is one nobody can hold in their
|
|
142
|
+
* head. It had diverged in a way that produced a false RED: verify used only
|
|
143
|
+
* the block wrapper, so a bare-expression assert (`document.title.length > 0` —
|
|
144
|
+
* exactly what the flow surface accepts, and what our own docs show) returned
|
|
145
|
+
* `undefined` here and failed a page that was fine. One evaluator, one contract:
|
|
146
|
+
* expression-or-block, `await` allowed, `{ pass, detail }` honoured.
|
|
147
|
+
*
|
|
148
|
+
* A broken PROBE stays distinguishable from a failing app: syntax + throw are
|
|
149
|
+
* re-thrown, so the run reports `error` with the reason rather than `fail` —
|
|
150
|
+
* "your predicate is false" and "your predicate never ran" are different
|
|
151
|
+
* sentences, and this surface has always been honest about that.
|
|
152
|
+
*/
|
|
153
|
+
export async function runAssert(page: Page, assertBody: string): Promise<{ pass: boolean; detail: unknown }> {
|
|
154
|
+
const out = await page.evaluate(evalAssertBody, assertBody);
|
|
155
|
+
if (out.kind === 'syntax') {
|
|
156
|
+
throw new Error(`assert body is not valid JavaScript (${out.message})`);
|
|
157
|
+
}
|
|
158
|
+
if (out.kind === 'threw') {
|
|
159
|
+
// No polling on this surface (verify is a single measurement), so a throw
|
|
160
|
+
// is terminal — and it is the INSTRUMENT that broke, never a verdict.
|
|
161
|
+
throw new Error(`assert body threw: ${out.message}`);
|
|
162
|
+
}
|
|
163
|
+
if (out.kind === 'no-verdict') {
|
|
164
|
+
// Same author error as on the flow path, and it throws here for the same
|
|
165
|
+
// reason a syntax error does: the instrument was handed something that
|
|
166
|
+
// cannot express a verdict, so reporting pass OR fail would be inventing
|
|
167
|
+
// one. Wording from the engine (0.6.1) so the two surfaces cannot drift.
|
|
168
|
+
throw new Error(noVerdictMessage(out.keys));
|
|
169
|
+
}
|
|
170
|
+
return { pass: out.value, detail: out.detail ?? null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** pixelmatch the current shot against the baseline. Dimension mismatch is an
|
|
174
|
+
* automatic fail (the surface changed size). */
|
|
175
|
+
function diffAgainstBaseline(current: Buffer, baseline: Buffer): DiffResult {
|
|
176
|
+
const a = PNG.sync.read(baseline);
|
|
177
|
+
const b = PNG.sync.read(current);
|
|
178
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
179
|
+
return {
|
|
180
|
+
diffRatio: 1,
|
|
181
|
+
diffPixels: a.width * a.height,
|
|
182
|
+
totalPixels: a.width * a.height,
|
|
183
|
+
diffPng: null,
|
|
184
|
+
dimensionMismatch: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const { width, height } = a;
|
|
188
|
+
const diff = new PNG({ width, height });
|
|
189
|
+
const diffPixels = pixelmatch(a.data, b.data, diff.data, width, height, {
|
|
190
|
+
threshold: 0.1, // per-pixel colour sensitivity (not the pass/fail ratio)
|
|
191
|
+
// F094.7 — detect + ignore anti-aliased pixels (the default, made explicit):
|
|
192
|
+
// text edges differ sub-pixel between captures and must not count as a diff.
|
|
193
|
+
includeAA: false,
|
|
194
|
+
});
|
|
195
|
+
const totalPixels = width * height;
|
|
196
|
+
return {
|
|
197
|
+
diffRatio: totalPixels === 0 ? 0 : diffPixels / totalPixels,
|
|
198
|
+
diffPixels,
|
|
199
|
+
totalPixels,
|
|
200
|
+
diffPng: PNG.sync.write(diff),
|
|
201
|
+
dimensionMismatch: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** F074.92 — A SELECTOR WITHOUT A MODE USED TO PHOTOGRAPH THE WHOLE PAGE.
|
|
206
|
+
*
|
|
207
|
+
* `mode` defaulted to 'viewport' unconditionally, and takeShot() reads the
|
|
208
|
+
* selector ONLY in 'element' mode — so a caller who passed `selector` and no
|
|
209
|
+
* `mode` got a full-page shot. The response gave no sign of it: the selector
|
|
210
|
+
* came back echoed, `surface_key` was set to it, and `dom_hash` was computed
|
|
211
|
+
* from that very element. Four fields agreed it was an element shot; only the
|
|
212
|
+
* picture disagreed. Reported by broberg-id, who measured two shots of the same
|
|
213
|
+
* size and concluded, correctly, that the element had been ignored.
|
|
214
|
+
*
|
|
215
|
+
* Nobody passes a selector meaning "and ignore it", so the mode is inferred.
|
|
216
|
+
* That is also what our OWN manifest meant: its `landing-hero` surface carries
|
|
217
|
+
* a selector and no mode, and has been storing a full-page baseline under an
|
|
218
|
+
* element's name ever since it was written. Expect that one baseline to diff
|
|
219
|
+
* on its next run — the new shot is right and the stored one never was.
|
|
220
|
+
*
|
|
221
|
+
* An explicit mode ALWAYS wins, so a surface that deliberately pairs a selector
|
|
222
|
+
* with viewport (to key a baseline by name) is untouched. And the inference is
|
|
223
|
+
* visible rather than magic: `mode` is echoed in the result. */
|
|
224
|
+
export function resolveCaptureMode(opts: { mode?: CaptureMode; selector?: string | null }): CaptureMode {
|
|
225
|
+
return opts.mode ?? (opts.selector ? 'element' : 'viewport');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function verify(opts: VerifyOptions): Promise<VerifyResult> {
|
|
229
|
+
const startedAt = Date.now();
|
|
230
|
+
const mode: CaptureMode = resolveCaptureMode(opts);
|
|
231
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
232
|
+
const threshold = opts.diffThreshold ?? 0.01;
|
|
233
|
+
const collectConsole = opts.collectConsole ?? true;
|
|
234
|
+
const collectNetwork = opts.collectNetwork ?? true;
|
|
235
|
+
|
|
236
|
+
// F074.30 — stamped HERE, at capture, not read back later: asking after the
|
|
237
|
+
// fact returns today's build for an OLD recording. Best-effort: null when the
|
|
238
|
+
// target has no marker, and never able to fail a capture that would pass.
|
|
239
|
+
// F074.69 — an extension:// url is not reachable over the network and has no
|
|
240
|
+
// build marker. Probing it would fail for a reason that has nothing to do with
|
|
241
|
+
// the surface, and the failure would read as "the extension is broken".
|
|
242
|
+
const isExtension = !!opts.extension || opts.url.startsWith('extension://');
|
|
243
|
+
const build = isExtension ? null : await observeBuild(opts.url);
|
|
244
|
+
|
|
245
|
+
const empty = {
|
|
246
|
+
mode,
|
|
247
|
+
selector: opts.selector ?? null,
|
|
248
|
+
url: opts.url,
|
|
249
|
+
build,
|
|
250
|
+
finalUrl: null,
|
|
251
|
+
title: null,
|
|
252
|
+
screenshot: null,
|
|
253
|
+
width: 0,
|
|
254
|
+
height: 0,
|
|
255
|
+
elementBox: null,
|
|
256
|
+
diff: null,
|
|
257
|
+
assertResult: null,
|
|
258
|
+
domHash: null,
|
|
259
|
+
console: [] as ConsoleEntry[],
|
|
260
|
+
network: [] as NetworkEntry[],
|
|
261
|
+
pageErrors: [] as string[],
|
|
262
|
+
critic: null as CriticReport | null,
|
|
263
|
+
video: null as VideoResult | null,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// 1. Pre-flight (unless the caller already proved reachability).
|
|
267
|
+
let pre: PreflightResult | null = null;
|
|
268
|
+
if (!opts.skipPreflight && !isExtension) {
|
|
269
|
+
pre = await preflight(opts.url, Math.min(timeoutMs, 5_000));
|
|
270
|
+
if (!pre.ok) {
|
|
271
|
+
return { ...empty, status: 'error', reason: pre.reason ?? 'pre-flight failed', preflight: pre, durationMs: Date.now() - startedAt };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const result = await withPage(
|
|
277
|
+
{
|
|
278
|
+
engine: opts.engine,
|
|
279
|
+
locale: opts.locale,
|
|
280
|
+
timezone: opts.timezone,
|
|
281
|
+
reducedMotion: opts.reducedMotion,
|
|
282
|
+
viewport: opts.viewport,
|
|
283
|
+
deviceScaleFactor: opts.deviceScaleFactor,
|
|
284
|
+
prepareContext: resolvePrepareContext(opts),
|
|
285
|
+
// F074.97 — seeded state reaches lens_verify too: the tool that GATES a
|
|
286
|
+
// card is the one that most needs to photograph a saved-state bug.
|
|
287
|
+
seedStorage: storagePayload(opts.storage, opts.url),
|
|
288
|
+
extension: opts.extension,
|
|
289
|
+
},
|
|
290
|
+
async (page, _context, extension) => {
|
|
291
|
+
const pageErrors: string[] = [];
|
|
292
|
+
const consoleEntries: ConsoleEntry[] = [];
|
|
293
|
+
const network: NetworkEntry[] = [];
|
|
294
|
+
page.on('pageerror', (err) => pageErrors.push(err.message));
|
|
295
|
+
if (collectConsole) {
|
|
296
|
+
page.on('console', (msg) => consoleEntries.push({ type: msg.type(), text: msg.text() }));
|
|
297
|
+
}
|
|
298
|
+
if (collectNetwork) {
|
|
299
|
+
page.on('response', (res) => {
|
|
300
|
+
if (!res.ok()) network.push({ url: res.url(), status: res.status(), ok: false });
|
|
301
|
+
});
|
|
302
|
+
page.on('requestfailed', (req) => {
|
|
303
|
+
network.push({ url: req.url(), status: 0, ok: false, failure: req.failure()?.errorText ?? 'failed' });
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// F074.69 — substitute the generated extension id only now that the
|
|
308
|
+
// browser has reported it. A caller cannot know it in advance, so
|
|
309
|
+
// `extension://popup.html` is the shorthand they write instead.
|
|
310
|
+
await navigateAndAct(page, { ...opts, url: resolveExtensionUrl(opts.url, extension?.id) });
|
|
311
|
+
const title = await page.title();
|
|
312
|
+
const finalUrl = page.url();
|
|
313
|
+
const shot = await takeShot(page, mode, opts.selector ?? null, timeoutMs);
|
|
314
|
+
const domHash = await computeDomHash(page, opts.selector ?? null);
|
|
315
|
+
const assertResult = opts.assert ? await runAssert(page, opts.assert) : null;
|
|
316
|
+
const critic =
|
|
317
|
+
opts.critic && opts.critic !== 'off'
|
|
318
|
+
? await runCritic(page, shot.screenshot, opts.critic, opts.criticVisionModel)
|
|
319
|
+
: null;
|
|
320
|
+
const video = opts.video ? await checkVideo(page, opts.video) : null;
|
|
321
|
+
|
|
322
|
+
return { title, finalUrl, shot, domHash, assertResult, pageErrors, console: consoleEntries, network, critic, video, extension: extension ?? null };
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// 3. Diff against baseline (pure, post-session).
|
|
327
|
+
let diff: DiffResult | null = null;
|
|
328
|
+
if (opts.baselineKey && opts.baseline) {
|
|
329
|
+
diff = diffAgainstBaseline(result.shot.screenshot, opts.baseline);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 4. Prioritised status logic.
|
|
333
|
+
let status: LensStatus;
|
|
334
|
+
let reason: string;
|
|
335
|
+
if (opts.assert && result.assertResult && !result.assertResult.pass) {
|
|
336
|
+
status = 'fail';
|
|
337
|
+
reason = `assertion failed: ${JSON.stringify(result.assertResult.detail)}`;
|
|
338
|
+
} else if (opts.baselineKey && opts.baseline && diff) {
|
|
339
|
+
if (diff.dimensionMismatch) {
|
|
340
|
+
status = 'fail';
|
|
341
|
+
reason = 'baseline dimension mismatch — surface changed size';
|
|
342
|
+
} else if (diff.diffRatio < threshold) {
|
|
343
|
+
status = 'pass';
|
|
344
|
+
reason = `diff ${(diff.diffRatio * 100).toFixed(3)}% < ${(threshold * 100).toFixed(2)}% threshold`;
|
|
345
|
+
} else {
|
|
346
|
+
status = 'fail';
|
|
347
|
+
reason = `diff ${(diff.diffRatio * 100).toFixed(3)}% ≥ ${(threshold * 100).toFixed(2)}% threshold`;
|
|
348
|
+
}
|
|
349
|
+
} else if (opts.baselineKey && !opts.baseline) {
|
|
350
|
+
status = 'no-baseline';
|
|
351
|
+
reason = `no baseline for "${opts.baselineKey}" — shot is promotable`;
|
|
352
|
+
} else {
|
|
353
|
+
const ok = result.pageErrors.length === 0;
|
|
354
|
+
status = ok ? 'pass' : 'fail';
|
|
355
|
+
reason = ok ? 'page loaded with no uncaught error' : `uncaught page error(s): ${result.pageErrors.join('; ')}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// F126.4 — a high-severity composition finding folds an otherwise-passing
|
|
359
|
+
// (or promotable) surface to `fail`: the screen is visibly broken
|
|
360
|
+
// (something overlapping / covered / clipped / off-screen) even though its
|
|
361
|
+
// pixels/assert passed. This is what makes the critic BLOCK Review→Done. An
|
|
362
|
+
// existing assert/baseline/error fail keeps its (more specific) reason.
|
|
363
|
+
if (result.critic && result.critic.high > 0 && status !== 'fail') {
|
|
364
|
+
status = 'fail';
|
|
365
|
+
const top = topFindingMessage(result.critic);
|
|
366
|
+
reason = `composition: ${result.critic.high} high-severity issue(s)${top ? ` — ${top}` : ''}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Video-liveness: if a video check was requested and the video isn't
|
|
370
|
+
// playing, that's a definitive fail (it's the point of the check). Keep an
|
|
371
|
+
// earlier assert/baseline fail's more-specific reason.
|
|
372
|
+
if (result.video && !result.video.playing && status !== 'fail') {
|
|
373
|
+
status = 'fail';
|
|
374
|
+
reason = `video: ${result.video.reason}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
status,
|
|
379
|
+
reason,
|
|
380
|
+
mode,
|
|
381
|
+
selector: opts.selector ?? null,
|
|
382
|
+
url: opts.url,
|
|
383
|
+
build,
|
|
384
|
+
finalUrl: result.finalUrl,
|
|
385
|
+
title: result.title,
|
|
386
|
+
screenshot: result.shot.screenshot,
|
|
387
|
+
width: result.shot.width,
|
|
388
|
+
height: result.shot.height,
|
|
389
|
+
elementBox: result.shot.elementBox,
|
|
390
|
+
diff,
|
|
391
|
+
assertResult: result.assertResult,
|
|
392
|
+
domHash: result.domHash,
|
|
393
|
+
console: result.console,
|
|
394
|
+
network: result.network,
|
|
395
|
+
pageErrors: result.pageErrors,
|
|
396
|
+
preflight: pre,
|
|
397
|
+
durationMs: Date.now() - startedAt,
|
|
398
|
+
critic: result.critic,
|
|
399
|
+
video: result.video,
|
|
400
|
+
extension: result.extension,
|
|
401
|
+
};
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return {
|
|
404
|
+
...empty,
|
|
405
|
+
status: 'error',
|
|
406
|
+
reason: `capture/verify threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
407
|
+
preflight: pre,
|
|
408
|
+
durationMs: Date.now() - startedAt,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|