@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,427 @@
1
+ // F312.25 — ONE recording implementation, reachable through TWO doors.
2
+ //
3
+ // This was the body of `app.post('/lens/record')` in index.ts and nothing else.
4
+ // It moved out unchanged (only the Hono response wrappers became plain values)
5
+ // so the MCP verb and the HTTP route cannot drift: F095.15 is this repo's
6
+ // precedent — a rule present through move_card and absent through update_card,
7
+ // correct-looking from either side, and it took a peer repo to notice.
8
+ //
9
+ // The alternative was a second handler in mcp.ts that "does the same thing".
10
+ // It would have been right on the day it was written.
11
+
12
+ import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
13
+ import * as nodePath from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { loadManifest } from './manifest.ts';
16
+ import { recordSurface, scrollUpBy } from './record.ts';
17
+ import { defects, measureExercise } from './motion-probe.ts';
18
+ import { cutFrames } from './frames.ts';
19
+ import { authPrepare, type AuthConfig } from './auth/index.ts';
20
+
21
+ export type RecordDeps = {
22
+ config: { port: number; sandbox_roots?: string[] };
23
+ storage: { put(key: string, bytes: Uint8Array | Buffer): Promise<unknown> };
24
+ };
25
+
26
+ /** Every knob the recorder takes. Kept as one type so the MCP schema and the
27
+ * HTTP route describe the same call rather than two overlapping ones. */
28
+ export type RecordRequest = Record<string, unknown>;
29
+
30
+ export async function runRecord(raw: RecordRequest, deps: RecordDeps) {
31
+ const { config, storage: lensStorage } = deps;
32
+ const body = raw as {
33
+ project?: string;
34
+ url?: string;
35
+ seconds?: number;
36
+ selector?: string;
37
+ row_attr?: string;
38
+ interval_ms?: number;
39
+ min_delta_px?: number;
40
+ viewport?: { width: number; height: number };
41
+ reduced_motion?: 'reduce' | 'no-preference';
42
+ local_path?: string;
43
+ auth?: unknown;
44
+ fps?: number;
45
+ /** Cut a dense burst around each finding, at this fps. 0 disables. */
46
+ burst_fps?: number;
47
+ /** F312.5 — testid PREFIX of a field to focus and type into throughout. */
48
+ type_into?: string;
49
+ /**
50
+ * F312.9 — SEND A MESSAGE INTO THE CHAT EVERY N MS WHILE RECORDING.
51
+ *
52
+ * Christian, and he is right that it should have been obvious: a recording of
53
+ * a stream that never moves proves nothing, and waiting for one to move by
54
+ * luck is not an experiment. Two 120s runs of the same build gave 4 findings
55
+ * and then 0 — the second window was simply quiet, and the exercise check
56
+ * (F312.8) correctly refused to call it a result.
57
+ *
58
+ * The generator removes the luck: the run produces its own traffic, at a
59
+ * fixed cadence, so every recording is comparable to every other one.
60
+ */
61
+ send_every_ms?: number;
62
+ /** F312.10 — scroll UP this far before watching: HIS situation, not the bottom. */
63
+ scroll_up_px?: number;
64
+ /** F312.11 — ms after each generated send to put the view BACK up. */
65
+ rescroll_after_ms?: number;
66
+ /** F312.13 — send from a SECOND tab, so the watched one never sends at all. */
67
+ traffic_tab?: boolean;
68
+ /**
69
+ * F312.16 — FEED THE SESSION, not the page. Christian's correction, and it
70
+ * closes the last hole in the experiment: a message typed into the composer
71
+ * is the page's OWN echo, and a second browser tab's messages were measured
72
+ * never arriving at all (3 sends, 0 landings). Neither is what he complains
73
+ * about, which is the AGENT writing while he sits still. Driving the tmux
74
+ * pane makes the agent produce genuine streamed output, and the watched tab
75
+ * does nothing but read.
76
+ */
77
+ feed_tmux?: string;
78
+ feed_every_ms?: number;
79
+ /** F312.20 — press controls DURING the recording, so "does this button do
80
+ * what it claims" becomes a reading instead of an argument. A SEQUENCE,
81
+ * because the interesting questions are two-step: leave a tab and come
82
+ * back, open a thing and close it. One click can only ask half of those. */
83
+ clicks?: { testid: string; at_ms: number }[];
84
+ /** F312.21 — seed the chat's open-tab set so a tab SWITCH can be driven. */
85
+ seed_tabs?: { session: string; channel?: string; label?: string }[];
86
+ };
87
+ if (!body.url) return { ok: false as const, status: 400, error: 'url required' };
88
+ const project = body.project ?? 'lens';
89
+ try {
90
+ let auth = body.auth as AuthConfig | undefined;
91
+ if (!auth && body.local_path) {
92
+ try {
93
+ auth = loadManifest(body.local_path, config.sandbox_roots ?? []).auth as AuthConfig | undefined;
94
+ } catch {
95
+ auth = undefined; // no manifest → an anonymous recording, said by omission
96
+ }
97
+ }
98
+ const runId = crypto.randomUUID();
99
+ // F312.16 — what we FED, with UTC send times, kept beside what the probe SAW.
100
+ // Declared BEFORE the run because the feeder closure below writes to it.
101
+ const feedLog: { marker: string; sentAt: number; error?: string }[] = [];
102
+ // F312.20 — what we PRESSED and whether the press landed at all.
103
+ const clickLog: { testid: string; at_ms: number; pressed: boolean }[] = [];
104
+ const result = await recordSurface({
105
+ url: body.url,
106
+ // F312.5 — put the caret in a field and KEEP TYPING while we watch. The
107
+ // worst version of this defect is the one where the ground moves
108
+ // mid-sentence, and it cannot be reproduced by a browser sitting still.
109
+ // F312.9 — the traffic generator. Runs WHILE the probe watches, and its
110
+ // sends deliberately do NOT stamp the driver clock: a message arriving is
111
+ // exactly the event under test, so a move it causes must stay reportable.
112
+ // F312.20 — press a control mid-recording. The click is NOT driver-stamped:
113
+ // pressing Now is the user asking to be moved, so the move it causes must
114
+ // stay fully reportable — and the ledger then says whether the app obeyed,
115
+ // refused, or never heard the press at all. Those are three different
116
+ // answers and a screenshot gives none of them.
117
+ ...(body.clicks && body.clicks.length > 0
118
+ ? {
119
+ during: async (page: import('playwright').Page, seconds: number) => {
120
+ const end = seconds * 1000;
121
+ let now = 0;
122
+ for (const c of [...body.clicks!].sort((a, b) => a.at_ms - b.at_ms)) {
123
+ const at = Math.min(Math.max(500, c.at_ms), end - 200);
124
+ if (at > now) await page.waitForTimeout(at - now);
125
+ now = at;
126
+ const ok = await page
127
+ .locator(`[data-testid="${c.testid}"]`)
128
+ .first()
129
+ .click({ timeout: 5000 })
130
+ .then(() => true)
131
+ .catch(() => false);
132
+ // A press that never landed and a press the app refused are
133
+ // different answers; the second is a finding and the first is a
134
+ // broken test. Recorded, never inferred.
135
+ clickLog.push({ testid: c.testid, at_ms: at, pressed: ok });
136
+ }
137
+ await page.waitForTimeout(Math.max(0, end - now));
138
+ },
139
+ }
140
+ : {}),
141
+ // F312.16 — the tmux feeder. Drives the AGENT, then the agent's own output
142
+ // streams into the watched tab. Nothing is typed into the page.
143
+ ...(body.feed_tmux
144
+ ? {
145
+ during: async (page: import('playwright').Page, seconds: number) => {
146
+ // The pane name comes over the wire, so it is validated rather than
147
+ // interpolated on trust — session:window.pane and nothing else.
148
+ const pane = body.feed_tmux!;
149
+ if (!/^[A-Za-z0-9_-]{1,64}:\d{1,3}\.\d{1,3}$/.test(pane)) {
150
+ feedLog.push({ marker: 'invalid-target', sentAt: Date.now(), error: pane.slice(0, 40) });
151
+ await page.waitForTimeout(seconds * 1000);
152
+ return;
153
+ }
154
+ const every = Math.max(3000, body.feed_every_ms ?? 8000);
155
+ const end = Date.now() + seconds * 1000;
156
+ let n = 0;
157
+ while (Date.now() < end) {
158
+ await page.waitForTimeout(every);
159
+ if (Date.now() >= end) break;
160
+ n += 1;
161
+ const marker = `LENSPROBE-${runId.slice(0, 6)}-${n}`;
162
+ const sentAt = Date.now();
163
+ try {
164
+ const { spawn } = await import('node:child_process');
165
+ await new Promise<void>((resolve) => {
166
+ const t = spawn('tmux', [
167
+ 'send-keys',
168
+ '-t',
169
+ pane,
170
+ `${marker} — automatisk måletrafik fra Lens Video. Svar KUN med: ${marker} ok`,
171
+ 'Enter',
172
+ ]);
173
+ t.on('error', () => resolve());
174
+ t.on('close', () => resolve());
175
+ });
176
+ feedLog.push({ marker, sentAt });
177
+ } catch (e) {
178
+ feedLog.push({
179
+ marker,
180
+ sentAt,
181
+ error: e instanceof Error ? e.message.slice(0, 120) : String(e).slice(0, 120),
182
+ });
183
+ }
184
+ }
185
+ },
186
+ }
187
+ : {}),
188
+ ...(body.send_every_ms && body.send_every_ms > 0
189
+ ? {
190
+ during: async (page: import('playwright').Page, seconds: number) => {
191
+ const every = Math.max(2000, body.send_every_ms!);
192
+ const end = Date.now() + seconds * 1000;
193
+ let n = 0;
194
+ // F312.13 — THE SENDER MUST NOT BE THE WATCHED TAB.
195
+ //
196
+ // Sending is itself a fair reason to snap to the bottom, and the
197
+ // owner's complaint is the opposite case: he sits STILL, the agent
198
+ // writes, and the view moves. A generator that sends from the tab
199
+ // being measured can never show that — measured 6 Sept, every
200
+ // remaining move landed within 600ms of its own send, i.e. inside
201
+ // the snap he asked for. Sending from a second tab in the same
202
+ // browser context puts real traffic on the channel while the
203
+ // watched tab does nothing but read.
204
+ // F312.13 — COUNT THE SENDS. "0 content events" cannot otherwise be
205
+ // told apart from "the generator never managed to send anything",
206
+ // and the second reads as a clean result while proving nothing.
207
+ const sendTally = { attempted: 0, sent: 0, failed: 0, lastError: null as string | null };
208
+ (globalThis as { __lensSends?: typeof sendTally }).__lensSends = sendTally;
209
+ const sender = body.traffic_tab && body.url ? await page.context().newPage() : page;
210
+ if (sender !== page) {
211
+ // Follow the WATCHED tab's own address, not the one we were given.
212
+ // The app writes the picked edge/session/channel into the URL once
213
+ // it has resolved them, so copying it puts both tabs on the same
214
+ // conversation. Measured 6 Sept: two tabs opened on a bare /chat
215
+ // each picked their own channel — 3 sends, 0 arrivals, and the run
216
+ // reported "the surface did not move".
217
+ await sender.goto(page.url(), { waitUntil: 'domcontentloaded', timeout: 30_000 });
218
+ await sender.waitForTimeout(3500);
219
+ await page.bringToFront();
220
+ }
221
+ while (Date.now() < end) {
222
+ await page.waitForTimeout(every);
223
+ if (Date.now() >= end) break;
224
+ n += 1;
225
+ sendTally.attempted += 1;
226
+ try {
227
+ // FOCUS, never CLICK. A pointerdown stamps the probe's
228
+ // scroll-intent clock, so clicking the composer made the
229
+ // generator label its own arrivals as "he scrolled" — measured:
230
+ // 13 of 15 moves misattributed that way.
231
+ const box = sender.locator('[data-testid="chat-send-task-input"]').first();
232
+ await box.focus({ timeout: 4000 });
233
+ await box.fill(`lens-video probe ${n} — ${'x'.repeat(40 + ((n * 17) % 400))}`);
234
+ await sender.keyboard.press('Meta+Enter').catch(() => {});
235
+ await sender.locator('[data-testid="chat-send-task-btn"]').first().dispatchEvent('click').catch(() => {});
236
+ if (sender !== page) await page.bringToFront();
237
+ // F312.11 — THE DISCRIMINATOR. Pressing send is itself a fair
238
+ // reason to go to the bottom, so a run that only sends can
239
+ // never tell "content arrived and moved me" apart from "I sent
240
+ // and it followed". Putting the view back up mid-stream — and
241
+ // stamping it as the driver's own — makes every later move in
242
+ // that window an answer to the question actually being asked.
243
+ if (body.rescroll_after_ms && body.scroll_up_px) {
244
+ await page.waitForTimeout(body.rescroll_after_ms);
245
+ await scrollUpBy(page, body.selector ?? null, body.scroll_up_px);
246
+ }
247
+ // 'sent' means the COMPOSER CLEARED — an exception-free run of
248
+ // the locators proves the elements existed, not that a message
249
+ // left. That distinction is the same false green this whole
250
+ // feature exists to remove.
251
+ const cleared = await box.inputValue().then((v) => v === '').catch(() => false);
252
+ if (cleared) sendTally.sent += 1;
253
+ else {
254
+ sendTally.failed += 1;
255
+ sendTally.lastError = 'composer did not clear — the message never left';
256
+ }
257
+ } catch (e) {
258
+ // A send that fails must not end the recording — but it MUST be
259
+ // COUNTED, or a run that sent nothing looks like a surface that
260
+ // did not move. Measured 6 Sept: a second-tab generator sent
261
+ // zero messages and the run reported "0 content events", which
262
+ // reads as a result and was an instrument failure.
263
+ sendTally.failed += 1;
264
+ sendTally.lastError = e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200);
265
+ }
266
+ }
267
+ },
268
+ }
269
+ : {}),
270
+ ...(body.type_into
271
+ ? {
272
+ before: async (page: import('playwright').Page) => {
273
+ const el = page.locator(`[data-testid^="${body.type_into}"]`).first();
274
+ await el.click({ timeout: 8000 }).catch(() => {});
275
+ },
276
+ during: async (page: import('playwright').Page, seconds: number) => {
277
+ const el = page.locator(`[data-testid^="${body.type_into}"]`).first();
278
+ const end = Date.now() + seconds * 1000;
279
+ while (Date.now() < end) {
280
+ await el.type('svar ', { delay: 120 }).catch(() => {});
281
+ }
282
+ },
283
+ }
284
+ : {}),
285
+ seconds: Math.min(300, Math.max(1, body.seconds ?? 30)),
286
+ ...(body.seed_tabs && body.seed_tabs.length > 0 ? { seedTabs: body.seed_tabs } : {}),
287
+ ...(body.scroll_up_px ? { scrollUpPx: body.scroll_up_px } : {}),
288
+ ...(body.selector ? { selector: body.selector } : {}),
289
+ ...(body.row_attr ? { rowAttr: body.row_attr } : {}),
290
+ ...(body.interval_ms ? { intervalMs: body.interval_ms } : {}),
291
+ ...(body.min_delta_px != null ? { minDeltaPx: body.min_delta_px } : {}),
292
+ ...(body.viewport ? { viewport: body.viewport } : {}),
293
+ ...(body.reduced_motion ? { reducedMotion: body.reduced_motion } : {}),
294
+ ...(auth ? { prepareContext: authPrepare(auth, process.env) } : {}),
295
+ });
296
+
297
+ let videoUrl: string | null = null;
298
+ if (result.video) {
299
+ const key = `${project}/recordings/${runId}/run.webm`;
300
+ await lensStorage.put(key, result.video);
301
+ videoUrl = `http://127.0.0.1:${config.port}/lens/artifact?key=${encodeURIComponent(key)}`;
302
+ }
303
+
304
+ // Frames are DERIVED and cut only when there is a video to cut. ffmpeg missing
305
+ // degrades to "video, no stills" — it never fails the run, because the probe
306
+ // timeline is the primary result and it is already in hand.
307
+ const frames: { t: number; url: string; near_finding?: number }[] = [];
308
+ let framesUnavailable: string | null = null;
309
+ if (result.video) {
310
+ const dir = await mkdtemp(nodePath.join(tmpdir(), 'lens-frames-'));
311
+ const vid = nodePath.join(dir, 'run.webm');
312
+ await writeFile(vid, result.video);
313
+ const cuts: { t: number; file: string; near?: number }[] = [];
314
+ const sparse = await cutFrames(vid, nodePath.join(dir, 'sparse'), { fps: body.fps ?? 2 });
315
+ if (sparse.unavailable) framesUnavailable = sparse.unavailable;
316
+ cuts.push(...sparse.frames.map((f) => ({ t: f.t, file: f.file })));
317
+ const burstFps = body.burst_fps ?? 12;
318
+ if (burstFps > 0 && !sparse.unavailable) {
319
+ for (const [i, f] of result.findings.slice(0, 3).entries()) {
320
+ const from = Math.max(0, f.t - 700);
321
+ const b = await cutFrames(vid, nodePath.join(dir, `burst-${i}`), {
322
+ fps: burstFps,
323
+ fromMs: from,
324
+ durationMs: 1600,
325
+ });
326
+ cuts.push(...b.frames.map((x) => ({ t: x.t, file: x.file, near: i })));
327
+ }
328
+ }
329
+ for (const cut of cuts) {
330
+ const key = `${project}/recordings/${runId}/f-${String(cut.t).padStart(7, '0')}${cut.near != null ? `-b${cut.near}` : ''}.png`;
331
+ await lensStorage.put(key, await readFile(cut.file));
332
+ frames.push({
333
+ t: cut.t,
334
+ url: `http://127.0.0.1:${config.port}/lens/artifact?key=${encodeURIComponent(key)}`,
335
+ ...(cut.near != null ? { near_finding: cut.near } : {}),
336
+ });
337
+ }
338
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
339
+ }
340
+
341
+ return {
342
+ ok: true as const,
343
+ status: 200,
344
+ run_id: runId,
345
+ duration_ms: result.durationMs,
346
+ watched: result.watched,
347
+ probe_error: result.probeError,
348
+ samples: result.samples.length,
349
+ // F312.8 — a zero finding is only a RESULT if the surface was exercised.
350
+ // Two identical runs of the same build gave 4 findings then 0, because the
351
+ // second window was quiet. Never let that read as "fixed".
352
+ exercise: measureExercise(result.samples),
353
+ // F312.18 — false means the run proves NOTHING. Zero findings from a blind
354
+ // camera and zero from a fixed product are the same number; this is the
355
+ // only thing that separates them.
356
+ detector_alive: result.detectorAlive,
357
+ self_test: result.selfTest,
358
+ seed: result.seed,
359
+ clicks: clickLog,
360
+ // F312.19 — the headline number. A move nobody caused AND the reader could
361
+ // SEE. Every finding is still returned below; this is what "is it broken"
362
+ // means, so a reader does not have to know to filter.
363
+ defect_count: defects(result.findings).length,
364
+ // What the GENERATOR managed, kept separate from what the SURFACE did.
365
+ sends: (globalThis as { __lensSends?: unknown }).__lensSends ?? null,
366
+ // F312.16 — SENT vs LANDED, per marker. `landed_ms` is null when a fed
367
+ // message never reached the screen at all, which is a different and much
368
+ // worse answer than a slow one — and the pair is the only way to tell them
369
+ // apart. Times are UTC epoch ms; latency_ms is what the reader waited.
370
+ feed: feedLog.map((f) => {
371
+ const seen = result.markers[f.marker];
372
+ return {
373
+ marker: f.marker,
374
+ sent_at_utc: new Date(f.sentAt).toISOString(),
375
+ landed_ms: seen ?? null,
376
+ latency_ms: seen == null ? null : Math.max(0, result.probeStartedAt + seen - f.sentAt),
377
+ ...(f.error ? { error: f.error } : {}),
378
+ };
379
+ }),
380
+ // F312.12 — the page's own account of who moved the view. Line an entry up
381
+ // with a finding's at_ms and the call site stops being an argument.
382
+ ledger: result.ledger,
383
+ // The finding carries WHAT moved and WHEN — never merely that something did.
384
+ findings: result.findings.map((f, i) => ({
385
+ index: i,
386
+ at_ms: f.t,
387
+ from: f.from,
388
+ to: f.to,
389
+ delta: f.delta,
390
+ direction: f.delta < 0 ? 'backwards' : 'forwards',
391
+ first_row_before: f.firstRowBefore,
392
+ first_row_after: f.firstRowAfter,
393
+ content_changed: f.contentChanged,
394
+ // F312.7 — the discriminator: did the browser CLAMP him because the
395
+ // content above shrank, or did something actively move him?
396
+ clamped: f.clamped,
397
+ // F312.14 — how far the PICTURE moved. 0 with a large delta means the
398
+ // anchor held his message still while content shifted around it.
399
+ visible_shift: f.visibleShift,
400
+ // F312.23 — the second, independent read on the same move: where HIS row
401
+ // sat versus where the app promised to hold it. `visible_shift` goes null
402
+ // on exactly the big jumps that matter; this does not.
403
+ anchor_drift_before: f.anchorDriftBefore,
404
+ anchor_drift_after: f.anchorDriftAfter,
405
+ anchor_state_before: f.anchorStateBefore,
406
+ anchor_state_after: f.anchorStateAfter,
407
+ // F312.24 — 0 means the layout was rigid and the VIEW moved; a number
408
+ // means the ground moved under him. Same visible_shift either way.
409
+ layout_spread: f.layoutSpread,
410
+ rows_before: f.rowsBefore,
411
+ rows_after: f.rowsAfter,
412
+ first_row_y_before: f.firstRowYBefore,
413
+ first_row_y_after: f.firstRowYAfter,
414
+ max_before: f.maxBefore,
415
+ max_after: f.maxAfter,
416
+ cause: f.cause,
417
+ since_input: f.sinceInput,
418
+ while_typing: f.whileTyping,
419
+ })),
420
+ video_url: videoUrl,
421
+ frames,
422
+ ...(framesUnavailable ? { frames_unavailable: framesUnavailable } : {}),
423
+ };
424
+ } catch (err) {
425
+ return { ok: false as const, status: 400, error: err instanceof Error ? err.message : String(err) };
426
+ }
427
+ }