@broberg/lens-daemon 0.0.0-stage → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +239 -0
- package/src/artifact-uploads.ts +397 -0
- package/src/auth/index.ts +301 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2953 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/secret-fill.ts +80 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1493 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -0,0 +1,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
|
+
}
|