@stage5/lumine 0.2.68 → 0.2.70

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.
@@ -12,12 +12,22 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
14
  import { requestJson } from "./http.js";
15
- import { writeAdminJsonFile } from "./admin-news.js";
15
+ import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
16
16
 
17
17
  const DEFAULT_REVIEW_WAIT_MS = 10_000;
18
18
  const MAX_REVIEW_WAIT_MS = 45_000;
19
19
  const MAX_CAPTURED_LOG_LINES = 500;
20
20
  const CDP_COMMAND_TIMEOUT_MS = 10_000;
21
+ // --interact bounds: a short, ordered script inside the app's runtime frame.
22
+ export const MAX_INTERACTION_STEPS = 12;
23
+ export const MAX_INTERACTION_TOTAL_MS = 60_000;
24
+ export const MAX_INTERACTION_WAIT_MS = 5_000;
25
+ const MAX_INTERACTION_SELECTOR_LENGTH = 500;
26
+ const MAX_INTERACTION_TEXT_LENGTH = 200;
27
+ const MAX_INTERACTION_FILE_BYTES = 64 * 1024;
28
+ const SCREENSHOT_LABEL_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
29
+ const RESERVED_SCREENSHOT_LABELS = new Set(["runtime", "review"]);
30
+ const APP_FRAME_SELECTOR = 'iframe[title="App preview"]';
21
31
 
