@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,76 @@
1
+ // F074.2 — Lens pre-flight.
2
+ //
3
+ // Pure, cheap checks run BEFORE launching a browser so a verify failure
4
+ // means "the surface is wrong", not "the dev-server wasn't up" or
5
+ // "Chromium isn't installed". Without this a forgotten `pnpm dev` reads as
6
+ // a red AC — a false fail that erodes trust in the whole gate.
7
+
8
+ import { chromiumExpectation, chromiumGapReason, ensureChromium } from './ensure-chromium.ts';
9
+
10
+ export interface PreflightResult {
11
+ ok: boolean;
12
+ checks: {
13
+ playwright_installed: boolean;
14
+ target_reachable: boolean;
15
+ };
16
+ /** Present when ok === false: the first failing reason, caller-actionable. */
17
+ reason?: string;
18
+ http_status?: number;
19
+ }
20
+
21
+ /** Can we reach the target at all? A GET with a short timeout. Any HTTP
22
+ * response (even 4xx/5xx) proves the server is up — only a connection
23
+ * failure / timeout means "not reachable". */
24
+ async function targetReachable(
25
+ url: string,
26
+ timeoutMs: number,
27
+ ): Promise<{ reachable: boolean; status?: number; error?: string }> {
28
+ try {
29
+ const res = await fetch(url, {
30
+ method: 'GET',
31
+ redirect: 'manual',
32
+ signal: AbortSignal.timeout(timeoutMs),
33
+ });
34
+ return { reachable: true, status: res.status };
35
+ } catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ // A TLS cert error (self-signed / mkcert dev-server, e.g. cms-admin :3010)
38
+ // means the handshake REACHED the server — it IS up, which is all preflight
39
+ // checks. The browser context ignores HTTPS errors at capture time, so don't
40
+ // fail preflight on a cert it would otherwise accept.
41
+ if (/certificate|self.?signed|unable to verify|ERR_TLS|SSL|DEPTH_ZERO/i.test(msg)) {
42
+ return { reachable: true };
43
+ }
44
+ return { reachable: false, error: msg };
45
+ }
46
+ }
47
+
48
+ export async function preflight(url: string, timeoutMs = 5_000): Promise<PreflightResult> {
49
+ const exp = chromiumExpectation();
50
+ if (!exp.installed) {
51
+ // lens-gap 019efc0e — kick off a self-provision in the background (deduped:
52
+ // boot may already be installing) and hand back an actionable reason naming
53
+ // the EXACT revision + path, so a retry succeeds without operator action.
54
+ void ensureChromium();
55
+ return {
56
+ ok: false,
57
+ checks: { playwright_installed: false, target_reachable: false },
58
+ reason: chromiumGapReason(exp),
59
+ };
60
+ }
61
+
62
+ const reach = await targetReachable(url, timeoutMs);
63
+ if (!reach.reachable) {
64
+ return {
65
+ ok: false,
66
+ checks: { playwright_installed: true, target_reachable: false },
67
+ reason: `target unreachable: ${url} (${reach.error}) — is the dev-server running?`,
68
+ };
69
+ }
70
+
71
+ return {
72
+ ok: true,
73
+ checks: { playwright_installed: true, target_reachable: true },
74
+ http_status: reach.status,
75
+ };
76
+ }
@@ -0,0 +1,282 @@
1
+ // F213.8 — "is this page LISTENING?", without asking which framework it is.
2
+ //
3
+ // THE DEFECT THIS EXISTS FOR. The browser renders and owns form controls itself,
4
+ // so a value written before the app's scripts attach lands in the DOM perfectly
5
+ // and is readable back — while nothing ever hears it. F213.7's read-back asks
6
+ // the ELEMENT; the element is not the APPLICATION. sanne lost three runs to this
7
+ // and nearly filed a real product defect as a test flake.
8
+ //
9
+ // THE SIGNAL. Not a heuristic, and not a framework sniff: an event is heard iff
10
+ // something is registered for it on the target or somewhere on its propagation
11
+ // path. So we observe the DOM API that does the registering —
12
+ // `EventTarget.prototype.addEventListener` — from an init script that runs
13
+ // BEFORE any page script, and later ask whether anything on the path registered.
14
+ //
15
+ // WHY NOT CDP `DOMDebugger.getEventListeners`, which is the obvious answer:
16
+ // it is Chromium-only, and this daemon also runs webkit and firefox (browser.ts
17
+ // ENGINES). A gate that silently does nothing on two of three engines is an
18
+ // absent guard that looks present — the exact shape the rest of this epic exists
19
+ // to remove. addInitScript is engine-agnostic.
20
+ //
21
+ // WHY IT COVERS REACT WITHOUT KNOWING ABOUT REACT: React 17+ delegates, binding
22
+ // one listener on the root container rather than per element. Walking the
23
+ // ancestor path to document+window finds that listener, because that is the same
24
+ // path the event itself would travel. Anything that hears the event is on it.
25
+
26
+ /** Event types that carry a user's edit into an application.
27
+ *
28
+ * MEASURED, and it is why `click` is NOT in here: `click` is bound on
29
+ * `document` by almost every page (and by our own injected scripts), so
30
+ * including it made the probe answer "listening" on a page that had not run a
31
+ * line of its own code. A probe that is always true is not a probe. The
32
+ * question is per-action — "is anything listening for the events THIS step will
33
+ * dispatch?" — so a verb that clicks should ask for `click` explicitly rather
34
+ * than have it folded into the default. */
35
+ export const EDIT_EVENTS = ['input', 'change'] as const;
36
+
37
+ /** How often to re-ask while waiting. Small enough that an already-listening
38
+ * page pays roughly nothing (measured: it answers on the first poll, ~60-210ms
39
+ * after navigation), large enough not to spin. */
40
+ const POLL_MS = 50;
41
+
42
+ /**
43
+ * Installed via `context.addInitScript` before any page script.
44
+ *
45
+ * It WRAPS, never blocks: the original is always called, and every bookkeeping
46
+ * step is inside try/catch. A readiness probe that could break the page it is
47
+ * measuring would be worse than no probe — this runs on every flow, including
48
+ * ones that never ask about readiness.
49
+ */
50
+ export const READINESS_INIT_SCRIPT = `(() => {
51
+ try {
52
+ if (window.__lensListenerReg) return;
53
+ var reg = new WeakMap();
54
+ var origAdd = EventTarget.prototype.addEventListener;
55
+ Object.defineProperty(window, '__lensListenerReg', { value: reg, configurable: false, enumerable: false });
56
+ EventTarget.prototype.addEventListener = function (type) {
57
+ try {
58
+ var s = reg.get(this);
59
+ if (!s) { s = new Set(); reg.set(this, s); }
60
+ s.add(String(type));
61
+ } catch (e) {}
62
+ return origAdd.apply(this, arguments);
63
+ };
64
+ } catch (e) {}
65
+ })();`;
66
+
67
+ /**
68
+ * Page-side expression: is anything listening for `events` on `selector`'s
69
+ * propagation path?
70
+ *
71
+ * Returns a discriminated result rather than a bare boolean, because the three
72
+ * "no" answers need different messages: the element is not there yet, nothing is
73
+ * listening yet, or WE were never installed (in which case a `false` would be an
74
+ * instrument failure reported as a page verdict — the failure mode this whole
75
+ * epic keeps finding).
76
+ */
77
+ export function readinessProbeExpr(selector: string, events: readonly string[] = EDIT_EVENTS): string {
78
+ const sel = JSON.stringify(selector);
79
+ const evs = JSON.stringify([...events]);
80
+ return `(${readinessOfElement.toString()})(document.querySelector(${sel}), ${evs})`;
81
+ }
82
+
83
+ /**
84
+ * The walk itself, as a REAL function so both callers can share one definition.
85
+ *
86
+ * ONE definition, two callers (F074.99). `waitForReady` asks by selector, via the
87
+ * expression above; `fill` hands this function straight to `locator.evaluate`, so
88
+ * it probes the exact element it is about to write to — not the same question
89
+ * when a selector matches several, because re-querying would probe one element
90
+ * and write to another.
91
+ *
92
+ * MEASURED, and it is why this is a function and not a string: Playwright's
93
+ * `locator.evaluate` does NOT call a string page-function with the element. All
94
+ * four shapes we tried — one-liner, braces, multiline, multiline-with-comment —
95
+ * returned `undefined` rather than throwing. A silent `undefined` read as a
96
+ * readiness verdict is exactly the false-green this card exists to remove.
97
+ *
98
+ * So it must stay SELF-CONTAINED: it is serialized into the page by `toString()`,
99
+ * and anything it closes over is not there. Same contract as `isEditableElement`.
100
+ */
101
+ export function readinessOfElement(el: Element | null, want: readonly string[]): ReadinessResult {
102
+ const reg = (window as unknown as { __lensListenerReg?: WeakMap<object, Set<string>> }).__lensListenerReg;
103
+ if (!reg) return { state: 'uninstrumented' };
104
+ if (!el) return { state: 'no-element' };
105
+ // The propagation path: the element, every ancestor, then document + window.
106
+ // Delegated frameworks register at the top of it.
107
+ for (let n: Node | null = el; n; n = n.parentNode) {
108
+ const s = reg.get(n);
109
+ if (s) { for (let i = 0; i < want.length; i++) if (s.has(want[i]!)) return { state: 'listening', via: 'path', event: want[i] }; }
110
+ }
111
+ const tops: object[] = [document, window];
112
+ for (let j = 0; j < tops.length; j++) {
113
+ const ts = reg.get(tops[j]!);
114
+ if (ts) { for (let k = 0; k < want.length; k++) if (ts.has(want[k]!)) return { state: 'listening', via: 'delegated', event: want[k] }; }
115
+ }
116
+ // addEventListener is not the only way to register. An inline onchange="" or
117
+ // a direct el.oninput = fn never passes through our wrapper, and a page using
118
+ // them IS listening — reporting "not ready" there would hang a working flow.
119
+ for (let m = 0; m < want.length; m++) {
120
+ if (typeof (el as unknown as Record<string, unknown>)['on' + want[m]] === 'function') return { state: 'listening', via: 'property', event: want[m] };
121
+ }
122
+ return { state: 'not-listening' };
123
+ }
124
+
125
+ export type ReadinessState = 'listening' | 'not-listening' | 'no-element' | 'uninstrumented';
126
+ export type ReadinessResult = { state: ReadinessState; via?: string; event?: string };
127
+
128
+ /**
129
+ * Turn a probe result + how long we waited into the message the author reads.
130
+ *
131
+ * Pure and exported so the wording is testable without a browser — and because
132
+ * AC6 is specifically about the MESSAGE: a page that never becomes interactive
133
+ * has to say so, instead of producing a generic timeout that reads exactly like
134
+ * a mistyped selector.
135
+ */
136
+ export function readinessFailure(r: ReadinessResult, selector: string, waitedMs: number, events: readonly string[]): string {
137
+ if (r.state === 'no-element') {
138
+ return `waitForReady: no element matched ${selector} within ${waitedMs}ms — readiness was never tested (this is a selector problem, not a hydration problem)`;
139
+ }
140
+ if (r.state === 'uninstrumented') {
141
+ // The instrument failed, and saying "not ready" here would report an
142
+ // instrument failure as a page verdict — the exact confusion this epic keeps
143
+ // finding. Name it as ours.
144
+ return `waitForReady: the listener observer was not installed on this page, so readiness cannot be judged — this is a Lens fault, not a page fault (init script missing; a page reached via a context Lens did not create, e.g. the flat/engine runner)`;
145
+ }
146
+ return `waitForReady: ${selector} exists and is rendered, but nothing is listening for ${events.join('/')} on its propagation path after ${waitedMs}ms — the page painted but its scripts never attached. A write here would land in the DOM and be silently lost.`;
147
+ }
148
+
149
+ /** What a readiness wait concluded. The failing half carries the STATE, not just
150
+ * a message: a caller that must decide whether to act (F074.99's `fill`) needs
151
+ * to tell "nothing is listening" — a verdict about the page — from "no element
152
+ * yet" and "we were never installed", which are not verdicts about the page at
153
+ * all. String-matching the message to recover that would be the same mistake
154
+ * InstrumentError exists to avoid. */
155
+ export type ListeningVerdict =
156
+ | { ok: true; waitedMs: number; via: string }
157
+ | { ok: false; error: string; state: ReadinessState; waitedMs: number; answered: boolean };
158
+
159
+ /**
160
+ * Wait until the page is actually listening for `events` on `selector`.
161
+ *
162
+ * NOT a fixed sleep (AC3): the floor is "whatever this page's hydration costs on
163
+ * this machine", which sanne showed is unknowable in advance — her workaround
164
+ * `assert performance.now() > 3000` is green on a fast machine and red in CI,
165
+ * and that is the failure mode being replaced.
166
+ *
167
+ * On an already-listening page this returns on the FIRST poll (measured ~60-210ms
168
+ * after navigation, which is the poll interval, not a wait) so it cannot become
169
+ * the blanket delay AC5 forbids.
170
+ */
171
+ export async function waitForListening(
172
+ page: { evaluate: (expr: string) => Promise<unknown> },
173
+ selector: string,
174
+ events: readonly string[],
175
+ timeoutMs: number,
176
+ ): Promise<ListeningVerdict> {
177
+ const expr = readinessProbeExpr(selector, events);
178
+ return waitForListeningOn(
179
+ () => page.evaluate(expr).then((v) => v as ReadinessResult).catch(() => null),
180
+ selector,
181
+ events,
182
+ timeoutMs,
183
+ );
184
+ }
185
+
186
+ /**
187
+ * The same wait, driven by whatever way the caller can ask the page ONCE.
188
+ *
189
+ * Exists so `fill` can ask through the LOCATOR it is about to write to, instead
190
+ * of re-querying a selector that might resolve to a different element (F074.99).
191
+ * `pollOnce` returns null for "no answer this round" — a rejection, a navigation
192
+ * mid-poll, a detached element — and the loop's deadline is what ends it.
193
+ */
194
+ export async function waitForListeningOn(
195
+ pollOnce: () => Promise<ReadinessResult | null>,
196
+ selector: string,
197
+ events: readonly string[],
198
+ timeoutMs: number,
199
+ ): Promise<ListeningVerdict> {
200
+ const started = Date.now();
201
+ // THE SEED IS A GUESS, NOT AN ANSWER, and a caller that ACTS on the verdict has
202
+ // to be able to tell (F074.99). If every poll rejects — a locator that matches
203
+ // several elements, a detached node, a page navigating on every tick — the loop
204
+ // ends holding this seed, and reporting it as "the page is not listening" would
205
+ // be a verdict about the page derived from never having asked it.
206
+ let last: ReadinessResult = { state: 'not-listening' };
207
+ let answered = false;
208
+ for (;;) {
209
+ // TWO WAYS THE PROBE ITSELF CAN FAIL, and neither may take the run with it:
210
+ //
211
+ // · the page navigates mid-poll → Playwright rejects with "Execution context
212
+ // was destroyed". That is not a readiness verdict, it is the page moving
213
+ // under us; the honest response is to ask again on the new document.
214
+ // · `page.evaluate` has NO default timeout in Playwright, so a page busy in a
215
+ // synchronous loop would hang here FOREVER — straight past the caller's
216
+ // timeout. A gate that hangs is worse than one that fails: the author gets
217
+ // no verdict at all and the flow never ends.
218
+ //
219
+ // Both collapse to "no answer this round". The loop's own deadline below is
220
+ // what ends it, so a caller's timeout_ms is always the wall clock.
221
+ const raced = await Promise.race([
222
+ pollOnce(),
223
+ new Promise<null>((r) => setTimeout(() => r(null), Math.max(POLL_MS, Math.min(2_000, timeoutMs)))),
224
+ ]);
225
+ if (raced) { last = raced; answered = true; }
226
+ if (last.state === 'listening') return { ok: true, waitedMs: Date.now() - started, via: last.via ?? 'path' };
227
+ // An uninstrumented page will never become instrumented by waiting — the
228
+ // init script runs at navigation or not at all. Failing fast here keeps a
229
+ // Lens fault from being reported as a slow page.
230
+ if (last.state === 'uninstrumented') break;
231
+ if (Date.now() - started >= timeoutMs) break;
232
+ await new Promise((r) => setTimeout(r, POLL_MS));
233
+ }
234
+ const waitedMs = Date.now() - started;
235
+ return { ok: false, error: readinessFailure(last, selector, waitedMs, events), state: last.state, waitedMs, answered };
236
+ }
237
+
238
+ /** F074.99 — an edit verb wrote to a field on a page that was not listening.
239
+ *
240
+ * Reported on a PASSING run, never failing it: unlike `password_in_url` this
241
+ * finding HAS a benign reading (a plain server-rendered form), so turning it red
242
+ * would redden working flows fleet-wide on the day it ships. Naming the benign
243
+ * reading in the message is the price of that choice — a warning a reader cannot
244
+ * dismiss on sight is a warning they stop reading. */
245
+ export interface NotListeningFinding {
246
+ /** How the step addressed the field, for the report. */
247
+ anchor: string;
248
+ /** The page it happened on. Also the memo key: once a page has answered, the
249
+ * other edit verbs on it skip the probe rather than each paying the budget. */
250
+ url: string;
251
+ /** Which of the two answers this is. `uninstrumented` is OUR fault, not the
252
+ * page's, and it means the check DID NOT RUN — which is a different fact from
253
+ * "the page was fine" and must never be readable as it. */
254
+ state: 'not-listening' | 'uninstrumented';
255
+ waited_ms: number;
256
+ /** Ready to print. */
257
+ message: string;
258
+ }
259
+
260
+ export function notListeningMessage(
261
+ state: 'not-listening' | 'uninstrumented',
262
+ anchor: string,
263
+ waitedMs: number,
264
+ events: readonly string[],
265
+ ): string {
266
+ if (state === 'uninstrumented') {
267
+ return (
268
+ `The readiness check DID NOT RUN before writing to ${anchor}: the listener observer was not ` +
269
+ 'installed on this page, so whether anything was listening is UNKNOWN — this is a Lens fault, ' +
270
+ 'not a page fault, and it means this run carries no hydration verdict either way.'
271
+ );
272
+ }
273
+ return (
274
+ `A value was typed into ${anchor} on a page where nothing was listening for ${events.join('/')} ` +
275
+ `after ${waitedMs}ms. The element exists and is rendered, and the write landed in the DOM where it ` +
276
+ 'reads back correctly — no application code heard it. Two readings: the page\'s scripts never ' +
277
+ 'attached, in which case every step can still be green over fields the app never received (check ' +
278
+ 'the app is served on the same hostname its client bundle is allowed to load from) — or this is a ' +
279
+ 'plain server-rendered form that legitimately has no listener, in which case the write is real and ' +
280
+ 'this note is noise.'
281
+ );
282
+ }