@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
package/src/service.ts
ADDED
|
@@ -0,0 +1,1493 @@
|
|
|
1
|
+
// F074.5 — Lens service layer.
|
|
2
|
+
//
|
|
3
|
+
// The browser-owning operations, as pure functions shared by BOTH the HTTP
|
|
4
|
+
// router (http.ts) and the MCP server (mcp.ts). Each is hard-scoped to a
|
|
5
|
+
// project_slug — a project only ever touches its own runs/baselines.
|
|
6
|
+
//
|
|
7
|
+
// Artifact bytes are served back by the daemon itself (artifactUrl); the SPA
|
|
8
|
+
// dashboard + cc point <img>/links at the daemon, the same way the rest of
|
|
9
|
+
// the SPA already talks to 127.0.0.1:7475.
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import sharp from 'sharp';
|
|
13
|
+
import { durabilityOf } from './durability.ts';
|
|
14
|
+
import type { Database } from 'bun:sqlite';
|
|
15
|
+
import type { CaptureAction, CaptureMode } from './capture.ts';
|
|
16
|
+
import { coerceStorageSeed } from './seed-storage.ts';
|
|
17
|
+
import type { LensEngine } from './browser.ts';
|
|
18
|
+
import { verify as runVerify, type LensStatus } from './verify.ts';
|
|
19
|
+
import type { CriticMode } from './critic.ts';
|
|
20
|
+
import type { VisionModelKey } from './critic-vision.ts';
|
|
21
|
+
import type { VideoCheckOptions } from './video.ts';
|
|
22
|
+
import type { AuthConfig, AuthEnv } from './auth/index.ts';
|
|
23
|
+
import {
|
|
24
|
+
recordRun,
|
|
25
|
+
listRuns,
|
|
26
|
+
getRun,
|
|
27
|
+
listBaselines,
|
|
28
|
+
getBaselineBytes,
|
|
29
|
+
promoteBaseline,
|
|
30
|
+
deleteBaseline,
|
|
31
|
+
listBaselineVersions,
|
|
32
|
+
revertBaseline,
|
|
33
|
+
} from './store.ts';
|
|
34
|
+
import { runFlow, parseFlow, compileManuscript, type FlowResult, type FlowStepResult } from './flow.ts';
|
|
35
|
+
// F074.24 — the daemon HOSTS the engine's own runFlow for flat cloud-shape
|
|
36
|
+
// FlowRequests (one wire-shape, driven via @broberg/lens-client). The engine
|
|
37
|
+
// natively speaks the cloud grammar (all 12 action-verbs), self-heals via the
|
|
38
|
+
// same resolveTarget the daemon uses (F074.23), and does the F049 base_url nav —
|
|
39
|
+
// so there is ZERO grammar translation. It owns its own Chromium + takes a
|
|
40
|
+
// storageState directly, so nothing crosses the playwright version boundary.
|
|
41
|
+
import { runFlow as engineRunFlow, flowBodySchema, type FlowResult as EngineFlowResult } from '@broberg/lens-engine';
|
|
42
|
+
import { authProvenance, resolveAuthPaths, resolveLocale, manifestLocale } from './manifest.ts';
|
|
43
|
+
import { recordFlowRun, listFlowRuns, type StoredFlowStep } from './flow-store.ts';
|
|
44
|
+
import { artifactKeys, type LensStorage } from './storage.ts';
|
|
45
|
+
import { exportPage, looksLikePdf, readMediaBox, ptToMm, type ExportMedia } from './export.ts';
|
|
46
|
+
import { pdfContentStats, isVectorText, matchPhrase, outlineFontNames } from './pdf-text.ts';
|
|
47
|
+
import type { LensRunRow, LensBaselineRow, LensFlowRunRow } from './schema.ts';
|
|
48
|
+
import { runRecord } from './record-run.ts';
|
|
49
|
+
|
|
50
|
+
export interface LensDeps {
|
|
51
|
+
db: Database;
|
|
52
|
+
storage: LensStorage;
|
|
53
|
+
port: number;
|
|
54
|
+
/** Sandbox roots — used to resolve a flow/manuscript's relative auth statePath
|
|
55
|
+
* when local_path is passed. NOTE: inheriting a project's manifest auth is
|
|
56
|
+
* the FLOW/MANUSCRIPT path only (effectiveFlowAuth in manifest.ts); this
|
|
57
|
+
* comment used to claim it happened here too, which measurably it does not
|
|
58
|
+
* — see resolveVerifyAuth below, which returns early when no auth is given. */
|
|
59
|
+
sandboxRoots: string[];
|
|
60
|
+
/** F272.2 — a getter, not the client itself: the daemon's cloud client is
|
|
61
|
+
* re-made on login and nulled on logout, so capturing the value at wiring time
|
|
62
|
+
* would hand this a client that is permanently stale (or permanently null on a
|
|
63
|
+
* daemon that started signed-out and was signed in afterwards). */
|
|
64
|
+
cloud?: () => CloudClientLike | null;
|
|
65
|
+
/** lens F001.5 — the daemon's own sandbox check, injected (see sandbox.ts). The
|
|
66
|
+
* package holds no copy of this security boundary; createLensRouter installs it. */
|
|
67
|
+
resolveSandboxedPath?: (path: string, sandboxRoots: string[]) => string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The one method promoteFlowEvidence needs. Structural on purpose so a test can
|
|
71
|
+
* pass a stub without dragging the daemon's real client (and its fetch) in. */
|
|
72
|
+
export interface CloudClientLike {
|
|
73
|
+
callTool<T = unknown>(name: string, args?: Record<string, unknown>): Promise<T>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** F312.25 — `lens_record` through the MCP door.
|
|
77
|
+
*
|
|
78
|
+
* Christian: «byg lens_record MCP-verbet». Before this, recording existed ONLY
|
|
79
|
+
* as POST /lens/record on the daemon, so no other repo could use it — and F112
|
|
80
|
+
* forbids them hand-rolling a call to :7475. Two repos hit that wall within one
|
|
81
|
+
* hour on 6 September (this one, and fd-sundhed's F006.385).
|
|
82
|
+
*
|
|
83
|
+
* IT DELEGATES. runRecord() is the same function the HTTP route calls; there is
|
|
84
|
+
* no second handler here that "does the same thing". F095.15 is the precedent —
|
|
85
|
+
* a rule present through one door and absent through the other looked correct
|
|
86
|
+
* from either side, and a peer repo found it.
|
|
87
|
+
*/
|
|
88
|
+
export async function lensRecord(deps: LensDeps, args: Record<string, unknown>) {
|
|
89
|
+
// NAMED before a browser is launched. An empty `seconds` that reaches the
|
|
90
|
+
// recorder becomes a 0-second run that reports zero findings — indistinguishable
|
|
91
|
+
// from a clean result, which is the confusion this whole feature removes.
|
|
92
|
+
const missing = (['url', 'seconds'] as const).filter((k) => args[k] == null);
|
|
93
|
+
if (missing.length)
|
|
94
|
+
return {
|
|
95
|
+
ok: false as const,
|
|
96
|
+
error: `lens_record needs ${missing.join(' and ')}. \`url\` is the surface to watch and \`seconds\` is how long — a recording with no duration cannot say whether nothing happened or nothing was watched.`,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const { status: _status, ...rest } = await runRecord(args, {
|
|
100
|
+
config: { port: deps.port, sandbox_roots: deps.sandboxRoots },
|
|
101
|
+
storage: deps.storage,
|
|
102
|
+
});
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function artifactUrl(port: number, key: string | null): string | null {
|
|
107
|
+
if (!key) return null;
|
|
108
|
+
return `http://127.0.0.1:${port}/lens/artifact?key=${encodeURIComponent(key)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface SurfaceInput {
|
|
112
|
+
url?: string;
|
|
113
|
+
mode?: CaptureMode;
|
|
114
|
+
selector?: string;
|
|
115
|
+
selector_index?: number;
|
|
116
|
+
/** {width,height} OR a device preset (`device`). A string is tolerated (some
|
|
117
|
+
* MCP harnesses stringify object params). See coerceViewport. */
|
|
118
|
+
viewport?: { width: number; height: number } | string;
|
|
119
|
+
device?: string;
|
|
120
|
+
/** F252.1 — deviceScaleFactor for retina / 2×–3× captures. Default 1. */
|
|
121
|
+
scale?: number;
|
|
122
|
+
/** F098.9 — engine to drive (chromium default; webkit/firefox for cross-engine bugs). */
|
|
123
|
+
engine?: LensEngine;
|
|
124
|
+
/** F074.57 — BCP-47 locale + IANA timezone. Wins over the project's
|
|
125
|
+
* lens.manifest.json; unset falls back to the manifest, then to nothing. */
|
|
126
|
+
locale?: string;
|
|
127
|
+
timezone?: string;
|
|
128
|
+
/** F074.59 — what this run reports for `prefers-reduced-motion`. Default
|
|
129
|
+
* 'reduce' (unchanged from every run before the option existed); pass
|
|
130
|
+
* 'no-preference' to let the run SEE motion. Never inherited from the
|
|
131
|
+
* manifest: whether a run may animate is a property of the QUESTION being
|
|
132
|
+
* asked, not of the project, and inheriting it would silently move baselines
|
|
133
|
+
* for every capture in a repo that set it once. */
|
|
134
|
+
reduced_motion?: 'reduce' | 'no-preference';
|
|
135
|
+
/** F098.6 — navigation settle strategy (default 'domcontentloaded'). */
|
|
136
|
+
waitUntil?: 'load' | 'domcontentloaded' | 'commit';
|
|
137
|
+
waitFor?: string | number;
|
|
138
|
+
actions?: CaptureAction[];
|
|
139
|
+
auth?: AuthConfig;
|
|
140
|
+
/** F074.97 — {local, session} written BEFORE the first navigation, scoped to
|
|
141
|
+
* this url's origin. Lets a saved-client-state bug be PHOTOGRAPHED instead of
|
|
142
|
+
* only unit-fixtured. Omitted registers no init script, so no baseline moves. */
|
|
143
|
+
storage?: unknown;
|
|
144
|
+
surface_key?: string | null;
|
|
145
|
+
timeout_ms?: number;
|
|
146
|
+
/** F126.4 — composition critic: 'off' (default) | 'dom' | 'vision' | 'both'. */
|
|
147
|
+
critic?: CriticMode;
|
|
148
|
+
/** F126.4 — vision model for the critic's vision pass ('haiku' | 'sonnet'). */
|
|
149
|
+
critic_vision_model?: VisionModelKey;
|
|
150
|
+
/** F074.69 — load an UNPACKED extension build directory and capture its own
|
|
151
|
+
* pages. Write the url as `extension://popup.html`; Lens substitutes the id
|
|
152
|
+
* Chromium generates, because the caller cannot know it in advance. */
|
|
153
|
+
extension?: { path: string };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CaptureInput extends SurfaceInput {
|
|
157
|
+
project: string;
|
|
158
|
+
url: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface VerifyInput extends CaptureInput {
|
|
162
|
+
assert?: string;
|
|
163
|
+
baseline_key?: string;
|
|
164
|
+
diff_threshold?: number;
|
|
165
|
+
/** F089.2 — smoke-only: skip the baseline diff entirely (render + assert). */
|
|
166
|
+
no_diff?: boolean;
|
|
167
|
+
/** Video-liveness: drive a <video> + assert it plays. Not-playing → fail. */
|
|
168
|
+
video?: VideoCheckOptions;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requireProject(project: unknown): string {
|
|
172
|
+
if (typeof project !== 'string' || !project.trim()) {
|
|
173
|
+
throw new Error('project (slug) is required and scopes all runs/baselines');
|
|
174
|
+
}
|
|
175
|
+
return project.trim();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runView(run: LensRunRow, port: number) {
|
|
179
|
+
return {
|
|
180
|
+
run_id: run.id,
|
|
181
|
+
project: run.project_slug,
|
|
182
|
+
surface_key: run.surface_key,
|
|
183
|
+
url: run.url,
|
|
184
|
+
mode: run.mode,
|
|
185
|
+
selector: run.selector,
|
|
186
|
+
status: run.status,
|
|
187
|
+
reason: run.reason,
|
|
188
|
+
diff_ratio: run.diff_ratio,
|
|
189
|
+
dom_hash: run.dom_hash,
|
|
190
|
+
assert_pass: run.assert_pass === null ? null : run.assert_pass === 1,
|
|
191
|
+
width: run.width,
|
|
192
|
+
height: run.height,
|
|
193
|
+
duration_ms: run.duration_ms,
|
|
194
|
+
created_at: run.created_at,
|
|
195
|
+
// F074.30 — which build this capture measured; null = not measured.
|
|
196
|
+
build: run.build ?? null,
|
|
197
|
+
screenshot_url: artifactUrl(port, run.artifact_key),
|
|
198
|
+
diff_url: artifactUrl(port, run.diff_artifact_key),
|
|
199
|
+
// F126.4 — composition-critic findings persisted with the run (history).
|
|
200
|
+
critic: run.critic_json ? safeParse(run.critic_json) : null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function safeParse(json: string): unknown {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(json);
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Lens-mobile — resolve a viewport from a {width,height} object, a device
|
|
213
|
+
// preset, or a STRINGIFIED object. The MCP harness stringifies nested object
|
|
214
|
+
// params that aren't declared in the tool schema → the daemon used to forward
|
|
215
|
+
// that string to Playwright newContext ("expected object, got string" → hang).
|
|
216
|
+
// Declaring `viewport` in the schema (mcp.ts) fixes the root; this coerce is the
|
|
217
|
+
// belt-and-suspenders + the `device` preset convenience.
|
|
218
|
+
const DEVICE_PRESETS: Record<string, { width: number; height: number }> = {
|
|
219
|
+
'iphone-15': { width: 393, height: 852 },
|
|
220
|
+
'iphone-13': { width: 390, height: 664 }, // F098.9 — sa's F024 webkit repro device
|
|
221
|
+
'iphone-15-pro-max': { width: 430, height: 932 },
|
|
222
|
+
'iphone-se': { width: 375, height: 667 },
|
|
223
|
+
'pixel-8': { width: 412, height: 915 },
|
|
224
|
+
ipad: { width: 820, height: 1180 },
|
|
225
|
+
mobile: { width: 390, height: 844 },
|
|
226
|
+
tablet: { width: 820, height: 1180 },
|
|
227
|
+
desktop: { width: 1280, height: 800 },
|
|
228
|
+
};
|
|
229
|
+
export function coerceViewport(
|
|
230
|
+
viewport?: { width: number; height: number } | string,
|
|
231
|
+
device?: string,
|
|
232
|
+
): { width: number; height: number } | undefined {
|
|
233
|
+
// F098.4 — AN UNRESOLVABLE VIEWPORT IS AN ERROR, NOT A DESKTOP CAPTURE.
|
|
234
|
+
//
|
|
235
|
+
// Every branch below used to `return undefined` on bad input, and undefined
|
|
236
|
+
// means "use Playwright's default" — 1280×800. So asking for mobile with a
|
|
237
|
+
// typo'd device name or a viewport string that isn't JSON produced a DESKTOP
|
|
238
|
+
// screenshot with status "pass". Measured 20 Sep 2026: viewport:"ikke-json"
|
|
239
|
+
// → width 1280, height 800, pass. You get an answer about the wrong screen
|
|
240
|
+
// and nothing in it says so — the exact shape this card's original bug had,
|
|
241
|
+
// one branch further along.
|
|
242
|
+
//
|
|
243
|
+
// Asking for nothing is still fine: no viewport and no device → undefined →
|
|
244
|
+
// the default, deliberately. Only a viewport that was REQUESTED and could not
|
|
245
|
+
// be honoured throws.
|
|
246
|
+
if (device) {
|
|
247
|
+
const preset = DEVICE_PRESETS[device.toLowerCase()];
|
|
248
|
+
if (preset) return preset;
|
|
249
|
+
throw new Error(
|
|
250
|
+
`unknown device preset "${device}" — known: ${Object.keys(DEVICE_PRESETS).join(', ')}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (viewport === undefined || viewport === null) return undefined;
|
|
254
|
+
let v: unknown = viewport;
|
|
255
|
+
if (typeof v === 'string') {
|
|
256
|
+
try {
|
|
257
|
+
v = JSON.parse(v);
|
|
258
|
+
} catch {
|
|
259
|
+
throw new Error(`viewport is not valid JSON: ${JSON.stringify(viewport)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (v && typeof v === 'object') {
|
|
263
|
+
const w = (v as { width?: unknown }).width;
|
|
264
|
+
const h = (v as { height?: unknown }).height;
|
|
265
|
+
if (typeof w === 'number' && typeof h === 'number') return { width: w, height: h };
|
|
266
|
+
}
|
|
267
|
+
throw new Error(`viewport must be {width:number,height:number} — got ${JSON.stringify(viewport)}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** F074.22 — resolve a bare capture/verify's relative auth secretPath/statePath
|
|
271
|
+
* against the CALLING PROJECT's registered local_path (the flow/manuscript path
|
|
272
|
+
* already does this via effectiveFlowAuth). Without it a relative path is read
|
|
273
|
+
* against the daemon's cwd — i.e. cardmem's own repo — so a consumer's relative
|
|
274
|
+
* `.lens-mint-secret` silently reads cardmem's credential and 401s. Unregistered
|
|
275
|
+
* project or an unresolvable path → left as-is (an absolute path still works).
|
|
276
|
+
*
|
|
277
|
+
* IT DOES NOT INHERIT. `if (!auth) return auth` is deliberate: a capture with no
|
|
278
|
+
* auth stays unauthenticated. Reported by xrt81 (2026-08-12) after a capture
|
|
279
|
+
* passed only local_path, landed on the sign-in wall and timed out after 30s
|
|
280
|
+
* while the same call as a manuscript was green — the tool description implied
|
|
281
|
+
* inheritance applied to both. The description now says which is which.
|
|
282
|
+
*
|
|
283
|
+
* Whether capture SHOULD inherit is a real question and deliberately not
|
|
284
|
+
* answered here: auto-inheriting would change what every existing capture
|
|
285
|
+
* photographs, and a manifest can carry `auth: null` per surface precisely
|
|
286
|
+
* because an authenticated visitor gets redirected away from a public landing
|
|
287
|
+
* page. Opt-in, if ever. */
|
|
288
|
+
/** F074.57 — the project's registered repo root, so a capture inherits its
|
|
289
|
+
* project's locale WITHOUT the caller passing local_path. Deliberately unlike
|
|
290
|
+
* auth, and the asymmetry is the point: inheriting a credential changes WHO the
|
|
291
|
+
* browser is and can silently photograph a login wall, so it stayed opt-in.
|
|
292
|
+
* Inheriting a locale changes only how the SAME page is formatted, which is
|
|
293
|
+
* exactly what "per project" has to mean for Christian's case — otherwise every
|
|
294
|
+
* call site has to remember, and a rule you must remember is not a rule. */
|
|
295
|
+
function projectLocale(deps: LensDeps, project: string): { locale?: string; timezone?: string } | null {
|
|
296
|
+
const row = deps.db
|
|
297
|
+
.query('SELECT local_path FROM registered_projects WHERE slug = ?')
|
|
298
|
+
.get(project) as { local_path: string } | undefined;
|
|
299
|
+
return row?.local_path ? manifestLocale(row.local_path, deps.sandboxRoots) : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function resolveVerifyAuth(
|
|
303
|
+
deps: LensDeps,
|
|
304
|
+
project: string,
|
|
305
|
+
auth: AuthConfig | undefined,
|
|
306
|
+
): AuthConfig | undefined {
|
|
307
|
+
if (!auth) return auth;
|
|
308
|
+
const row = deps.db
|
|
309
|
+
.query('SELECT local_path FROM registered_projects WHERE slug = ?')
|
|
310
|
+
.get(project) as { local_path: string } | undefined;
|
|
311
|
+
if (!row?.local_path) {
|
|
312
|
+
// F009.22 — a RELATIVE credential path with no registered project would be
|
|
313
|
+
// resolved against the daemon's own cwd, i.e. read another repo's secret
|
|
314
|
+
// and send it to whatever url the caller named. Refuse instead.
|
|
315
|
+
if (resolveAuthPaths(auth, '/') !== auth) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`project "${project}" is not registered, so a relative ${auth.adapter === 'storageState' ? 'statePath' : 'secretPath'} cannot be resolved — register the project or pass an absolute path`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return auth;
|
|
321
|
+
}
|
|
322
|
+
return (resolveAuthPaths(auth, row.local_path) as AuthConfig | undefined) ?? auth;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** F074.39 — lens_capture takes no `assert` and never ran one, but accepted the
|
|
326
|
+
* parameter and answered `assert_pass: null` on every call, including a body
|
|
327
|
+
* that could not possibly pass (beacon, idea `019fdcae`). A caller reading
|
|
328
|
+
* "status: pass" had no way to know their assertion had simply been dropped.
|
|
329
|
+
*
|
|
330
|
+
* So it REFUSES rather than ignores, and the message names the tool that does
|
|
331
|
+
* run asserts. Same family as the `project_id` find of 2026-08-07: a parameter
|
|
332
|
+
* the tool does not implement is silently accepted and the call succeeds. */
|
|
333
|
+
export function rejectUnsupportedAssert(input: unknown, tool: string): void {
|
|
334
|
+
if (input && typeof input === 'object' && (input as { assert?: unknown }).assert !== undefined) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`${tool} does not run asserts — it only screenshots, and silently ignoring one would report a pass for an assertion that never ran. Use lens_verify({ assert }) instead.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** lens_capture — screenshot a surface, record the run, return the shot URL. */
|
|
342
|
+
export async function lensCapture(deps: LensDeps, input: CaptureInput) {
|
|
343
|
+
const project = requireProject(input.project);
|
|
344
|
+
rejectUnsupportedAssert(input, 'lens_capture');
|
|
345
|
+
const result = await runVerify({
|
|
346
|
+
url: input.url,
|
|
347
|
+
mode: input.mode,
|
|
348
|
+
selector: input.selector,
|
|
349
|
+
engine: input.engine,
|
|
350
|
+
...resolveLocale(input, projectLocale(deps, project)),
|
|
351
|
+
reducedMotion: input.reduced_motion,
|
|
352
|
+
viewport: coerceViewport(input.viewport, input.device),
|
|
353
|
+
// F252.1 — some MCP harnesses stringify number params ("2"), and Playwright's
|
|
354
|
+
// newContext rejects a string deviceScaleFactor. Coerce; garbage → default 1.
|
|
355
|
+
deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
|
|
356
|
+
waitUntil: input.waitUntil,
|
|
357
|
+
waitFor: input.waitFor,
|
|
358
|
+
actions: input.actions,
|
|
359
|
+
auth: resolveVerifyAuth(deps, project, input.auth),
|
|
360
|
+
timeoutMs: input.timeout_ms,
|
|
361
|
+
critic: input.critic,
|
|
362
|
+
criticVisionModel: input.critic_vision_model,
|
|
363
|
+
storage: coerceStorageSeed(input.storage),
|
|
364
|
+
extension: input.extension,
|
|
365
|
+
// capture-only: no baseline, no assert → status is page-loaded-ok
|
|
366
|
+
});
|
|
367
|
+
const run = await recordRun(deps.db, deps.storage, result, {
|
|
368
|
+
project_slug: project,
|
|
369
|
+
surface_key: input.surface_key ?? null,
|
|
370
|
+
});
|
|
371
|
+
// F074.69 — the extension id is returned so the caller can cite WHICH surface
|
|
372
|
+
// was photographed. It is generated at load time, so without this the run says
|
|
373
|
+
// only "an extension" and a reader cannot tell one build from another.
|
|
374
|
+
return { ...runView(run, deps.port), critic: result.critic, extension: result.extension ?? null };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** lens_verify — capture + (optional) baseline diff + (optional) assert, with
|
|
378
|
+
* the prioritised status logic. Baseline is auto-loaded from the store by
|
|
379
|
+
* baseline_key when present. */
|
|
380
|
+
export async function lensVerify(deps: LensDeps, input: VerifyInput) {
|
|
381
|
+
const project = requireProject(input.project);
|
|
382
|
+
// upmetrics #3 — auto-diff when a baseline EXISTS for the surface, even if the
|
|
383
|
+
// manifest surface didn't repeat baseline_key. lens_approve_baseline stores
|
|
384
|
+
// under the run's surface_key, so fall back to surface_key: approving a shot
|
|
385
|
+
// now activates the diff on the next gate with no manifest edit.
|
|
386
|
+
// F089.2 — a smoke surface NEVER diffs, even if a baseline exists (live-data
|
|
387
|
+
// dashboards drift to a false red otherwise). Render + anchor + assert only.
|
|
388
|
+
const effectiveBaselineKey = input.no_diff ? null : (input.baseline_key ?? input.surface_key ?? null);
|
|
389
|
+
let baseline: Buffer | null = null;
|
|
390
|
+
if (effectiveBaselineKey) {
|
|
391
|
+
baseline = await getBaselineBytes(deps.db, deps.storage, project, effectiveBaselineKey);
|
|
392
|
+
}
|
|
393
|
+
const result = await runVerify({
|
|
394
|
+
url: input.url,
|
|
395
|
+
mode: input.mode,
|
|
396
|
+
selector: input.selector,
|
|
397
|
+
engine: input.engine,
|
|
398
|
+
...resolveLocale(input, projectLocale(deps, project)),
|
|
399
|
+
reducedMotion: input.reduced_motion,
|
|
400
|
+
viewport: coerceViewport(input.viewport, input.device),
|
|
401
|
+
// F255.10 — verify inherits `scale` from the capture schema; it was accepted and
|
|
402
|
+
// dropped here, so a scale:4 verify rendered 1×. Same coercion as lensCapture.
|
|
403
|
+
deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
|
|
404
|
+
waitUntil: input.waitUntil,
|
|
405
|
+
waitFor: input.waitFor,
|
|
406
|
+
actions: input.actions,
|
|
407
|
+
auth: resolveVerifyAuth(deps, project, input.auth),
|
|
408
|
+
timeoutMs: input.timeout_ms,
|
|
409
|
+
assert: input.assert,
|
|
410
|
+
// Only declare a baselineKey to the status logic when a baseline actually
|
|
411
|
+
// exists — else a surface with a key but no approved shot would read as a
|
|
412
|
+
// hard "no-baseline" fail instead of a smoke pass.
|
|
413
|
+
// no_diff → no baselineKey at all (pure smoke). Else only declare one when a
|
|
414
|
+
// baseline actually exists, so a key with no approved shot reads as a smoke
|
|
415
|
+
// pass, not a hard "no-baseline" fail.
|
|
416
|
+
baselineKey: input.no_diff ? undefined : baseline ? effectiveBaselineKey ?? undefined : input.baseline_key,
|
|
417
|
+
baseline,
|
|
418
|
+
diffThreshold: input.diff_threshold,
|
|
419
|
+
critic: input.critic,
|
|
420
|
+
criticVisionModel: input.critic_vision_model,
|
|
421
|
+
storage: coerceStorageSeed(input.storage),
|
|
422
|
+
video: input.video,
|
|
423
|
+
});
|
|
424
|
+
const run = await recordRun(deps.db, deps.storage, result, {
|
|
425
|
+
project_slug: project,
|
|
426
|
+
surface_key: input.surface_key ?? input.baseline_key ?? null,
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
...runView(run, deps.port),
|
|
430
|
+
assert_detail: result.assertResult?.detail ?? null,
|
|
431
|
+
console: result.console,
|
|
432
|
+
network: result.network,
|
|
433
|
+
page_errors: result.pageErrors,
|
|
434
|
+
preflight_ok: result.preflight?.ok ?? true,
|
|
435
|
+
critic: result.critic,
|
|
436
|
+
video: result.video,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface CatalogueSurface {
|
|
441
|
+
name: string;
|
|
442
|
+
path: string;
|
|
443
|
+
mode?: CaptureMode;
|
|
444
|
+
selector?: string;
|
|
445
|
+
/** F098.6 — navigation settle strategy (default 'domcontentloaded'). */
|
|
446
|
+
waitUntil?: 'load' | 'domcontentloaded' | 'commit';
|
|
447
|
+
waitFor?: string | number;
|
|
448
|
+
assert?: string;
|
|
449
|
+
baseline_key?: string;
|
|
450
|
+
/** F089.2 — smoke-only: never pixel-diff this surface (live-data dashboard). */
|
|
451
|
+
no_diff?: boolean;
|
|
452
|
+
/** F092.9 — per-surface viewport (e.g. 390x844 for a mobile baseline). Object,
|
|
453
|
+
* device preset, or stringified object (coerceViewport). */
|
|
454
|
+
viewport?: { width: number; height: number } | string;
|
|
455
|
+
device?: string;
|
|
456
|
+
/** F098.9 — per-surface engine override (chromium default; webkit/firefox). */
|
|
457
|
+
engine?: LensEngine;
|
|
458
|
+
/** Per-surface auth override. `undefined` = inherit the catalogue-level auth;
|
|
459
|
+
* `null` = explicitly NO auth (e.g. a public landing page that would redirect
|
|
460
|
+
* an authenticated visitor away). Lets one manifest mix public + authed. */
|
|
461
|
+
auth?: AuthConfig | null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface CatalogueInput {
|
|
465
|
+
project: string;
|
|
466
|
+
base_url: string;
|
|
467
|
+
surfaces: CatalogueSurface[];
|
|
468
|
+
auth?: AuthConfig;
|
|
469
|
+
diff_threshold?: number;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** lens_capture_catalogue — verify a batch of surfaces against one base URL.
|
|
473
|
+
* F074.6 adds a lens.manifest.json loader that feeds this; here surfaces are
|
|
474
|
+
* passed inline. Returns a per-surface verdict + an aggregate pass/fail. */
|
|
475
|
+
export async function lensCaptureCatalogue(deps: LensDeps, input: CatalogueInput) {
|
|
476
|
+
const project = requireProject(input.project);
|
|
477
|
+
if (!Array.isArray(input.surfaces) || input.surfaces.length === 0) {
|
|
478
|
+
throw new Error('surfaces[] is required');
|
|
479
|
+
}
|
|
480
|
+
const base = input.base_url.replace(/\/$/, '');
|
|
481
|
+
const results = [];
|
|
482
|
+
for (const s of input.surfaces) {
|
|
483
|
+
const url = `${base}${s.path.startsWith('/') ? s.path : `/${s.path}`}`;
|
|
484
|
+
const view = await lensVerify(deps, {
|
|
485
|
+
project,
|
|
486
|
+
url,
|
|
487
|
+
mode: s.mode,
|
|
488
|
+
selector: s.selector,
|
|
489
|
+
waitUntil: s.waitUntil,
|
|
490
|
+
waitFor: s.waitFor,
|
|
491
|
+
assert: s.assert,
|
|
492
|
+
baseline_key: s.baseline_key,
|
|
493
|
+
no_diff: s.no_diff,
|
|
494
|
+
engine: s.engine,
|
|
495
|
+
viewport: coerceViewport(s.viewport, s.device),
|
|
496
|
+
surface_key: s.baseline_key ?? s.name,
|
|
497
|
+
// Per-surface auth wins; `null` = explicit no-auth; undefined inherits.
|
|
498
|
+
auth: (s.auth === null ? undefined : s.auth ?? input.auth),
|
|
499
|
+
diff_threshold: input.diff_threshold,
|
|
500
|
+
});
|
|
501
|
+
results.push({ name: s.name, ...view });
|
|
502
|
+
}
|
|
503
|
+
const failed = results.filter((r) => r.status === 'fail' || r.status === 'error');
|
|
504
|
+
// upmetrics #5 — a "pass" is ambiguous: a surface diffed against a baseline
|
|
505
|
+
// (diff_ratio !== null) is genuinely VISUAL-verified; a surface with no
|
|
506
|
+
// baseline that merely loaded without errors is a SMOKE pass. Split them so a
|
|
507
|
+
// green "7/7" can't masquerade as visual coverage it doesn't have.
|
|
508
|
+
const passes = results.filter((r) => r.status === 'pass');
|
|
509
|
+
const diffVerified = passes.filter((r) => r.diff_ratio !== null).length;
|
|
510
|
+
const smoke = passes.length - diffVerified;
|
|
511
|
+
return {
|
|
512
|
+
project,
|
|
513
|
+
total: results.length,
|
|
514
|
+
passed: passes.length,
|
|
515
|
+
diff_verified: diffVerified,
|
|
516
|
+
smoke,
|
|
517
|
+
no_baseline: results.filter((r) => r.status === 'no-baseline').length,
|
|
518
|
+
failed: failed.length,
|
|
519
|
+
aggregate: (failed.length === 0 ? 'pass' : 'fail') as LensStatus | 'pass' | 'fail',
|
|
520
|
+
surfaces: results,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface ApproveInput {
|
|
525
|
+
project: string;
|
|
526
|
+
run_id: string;
|
|
527
|
+
baseline_key?: string;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function lensApproveBaseline(deps: LensDeps, input: ApproveInput): Promise<LensBaselineRow & { artifact_url: string | null }> {
|
|
531
|
+
const project = requireProject(input.project);
|
|
532
|
+
const run = getRun(deps.db, input.run_id);
|
|
533
|
+
if (!run) throw new Error(`run not found: ${input.run_id}`);
|
|
534
|
+
if (run.project_slug !== project) {
|
|
535
|
+
throw new Error(`run ${input.run_id} belongs to "${run.project_slug}", not "${project}"`);
|
|
536
|
+
}
|
|
537
|
+
const baseline = await promoteBaseline(deps.db, deps.storage, input.run_id, input.baseline_key);
|
|
538
|
+
return { ...baseline, artifact_url: artifactUrl(deps.port, baseline.artifact_key) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// F089 — clear (unapprove) a baseline so its surface returns to no-baseline
|
|
542
|
+
// (smoke). A live-data surface wrongly approved diffs forever otherwise; approve
|
|
543
|
+
// was one-way. upmetrics 2026-06-02.
|
|
544
|
+
export async function lensClearBaseline(deps: LensDeps, input: { project: string; baseline_key: string }) {
|
|
545
|
+
const project = requireProject(input.project);
|
|
546
|
+
if (!input.baseline_key) throw new Error('baseline_key is required');
|
|
547
|
+
const cleared = await deleteBaseline(deps.db, deps.storage, project, input.baseline_key);
|
|
548
|
+
return { project, baseline_key: input.baseline_key, cleared };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export interface ListRunsInput {
|
|
552
|
+
project: string;
|
|
553
|
+
status?: string;
|
|
554
|
+
surface_key?: string;
|
|
555
|
+
limit?: number;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function lensListRuns(deps: LensDeps, input: ListRunsInput) {
|
|
559
|
+
const project = requireProject(input.project);
|
|
560
|
+
const runs = listRuns(deps.db, project, {
|
|
561
|
+
status: input.status,
|
|
562
|
+
surface_key: input.surface_key,
|
|
563
|
+
limit: input.limit,
|
|
564
|
+
});
|
|
565
|
+
return { project, count: runs.length, runs: runs.map((r) => runView(r, deps.port)) };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** lens_billboard — a Sharp contact-sheet grid of recent run screenshots.
|
|
569
|
+
* Returns PNG bytes (a glanceable "wall" of the project's surfaces). */
|
|
570
|
+
export async function lensBillboard(deps: LensDeps, project: string, limit = 24): Promise<Buffer> {
|
|
571
|
+
const p = requireProject(project);
|
|
572
|
+
const runs = listRuns(deps.db, p, { limit });
|
|
573
|
+
const cw = 220;
|
|
574
|
+
const ch = 140;
|
|
575
|
+
const gap = 8;
|
|
576
|
+
const cols = 4;
|
|
577
|
+
const bg = { r: 244, g: 241, b: 234 }; // --color-ivory-ish
|
|
578
|
+
|
|
579
|
+
const thumbs: Buffer[] = [];
|
|
580
|
+
for (const r of runs) {
|
|
581
|
+
if (!r.artifact_key) continue;
|
|
582
|
+
const bytes = await deps.storage.get(r.artifact_key);
|
|
583
|
+
if (!bytes) continue;
|
|
584
|
+
thumbs.push(await sharp(bytes).resize(cw, ch, { fit: 'cover', position: 'top' }).png().toBuffer());
|
|
585
|
+
}
|
|
586
|
+
if (thumbs.length === 0) {
|
|
587
|
+
return sharp({ create: { width: cw, height: ch, channels: 3, background: bg } }).png().toBuffer();
|
|
588
|
+
}
|
|
589
|
+
const rows = Math.ceil(thumbs.length / cols);
|
|
590
|
+
const W = cols * cw + (cols + 1) * gap;
|
|
591
|
+
const H = rows * ch + (rows + 1) * gap;
|
|
592
|
+
const composite = thumbs.map((input, i) => ({
|
|
593
|
+
input,
|
|
594
|
+
left: gap + (i % cols) * (cw + gap),
|
|
595
|
+
top: gap + Math.floor(i / cols) * (ch + gap),
|
|
596
|
+
}));
|
|
597
|
+
return sharp({ create: { width: W, height: H, channels: 3, background: bg } })
|
|
598
|
+
.composite(composite)
|
|
599
|
+
.png()
|
|
600
|
+
.toBuffer();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── F082 — E2E flow runs ────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* F074.36 — accept an auth block that arrived as a JSON STRING.
|
|
607
|
+
*
|
|
608
|
+
* `parseFlow` already tolerates this for `flow`, because MCP clients routinely
|
|
609
|
+
* serialize a nested object argument. `auth` did not, and the result was a
|
|
610
|
+
* truthy non-object that reached the adapter switch as `unknown auth adapter:
|
|
611
|
+
* undefined` — so the manuscript capability was broken for exactly the callers
|
|
612
|
+
* it was built for (MCP), while working over HTTP. Verified both ways before
|
|
613
|
+
* adding this: the HTTP call with a real object passed, the MCP call with the
|
|
614
|
+
* same content did not.
|
|
615
|
+
*
|
|
616
|
+
* Deliberately NOT silent: unparseable input throws rather than degrading to "no
|
|
617
|
+
* auth", which would drop the run back into the exact silence this card removes.
|
|
618
|
+
*/
|
|
619
|
+
export function coerceAuth(raw: unknown): AuthConfig | undefined {
|
|
620
|
+
if (raw == null) return undefined;
|
|
621
|
+
if (typeof raw === 'string') {
|
|
622
|
+
const s = raw.trim();
|
|
623
|
+
if (!s) return undefined;
|
|
624
|
+
let parsed: unknown;
|
|
625
|
+
try {
|
|
626
|
+
parsed = JSON.parse(s);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new Error(`auth was a string but not valid JSON: ${err instanceof Error ? err.message : err}`);
|
|
629
|
+
}
|
|
630
|
+
return coerceAuth(parsed);
|
|
631
|
+
}
|
|
632
|
+
if (typeof raw !== 'object') throw new Error(`auth must be an object, got ${typeof raw}`);
|
|
633
|
+
const a = raw as { adapter?: unknown };
|
|
634
|
+
if (typeof a.adapter !== 'string' || !a.adapter) {
|
|
635
|
+
throw new Error('auth.adapter is required (storageState | cookieJwt | mintEndpoint)');
|
|
636
|
+
}
|
|
637
|
+
return raw as AuthConfig;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** F074.36 — the run's auth provenance for the report. A run recorded before
|
|
641
|
+
* this shipped has no column value; it says so rather than implying it ran
|
|
642
|
+
* authenticated. */
|
|
643
|
+
function parseAuthJson(raw: string | null | undefined): { source: string; adapter: string | null; note?: string } {
|
|
644
|
+
if (!raw) return { source: 'unknown', adapter: null, note: 'this run predates auth reporting' };
|
|
645
|
+
try {
|
|
646
|
+
return JSON.parse(raw) as { source: string; adapter: string | null; note?: string };
|
|
647
|
+
} catch {
|
|
648
|
+
return { source: 'unknown', adapter: null, note: 'auth provenance could not be read back' };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** NULL = the row predates the column, so we have no answer; `{}` = the run
|
|
653
|
+
* looked and found nothing. Malformed JSON is reported as unknown rather than
|
|
654
|
+
* as "clean": a parse failure must never read as an all-clear. */
|
|
655
|
+
function parseFindingsJson(raw: string | null | undefined): Record<string, unknown> | null {
|
|
656
|
+
if (raw == null) return null;
|
|
657
|
+
try {
|
|
658
|
+
const v = JSON.parse(raw);
|
|
659
|
+
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function flowRunView(row: LensFlowRunRow, port: number) {
|
|
666
|
+
const steps = (JSON.parse(row.steps_json) as StoredFlowStep[]).map((s) => ({
|
|
667
|
+
index: s.index,
|
|
668
|
+
type: s.type,
|
|
669
|
+
label: s.label,
|
|
670
|
+
status: s.status,
|
|
671
|
+
testid: s.testid,
|
|
672
|
+
detail: s.detail,
|
|
673
|
+
...(s.fault ? { fault: s.fault } : {}),
|
|
674
|
+
// F074.49 — the app's own failed responses. Present only on steps that have
|
|
675
|
+
// them, so a clean run reads exactly as before.
|
|
676
|
+
...(s.network?.length ? { network: s.network } : {}),
|
|
677
|
+
// F126.7 — the composition findings, all of them. Named here as well as in
|
|
678
|
+
// flow.ts and flow-store.ts: the three-place rule F074.49 exists to enforce.
|
|
679
|
+
...(s.critic ? { critic: s.critic } : {}),
|
|
680
|
+
// F213.12 — the third layer of the three-place rule, for F213.11's timing.
|
|
681
|
+
// Absent means the step never ran; 0 means it ran immediately, so the test
|
|
682
|
+
// is on the TYPE. A skipped step must stay silent rather than claim 0.
|
|
683
|
+
...(typeof s.ms_after_navigation === 'number' ? { ms_after_navigation: s.ms_after_navigation } : {}),
|
|
684
|
+
// F213.13 — the step's own duration. start + duration is what makes a step
|
|
685
|
+
// with internal waiting (a click retrying actionability) answerable at all.
|
|
686
|
+
...(typeof s.ms_step_duration === 'number' ? { ms_step_duration: s.ms_step_duration } : {}),
|
|
687
|
+
screenshot_url: artifactUrl(port, s.artifact_key),
|
|
688
|
+
}));
|
|
689
|
+
return {
|
|
690
|
+
flow_run_id: row.id,
|
|
691
|
+
project: row.project_slug,
|
|
692
|
+
flow_name: row.flow_name,
|
|
693
|
+
status: row.status,
|
|
694
|
+
failed_at: row.failed_at,
|
|
695
|
+
reason: row.reason,
|
|
696
|
+
step_count: row.step_count,
|
|
697
|
+
duration_ms: row.duration_ms,
|
|
698
|
+
created_at: row.created_at,
|
|
699
|
+
// F074.30 — WHICH build this run measured. null = not measured; a caller
|
|
700
|
+
// comparing two runs must refuse on a null rather than assume "same build".
|
|
701
|
+
build: row.build ?? null,
|
|
702
|
+
// F074.36 — the two facts that decide whether a verdict is worth anything:
|
|
703
|
+
// which page it judged, and whether it was logged in. `auth` is never
|
|
704
|
+
// omitted — a run with no credential says so, with the reason.
|
|
705
|
+
final_url: row.final_url ?? null,
|
|
706
|
+
auth: parseAuthJson(row.auth_json),
|
|
707
|
+
// F074.59 — which browser this verdict came from. 'reduce' | 'no-preference' |
|
|
708
|
+
// null = NOT RECORDED (a run predating the field). Always present, never
|
|
709
|
+
// defaulted: a green run that cannot say whether motion was on is
|
|
710
|
+
// uninterpretable, and guessing on its behalf is how that ambiguity survives.
|
|
711
|
+
reduced_motion: (row.reduced_motion as 'reduce' | 'no-preference' | null) ?? null,
|
|
712
|
+
// F272.2 — WHERE this run's screenshots live, so a session citing the run as
|
|
713
|
+
// evidence can see the answer AT CITATION TIME instead of discovering it after
|
|
714
|
+
// the machine is gone. 'local' = one Mac's disk behind 127.0.0.1, reachable by
|
|
715
|
+
// nobody else and by no one once the daemon stops. 'tigris' = durable. null =
|
|
716
|
+
// NOT RECORDED (a run predating the column) — a third fact, and `unknown` is
|
|
717
|
+
// never rounded up to durable. Always present, never defaulted, for the same
|
|
718
|
+
// reason reduced_motion above is.
|
|
719
|
+
artifact_backend: (row.artifact_backend as 'local' | 'tigris' | null) ?? null,
|
|
720
|
+
// F272.2 — the derived one-word answer to the only question a citation asks:
|
|
721
|
+
// can the owner still open this after this machine is gone?
|
|
722
|
+
evidence_durability: durabilityOf(row.artifact_backend),
|
|
723
|
+
// F074.99 — the run-level findings a reader can ACT on: a password that came
|
|
724
|
+
// back in the URL, a page that was never listening while we wrote to it.
|
|
725
|
+
// `{}` means the run looked and found nothing; NULL means the row predates
|
|
726
|
+
// the field and carries no answer either way — the same three-value posture
|
|
727
|
+
// as reduced_motion and artifact_backend above, and for the same reason.
|
|
728
|
+
//
|
|
729
|
+
// WHY THIS LINE EXISTS: both findings were computed correctly in flow.ts and
|
|
730
|
+
// surfaced NOWHERE. fd-sundhed searched the whole body of lens_run_manuscript
|
|
731
|
+
// and lens_list_flow_runs for the string and found neither — "the instrument
|
|
732
|
+
// measures right and nobody is told", which is the same shape as the probe
|
|
733
|
+
// that sat unwired to `fill` for a month.
|
|
734
|
+
findings: parseFindingsJson(row.findings_json),
|
|
735
|
+
steps,
|
|
736
|
+
// F255.5 — present only when the run was recorded. Same artifact mechanism as
|
|
737
|
+
// the step screenshots, so a consumer fetches it exactly the same way.
|
|
738
|
+
...(row.video_key ? { video_url: artifactUrl(port, row.video_key) } : {}),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export interface RunFlowInput {
|
|
743
|
+
project: string;
|
|
744
|
+
/** Untrusted flow object — validated by parseFlow at the boundary. */
|
|
745
|
+
flow: unknown;
|
|
746
|
+
/** F082.4 — resume: skip flattened steps before this index. */
|
|
747
|
+
from?: number;
|
|
748
|
+
/** F082.6 — repo root. Resolves a flow's relative auth statePath AND lets the
|
|
749
|
+
* flow inherit the project's lens.manifest.json auth when it has none. */
|
|
750
|
+
local_path?: string;
|
|
751
|
+
/** F092.9 — run the flow at a non-default viewport (e.g. 390x844 for mobile
|
|
752
|
+
* E2E + baselines). Defaults to the 1280x800 desktop context. Object, device
|
|
753
|
+
* preset, or stringified object (coerceViewport). */
|
|
754
|
+
viewport?: { width: number; height: number } | string;
|
|
755
|
+
device?: string;
|
|
756
|
+
/** F098.9 — engine to drive the flow (chromium default; webkit/firefox). */
|
|
757
|
+
engine?: LensEngine;
|
|
758
|
+
/** F074.57 — BCP-47 locale + IANA timezone. Wins over lens.manifest.json. A
|
|
759
|
+
* bilingual site is captured once per language via this override. */
|
|
760
|
+
locale?: string;
|
|
761
|
+
timezone?: string;
|
|
762
|
+
/** F074.59 — what this run reports for `prefers-reduced-motion`. Default
|
|
763
|
+
* 'reduce' (unchanged from every run before the option existed); pass
|
|
764
|
+
* 'no-preference' to let the run SEE motion. Never inherited from the
|
|
765
|
+
* manifest: whether a run may animate is a property of the QUESTION being
|
|
766
|
+
* asked, not of the project, and inheriting it would silently move baselines
|
|
767
|
+
* for every capture in a repo that set it once. */
|
|
768
|
+
reduced_motion?: 'reduce' | 'no-preference';
|
|
769
|
+
/** F126.4 — composition critic on each screenshot step ('off'|'dom'|'vision'|'both'). */
|
|
770
|
+
critic?: CriticMode;
|
|
771
|
+
/** F255.5 — record the whole run to a .webm and return it as video_url. */
|
|
772
|
+
record?: boolean;
|
|
773
|
+
/** F074.97 — {local, session} seeded before the flow's first navigation,
|
|
774
|
+
* scoped to the flow's own base_url origin. */
|
|
775
|
+
storage?: unknown;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// F074.24 — a flat cloud FlowRequest ({name, base_url, steps:[{action,...}]}, no
|
|
779
|
+
// {project,flow} wrapper) is recognised by a top-level `steps`/`base_url` and the
|
|
780
|
+
// absence of `flow`. It is handed to the engine's runFlow; the native
|
|
781
|
+
// {project, flow} body keeps the daemon's own manuscript executor.
|
|
782
|
+
export interface FlatFlowInput {
|
|
783
|
+
/** Optional project scope (recommendation A: optional daemon-side). Runs land
|
|
784
|
+
* under FLAT_FLOW_DEFAULT_PROJECT when absent. */
|
|
785
|
+
project?: string;
|
|
786
|
+
base_url: string;
|
|
787
|
+
steps: unknown[];
|
|
788
|
+
[k: string]: unknown;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function isFlatFlowBody(input: unknown): input is FlatFlowInput {
|
|
792
|
+
if (!input || typeof input !== 'object') return false;
|
|
793
|
+
const o = input as Record<string, unknown>;
|
|
794
|
+
return o.flow === undefined && (Array.isArray(o.steps) || typeof o.base_url === 'string');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Runs from a flat cloud FlowRequest with no `project` land under this bucket. */
|
|
798
|
+
export const FLAT_FLOW_DEFAULT_PROJECT = '_external';
|
|
799
|
+
|
|
800
|
+
/** Map the engine's FlowResult (cloud shape) onto the daemon's FlowResult so it
|
|
801
|
+
* persists as an ordinary daemon flow-run (recordFlowRun). resolved_via becomes
|
|
802
|
+
* the step detail — the self-heal audit trail for a testid-less field. */
|
|
803
|
+
export function mapEngineFlowResult(r: EngineFlowResult): FlowResult {
|
|
804
|
+
let failed_at: number | null = null;
|
|
805
|
+
let reason: string | null = null;
|
|
806
|
+
let duration_ms = 0;
|
|
807
|
+
const steps: FlowStepResult[] = r.steps.map((s) => {
|
|
808
|
+
duration_ms += s.ms ?? 0;
|
|
809
|
+
const status: FlowStepResult['status'] = s.status === 'ok' ? 'pass' : 'fail';
|
|
810
|
+
if (status === 'fail' && failed_at === null) {
|
|
811
|
+
failed_at = s.index;
|
|
812
|
+
reason = s.error ?? 'step failed';
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
index: s.index,
|
|
816
|
+
type: s.action,
|
|
817
|
+
label: s.detail ?? s.action,
|
|
818
|
+
status,
|
|
819
|
+
testid: null,
|
|
820
|
+
detail: s.error ?? (s.resolved_via ? `resolved via ${s.resolved_via}` : (s.detail ?? null)),
|
|
821
|
+
screenshot: s.png ?? null,
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
name: r.name ?? 'flow',
|
|
826
|
+
status: r.status === 'passed' ? 'pass' : 'fail',
|
|
827
|
+
steps,
|
|
828
|
+
failed_at,
|
|
829
|
+
reason,
|
|
830
|
+
duration_ms,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Build the engine's NATIVE cloud FlowResult for a flat-body response (F074.24
|
|
835
|
+
* response convergence — storeform #16012). A flat (cloud-shape) caller must get
|
|
836
|
+
* the same shape BOTH Lens surfaces share: `action` verbs, status `passed`/`ok`,
|
|
837
|
+
* and a STRUCTURED `resolved_via` (not free-text detail) — so a consumer's
|
|
838
|
+
* pass/fail check + self-heal drift telemetry read ONE contract. Re-mapping to
|
|
839
|
+
* the daemon's legacy step-shape (as flowRunView does for native callers) is the
|
|
840
|
+
* bug this fixes. png bytes are stripped; screenshot_url reuses the artifacts
|
|
841
|
+
* recordFlowRun already persisted. */
|
|
842
|
+
export function toCloudFlowResult(result: EngineFlowResult, row: LensFlowRunRow, port: number) {
|
|
843
|
+
const keyByIndex = new Map(
|
|
844
|
+
(JSON.parse(row.steps_json) as StoredFlowStep[]).map((s) => [s.index, s.artifact_key] as const),
|
|
845
|
+
);
|
|
846
|
+
return {
|
|
847
|
+
run_id: row.id,
|
|
848
|
+
name: result.name,
|
|
849
|
+
status: result.status,
|
|
850
|
+
final_url: result.final_url,
|
|
851
|
+
// F074.99 — the same findings the native surface carries. A flat/cloud caller
|
|
852
|
+
// (a CI runner with no daemon) is the consumer LEAST able to go and look
|
|
853
|
+
// somewhere else, so leaving them off this shape would be the gap at its
|
|
854
|
+
// worst.
|
|
855
|
+
findings: parseFindingsJson(row.findings_json),
|
|
856
|
+
steps: result.steps.map((s) => ({
|
|
857
|
+
index: s.index,
|
|
858
|
+
action: s.action,
|
|
859
|
+
status: s.status,
|
|
860
|
+
ms: s.ms,
|
|
861
|
+
detail: s.detail ?? null,
|
|
862
|
+
resolved_via: s.resolved_via ?? null,
|
|
863
|
+
error: s.error ?? null,
|
|
864
|
+
screenshot_url: artifactUrl(port, keyByIndex.get(s.index) ?? null),
|
|
865
|
+
})),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Host the engine's runFlow for a flat cloud FlowRequest (F074.24). Validates
|
|
870
|
+
* with the engine's OWN flowBodySchema (single-source contract), runs it (the
|
|
871
|
+
* engine owns its Chromium + applies any supplied storageState), persists the
|
|
872
|
+
* result as a daemon flow-run, and returns the engine's NATIVE cloud FlowResult
|
|
873
|
+
* so the response shape converges with the request (storeform #16012). Dispatched
|
|
874
|
+
* from the HTTP /lens/flow handler on shape (isFlatFlowBody); the MCP tool +
|
|
875
|
+
* native {project, flow} callers stay on lensRunFlow (unchanged daemon shape). */
|
|
876
|
+
export async function runFlatFlow(deps: LensDeps, input: FlatFlowInput) {
|
|
877
|
+
const project = typeof input.project === 'string' && input.project.trim() ? input.project.trim() : FLAT_FLOW_DEFAULT_PROJECT;
|
|
878
|
+
// `project` is OUR routing field, not part of the engine body — strip it before
|
|
879
|
+
// validating instead of relying on the schema to ignore it.
|
|
880
|
+
//
|
|
881
|
+
// @broberg/lens-engine 0.7.0 made flowBodySchema STRICT: it now rejects any
|
|
882
|
+
// unrecognized key, where 0.6.x silently stripped them. So this line was the
|
|
883
|
+
// difference between "cloud flows run" and "every cloud flow throws
|
|
884
|
+
// Unrecognized key(s) in object: 'project'". Caught by the suite on the bump,
|
|
885
|
+
// which is the whole reason the bump goes through a gate.
|
|
886
|
+
//
|
|
887
|
+
// Strict is the BETTER contract (a typo'd `base_ur1` is now an error instead of
|
|
888
|
+
// a silently ignored key), so we adapt rather than ask for a revert.
|
|
889
|
+
//
|
|
890
|
+
// AND IT WAS DOCUMENTED. My first version of this comment said the change was
|
|
891
|
+
// unflagged. It was not: the 0.7.0 README leads with "Read this before
|
|
892
|
+
// upgrading: a flow that parses today can stop parsing", names `project`
|
|
893
|
+
// explicitly, and gives `.extend()` as the supported path. I concluded
|
|
894
|
+
// "not flagged" from "I did not see a flag", without looking where flags live —
|
|
895
|
+
// the same shape as everything else this week, aimed at a peer this time.
|
|
896
|
+
// Verified against the published tarball after they corrected me (#21371).
|
|
897
|
+
const { project: _routing, ...engineBody } = input;
|
|
898
|
+
const parsed = flowBodySchema.safeParse(engineBody);
|
|
899
|
+
if (!parsed.success) {
|
|
900
|
+
throw new Error(`flat flow: ${parsed.error.issues[0]?.message ?? 'invalid flow body'}`);
|
|
901
|
+
}
|
|
902
|
+
const result = await engineRunFlow(parsed.data);
|
|
903
|
+
// Persist as a daemon flow-run (own /flow-runs listing + artifact storage)…
|
|
904
|
+
const row = await recordFlowRun(deps.db, deps.storage, project, mapEngineFlowResult(result));
|
|
905
|
+
// …but RETURN the engine's native cloud FlowResult (not the daemon step-shape).
|
|
906
|
+
return toCloudFlowResult(result, row, deps.port);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** lens_run_flow — execute a flow end-to-end in one authed page, persist the
|
|
910
|
+
* per-step report + screenshots, return the E2E result. F074.24: a flat cloud
|
|
911
|
+
* FlowRequest is delegated to the engine's runFlow; the native {project, flow}
|
|
912
|
+
* body keeps the daemon's own manuscript executor. */
|
|
913
|
+
export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
|
|
914
|
+
const project = requireProject(input.project);
|
|
915
|
+
const flow = parseFlow(input.flow);
|
|
916
|
+
// F082.6 — auth reaches flows: own auth (relative paths resolved) or inherit
|
|
917
|
+
// the manifest's. Fixes the blocker where authed routes redirected to login.
|
|
918
|
+
// F074.36 — and the run now REPORTS which of those two it used, or why neither.
|
|
919
|
+
const provenance = authProvenance(flow.auth, input.local_path, deps.sandboxRoots);
|
|
920
|
+
flow.auth = provenance.auth;
|
|
921
|
+
const result = await runFlow(flow, process.env as AuthEnv, {
|
|
922
|
+
from: input.from,
|
|
923
|
+
engine: input.engine,
|
|
924
|
+
...resolveLocale(input, manifestLocale(input.local_path, deps.sandboxRoots)),
|
|
925
|
+
reducedMotion: input.reduced_motion,
|
|
926
|
+
viewport: coerceViewport(input.viewport, input.device),
|
|
927
|
+
critic: input.critic,
|
|
928
|
+
localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
|
|
929
|
+
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
930
|
+
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
931
|
+
});
|
|
932
|
+
const row = await recordFlowRun(deps.db, deps.storage, project, result, provenance);
|
|
933
|
+
return flowRunView(row, deps.port);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export interface RunManuscriptInput {
|
|
937
|
+
project: string;
|
|
938
|
+
/** Markdown manuscript — compiled to a flow then executed. */
|
|
939
|
+
manuscript: string;
|
|
940
|
+
/** F074.36 — an explicit auth block, same shape lens_run_flow takes. The prose
|
|
941
|
+
* manuscript is the surface we tell people to reach for first, and until now
|
|
942
|
+
* it was the only one that could not authenticate — so the easiest tool was
|
|
943
|
+
* the one that silently tested the login screen. Wins over the manifest. */
|
|
944
|
+
auth?: AuthConfig;
|
|
945
|
+
/** F082.6 — repo root for relative auth + manifest-auth inheritance. */
|
|
946
|
+
local_path?: string;
|
|
947
|
+
/** F252.2 — run the manuscript at a non-default viewport/device so mobile-only
|
|
948
|
+
* UI (behind lg:hidden etc.) can be click-driven, not just DOM-asserted. */
|
|
949
|
+
viewport?: { width: number; height: number } | string;
|
|
950
|
+
/** F074.57 — locale/timezone for the manuscript run; wins over the manifest. */
|
|
951
|
+
locale?: string;
|
|
952
|
+
timezone?: string;
|
|
953
|
+
/** F074.59 — what this run reports for `prefers-reduced-motion`. Default
|
|
954
|
+
* 'reduce' (unchanged from every run before the option existed); pass
|
|
955
|
+
* 'no-preference' to let the run SEE motion. Never inherited from the
|
|
956
|
+
* manifest: whether a run may animate is a property of the QUESTION being
|
|
957
|
+
* asked, not of the project, and inheriting it would silently move baselines
|
|
958
|
+
* for every capture in a repo that set it once. */
|
|
959
|
+
reduced_motion?: 'reduce' | 'no-preference';
|
|
960
|
+
device?: string;
|
|
961
|
+
engine?: LensEngine;
|
|
962
|
+
/** F255.5 — record the whole run to a .webm and return it as video_url. */
|
|
963
|
+
record?: boolean;
|
|
964
|
+
/** F074.97 — {local, session} seeded before the manuscript's first navigation.
|
|
965
|
+
* The prose surface is the one we tell people to reach for first, so a
|
|
966
|
+
* capability it lacks is a capability most callers do not have. */
|
|
967
|
+
storage?: unknown;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** lens_run_manuscript — compile a markdown manuscript to a flow and run it E2E.
|
|
971
|
+
* The authoring shortcut: a repo writes prose, not JSON. */
|
|
972
|
+
export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInput) {
|
|
973
|
+
const project = requireProject(input.project);
|
|
974
|
+
if (typeof input.manuscript !== 'string' || !input.manuscript.trim()) {
|
|
975
|
+
throw new Error('manuscript (markdown string) is required');
|
|
976
|
+
}
|
|
977
|
+
const flow = compileManuscript(input.manuscript);
|
|
978
|
+
// F082.6 — a manuscript inherits the project's manifest auth (DRY: the
|
|
979
|
+
// credential lives in one place) unless it declared its own.
|
|
980
|
+
// F074.36 — an explicit `auth` argument outranks both, and the provenance is
|
|
981
|
+
// REPORTED: "we ran without a credential" must never be something the caller
|
|
982
|
+
// has to infer from a confusing failure.
|
|
983
|
+
const provenance = authProvenance(coerceAuth(input.auth) ?? flow.auth, input.local_path, deps.sandboxRoots);
|
|
984
|
+
flow.auth = provenance.auth;
|
|
985
|
+
const result = await runFlow(flow, process.env as AuthEnv, {
|
|
986
|
+
engine: input.engine,
|
|
987
|
+
...resolveLocale(input, manifestLocale(input.local_path, deps.sandboxRoots)),
|
|
988
|
+
reducedMotion: input.reduced_motion,
|
|
989
|
+
viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
|
|
990
|
+
localPath: input.local_path, // F213.6 — uploadFile fixture root
|
|
991
|
+
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
992
|
+
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
993
|
+
});
|
|
994
|
+
const row = await recordFlowRun(deps.db, deps.storage, project, result, provenance);
|
|
995
|
+
return flowRunView(row, deps.port);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export interface ListFlowRunsInput {
|
|
999
|
+
project: string;
|
|
1000
|
+
flow_name?: string;
|
|
1001
|
+
status?: string;
|
|
1002
|
+
limit?: number;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export function lensListFlowRuns(deps: LensDeps, input: ListFlowRunsInput) {
|
|
1006
|
+
const project = requireProject(input.project);
|
|
1007
|
+
const runs = listFlowRuns(deps.db, project, {
|
|
1008
|
+
flow_name: input.flow_name,
|
|
1009
|
+
status: input.status,
|
|
1010
|
+
limit: input.limit,
|
|
1011
|
+
});
|
|
1012
|
+
return { project, count: runs.length, flow_runs: runs.map((r) => flowRunView(r, deps.port)) };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export function lensListBaselines(deps: LensDeps, project: string) {
|
|
1016
|
+
const p = requireProject(project);
|
|
1017
|
+
const baselines = listBaselines(deps.db, p);
|
|
1018
|
+
return {
|
|
1019
|
+
project: p,
|
|
1020
|
+
count: baselines.length,
|
|
1021
|
+
baselines: baselines.map((b) => ({
|
|
1022
|
+
baseline_key: b.baseline_key,
|
|
1023
|
+
width: b.width,
|
|
1024
|
+
height: b.height,
|
|
1025
|
+
approved_from_run_id: b.approved_from_run_id,
|
|
1026
|
+
updated_at: b.updated_at,
|
|
1027
|
+
current_version_id: b.current_version_id,
|
|
1028
|
+
artifact_url: artifactUrl(deps.port, b.artifact_key),
|
|
1029
|
+
})),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// F094.3 — the approved-version history for one surface (newest first), each with
|
|
1034
|
+
// a thumbnail URL, so the dashboard can render a visual timeline (F094.4).
|
|
1035
|
+
export function lensListBaselineVersions(
|
|
1036
|
+
deps: LensDeps,
|
|
1037
|
+
input: { project: string; baseline_key: string },
|
|
1038
|
+
) {
|
|
1039
|
+
const p = requireProject(input.project);
|
|
1040
|
+
if (!input.baseline_key) throw new Error('baseline_key is required');
|
|
1041
|
+
const versions = listBaselineVersions(deps.db, p, input.baseline_key);
|
|
1042
|
+
const current = listBaselines(deps.db, p).find((b) => b.baseline_key === input.baseline_key);
|
|
1043
|
+
return {
|
|
1044
|
+
project: p,
|
|
1045
|
+
baseline_key: input.baseline_key,
|
|
1046
|
+
current_version_id: current?.current_version_id ?? null,
|
|
1047
|
+
count: versions.length,
|
|
1048
|
+
versions: versions.map((v) => ({
|
|
1049
|
+
version_no: v.version_no,
|
|
1050
|
+
version_id: v.id,
|
|
1051
|
+
width: v.width,
|
|
1052
|
+
height: v.height,
|
|
1053
|
+
approved_from_run_id: v.approved_from_run_id,
|
|
1054
|
+
commit_sha: v.commit_sha,
|
|
1055
|
+
note: v.note,
|
|
1056
|
+
approved_at: v.approved_at,
|
|
1057
|
+
is_current: v.id === current?.current_version_id,
|
|
1058
|
+
artifact_url: artifactUrl(deps.port, v.artifact_key),
|
|
1059
|
+
})),
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// F094.3 — revert a surface's active baseline to a prior version (re-point, no
|
|
1064
|
+
// history loss). The next verify diffs against the reverted image.
|
|
1065
|
+
export function lensRevertBaseline(
|
|
1066
|
+
deps: LensDeps,
|
|
1067
|
+
input: { project: string; baseline_key: string; version_no: number },
|
|
1068
|
+
): LensBaselineRow & { artifact_url: string | null } {
|
|
1069
|
+
const project = requireProject(input.project);
|
|
1070
|
+
if (!input.baseline_key) throw new Error('baseline_key is required');
|
|
1071
|
+
if (typeof input.version_no !== 'number') throw new Error('version_no (number) is required');
|
|
1072
|
+
const baseline = revertBaseline(deps.db, project, input.baseline_key, input.version_no);
|
|
1073
|
+
return { ...baseline, artifact_url: artifactUrl(deps.port, baseline.artifact_key) };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
// ── F272.2 — promote a flow run's evidence into durable storage ──────────
|
|
1078
|
+
//
|
|
1079
|
+
// THE CLAIM THIS EXISTS TO MAKE TRUE: "the owner can open this with the daemon
|
|
1080
|
+
// off." A 127.0.0.1 artifact URL can never satisfy it, no matter how correct the
|
|
1081
|
+
// bytes are — it is reachable from exactly one machine, by exactly one person,
|
|
1082
|
+
// and only while a process happens to be up.
|
|
1083
|
+
//
|
|
1084
|
+
// The daemon does the copying itself, deliberately. It is the only process that
|
|
1085
|
+
// can read the PNGs, and having it POST them straight to the cloud means the
|
|
1086
|
+
// bytes never pass through an agent's context — which is both the token-frugal
|
|
1087
|
+
// choice and the one that keeps a screenshot of a customer's screen out of a
|
|
1088
|
+
// transcript.
|
|
1089
|
+
|
|
1090
|
+
export interface PromoteEvidenceInput {
|
|
1091
|
+
project: string;
|
|
1092
|
+
/** A flow run (lens_run_flow). Exactly one of flow_run_id / run_id. */
|
|
1093
|
+
flow_run_id?: string;
|
|
1094
|
+
/** F074.68 — a plain CAPTURE run (lens_capture / lens_verify). Until this
|
|
1095
|
+
* existed, the durable-evidence path we RECOMMEND could not be taken for a
|
|
1096
|
+
* screenshot: the warning told people to promote, and promote refused the id
|
|
1097
|
+
* they had. sanne cited six captures as proof for acceptance criteria and none
|
|
1098
|
+
* was promotable, so a correct action still read as `local_only` — which is
|
|
1099
|
+
* how a warning teaches people to ignore it. */
|
|
1100
|
+
run_id?: string;
|
|
1101
|
+
/** Only steps whose screenshot exists are promoted; pass a subset to promote
|
|
1102
|
+
* fewer. Omitted = every step that has one. Flow runs only. */
|
|
1103
|
+
step_indexes?: number[];
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
export interface PromotedStep {
|
|
1107
|
+
index: number;
|
|
1108
|
+
label: string;
|
|
1109
|
+
/** F074.68 — WHAT this artifact is. A capture run can carry two pictures, and
|
|
1110
|
+
* "2 promoted" does not say whether the second is the diff or a second screen.
|
|
1111
|
+
* Named rather than counted, so a caller never has to infer it. */
|
|
1112
|
+
kind: 'step' | 'screenshot' | 'diff';
|
|
1113
|
+
status: string;
|
|
1114
|
+
artifact_id: string;
|
|
1115
|
+
url: string;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export interface PromoteEvidenceResult {
|
|
1119
|
+
/** Whichever kind of run was promoted; the other is null. */
|
|
1120
|
+
flow_run_id: string | null;
|
|
1121
|
+
run_id: string | null;
|
|
1122
|
+
project: string;
|
|
1123
|
+
/** What the run's backend was BEFORE this call — the reason it needed promoting. */
|
|
1124
|
+
was: 'local' | 'tigris' | null;
|
|
1125
|
+
promoted: PromotedStep[];
|
|
1126
|
+
/** Steps that had no screenshot at all. Reported, never silently dropped: with
|
|
1127
|
+
* today's 12% capture rate (F272.3) this is the majority, and a caller that is
|
|
1128
|
+
* not told will read "3 promoted" as "the whole run is keepable". */
|
|
1129
|
+
steps_without_screenshot: number;
|
|
1130
|
+
/** The line to paste as AC evidence. Server-verifiable — cardmem_record_review
|
|
1131
|
+
* looks these ids up rather than believing a claim about them. */
|
|
1132
|
+
evidence: string;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export async function promoteEvidence(
|
|
1136
|
+
deps: LensDeps,
|
|
1137
|
+
input: PromoteEvidenceInput,
|
|
1138
|
+
): Promise<PromoteEvidenceResult> {
|
|
1139
|
+
// Exactly one id. Accepting both and silently preferring one is how a caller
|
|
1140
|
+
// ends up promoting a run they did not mean; refusing says which they gave.
|
|
1141
|
+
if (!!input.flow_run_id === !!input.run_id) {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
'pass exactly ONE of flow_run_id (a lens_run_flow run) or run_id (a lens_capture / lens_verify run)' +
|
|
1144
|
+
(input.flow_run_id ? ' — both were given' : ' — neither was given'),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const cloud = deps.cloud?.();
|
|
1149
|
+
if (!cloud) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
'not signed in to cardmem — promoting evidence needs the daemon\'s cloud key (run `cardmem login`). Nothing was copied.',
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Resolve the cloud project id LAZILY. The daemon knows slugs; place_artifact
|
|
1156
|
+
// needs the uuid, and guessing one would write into whatever project happened to
|
|
1157
|
+
// match. Deferred until there is actually something to place, so the LOCAL "that
|
|
1158
|
+
// run is not in this project" answer still comes first — resolving eagerly made
|
|
1159
|
+
// an unknown project report "not known to cardmem" instead, which is true but is
|
|
1160
|
+
// not the reason the caller's request failed. It also spares a network call for
|
|
1161
|
+
// a run that turns out to have no screenshot at all.
|
|
1162
|
+
let projectIdCache: string | null = null;
|
|
1163
|
+
const projectId = async () => {
|
|
1164
|
+
if (projectIdCache) return projectIdCache;
|
|
1165
|
+
const projects = await cloud.callTool<{ projects?: Array<{ id: string; slug: string }> }>(
|
|
1166
|
+
'cardmem_list_projects',
|
|
1167
|
+
{},
|
|
1168
|
+
);
|
|
1169
|
+
const id = projects?.projects?.find((p) => p.slug === input.project)?.id;
|
|
1170
|
+
if (!id) throw new Error(`project ${input.project} is not known to cardmem — cannot place artifacts`);
|
|
1171
|
+
projectIdCache = id;
|
|
1172
|
+
return id;
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
/** Read the bytes and hand them to the cloud. The daemon copies them itself, so a
|
|
1176
|
+
* screenshot of a customer's screen never passes through an agent's context. */
|
|
1177
|
+
const place = async (key: string, name: string, whose: string) => {
|
|
1178
|
+
// Project first, bytes second — the order the tests pin, and the right one: an
|
|
1179
|
+
// unknown project is a mistake in the REQUEST, while missing bytes are a fact
|
|
1180
|
+
// about the world. Report the caller's error before the world's.
|
|
1181
|
+
const target = await projectId();
|
|
1182
|
+
const bytes = await deps.storage.get(key);
|
|
1183
|
+
if (!bytes) {
|
|
1184
|
+
// The row says there is a picture and the storage does not have it. That is
|
|
1185
|
+
// the exact silent loss this mechanism exists to prevent, so it is a real
|
|
1186
|
+
// failure rather than a quiet omission.
|
|
1187
|
+
throw new Error(
|
|
1188
|
+
`${whose} claims a screenshot (${key}) but the bytes are GONE from ${deps.storage.backend} storage — the evidence this run cites no longer exists.`,
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
return cloud.callTool<{ artifact_id: string; url: string }>('cardmem_place_artifact', {
|
|
1192
|
+
project_id: target,
|
|
1193
|
+
name,
|
|
1194
|
+
mime: 'image/png',
|
|
1195
|
+
base64: bytes.toString('base64'),
|
|
1196
|
+
source: 'lens-evidence',
|
|
1197
|
+
});
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const promoted: PromotedStep[] = [];
|
|
1201
|
+
let withoutShot = 0;
|
|
1202
|
+
|
|
1203
|
+
// ── a plain CAPTURE run (F074.68) ────────────────────────────────────────
|
|
1204
|
+
if (input.run_id) {
|
|
1205
|
+
const run = deps.db
|
|
1206
|
+
.query('SELECT * FROM lens_runs WHERE id = ? AND project_slug = ?')
|
|
1207
|
+
.get(input.run_id, input.project) as LensRunRow | undefined;
|
|
1208
|
+
// Scoped by project on purpose: a run id under a DIFFERENT project is not this
|
|
1209
|
+
// project's evidence, and "not found" is the honest answer.
|
|
1210
|
+
if (!run) throw new Error(`capture run ${input.run_id} not found in project ${input.project}`);
|
|
1211
|
+
|
|
1212
|
+
const stem = `lens-${run.surface_key || run.mode}-${input.run_id.slice(0, 8)}`;
|
|
1213
|
+
// BOTH pictures when both exist, each NAMED. A diff is evidence of a different
|
|
1214
|
+
// claim than the screenshot is, and promoting one silently while counting two
|
|
1215
|
+
// is the ambiguity this card was told to decide rather than leave open.
|
|
1216
|
+
if (run.artifact_key) {
|
|
1217
|
+
const placed = await place(run.artifact_key, `${stem}.png`, `run ${input.run_id}`);
|
|
1218
|
+
// Named fields, NOT `...placed`. The cloud's place_artifact response carries its
|
|
1219
|
+
// own `kind` ("image"), and spreading it last silently overwrote ours — the
|
|
1220
|
+
// stub in the test returns only {artifact_id,url}, so it could not see that.
|
|
1221
|
+
// Measured live: the very first real promote came back kind:"image" while every
|
|
1222
|
+
// test said "screenshot".
|
|
1223
|
+
promoted.push({
|
|
1224
|
+
index: 0, label: 'screenshot', kind: 'screenshot', status: run.status,
|
|
1225
|
+
artifact_id: placed.artifact_id, url: placed.url,
|
|
1226
|
+
});
|
|
1227
|
+
} else {
|
|
1228
|
+
withoutShot++;
|
|
1229
|
+
}
|
|
1230
|
+
if (run.diff_artifact_key) {
|
|
1231
|
+
const placed = await place(run.diff_artifact_key, `${stem}-diff.png`, `run ${input.run_id} (diff)`);
|
|
1232
|
+
promoted.push({
|
|
1233
|
+
index: 1, label: 'diff', kind: 'diff', status: run.status,
|
|
1234
|
+
artifact_id: placed.artifact_id, url: placed.url,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const evidence =
|
|
1239
|
+
promoted.length === 0
|
|
1240
|
+
? `Lens capture run ${input.run_id} (${run.surface_key || run.mode}): NOTHING PROMOTED — this run stored no screenshot, so there is no pictorial evidence for it.`
|
|
1241
|
+
: `Lens capture run ${input.run_id} (${run.surface_key || run.mode}, ${run.status}) — ${promoted
|
|
1242
|
+
.map((p) => p.label)
|
|
1243
|
+
.join(' + ')} promoted to durable storage: ${promoted.map((p) => p.url).join(' ')}`;
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
flow_run_id: null,
|
|
1247
|
+
run_id: input.run_id,
|
|
1248
|
+
project: input.project,
|
|
1249
|
+
// lens_runs carries no artifact_backend column, so this is genuinely unknown
|
|
1250
|
+
// rather than 'local' — saying 'local' would be a guess dressed as a reading.
|
|
1251
|
+
was: null,
|
|
1252
|
+
promoted,
|
|
1253
|
+
steps_without_screenshot: withoutShot,
|
|
1254
|
+
evidence,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ── a FLOW run ───────────────────────────────────────────────────────────
|
|
1259
|
+
const row = deps.db
|
|
1260
|
+
.query('SELECT * FROM lens_flow_runs WHERE id = ? AND project_slug = ?')
|
|
1261
|
+
.get(input.flow_run_id!, input.project) as LensFlowRunRow | undefined;
|
|
1262
|
+
if (!row) {
|
|
1263
|
+
throw new Error(`flow run ${input.flow_run_id} not found in project ${input.project}`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const steps = JSON.parse(row.steps_json) as StoredFlowStep[];
|
|
1267
|
+
const wanted = input.step_indexes ? new Set(input.step_indexes) : null;
|
|
1268
|
+
|
|
1269
|
+
for (const step of steps) {
|
|
1270
|
+
if (wanted && !wanted.has(step.index)) continue;
|
|
1271
|
+
if (!step.artifact_key) {
|
|
1272
|
+
withoutShot++;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const name = `lens-${row.flow_name}-${input.flow_run_id!.slice(0, 8)}-step-${String(step.index).padStart(2, '0')}.png`;
|
|
1276
|
+
const placed = await place(step.artifact_key, name, `step ${step.index} of ${input.flow_run_id}`);
|
|
1277
|
+
promoted.push({
|
|
1278
|
+
index: step.index,
|
|
1279
|
+
label: step.label,
|
|
1280
|
+
kind: 'step',
|
|
1281
|
+
status: step.status,
|
|
1282
|
+
artifact_id: placed.artifact_id,
|
|
1283
|
+
url: placed.url,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const evidence =
|
|
1288
|
+
promoted.length === 0
|
|
1289
|
+
? `Lens flow run ${input.flow_run_id} (${row.flow_name}): NOTHING PROMOTED — none of the ${steps.length} steps stored a screenshot. There is no pictorial evidence for this run.`
|
|
1290
|
+
: `Lens flow run ${input.flow_run_id} (${row.flow_name}, ${row.status}) — ${promoted.length} step still(s) promoted to durable storage: ${promoted
|
|
1291
|
+
.map((p) => p.url)
|
|
1292
|
+
.join(' ')}`;
|
|
1293
|
+
|
|
1294
|
+
return {
|
|
1295
|
+
flow_run_id: input.flow_run_id!,
|
|
1296
|
+
run_id: null,
|
|
1297
|
+
project: input.project,
|
|
1298
|
+
was: (row.artifact_backend as 'local' | 'tigris' | null) ?? null,
|
|
1299
|
+
promoted,
|
|
1300
|
+
steps_without_screenshot: withoutShot,
|
|
1301
|
+
evidence,
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** @deprecated F074.68 — kept so nothing breaks mid-rename; promoteEvidence now
|
|
1306
|
+
* takes a capture run too, and the old name says it does not. */
|
|
1307
|
+
export const promoteFlowEvidence = promoteEvidence;
|
|
1308
|
+
|
|
1309
|
+
// ─── F074.63 — lens_export ────────────────────────────────────────────────────
|
|
1310
|
+
|
|
1311
|
+
export interface ExportInput {
|
|
1312
|
+
project: string;
|
|
1313
|
+
url: string;
|
|
1314
|
+
format?: 'pdf' | 'svg';
|
|
1315
|
+
page_size?: string;
|
|
1316
|
+
landscape?: boolean;
|
|
1317
|
+
print_background?: boolean;
|
|
1318
|
+
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
|
1319
|
+
media?: ExportMedia;
|
|
1320
|
+
/** F074.64 — which surface to export; a print job is one sheet. */
|
|
1321
|
+
/** F074.64 — which surface to export; a print job is ONE sheet and a mockup
|
|
1322
|
+
* document usually holds several. */
|
|
1323
|
+
selector?: string;
|
|
1324
|
+
/** Which match when the selector hits several — a bifold folder has two sheets
|
|
1325
|
+
* under one class. Default 0. */
|
|
1326
|
+
selector_index?: number;
|
|
1327
|
+
engine?: LensEngine;
|
|
1328
|
+
viewport?: { width: number; height: number } | string;
|
|
1329
|
+
device?: string;
|
|
1330
|
+
scale?: number;
|
|
1331
|
+
waitUntil?: 'load' | 'domcontentloaded' | 'commit';
|
|
1332
|
+
waitFor?: string | number;
|
|
1333
|
+
actions?: CaptureAction[];
|
|
1334
|
+
auth?: AuthConfig;
|
|
1335
|
+
timeout_ms?: number;
|
|
1336
|
+
/** Strings the caller expects to find AS TEXT in the produced PDF. Checked
|
|
1337
|
+
* server-side and answered as booleans — the extracted text itself is never
|
|
1338
|
+
* returned, because a poster's worth of it is thousands of tokens the caller
|
|
1339
|
+
* did not ask for and cannot use. */
|
|
1340
|
+
expect_text?: string[];
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** lens_export — render a URL to a VECTOR document, where text stays text.
|
|
1344
|
+
*
|
|
1345
|
+
* The verdict block is the point of the whole tool, so it is computed from the
|
|
1346
|
+
* produced bytes and returned every time rather than left to the caller to
|
|
1347
|
+
* check. fd-sundhed's existing broken artifact is a valid PDF with a valid MIME
|
|
1348
|
+
* type; the only thing that separates it from a usable one is whether a
|
|
1349
|
+
* designer can select the text. So that is what the response reports. */
|
|
1350
|
+
export async function lensExport(deps: LensDeps, input: ExportInput) {
|
|
1351
|
+
const project = requireProject(input.project);
|
|
1352
|
+
const format = input.format ?? 'pdf';
|
|
1353
|
+
if (format !== 'pdf' && format !== 'svg') {
|
|
1354
|
+
throw new Error(`lens_export supports format:'pdf' or 'svg'. Got '${format}'.`);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const result = await exportPage({
|
|
1358
|
+
url: input.url,
|
|
1359
|
+
engine: input.engine,
|
|
1360
|
+
viewport: coerceViewport(input.viewport, input.device),
|
|
1361
|
+
deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
|
|
1362
|
+
waitUntil: input.waitUntil,
|
|
1363
|
+
waitFor: input.waitFor,
|
|
1364
|
+
actions: input.actions,
|
|
1365
|
+
auth: resolveVerifyAuth(deps, project, input.auth),
|
|
1366
|
+
timeoutMs: input.timeout_ms,
|
|
1367
|
+
format,
|
|
1368
|
+
pageSize: input.page_size,
|
|
1369
|
+
landscape: input.landscape,
|
|
1370
|
+
printBackground: input.print_background,
|
|
1371
|
+
margin: input.margin,
|
|
1372
|
+
media: input.media,
|
|
1373
|
+
selector: input.selector,
|
|
1374
|
+
selectorIndex: input.selector_index,
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
// "We wrote bytes" and "we wrote the format we promised" are different claims;
|
|
1378
|
+
// only one of them is worth reporting to someone with a print deadline.
|
|
1379
|
+
if (format === 'pdf' && !looksLikePdf(result.bytes)) {
|
|
1380
|
+
throw new Error('lens_export produced bytes that are not a PDF (missing %PDF- header)');
|
|
1381
|
+
}
|
|
1382
|
+
if (format === 'svg' && !result.bytes.subarray(0, 200).toString('utf8').includes('<svg')) {
|
|
1383
|
+
throw new Error('lens_export produced bytes that are not an SVG (no <svg root)');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const exportId = randomUUID();
|
|
1387
|
+
const key = artifactKeys.exportDoc(project, exportId, format);
|
|
1388
|
+
await deps.storage.put(key, result.bytes, format === 'svg' ? 'image/svg+xml' : 'application/pdf');
|
|
1389
|
+
|
|
1390
|
+
const stats = format === 'pdf' ? pdfContentStats(result.bytes) : null;
|
|
1391
|
+
const box = format === 'pdf' ? readMediaBox(result.bytes) : null;
|
|
1392
|
+
// Answered here, on the bytes, rather than by handing back the whole text:
|
|
1393
|
+
// the caller wants to know whether their headline survived, not to read the
|
|
1394
|
+
// document back through their own context.
|
|
1395
|
+
const expected = Array.isArray(input.expect_text) ? input.expect_text.filter((t) => typeof t === 'string') : [];
|
|
1396
|
+
const svgText = format === 'svg' ? result.bytes.toString('utf8').replace(/<[^>]+>/g, ' ') : '';
|
|
1397
|
+
// F074.67 — HOW each phrase matched, not just whether. A phrase the page drew
|
|
1398
|
+
// with different capitalisation than the caller wrote is FOUND (that is what
|
|
1399
|
+
// was asked) but the difference is reported, because it is almost always
|
|
1400
|
+
// `text-transform` in the stylesheet and the caller should see that the page
|
|
1401
|
+
// says something other than their source does.
|
|
1402
|
+
const matches = expected.map((t) => [t, matchPhrase(stats ?? svgText, t)] as const);
|
|
1403
|
+
const textFound = Object.fromEntries(matches.map(([t, m]) => [t, m !== 'absent']));
|
|
1404
|
+
const casingDiffers = matches.filter(([, m]) => m === 'case-insensitive').map(([t]) => t);
|
|
1405
|
+
// F074.65 — a `false` here had TWO meanings and no way to tell them apart.
|
|
1406
|
+
//
|
|
1407
|
+
// Measured on the committed fixture: `expect_text` answered false for a phrase
|
|
1408
|
+
// that IS on the page (drawn in a Type 3 font we could not decode) and false
|
|
1409
|
+
// for a phrase in no document at all. Identical answers, opposite fixes — one
|
|
1410
|
+
// says "your headline is missing", the other says "your headline became
|
|
1411
|
+
// outlines". A caller acting on the first would go looking for a layout bug
|
|
1412
|
+
// that does not exist.
|
|
1413
|
+
//
|
|
1414
|
+
// So when this document dropped text, the misses are named as UNVERIFIABLE
|
|
1415
|
+
// rather than reported as absences. THE HONEST LIMIT, stated rather than
|
|
1416
|
+
// papered over: within one document that dropped runs we cannot separate the
|
|
1417
|
+
// two, so every miss is listed. Across documents we can — a file with nothing
|
|
1418
|
+
// dropped lists nothing, which is what keeps this from degrading into "every
|
|
1419
|
+
// miss is probably outlines".
|
|
1420
|
+
const unreadable = stats && stats.undecodedRuns > 0
|
|
1421
|
+
? expected.filter((t) => !textFound[t])
|
|
1422
|
+
: [];
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
export_id: exportId,
|
|
1426
|
+
project,
|
|
1427
|
+
url: input.url,
|
|
1428
|
+
final_url: result.finalUrl,
|
|
1429
|
+
title: result.title,
|
|
1430
|
+
format,
|
|
1431
|
+
export_url: artifactUrl(deps.port, key),
|
|
1432
|
+
artifact_key: key,
|
|
1433
|
+
bytes: result.bytes.length,
|
|
1434
|
+
page_size: result.pageSize,
|
|
1435
|
+
landscape: result.landscape,
|
|
1436
|
+
media: result.media,
|
|
1437
|
+
print_background: result.printBackground,
|
|
1438
|
+
// null = NOT MEASURED, never a plausible default: an assertion on page
|
|
1439
|
+
// geometry must not pass on a PDF whose geometry was never read.
|
|
1440
|
+
page_mm: box
|
|
1441
|
+
? { width: Number(ptToMm(box.widthPt).toFixed(1)), height: Number(ptToMm(box.heightPt).toFixed(1)) }
|
|
1442
|
+
: null,
|
|
1443
|
+
// The verdict a graphic designer actually cares about.
|
|
1444
|
+
editable_text: stats ? isVectorText(stats) : (result.svg?.counts.text ?? 0) > 0,
|
|
1445
|
+
// Counts only. Extracted text is deliberately never returned — see
|
|
1446
|
+
// expect_text above.
|
|
1447
|
+
content: stats
|
|
1448
|
+
? {
|
|
1449
|
+
textRuns: stats.textRuns,
|
|
1450
|
+
fonts: stats.fonts,
|
|
1451
|
+
images: stats.images,
|
|
1452
|
+
undecodableStreams: stats.undecodableStreams,
|
|
1453
|
+
// F074.65 — runs whose characters we dropped. Separate from
|
|
1454
|
+
// undecodableStreams (inflate failures) on purpose: merging them is how
|
|
1455
|
+
// a lost line was reported as 0 problems.
|
|
1456
|
+
undecodedRuns: stats.undecodedRuns,
|
|
1457
|
+
textChars: stats.text.length,
|
|
1458
|
+
}
|
|
1459
|
+
: result.svg?.counts ?? null,
|
|
1460
|
+
// F074.65 — the verdict a designer can ACT on. Present only when the page is
|
|
1461
|
+
// drawn with outline fonts, so its absence is the good case rather than a
|
|
1462
|
+
// field nobody reads.
|
|
1463
|
+
...(stats && outlineFontNames(stats).length > 0
|
|
1464
|
+
? {
|
|
1465
|
+
outline_fonts: {
|
|
1466
|
+
families: outlineFontNames(stats),
|
|
1467
|
+
cause:
|
|
1468
|
+
'Chromium cannot subset a VARIABLE font into a PDF font program, so it draws every ' +
|
|
1469
|
+
'glyph as a Type 3 procedure. The document prints correctly and opens in Illustrator ' +
|
|
1470
|
+
'as paths — the text can be moved but never re-typed.',
|
|
1471
|
+
remedy:
|
|
1472
|
+
'Serve STATIC instances of these families instead of the variable font files ' +
|
|
1473
|
+
'(fontTools\' instancer produces them). Note that macOS system-ui / -apple-system is ' +
|
|
1474
|
+
'a variable font, so Tailwind\'s default --font-sans stack lands here by default. ' +
|
|
1475
|
+
'Or export format:\'svg\', which writes the font family by NAME and stays editable.',
|
|
1476
|
+
},
|
|
1477
|
+
}
|
|
1478
|
+
: {}),
|
|
1479
|
+
// F074.64 — what the SVG could NOT represent, named rather than approximated.
|
|
1480
|
+
// An export that silently omits an element looks complete and is not.
|
|
1481
|
+
...(result.svg ? { unsupported: result.svg.unsupported } : {}),
|
|
1482
|
+
...(expected.length > 0 ? { text_found: textFound } : {}),
|
|
1483
|
+
// A miss we cannot vouch for. Empty (and absent) whenever the document read
|
|
1484
|
+
// cleanly, so a plain `false` keeps meaning "not on the page".
|
|
1485
|
+
...(unreadable.length > 0 ? { text_found_unverifiable: unreadable } : {}),
|
|
1486
|
+
// F074.67 — found, but the page painted it in different capitals than the
|
|
1487
|
+
// caller typed. Named rather than folded into a bare `true`: the difference
|
|
1488
|
+
// is real and is nearly always a `text-transform` rule.
|
|
1489
|
+
...(casingDiffers.length > 0 ? { text_found_casing_differs: casingDiffers } : {}),
|
|
1490
|
+
page_errors: result.pageErrors,
|
|
1491
|
+
duration_ms: result.durationMs,
|
|
1492
|
+
};
|
|
1493
|
+
}
|