@broberg/lens-daemon 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,118 @@
1
+ // lens-gap 019efc0e — the daemon self-provisions its OWN pinned Chromium.
2
+ //
3
+ // Why this exists: the daemon pins playwright (see apps/agent/package.json) to a
4
+ // specific build revision (e.g. chromium-1223) and resolves the browser under its
5
+ // OWN PLAYWRIGHT_BROWSERS_PATH. An operator who runs an ambient
6
+ // `npx/bunx playwright install chromium` installs whatever revision THAT
7
+ // playwright resolves — a different revision/path than the daemon expects — so
8
+ // captures still fail with a vague "not installed" (the exact trap that cost the
9
+ // cb-2 operator time: install ran, daemon still red). The fix: the daemon installs
10
+ // from its OWN pinned playwright CLI, inheriting its OWN env, so it lands exactly
11
+ // the revision + path it looks in. Manual install becomes unnecessary.
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { dirname, join } from 'node:path';
17
+ import { chromium } from 'playwright';
18
+
19
+ export interface ChromiumExpectation {
20
+ installed: boolean;
21
+ /** Absolute path the daemon's pinned playwright resolves the chromium binary to. */
22
+ execPath: string | null;
23
+ /** The browsers root the daemon reads (PLAYWRIGHT_BROWSERS_PATH, or the OS default). */
24
+ browsersPath: string;
25
+ /** The pinned build-revision folder (e.g. "chromium-1223"), parsed from execPath. */
26
+ revision: string | null;
27
+ }
28
+
29
+ const DEFAULT_BROWSERS_PATH = '~/Library/Caches/ms-playwright (macOS default)';
30
+
31
+ /** What Chromium does the daemon expect, and is it on disk? Pure + cheap:
32
+ * executablePath() only COMPUTES the path; it does not check the binary exists. */
33
+ export function chromiumExpectation(): ChromiumExpectation {
34
+ let execPath: string | null = null;
35
+ try {
36
+ execPath = chromium.executablePath();
37
+ } catch {
38
+ execPath = null;
39
+ }
40
+ const installed = Boolean(execPath) && existsSync(execPath as string);
41
+ const revision = execPath?.match(/chromium[_-][0-9]+/)?.[0] ?? null;
42
+ return {
43
+ installed,
44
+ execPath,
45
+ browsersPath: process.env.PLAYWRIGHT_BROWSERS_PATH || DEFAULT_BROWSERS_PATH,
46
+ revision,
47
+ };
48
+ }
49
+
50
+ /** The caller-actionable reason a capture can't run — names the EXACT revision +
51
+ * path THIS daemon looks in, not a generic command that may install elsewhere. */
52
+ export function chromiumGapReason(exp: ChromiumExpectation): string {
53
+ const rev = exp.revision ? ` (${exp.revision})` : '';
54
+ return (
55
+ `Chromium${rev} not installed where this daemon looks. ` +
56
+ `Expected binary: ${exp.execPath ?? '(unresolved)'} · browsers root: ${exp.browsersPath}. ` +
57
+ `The daemon is self-provisioning it now (its own pinned playwright → matching revision) — ` +
58
+ `retry the capture in ~30s. To install manually, run from the cardmem repo root: ` +
59
+ `bunx playwright install chromium`
60
+ );
61
+ }
62
+
63
+ // Single in-flight install — never spawn two concurrent ~150MB downloads. A
64
+ // failed install resets so a later trigger can retry, but it never loops itself.
65
+ let inFlight: Promise<{ ok: boolean; error?: string }> | null = null;
66
+
67
+ /** Resolve the daemon's OWN pinned playwright CLI entry. `playwright/cli.js` is
68
+ * the package's bin but is NOT in its `exports` map (ERR_PACKAGE_PATH_NOT_EXPORTED),
69
+ * so resolve via package.json (which IS exported) + join the sibling cli.js. */
70
+ function resolvePlaywrightCli(): string {
71
+ const require = createRequire(import.meta.url);
72
+ const pkgJson = require.resolve('playwright/package.json');
73
+ return join(dirname(pkgJson), 'cli.js');
74
+ }
75
+
76
+ /** Ensure the daemon's pinned Chromium is on disk, installing it via the daemon's
77
+ * OWN playwright CLI (inherits env → exact revision + path). Idempotent + deduped;
78
+ * resolves immediately when already installed. Never throws. */
79
+ export function ensureChromium(): Promise<{
80
+ ok: boolean;
81
+ error?: string;
82
+ alreadyInstalled?: boolean;
83
+ }> {
84
+ const exp = chromiumExpectation();
85
+ if (exp.installed) return Promise.resolve({ ok: true, alreadyInstalled: true });
86
+ if (inFlight) return inFlight;
87
+
88
+ inFlight = new Promise<{ ok: boolean; error?: string }>((resolve) => {
89
+ let cliPath: string;
90
+ try {
91
+ cliPath = resolvePlaywrightCli();
92
+ } catch (err) {
93
+ resolve({
94
+ ok: false,
95
+ error: `cannot resolve playwright CLI: ${err instanceof Error ? err.message : String(err)}`,
96
+ });
97
+ return;
98
+ }
99
+ console.log(
100
+ `[lens] Chromium missing (${exp.revision ?? '?'}, expected ${exp.execPath}) — self-provisioning via ${cliPath} …`,
101
+ );
102
+ const child = spawn(process.execPath, [cliPath, 'install', 'chromium'], {
103
+ stdio: 'inherit',
104
+ env: process.env,
105
+ });
106
+ child.on('error', (err) => resolve({ ok: false, error: err.message }));
107
+ child.on('exit', (code) => {
108
+ const ok = code === 0 && chromiumExpectation().installed;
109
+ if (ok) console.log('[lens] Chromium self-provisioned ✓');
110
+ else console.warn(`[lens] Chromium self-provision failed (exit ${code})`);
111
+ resolve(ok ? { ok: true } : { ok: false, error: `playwright install exited ${code}` });
112
+ });
113
+ }).finally(() => {
114
+ inFlight = null;
115
+ });
116
+
117
+ return inFlight;
118
+ }
package/src/export.ts ADDED
@@ -0,0 +1,256 @@
1
+ // F074.63 — lens_export: HTML → a vector PDF, where text stays text.
2
+ //
3
+ // Raised by fd-sundhed with a customer deadline: information material for
4
+ // Aalborg Kommune lives as an HTML mockup, and the municipality's own graphic
5
+ // designer has to be able to correct the text. The only path before this was
6
+ // lens_capture PNGs stitched into a PDF — the owner opened that in Illustrator
7
+ // and counted ONE <Linked File> and ZERO text objects. It is a picture of a
8
+ // document, so nothing in it can be edited.
9
+ //
10
+ // It could not be rescued with more pixels either: `scale` is capped by the
11
+ // daemon's pixel limit (scale 8 on A3 → "Input image exceeds pixel limit",
12
+ // ~16M px), which puts the ceiling at 257 DPI against a 300 DPI print spec. A
13
+ // vector PDF has no resolution at all, so that constraint does not get pushed
14
+ // back — it stops existing.
15
+ //
16
+ // This is deliberately the CAPTURE pipeline with one terminal call changed:
17
+ // withPage → navigateAndAct → settle, then page.pdf() instead of
18
+ // page.screenshot(). A second navigation/auth implementation would drift from
19
+ // capture() the first time either was touched.
20
+ import type { Page } from 'playwright';
21
+ import { withPage } from './browser.ts';
22
+ import { navigateAndAct, resolvePrepareContext, settleForShot, type CaptureOptions } from './capture.ts';
23
+ import { collectFn } from '@broberg/lens-svg';
24
+ import { paperMm, serializeSvg, lintSvg, PAPER_SIZES, type SvgNode } from '@broberg/lens-svg';
25
+
26
+ export type ExportFormat = 'pdf' | 'svg';
27
+
28
+ /** Which CSS media the page is rendered as.
29
+ *
30
+ * This is the single most likely way an export ships looking broken while
31
+ * being technically correct. Playwright's page.pdf() renders PRINT media, so a
32
+ * page designed for screen can lose its entire layout to an @media print rule
33
+ * — and nothing errors, so the caller gets a confident, wrong PDF. We default
34
+ * to 'screen' so the caller gets what they SEE unless they ask otherwise. */
35
+ export type ExportMedia = 'screen' | 'print';
36
+
37
+ export interface ExportMargin {
38
+ top?: string;
39
+ right?: string;
40
+ bottom?: string;
41
+ left?: string;
42
+ }
43
+
44
+ export interface ExportOptions
45
+ extends Omit<CaptureOptions, 'mode' | 'critic' | 'criticVisionModel'> {
46
+ /** F074.64 — SVG only: export ONE surface, not the whole mockup document. A
47
+ * print job is one sheet; a mockup page holds five. Ignored by the PDF path,
48
+ * which has no element mode. */
49
+ selector?: string;
50
+ /** Which match to take when the selector hits several — a bifold folder has two
51
+ * sheets under one class. Default 0. */
52
+ selectorIndex?: number;
53
+ format?: ExportFormat;
54
+ /** Paper size — 'A3', 'A4', 'Letter', … Passed to Playwright's `format`. */
55
+ pageSize?: string;
56
+ landscape?: boolean;
57
+ /** Default TRUE, deliberately against Playwright's own default of false:
58
+ * with backgrounds off, every background colour and image disappears and an
59
+ * A3 poster comes out white — a silently ruined deliverable that still
60
+ * reports success. */
61
+ printBackground?: boolean;
62
+ margin?: ExportMargin;
63
+ media?: ExportMedia;
64
+ }
65
+
66
+ export interface ExportResult {
67
+ bytes: Buffer;
68
+ /** F074.64 — what the SVG path could and could not represent. Null for PDF. */
69
+ svg?: {
70
+ counts: { text: number; rect: number; image: number; unsupported: number };
71
+ unsupported: Array<{ reason: string; locator: string }>;
72
+ lint: { outOfBounds: Array<{ what: string; detail: string }>; malformed: string[] };
73
+ } | null;
74
+ format: ExportFormat;
75
+ pageSize: string;
76
+ landscape: boolean;
77
+ media: ExportMedia;
78
+ printBackground: boolean;
79
+ title: string;
80
+ finalUrl: string;
81
+ pageErrors: string[];
82
+ durationMs: number;
83
+ }
84
+
85
+ /** page.pdf() exists on Chromium ONLY. Firefox and WebKit do not implement it.
86
+ *
87
+ * This throws rather than degrading, and the reason matters: falling back to a
88
+ * screenshot would hand back EXACTLY the bitmap this whole card exists to
89
+ * replace, wearing a .pdf extension — the worst available outcome, because it
90
+ * would look like the fix had landed. */
91
+ export function assertEngineCanExport(engine: string | undefined | null): void {
92
+ const e = (engine ?? 'chromium').toLowerCase();
93
+ if (e !== 'chromium') {
94
+ throw new Error(
95
+ `lens_export requires engine:'chromium' — PDF generation is a Chromium-only capability ` +
96
+ `and ${e} cannot do it. Refusing rather than returning a screenshot renamed .pdf, ` +
97
+ `which is the un-editable bitmap this tool exists to replace.`,
98
+ );
99
+ }
100
+ }
101
+
102
+ /** The `%PDF-` magic. Cheap, and it separates "we wrote bytes" from "we wrote a
103
+ * PDF" — two different claims. */
104
+ export function looksLikePdf(bytes: Buffer): boolean {
105
+ return bytes.length > 5 && bytes.subarray(0, 5).toString('latin1') === '%PDF-';
106
+ }
107
+
108
+ /** Read the FIRST /MediaBox out of a PDF, in PostScript points (1/72").
109
+ *
110
+ * Chromium emits page dictionaries in cleartext, so this needs no parser and no
111
+ * dependency. Returns null when it cannot find one — a null must be reported as
112
+ * "not measured", never defaulted to a plausible size, or an assertion on page
113
+ * geometry would pass on a PDF whose geometry was never read. */
114
+ export function readMediaBox(bytes: Buffer): { widthPt: number; heightPt: number } | null {
115
+ const m = bytes
116
+ .toString('latin1')
117
+ .match(/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/);
118
+ if (!m) return null;
119
+ const w = Number(m[3]) - Number(m[1]);
120
+ const h = Number(m[4]) - Number(m[2]);
121
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
122
+ return { widthPt: w, heightPt: h };
123
+ }
124
+
125
+ /** Points → millimetres, for comparing against a paper size a human named. */
126
+ export function ptToMm(pt: number): number {
127
+ return (pt / 72) * 25.4;
128
+ }
129
+
130
+ /** The resolved defaults, as a PURE function.
131
+ *
132
+ * Split out of exportPage so the defaults can be SEALED without launching a
133
+ * browser. Two of them deviate from what the library would do on its own —
134
+ * printBackground true (Playwright says false) and media 'screen' (page.pdf()
135
+ * renders print) — and both deviations are invisible when wrong: the poster
136
+ * comes out white, or laid out by a print stylesheet, and the export still
137
+ * reports success. A default that only lives inside a call nothing can reach
138
+ * without Chromium is a default a library upgrade can quietly take back. */
139
+ export interface ResolvedExportOptions {
140
+ format: ExportFormat;
141
+ media: ExportMedia;
142
+ pageSize: string;
143
+ landscape: boolean;
144
+ printBackground: boolean;
145
+ }
146
+
147
+ export function resolveExportOptions(opts: Partial<ExportOptions> = {}): ResolvedExportOptions {
148
+ return {
149
+ format: opts.format ?? 'pdf',
150
+ media: opts.media ?? 'screen',
151
+ pageSize: opts.pageSize ?? 'A4',
152
+ landscape: opts.landscape ?? false,
153
+ printBackground: opts.printBackground ?? true,
154
+ };
155
+ }
156
+
157
+ export async function exportPage(opts: ExportOptions): Promise<ExportResult> {
158
+ const startedAt = Date.now();
159
+ assertEngineCanExport(opts.engine);
160
+
161
+ const { format, media, pageSize, landscape, printBackground } = resolveExportOptions(opts);
162
+
163
+ return withPage(
164
+ {
165
+ engine: 'chromium',
166
+ viewport: opts.viewport,
167
+ deviceScaleFactor: opts.deviceScaleFactor,
168
+ prepareContext: resolvePrepareContext(opts),
169
+ },
170
+ async (page: Page) => {
171
+ const pageErrors: string[] = [];
172
+ page.on('pageerror', (err) => pageErrors.push(err.message));
173
+
174
+ await navigateAndAct(page, opts as CaptureOptions);
175
+ // The same settle the screenshot path uses (fonts, images, animations) —
176
+ // a PDF taken mid-webfont-swap renders in the fallback face, which is a
177
+ // silent visual regression a designer WILL notice and we would not.
178
+ await settleForShot(page);
179
+
180
+ // Must come before pdf(): page.pdf() renders print media unless the page
181
+ // has been told otherwise.
182
+ await page.emulateMedia({ media });
183
+
184
+ const title = await page.title();
185
+ const finalUrl = page.url();
186
+
187
+ // F074.64 — SVG is read from the LIVE DOM, before anything becomes a PDF.
188
+ // By PDF time a heading is a run of glyphs with a coordinate per letter;
189
+ // here it is still one element with one string, one font and one box.
190
+ if (format === 'svg') {
191
+ const paper = paperMm(pageSize, landscape);
192
+ if (!paper) {
193
+ throw new Error(
194
+ `lens_export format:'svg' needs a known paper size to emit millimetres; ` +
195
+ `'${pageSize}' is not one of ${Object.keys(PAPER_SIZES).join(', ')}. ` +
196
+ `A pixel-unit SVG is a screenshot with editable text, so this refuses ` +
197
+ `rather than guessing the physical size of the page.`,
198
+ );
199
+ }
200
+ const collected = (await page.evaluate(collectFn(opts.selector ?? null, opts.selectorIndex ?? 0))) as {
201
+ nodes: SvgNode[];
202
+ pageWidthPx: number;
203
+ pageHeightPx: number;
204
+ };
205
+ // The page's own rendered width IS the paper width — that is what the
206
+ // caller laid the mockup out to. Everything else scales from it.
207
+ const pxPerMm = collected.pageWidthPx / paper.widthMm;
208
+ const out = serializeSvg(collected.nodes, paper, pxPerMm);
209
+ const lint = lintSvg(out.svg, collected.nodes, paper, pxPerMm);
210
+ return {
211
+ bytes: Buffer.from(out.svg, 'utf8'),
212
+ svg: {
213
+ counts: out.counts,
214
+ // F074.66 — everything EXCEPT the internal discriminator, rather than a
215
+ // hand-written field list. The list version silently dropped `count` the
216
+ // moment the collector started producing it, which is this card's own
217
+ // defect one layer further out: the report is assembled correctly and a
218
+ // boundary quietly narrows it.
219
+ unsupported: out.unsupported.map(({ kind: _kind, ...rest }) => rest),
220
+ lint,
221
+ },
222
+ format,
223
+ pageSize,
224
+ landscape,
225
+ media,
226
+ printBackground,
227
+ title,
228
+ finalUrl,
229
+ pageErrors,
230
+ durationMs: Date.now() - startedAt,
231
+ };
232
+ }
233
+
234
+ const bytes = await page.pdf({
235
+ format: pageSize,
236
+ landscape,
237
+ printBackground,
238
+ ...(opts.margin ? { margin: opts.margin } : {}),
239
+ });
240
+
241
+ return {
242
+ bytes,
243
+ svg: null,
244
+ format,
245
+ pageSize,
246
+ landscape,
247
+ media,
248
+ printBackground,
249
+ title,
250
+ finalUrl,
251
+ pageErrors,
252
+ durationMs: Date.now() - startedAt,
253
+ };
254
+ },
255
+ );
256
+ }
package/src/fill.ts ADDED
@@ -0,0 +1,149 @@
1
+ // F213.7 — a fill that cannot prove its own write is not a write.
2
+ //
3
+ // sanne measured this against a React-controlled <textarea> on a live surface: the
4
+ // first fill lost its FIRST character, a second fill landed IN FRONT of the old value
5
+ // (`ABCDEF-1234IDLERTIDIG-…`), and fill("") did not clear the field. All three
6
+ // returned SUCCESS, because Playwright's fill() resolves when it has driven the
7
+ // element — nothing asks the element what it now holds.
8
+ //
9
+ // WHY THAT IS CRITICAL RATHER THAN ANNOYING: cardmem shipped a hard rule to 29 repos
10
+ // saying a save is only proven by READING THE VALUE BACK, and that you must also prove
11
+ // the field can be EMPTY. `fill("")` silently not clearing is exactly that negative
12
+ // control — so the instrument the fleet was told to verify saves with could not run
13
+ // the half of the check that catches the worst failure.
14
+ //
15
+ // MEASURED BEFORE WRITING ANY OF THIS (the card blocked on it): NO layer translates
16
+ // the verb `fill` into the engine's `type`/pressSequentially. All four executors call
17
+ // Playwright's fill() directly — flow.ts, capture-session.ts, capture.ts, and
18
+ // @broberg/lens-engine's own flow. So the read-back is deliberately
19
+ // FRAMEWORK-AGNOSTIC and CAUSE-AGNOSTIC: it does not try to detect React, and it does
20
+ // not depend on knowing which layer misbehaves. It asserts the only thing that
21
+ // matters — after we drove the element, does it hold what we asked for?
22
+ //
23
+ // ────────────────────────────────────────────────────────────────────────────────────
24
+ // THE REACH OF THIS GUARD — read this before trusting a green fill (F213.8).
25
+ //
26
+ // It asks the ELEMENT. The element is not the APPLICATION. A green read-back proves the
27
+ // DOM node holds your value; it does NOT prove the page received it.
28
+ //
29
+ // sanne measured exactly that gap the day this shipped (lens-gap 01a01491):
30
+ //
31
+ // fillSelector input[type=time] = 07:15 → element.value === '07:15' ← green here
32
+ // the app's own state → still '10:00'
33
+ // the save request body → still '10:00'
34
+ // the file on disk after a reload → still '10:00'
35
+ //
36
+ // The page had finished painting ~1.5–2s before its scripts began listening, and the
37
+ // change never arrived afterwards (checked for 8s — it does not settle). Browsers render
38
+ // form controls themselves, so the value is there to read back whether or not anything
39
+ // is listening. Playwright's actionability checks do not cover it either: visible,
40
+ // enabled, stable, unobscured — "the application is listening" is not on that list.
41
+ //
42
+ // So: a green fill means "the field holds it", never "the write landed". To prove the
43
+ // latter, assert on what the APP did — the request it sent, the row it stored, the value
44
+ // after a reload.
45
+ //
46
+ // ── F213.8 SHIPPED THE GUARD FOR THE OTHER HALF: `waitForReady` ─────────────────────
47
+ //
48
+ // Put it BEFORE the first interaction and the flow waits until something is actually
49
+ // registered for input/change on the target's propagation path — not a fixed sleep,
50
+ // and no framework detection:
51
+ //
52
+ // { type: 'waitForReady', testid: 'start-time' }
53
+ // { type: 'fill', testid: 'start-time', value: '07:15' }
54
+ //
55
+ // MEASURED on a page that starts listening 3s after paint (input-only listener, the
56
+ // React-controlled shape):
57
+ //
58
+ // without the gate element 07:15 · app state 10:00 ← green fill, write LOST
59
+ // with the gate element 07:15 · app state 07:15 ← gate waited 3039ms
60
+ // already listening gate returned in 35ms ← not a blanket delay
61
+ //
62
+ // IT DOES NOT REPLACE THIS READ-BACK, and neither replaces asserting on the stored row.
63
+ // Three different questions: waitForReady asks "can the app hear me", the read-back asks
64
+ // "does the field hold it", and only the app's own request/row/reload answers "did the
65
+ // write land". A flow that needs the last one still has to ask for it.
66
+ //
67
+ // TWO ACCIDENTAL PROTECTIONS FOUND WHILE MEASURING — do not rely on either. `click`'s
68
+ // actionability work (sanne, #21295) and, on an `<input type=time>`, a late `change`
69
+ // fired on blur AFTER hydration both rescue an early write by luck. They are why this
70
+ // defect looks intermittent, and why the first fixture for it silently passed.
71
+ // ────────────────────────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * The minimal shape this needs. Deliberately structural rather than Playwright's
75
+ * concrete `Locator`: capture-session drives a narrow duck-typed page so its runner
76
+ * stays unit-testable with a fake, and a concrete type here would have forced a cast
77
+ * at exactly the call site the read-back is meant to protect.
78
+ */
79
+ export interface FillableLocator {
80
+ fill(value: string, opts?: { timeout?: number }): Promise<void>;
81
+ focus(opts?: { timeout?: number }): Promise<void>;
82
+ press(key: string, opts?: { timeout?: number }): Promise<void>;
83
+ evaluate<R>(fn: (el: HTMLElement) => R, arg?: unknown, opts?: { timeout?: number }): Promise<R>;
84
+ }
85
+
86
+ /** How much of a value to show in an error before truncating. */
87
+ const SHOWN = 120;
88
+
89
+ function show(s: string): string {
90
+ return s.length <= SHOWN ? JSON.stringify(s) : `${JSON.stringify(s.slice(0, SHOWN))}… (${s.length} chars)`;
91
+ }
92
+
93
+ /**
94
+ * The message a caller has to be able to fix their flow FROM — so it names BOTH
95
+ * strings. "the field did not match" sends them to the source; "expected X, holds
96
+ * XY" shows them the corruption itself.
97
+ */
98
+ export function fillMismatchMessage(want: string, got: string): string {
99
+ // ORDER MATTERS, and getting it wrong is how the most important message goes
100
+ // missing: EVERY string `startsWith('')`, so with the prefix test first the
101
+ // clear-refused case could never be reached — and that is precisely the fleet
102
+ // save-rule's negative control. The empty cases are therefore checked FIRST.
103
+ const extra =
104
+ want === '' && got !== ''
105
+ ? ' — the field refused to clear'
106
+ : want !== '' && got === ''
107
+ ? ' — the element is empty; the write did not land'
108
+ : got.startsWith(want) && got.length > want.length
109
+ ? ` — the element kept ${show(got.slice(want.length))} behind it`
110
+ : got.endsWith(want) && got.length > want.length
111
+ ? ` — the element kept ${show(got.slice(0, got.length - want.length))} in front of it`
112
+ : '';
113
+ return `fill did not land: expected ${show(want)}, element holds ${show(got)}${extra}`;
114
+ }
115
+
116
+ /** Read what the element ACTUALLY holds — input/textarea/select and contenteditable. */
117
+ export async function readElementValue(loc: FillableLocator, timeout: number): Promise<string> {
118
+ return loc.evaluate((el) => {
119
+ const e = el as HTMLElement & { value?: unknown };
120
+ if (e.isContentEditable) return e.textContent ?? '';
121
+ return typeof e.value === 'string' ? e.value : (e.textContent ?? '');
122
+ }, undefined, { timeout });
123
+ }
124
+
125
+ /**
126
+ * Fill, then PROVE it. On a mismatch, clear explicitly (select-all → Delete) and fill
127
+ * again — that recovers the shapes where fill() alone does not land — then read back
128
+ * once more and THROW if it still differs.
129
+ *
130
+ * The retry deliberately re-uses fill() rather than pressSequentially: typing
131
+ * character-by-character is the mechanism suspected of dropping the first character
132
+ * and writing at a caret that sits at position 0, so recovering with it would risk
133
+ * trading one silent corruption for another. And whatever the retry does, the final
134
+ * read-back stands in front of the caller — this function cannot return on a value it
135
+ * has not verified.
136
+ */
137
+ export async function fillAndVerify(loc: FillableLocator, want: string, timeout: number): Promise<void> {
138
+ await loc.fill(want, { timeout });
139
+ if ((await readElementValue(loc, timeout)) === want) return;
140
+
141
+ // Explicit clear, for the shapes where fill() did not replace what was there.
142
+ await loc.focus({ timeout });
143
+ await loc.press('ControlOrMeta+a', { timeout });
144
+ await loc.press('Delete', { timeout });
145
+ if (want !== '') await loc.fill(want, { timeout });
146
+
147
+ const got = await readElementValue(loc, timeout);
148
+ if (got !== want) throw new Error(fillMismatchMessage(want, got));
149
+ }