22
32
  function validationError(message, code = "CLI_ADMIN_CLI_VALIDATION") {
23
33
  const error = new Error(message);
@@ -42,6 +52,27 @@ export function parseBuildReviewReceipt(filePath) {
42
52
  screenshotPath && existsSync(screenshotPath)
43
53
  ? Number(statSync(screenshotPath).size || 0)
44
54
  : 0;
55
+ // Interaction screenshots are part of the evidence the receipt claims, so
56
+ // each listed file must still exist unchanged. A receipt whose script did
57
+ // not complete is never "confirmed" (status covers it), but check anyway.
58
+ const interactionScreenshots = Array.isArray(receipt?.screenshots)
59
+ ? receipt.screenshots
60
+ : [];
61
+ const screenshotsIntact = interactionScreenshots.every((shot) => {
62
+ const shotPath = String(shot?.path || "");
63
+ const bytes =
64
+ shotPath && existsSync(shotPath) ? Number(statSync(shotPath).size || 0) : 0;
65
+ return (
66
+ SCREENSHOT_LABEL_PATTERN.test(String(shot?.label || "")) &&
67
+ bytes > 0 &&
68
+ bytes === Number(shot?.bytes || 0)
69
+ );
70
+ });
71
+ const interactionIntact =
72
+ receipt?.interaction == null ||
73
+ (receipt.interaction.status === "completed" &&
74
+ Number(receipt.interaction.stepsCompleted) ===
75
+ Number(receipt.interaction.stepsPlanned));
45
76
  if (
46
77
  receipt?.schemaVersion !== 2 ||
47
78
  receipt?.reviewMethod !== "runtime" ||
@@ -54,7 +85,9 @@ export function parseBuildReviewReceipt(filePath) {
54
85
  receipt?.versionStable !== true ||
55
86
  receipt?.browser?.runtimeReadiness?.ready !== true ||
56
87
  screenshotBytes <= 0 ||
57
- screenshotBytes !== Number(receipt?.screenshot?.bytes || 0)
88
+ screenshotBytes !== Number(receipt?.screenshot?.bytes || 0) ||
89
+ !screenshotsIntact ||
90
+ !interactionIntact
58
91
  ) {
59
92
  throw validationError(
60
93
  "The Build review receipt is not a confirmed managed-runtime review.",
@@ -63,6 +96,446 @@ export function parseBuildReviewReceipt(filePath) {
63
96
  return receipt;
64
97
  }
65
98
 
99
+ // Keys a script may press. Letters and digits are accepted too (see
100
+ // keyDefinition). Everything is dispatched as trusted browser input.
101
+ export const KEY_DEFINITIONS = {
102
+ Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
103
+ Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
104
+ Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
105
+ Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 },
106
+ Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
107
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
108
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
109
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
110
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
111
+ };
112
+
113
+ export function keyDefinition(name) {
114
+ if (name === " ") return KEY_DEFINITIONS.Space;
115
+ const raw = String(name ?? "").trim();
116
+ if (!raw) return null;
117
+ const named = Object.keys(KEY_DEFINITIONS).find(
118
+ (key) => key.toLowerCase() === raw.toLowerCase(),
119
+ );
120
+ if (named) return KEY_DEFINITIONS[named];
121
+ if (/^[a-z]$/i.test(raw)) {
122
+ const upper = raw.toUpperCase();
123
+ return {
124
+ key: raw.toLowerCase(),
125
+ code: `Key${upper}`,
126
+ windowsVirtualKeyCode: upper.charCodeAt(0),
127
+ text: raw.toLowerCase(),
128
+ };
129
+ }
130
+ if (/^[0-9]$/.test(raw)) {
131
+ return {
132
+ key: raw,
133
+ code: `Digit${raw}`,
134
+ windowsVirtualKeyCode: 48 + Number(raw),
135
+ text: raw,
136
+ };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function interactionError(message) {
142
+ return validationError(
143
+ `--interact: ${message}`,
144
+ "CLI_ADMIN_BUILD_REVIEW_INTERACT_INVALID",
145
+ );
146
+ }
147
+
148
+ function normalizeSelector(value, index, kind) {
149
+ const selector = String(value ?? "").trim();
150
+ if (!selector || selector.length > MAX_INTERACTION_SELECTOR_LENGTH) {
151
+ throw interactionError(
152
+ `step ${index + 1} (${kind}) needs a CSS selector or text=... of at most ${MAX_INTERACTION_SELECTOR_LENGTH} characters.`,
153
+ );
154
+ }
155
+ if (selector.startsWith("text=") && !selector.slice(5).trim()) {
156
+ throw interactionError(`step ${index + 1} (${kind}) has an empty text= match.`);
157
+ }
158
+ return selector;
159
+ }
160
+
161
+ // Accepts a JSON array of steps, or { steps: [...] }. Each step names exactly
162
+ // one action: {click}, {type: {selector, text}}, {press}, {wait}, {screenshot}.
163
+ export function parseInteractionScript(filePath) {
164
+ const raw = readAdminJsonFile(filePath, "the --interact script", {
165
+ maxBytes: MAX_INTERACTION_FILE_BYTES,
166
+ });
167
+ const list = Array.isArray(raw) ? raw : raw?.steps;
168
+ if (!Array.isArray(list) || !list.length) {
169
+ throw interactionError(
170
+ "the script must be a non-empty JSON array of steps (or { steps: [...] }).",
171
+ );
172
+ }
173
+ if (list.length > MAX_INTERACTION_STEPS) {
174
+ throw interactionError(
175
+ `at most ${MAX_INTERACTION_STEPS} steps are allowed (got ${list.length}).`,
176
+ );
177
+ }
178
+ const labels = new Set();
179
+ const steps = list.map((item, index) => {
180
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
181
+ throw interactionError(`step ${index + 1} must be an object.`);
182
+ }
183
+ const kinds = ["click", "type", "press", "wait", "screenshot"].filter((key) =>
184
+ Object.hasOwn(item, key),
185
+ );
186
+ if (kinds.length !== 1) {
187
+ throw interactionError(
188
+ `step ${index + 1} must contain exactly one of click, type, press, wait, screenshot.`,
189
+ );
190
+ }
191
+ const kind = kinds[0];
192
+ if (kind === "click") {
193
+ return { kind, selector: normalizeSelector(item.click, index, kind) };
194
+ }
195
+ if (kind === "type") {
196
+ const text = item.type?.text;
197
+ if (
198
+ typeof text !== "string" ||
199
+ !text.length ||
200
+ text.length > MAX_INTERACTION_TEXT_LENGTH ||
201
+ /[\r\n\t\0]/.test(text)
202
+ ) {
203
+ throw interactionError(
204
+ `step ${index + 1} (type) needs { selector, text } with single-line text of at most ${MAX_INTERACTION_TEXT_LENGTH} characters.`,
205
+ );
206
+ }
207
+ return {
208
+ kind,
209
+ selector: normalizeSelector(item.type?.selector, index, kind),
210
+ text,
211
+ };
212
+ }
213
+ if (kind === "press") {
214
+ const definition = keyDefinition(item.press);
215
+ if (!definition) {
216
+ throw interactionError(
217
+ `step ${index + 1} (press) must name one of ${Object.keys(KEY_DEFINITIONS).join(", ")}, a letter, or a digit.`,
218
+ );
219
+ }
220
+ // Store the canonical name (Space, Enter, a, 7) so the receipt reads
221
+ // the same however the script spelled it.
222
+ const canonical =
223
+ Object.keys(KEY_DEFINITIONS).find(
224
+ (name) => KEY_DEFINITIONS[name] === definition,
225
+ ) || definition.key;
226
+ return { kind, key: canonical };
227
+ }
228
+ if (kind === "wait") {
229
+ const ms = Number(item.wait);
230
+ if (!Number.isInteger(ms) || ms < 1 || ms > MAX_INTERACTION_WAIT_MS) {
231
+ throw interactionError(
232
+ `step ${index + 1} (wait) must be an integer between 1 and ${MAX_INTERACTION_WAIT_MS} milliseconds.`,
233
+ );
234
+ }
235
+ return { kind, ms };
236
+ }
237
+ const label = String(item.screenshot ?? "").trim();
238
+ if (
239
+ !SCREENSHOT_LABEL_PATTERN.test(label) ||
240
+ RESERVED_SCREENSHOT_LABELS.has(label.toLowerCase()) ||
241
+ labels.has(label.toLowerCase())
242
+ ) {
243
+ throw interactionError(
244
+ `step ${index + 1} (screenshot) needs a unique label of 1-40 letters, digits, - or _ (not runtime/review).`,
245
+ );
246
+ }
247
+ labels.add(label.toLowerCase());
248
+ return { kind, label };
249
+ });
250
+ return { path: path.resolve(String(filePath)), steps };
251
+ }
252
+
253
+ // Locates an element inside the app frame's document and returns its centre
254
+ // in that frame's CSS pixels. text= matches the smallest visible element whose
255
+ // visible text equals the requested text (case-insensitive), then a bounded
256
+ // contains match.
257
+ function locateExpression(documentExpression, selector) {
258
+ return `(() => {
259
+ const doc = ${documentExpression};
260
+ if (!doc || !doc.defaultView) return { found: false, reason: 'frame-document-unavailable' };
261
+ const selector = ${JSON.stringify(selector)};
262
+ const visible = (el) => {
263
+ const r = el.getBoundingClientRect();
264
+ const s = doc.defaultView.getComputedStyle(el);
265
+ return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
266
+ };
267
+ const area = (el) => { const r = el.getBoundingClientRect(); return r.width * r.height; };
268
+ const norm = (el) => String(el.innerText ?? el.textContent ?? el.value ?? '').replace(/\\s+/g, ' ').trim().toLowerCase();
269
+ let element = null;
270
+ if (selector.startsWith('text=')) {
271
+ const wanted = selector.slice(5).replace(/\\s+/g, ' ').trim().toLowerCase();
272
+ const candidates = [...doc.querySelectorAll('button, a, [role="button"], input, label, summary, li, span, div, p, h1, h2, h3, h4, td, th')].filter(visible);
273
+ const exact = candidates.filter((el) => norm(el) === wanted).sort((a, b) => area(a) - area(b));
274
+ element = exact[0] || candidates
275
+ .filter((el) => { const t = norm(el); return t.includes(wanted) && t.length <= wanted.length + 40; })
276
+ .sort((a, b) => area(a) - area(b))[0] || null;
277
+ } else {
278
+ try { element = doc.querySelector(selector); } catch { return { found: false, reason: 'invalid-selector' }; }
279
+ }
280
+ if (!element) return { found: false, reason: 'not-found' };
281
+ if (!visible(element)) return { found: false, reason: 'not-visible' };
282
+ element.scrollIntoView({ block: 'center', inline: 'center' });
283
+ const rect = element.getBoundingClientRect();
284
+ return {
285
+ found: true,
286
+ x: rect.left + rect.width / 2,
287
+ y: rect.top + rect.height / 2,
288
+ width: rect.width,
289
+ height: rect.height,
290
+ tag: element.tagName.toLowerCase(),
291
+ text: norm(element).slice(0, 80)
292
+ };
293
+ })()`;
294
+ }
295
+
296
+ const APP_FRAME_RECT_EXPRESSION = `(() => {
297
+ const frame = document.querySelector(${JSON.stringify(APP_FRAME_SELECTOR)});
298
+ if (!frame) return null;
299
+ const rect = frame.getBoundingClientRect();
300
+ return {
301
+ left: rect.left + frame.clientLeft,
302
+ top: rect.top + frame.clientTop,
303
+ width: frame.clientWidth,
304
+ height: frame.clientHeight,
305
+ src: frame.src || '',
306
+ sameOriginDocument: (() => { try { return Boolean(frame.contentDocument); } catch { return false; } })()
307
+ };
308
+ })()`;
309
+
310
+ const APP_FRAME_FOCUS_EXPRESSION = `(() => {
311
+ const frame = document.querySelector(${JSON.stringify(APP_FRAME_SELECTOR)});
312
+ if (frame) frame.focus();
313
+ return Boolean(frame);
314
+ })()`;
315
+
316
+ async function evaluateValue(cdp, sessionId, expression) {
317
+ const evaluated = await cdp.send(
318
+ "Runtime.evaluate",
319
+ { expression, returnByValue: true, awaitPromise: false },
320
+ sessionId,
321
+ );
322
+ if (evaluated.exceptionDetails) {
323
+ throw new Error(
324
+ evaluated.exceptionDetails.exception?.description ||
325
+ evaluated.exceptionDetails.text ||
326
+ "Frame evaluation failed.",
327
+ );
328
+ }
329
+ return evaluated.result?.value;
330
+ }
331
+
332
+ // The published app runs directly inside the "App preview" iframe on the
333
+ // /app/:id page. Cross-origin (the normal case) it is an out-of-process frame
334
+ // with its own attached session; same-origin it is reachable through
335
+ // contentDocument from the page session. Either way clicks are dispatched on
336
+ // the page session at frame-offset coordinates so they are trusted input.
337
+ export async function resolveAppFrame({ cdp, pageSessionId, childTargets }) {
338
+ const rect = await evaluateValue(cdp, pageSessionId, APP_FRAME_RECT_EXPRESSION);
339
+ if (!rect) throw new Error("The app runtime frame is not on the page.");
340
+ const src = String(rect.src || "");
341
+ let origin = "";
342
+ try {
343
+ origin = new URL(src).origin;
344
+ } catch {
345
+ origin = "";
346
+ }
347
+ const targets = [...childTargets.entries()].map(([sessionId, info]) => ({
348
+ sessionId,
349
+ ...info,
350
+ }));
351
+ const exact = targets.find(
352
+ (target) => target.type === "iframe" && target.url === src,
353
+ );
354
+ const sameOrigin = targets.find((target) => {
355
+ if (target.type !== "iframe" || !origin) return false;
356
+ try {
357
+ return new URL(String(target.url || "")).origin === origin;
358
+ } catch {
359
+ return false;
360
+ }
361
+ });
362
+ const child = exact || sameOrigin;
363
+ if (child) {
364
+ return {
365
+ sessionId: child.sessionId,
366
+ documentExpression: "document",
367
+ via: "child-target",
368
+ url: child.url,
369
+ };
370
+ }
371
+ if (rect.sameOriginDocument) {
372
+ return {
373
+ sessionId: pageSessionId,
374
+ documentExpression: `document.querySelector(${JSON.stringify(APP_FRAME_SELECTOR)}).contentDocument`,
375
+ via: "same-origin-document",
376
+ url: src,
377
+ };
378
+ }
379
+ throw new Error(
380
+ "The app runtime frame's document is not reachable (no attached frame target).",
381
+ );
382
+ }
383
+
384
+ async function frameRect(cdp, pageSessionId) {
385
+ const rect = await evaluateValue(cdp, pageSessionId, APP_FRAME_RECT_EXPRESSION);
386
+ if (!rect) throw new Error("The app runtime frame disappeared.");
387
+ return rect;
388
+ }
389
+
390
+ async function locateInFrame({ cdp, pageSessionId, frame, selector }) {
391
+ const located = await evaluateValue(
392
+ cdp,
393
+ frame.sessionId,
394
+ locateExpression(frame.documentExpression, selector),
395
+ );
396
+ if (!located?.found) {
397
+ throw new Error(
398
+ `${JSON.stringify(selector)} ${located?.reason || "not-found"} in the app frame.`,
399
+ );
400
+ }
401
+ const rect = await frameRect(cdp, pageSessionId);
402
+ const x = Math.round(rect.left + located.x);
403
+ const y = Math.round(rect.top + located.y);
404
+ if (
405
+ located.x < 0 ||
406
+ located.y < 0 ||
407
+ located.x > rect.width ||
408
+ located.y > rect.height
409
+ ) {
410
+ throw new Error(
411
+ `${JSON.stringify(selector)} is outside the visible app frame.`,
412
+ );
413
+ }
414
+ return { x, y, tag: located.tag, text: located.text };
415
+ }
416
+
417
+ async function clickAt({ cdp, pageSessionId, x, y }) {
418
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y }, pageSessionId);
419
+ await cdp.send(
420
+ "Input.dispatchMouseEvent",
421
+ { type: "mousePressed", x, y, button: "left", clickCount: 1 },
422
+ pageSessionId,
423
+ );
424
+ await cdp.send(
425
+ "Input.dispatchMouseEvent",
426
+ { type: "mouseReleased", x, y, button: "left", clickCount: 1 },
427
+ pageSessionId,
428
+ );
429
+ }
430
+
431
+ async function pressKey({ cdp, pageSessionId, definition }) {
432
+ const base = {
433
+ key: definition.key,
434
+ code: definition.code,
435
+ windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
436
+ nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
437
+ };
438
+ await cdp.send(
439
+ "Input.dispatchKeyEvent",
440
+ {
441
+ type: definition.text ? "keyDown" : "rawKeyDown",
442
+ ...base,
443
+ ...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
444
+ },
445
+ pageSessionId,
446
+ );
447
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...base }, pageSessionId);
448
+ }
449
+
450
+ // Executes a parsed script against the running review browser. Stops at the
451
+ // first failed step or when the total budget is spent; every screenshot taken
452
+ // before that point is kept and listed so the receipt stays honest.
453
+ export async function runInteractionSteps({
454
+ cdp,
455
+ pageSessionId,
456
+ frame,
457
+ steps,
458
+ outputDir,
459
+ budgetMs = MAX_INTERACTION_TOTAL_MS,
460
+ now = Date.now,
461
+ wait = delay,
462
+ }) {
463
+ const startedAt = now();
464
+ const results = [];
465
+ const screenshots = [];
466
+ let focusedFrame = false;
467
+ let failedStep = null;
468
+ for (let index = 0; index < steps.length; index += 1) {
469
+ const step = steps[index];
470
+ const stepStartedAt = now();
471
+ const elapsedBefore = stepStartedAt - startedAt;
472
+ const record = { index: index + 1, ...step, status: "failed", error: null };
473
+ try {
474
+ if (elapsedBefore >= budgetMs) {
475
+ throw new Error(
476
+ `the ${budgetMs}ms interaction budget was spent before this step.`,
477
+ );
478
+ }
479
+ if (step.kind === "click") {
480
+ const point = await locateInFrame({ cdp, pageSessionId, frame, selector: step.selector });
481
+ await clickAt({ cdp, pageSessionId, x: point.x, y: point.y });
482
+ focusedFrame = true;
483
+ Object.assign(record, { x: point.x, y: point.y, tag: point.tag, text: point.text });
484
+ } else if (step.kind === "type") {
485
+ const point = await locateInFrame({ cdp, pageSessionId, frame, selector: step.selector });
486
+ await clickAt({ cdp, pageSessionId, x: point.x, y: point.y });
487
+ focusedFrame = true;
488
+ await cdp.send("Input.insertText", { text: step.text }, pageSessionId);
489
+ Object.assign(record, { x: point.x, y: point.y, tag: point.tag });
490
+ } else if (step.kind === "press") {
491
+ if (!focusedFrame) {
492
+ await evaluateValue(cdp, pageSessionId, APP_FRAME_FOCUS_EXPRESSION);
493
+ focusedFrame = true;
494
+ }
495
+ await pressKey({ cdp, pageSessionId, definition: keyDefinition(step.key) });
496
+ } else if (step.kind === "wait") {
497
+ const remaining = budgetMs - (now() - startedAt);
498
+ if (step.ms > remaining) {
499
+ throw new Error(
500
+ `waiting ${step.ms}ms would exceed the ${budgetMs}ms interaction budget.`,
501
+ );
502
+ }
503
+ await wait(step.ms);
504
+ } else if (step.kind === "screenshot") {
505
+ const captured = await cdp.send(
506
+ "Page.captureScreenshot",
507
+ { format: "png", fromSurface: true, captureBeyondViewport: false },
508
+ pageSessionId,
509
+ );
510
+ if (!captured?.data) throw new Error("Chrome returned no screenshot data.");
511
+ const shotPath = path.join(outputDir, `${step.label}.png`);
512
+ const bytes = Buffer.from(captured.data, "base64");
513
+ writeFileSync(shotPath, bytes);
514
+ const shot = { label: step.label, path: shotPath, bytes: bytes.length };
515
+ screenshots.push(shot);
516
+ Object.assign(record, { path: shotPath, bytes: bytes.length });
517
+ }
518
+ record.status = "completed";
519
+ } catch (error) {
520
+ record.error = String(error?.message || error);
521
+ failedStep = { index: index + 1, kind: step.kind, error: record.error };
522
+ }
523
+ record.elapsedMs = now() - stepStartedAt;
524
+ results.push(record);
525
+ if (failedStep) break;
526
+ }
527
+ return {
528
+ status: failedStep ? "failed" : "completed",
529
+ stepsPlanned: steps.length,
530
+ stepsCompleted: results.filter((item) => item.status === "completed").length,
531
+ failedStep,
532
+ frame: { via: frame.via, url: frame.url },
533
+ steps: results,
534
+ screenshots,
535
+ elapsedMs: now() - startedAt,
536
+ };
537
+ }
538
+
66
539
  function findChromeExecutable(explicitPath = "") {
67
540
  const candidates = [
68
541
  explicitPath,
@@ -244,7 +717,15 @@ function remoteArgumentText(argument) {
244
717
  return String(argument?.description || argument?.type || "");
245
718
  }
246
719
 
247
- async function runChromeReview({ executable, url, screenshotPath, profileDir, waitMs }) {
720
+ async function runChromeReview({
721
+ executable,
722
+ url,
723
+ screenshotPath,
724
+ profileDir,
725
+ waitMs,
726
+ interaction = null,
727
+ outputDir = path.dirname(screenshotPath),
728
+ }) {
248
729
  const args = [
249
730
  "--headless=new",
250
731
  "--no-first-run",
@@ -278,7 +759,16 @@ async function runChromeReview({ executable, url, screenshotPath, profileDir, wa
278
759
  resolve({ code: Number(code ?? -1), signal: signal || null });
279
760
  });
280
761
  });
281
- const timer = setTimeout(() => child.kill("SIGTERM"), Math.min(waitMs + 30_000, 58_000));
762
+ // The hard kill covers the observation window plus, when a script runs,
763
+ // its full interaction budget; the script itself stops at that budget.
764
+ const interactionAllowanceMs = interaction
765
+ ? MAX_INTERACTION_TOTAL_MS + 15_000
766
+ : 0;
767
+ const timer = setTimeout(
768
+ () => child.kill("SIGTERM"),
769
+ Math.min(waitMs + 30_000, 58_000) + interactionAllowanceMs,
770
+ );
771
+ const childTargets = new Map();
282
772
  try {
283
773
  const cdp = createCdpPipe(child);
284
774
  cdp.onEvent((message) => {
@@ -301,11 +791,29 @@ async function runChromeReview({ executable, url, screenshotPath, profileDir, wa
301
791
  } else if (message.method === "Target.attachedToTarget") {
302
792
  const childSessionId = String(message.params?.sessionId || "");
303
793
  if (childSessionId) {
794
+ childTargets.set(childSessionId, {
795
+ type: String(message.params?.targetInfo?.type || ""),
796
+ url: String(message.params?.targetInfo?.url || ""),
797
+ targetId: String(message.params?.targetInfo?.targetId || ""),
798
+ });
304
799
  Promise.all([
305
800
  cdp.send("Runtime.enable", {}, childSessionId),
306
801
  cdp.send("Log.enable", {}, childSessionId),
307
802
  ]).catch(() => {});
308
803
  }
804
+ } else if (message.method === "Target.detachedFromTarget") {
805
+ childTargets.delete(String(message.params?.sessionId || ""));
806
+ } else if (message.method === "Target.targetInfoChanged") {
807
+ const info = message.params?.targetInfo;
808
+ for (const [childSessionId, known] of childTargets) {
809
+ if (info?.targetId && known.targetId === info.targetId) {
810
+ childTargets.set(childSessionId, {
811
+ ...known,
812
+ type: String(info.type || known.type),
813
+ url: String(info.url || known.url),
814
+ });
815
+ }
816
+ }
309
817
  }
310
818
  });
311
819
  const target = await waitForPageTarget(cdp, url);
@@ -353,6 +861,53 @@ async function runChromeReview({ executable, url, screenshotPath, profileDir, wa
353
861
  if (screenshot.data) {
354
862
  writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
355
863
  }
864
+ // Interaction runs only after the start-screen evidence exists and only
865
+ // against a runtime that became reviewable; a script cannot rescue a
866
+ // review whose app never loaded.
867
+ let interactionResult = null;
868
+ if (interaction) {
869
+ if (!isRuntimeReady(readiness)) {
870
+ interactionResult = {
871
+ status: "failed",
872
+ stepsPlanned: interaction.steps.length,
873
+ stepsCompleted: 0,
874
+ failedStep: {
875
+ index: 0,
876
+ kind: "start",
877
+ error: "The runtime was not reviewable when the script would have started.",
878
+ },
879
+ frame: null,
880
+ steps: [],
881
+ screenshots: [],
882
+ elapsedMs: 0,
883
+ };
884
+ } else {
885
+ let frame;
886
+ try {
887
+ frame = await resolveAppFrame({ cdp, pageSessionId: sessionId, childTargets });
888
+ } catch (error) {
889
+ interactionResult = {
890
+ status: "failed",
891
+ stepsPlanned: interaction.steps.length,
892
+ stepsCompleted: 0,
893
+ failedStep: { index: 0, kind: "frame", error: String(error?.message || error) },
894
+ frame: null,
895
+ steps: [],
896
+ screenshots: [],
897
+ elapsedMs: 0,
898
+ };
899
+ }
900
+ if (frame) {
901
+ interactionResult = await runInteractionSteps({
902
+ cdp,
903
+ pageSessionId: sessionId,
904
+ frame,
905
+ steps: interaction.steps,
906
+ outputDir,
907
+ });
908
+ }
909
+ }
910
+ }
356
911
  await cdp.send("Browser.close").catch(() => {});
357
912
  const closeResult = await Promise.race([
358
913
  closed,
@@ -371,6 +926,7 @@ async function runChromeReview({ executable, url, screenshotPath, profileDir, wa
371
926
  ready: isRuntimeReady(readiness),
372
927
  firstReadyAt,
373
928
  },
929
+ interaction: interactionResult,
374
930
  };
375
931
  } finally {
376
932
  clearTimeout(timer);
@@ -392,6 +948,11 @@ function captureConsoleEvidence(stderr, cdpLines = []) {
392
948
 
393
949
  export async function runManagedBuildReview({ options, authToken, buildId }) {
394
950
  const waitMs = boundedWaitMs(options.adminWaitMs);
951
+ // Validate the script before any browser or network work so a bad file
952
+ // fails immediately.
953
+ const interaction = options.adminInteract
954
+ ? parseInteractionScript(options.adminInteract)
955
+ : null;
395
956
  const executable = findChromeExecutable(options.adminBrowserPath);
396
957
  let outputDir;
397
958
  if (options.adminOutputDir) {
@@ -422,10 +983,14 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
422
983
  screenshotPath,
423
984
  profileDir,
424
985
  waitMs,
986
+ interaction,
987
+ outputDir,
425
988
  });
426
989
  } finally {
427
990
  rmSync(profileDir, { recursive: true, force: true });
428
991
  }
992
+ // The receipt stays bound to one artifact: the version is re-read after the
993
+ // browser (and any script) finished, exactly as without --interact.
429
994
  const afterVersion = await loadPublishedVersion({
430
995
  apiUrl: options.apiUrl,
431
996
  buildId,
@@ -435,11 +1000,15 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
435
1000
  const screenshotBytes = existsSync(screenshotPath)
436
1001
  ? Number(statSync(screenshotPath).size || 0)
437
1002
  : 0;
1003
+ const interactionOutcome = browser.interaction || null;
1004
+ const interactionCompleted =
1005
+ !interaction || interactionOutcome?.status === "completed";
438
1006
  const confirmed =
439
1007
  browser.code === 0 &&
440
1008
  screenshotBytes > 0 &&
441
1009
  browser.runtimeReadiness?.ready === true &&
442
- beforeVersion === afterVersion;
1010
+ beforeVersion === afterVersion &&
1011
+ interactionCompleted;
443
1012
  const receipt = {
444
1013
  schemaVersion: 2,
445
1014
  status: confirmed ? "confirmed" : "failed",
@@ -462,17 +1031,40 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
462
1031
  path: screenshotPath,
463
1032
  bytes: screenshotBytes,
464
1033
  },
1034
+ // Script screenshots live beside runtime.png; the start screen stays in
1035
+ // `screenshot` above. Both lists are empty/null without --interact.
1036
+ screenshots: interactionOutcome?.screenshots || [],
1037
+ interaction: interaction
1038
+ ? {
1039
+ path: interaction.path,
1040
+ stepsPlanned: interactionOutcome?.stepsPlanned ?? interaction.steps.length,
1041
+ stepsCompleted: interactionOutcome?.stepsCompleted ?? 0,
1042
+ status: interactionOutcome?.status || "failed",
1043
+ failedStep: interactionOutcome?.failedStep || null,
1044
+ frame: interactionOutcome?.frame || null,
1045
+ elapsedMs: interactionOutcome?.elapsedMs ?? 0,
1046
+ steps: interactionOutcome?.steps || [],
1047
+ }
1048
+ : null,
465
1049
  console: captureConsoleEvidence(browser.stderr, browser.consoleLines),
466
1050
  };
467
1051
  writeAdminJsonFile(receiptPath, receipt);
468
1052
  if (!confirmed) {
1053
+ const interactionFailure =
1054
+ beforeVersion === afterVersion &&
1055
+ browser.runtimeReadiness?.ready === true &&
1056
+ !interactionCompleted;
469
1057
  const error = validationError(
470
1058
  beforeVersion !== afterVersion
471
1059
  ? "The Build was republished during review; review the new artifact before commenting."
472
1060
  : browser.runtimeReadiness?.runtimeErrorVisible
473
1061
  ? "The isolated Build runtime exposed a runtime error instead of a reviewable app."
474
- : "The isolated Build runtime did not become visibly reviewable before the observation window ended.",
475
- "CLI_ADMIN_BUILD_REVIEW_FAILED",
1062
+ : interactionFailure
1063
+ ? `Interaction step ${interactionOutcome?.failedStep?.index ?? "?"} (${interactionOutcome?.failedStep?.kind ?? "?"}) failed: ${interactionOutcome?.failedStep?.error ?? "unknown"} The receipt lists the ${interactionOutcome?.stepsCompleted ?? 0} completed step(s) and their screenshots; fix the script and review again.`
1064
+ : "The isolated Build runtime did not become visibly reviewable before the observation window ended.",
1065
+ interactionFailure
1066
+ ? "CLI_ADMIN_BUILD_REVIEW_INTERACTION_FAILED"
1067
+ : "CLI_ADMIN_BUILD_REVIEW_FAILED",
476
1068
  );
477
1069
  error.data = {
478
1070
  ok: false,
@@ -480,7 +1072,16 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
480
1072
  error: {
481
1073
  code: error.code,
482
1074
  message: error.message,
483
- details: { receiptPath, screenshotPath },
1075
+ details: {
1076
+ receiptPath,
1077
+ screenshotPath,
1078
+ ...(interaction
1079
+ ? {
1080
+ interaction: receipt.interaction,
1081
+ screenshots: receipt.screenshots,
1082
+ }
1083
+ : {}),
1084
+ },
484
1085
  },
485
1086
  };
486
1087
  throw error;