@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.
- package/README.md +23 -6
- package/lib/admin-featured-history.js +185 -0
- package/lib/admin-featured.js +33 -0
- package/lib/admin-runtime-logs.js +45 -6
- package/lib/admin-workflows.js +52 -2
- package/lib/admin.js +269 -87
- package/lib/assets.js +11 -8
- package/lib/build-review.js +609 -8
- package/lib/commands.js +18 -9
- package/lib/constants.js +26 -1
- package/lib/sdk.js +114 -4
- package/lib/sponsor-duty.js +15 -2
- package/lib/thumbnail.js +2 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +72 -6
- package/sdk/LUMINE_ADMIN.md +344 -21
package/lib/build-review.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
:
|
|
475
|
-
|
|
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: {
|
|
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;
|