@stage5/lumine 0.2.39 → 0.2.41

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,493 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ import { requestJson } from "./http.js";
15
+ import { writeAdminJsonFile } from "./admin-news.js";
16
+
17
+ const DEFAULT_REVIEW_WAIT_MS = 10_000;
18
+ const MAX_REVIEW_WAIT_MS = 45_000;
19
+ const MAX_CAPTURED_LOG_LINES = 500;
20
+ const CDP_COMMAND_TIMEOUT_MS = 10_000;
21
+
22
+ function validationError(message, code = "CLI_ADMIN_CLI_VALIDATION") {
23
+ const error = new Error(message);
24
+ error.code = code;
25
+ return error;
26
+ }
27
+
28
+ export function parseBuildReviewReceipt(filePath) {
29
+ let receipt;
30
+ try {
31
+ receipt = JSON.parse(readFileSync(String(filePath || ""), "utf8"));
32
+ } catch {
33
+ throw validationError(`Could not read Build review receipt ${filePath}.`);
34
+ }
35
+ const buildId = Number(receipt?.buildId || 0);
36
+ const publishedArtifactVersionId = Number(
37
+ receipt?.publishedArtifactVersionId || 0,
38
+ );
39
+ const versionAfterReview = Number(receipt?.versionAfterReview || 0);
40
+ const screenshotPath = String(receipt?.screenshot?.path || "");
41
+ const screenshotBytes =
42
+ screenshotPath && existsSync(screenshotPath)
43
+ ? Number(statSync(screenshotPath).size || 0)
44
+ : 0;
45
+ if (
46
+ receipt?.schemaVersion !== 2 ||
47
+ receipt?.reviewMethod !== "runtime" ||
48
+ receipt?.status !== "confirmed" ||
49
+ !Number.isSafeInteger(buildId) ||
50
+ buildId <= 0 ||
51
+ !Number.isSafeInteger(publishedArtifactVersionId) ||
52
+ publishedArtifactVersionId <= 0 ||
53
+ versionAfterReview !== publishedArtifactVersionId ||
54
+ receipt?.versionStable !== true ||
55
+ receipt?.browser?.runtimeReadiness?.ready !== true ||
56
+ screenshotBytes <= 0 ||
57
+ screenshotBytes !== Number(receipt?.screenshot?.bytes || 0)
58
+ ) {
59
+ throw validationError(
60
+ "The Build review receipt is not a confirmed managed-runtime review.",
61
+ );
62
+ }
63
+ return receipt;
64
+ }
65
+
66
+ function findChromeExecutable(explicitPath = "") {
67
+ const candidates = [
68
+ explicitPath,
69
+ process.env.CHROME_PATH,
70
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
71
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
72
+ "/usr/bin/google-chrome",
73
+ "/usr/bin/google-chrome-stable",
74
+ "/usr/bin/chromium",
75
+ "/usr/bin/chromium-browser",
76
+ ].filter(Boolean);
77
+ const match = candidates.find((candidate) => existsSync(candidate));
78
+ if (!match) {
79
+ throw validationError(
80
+ "A Chrome/Chromium executable was not found. Pass --browser-path <path>.",
81
+ "CLI_ADMIN_BUILD_REVIEW_BROWSER_MISSING",
82
+ );
83
+ }
84
+ return match;
85
+ }
86
+
87
+ function boundedWaitMs(value) {
88
+ if (value === undefined || value === null || value === "") {
89
+ return DEFAULT_REVIEW_WAIT_MS;
90
+ }
91
+ const parsed = Number(value);
92
+ if (!Number.isInteger(parsed) || parsed < 1000 || parsed > MAX_REVIEW_WAIT_MS) {
93
+ throw validationError(
94
+ `--wait-ms must be an integer between 1000 and ${MAX_REVIEW_WAIT_MS}.`,
95
+ );
96
+ }
97
+ return parsed;
98
+ }
99
+
100
+ async function loadPublishedVersion({ apiUrl, buildId, timeoutMs }) {
101
+ const result = await requestJson({
102
+ url: `${String(apiUrl).replace(/\/$/, "")}/build/${buildId}/published-version`,
103
+ timeoutMs,
104
+ });
105
+ const versionId = Number(result?.publishedArtifactVersionId || 0);
106
+ if (!Number.isSafeInteger(versionId) || versionId <= 0) {
107
+ throw validationError(
108
+ `Build #${buildId} has no canonical published artifact to review.`,
109
+ "CLI_ADMIN_BUILD_REVIEW_NOT_PUBLISHED",
110
+ );
111
+ }
112
+ return versionId;
113
+ }
114
+
115
+ function delay(milliseconds) {
116
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
117
+ }
118
+
119
+ function createCdpPipe(child) {
120
+ const commandPipe = child.stdio[3];
121
+ const eventPipe = child.stdio[4];
122
+ if (!commandPipe || !eventPipe) {
123
+ throw validationError(
124
+ "Chrome did not expose its managed review channel.",
125
+ "CLI_ADMIN_BUILD_REVIEW_BROWSER_CHANNEL_FAILED",
126
+ );
127
+ }
128
+ let nextId = 1;
129
+ let buffer = Buffer.alloc(0);
130
+ const pending = new Map();
131
+ const listeners = new Set();
132
+ eventPipe.on("data", (chunk) => {
133
+ buffer = Buffer.concat([buffer, chunk]);
134
+ for (let delimiter = buffer.indexOf(0); delimiter >= 0; delimiter = buffer.indexOf(0)) {
135
+ const payload = buffer.subarray(0, delimiter).toString("utf8");
136
+ buffer = buffer.subarray(delimiter + 1);
137
+ if (!payload) continue;
138
+ let message;
139
+ try {
140
+ message = JSON.parse(payload);
141
+ } catch {
142
+ continue;
143
+ }
144
+ if (message.id && pending.has(message.id)) {
145
+ const entry = pending.get(message.id);
146
+ pending.delete(message.id);
147
+ clearTimeout(entry.timer);
148
+ if (message.error) {
149
+ entry.reject(new Error(message.error.message || "Chrome command failed."));
150
+ } else {
151
+ entry.resolve(message.result || {});
152
+ }
153
+ continue;
154
+ }
155
+ for (const listener of [...listeners]) listener(message);
156
+ }
157
+ });
158
+ const rejectPending = () => {
159
+ for (const entry of pending.values()) {
160
+ clearTimeout(entry.timer);
161
+ entry.reject(new Error("Chrome closed its managed review channel."));
162
+ }
163
+ pending.clear();
164
+ };
165
+ child.once("close", rejectPending);
166
+ child.once("error", rejectPending);
167
+ return {
168
+ onEvent(listener) {
169
+ listeners.add(listener);
170
+ return () => listeners.delete(listener);
171
+ },
172
+ send(method, params = {}, sessionId = "") {
173
+ const id = nextId++;
174
+ return new Promise((resolve, reject) => {
175
+ const timer = setTimeout(() => {
176
+ pending.delete(id);
177
+ reject(new Error(`Chrome timed out while running ${method}.`));
178
+ }, CDP_COMMAND_TIMEOUT_MS);
179
+ pending.set(id, { resolve, reject, timer });
180
+ const message = { id, method, params };
181
+ if (sessionId) message.sessionId = sessionId;
182
+ commandPipe.write(`${JSON.stringify(message)}\0`);
183
+ });
184
+ },
185
+ };
186
+ }
187
+
188
+ async function waitForPageTarget(cdp, expectedUrl) {
189
+ const deadline = Date.now() + CDP_COMMAND_TIMEOUT_MS;
190
+ while (Date.now() < deadline) {
191
+ const result = await cdp.send("Target.getTargets");
192
+ const targets = Array.isArray(result.targetInfos) ? result.targetInfos : [];
193
+ const exact = targets.find(
194
+ (target) => target.type === "page" && target.url === expectedUrl,
195
+ );
196
+ const fallback = targets.find(
197
+ (target) =>
198
+ target.type === "page" &&
199
+ /^https?:/i.test(String(target.url || "")) &&
200
+ !String(target.url).startsWith("chrome://"),
201
+ );
202
+ if (exact || fallback) return exact || fallback;
203
+ await delay(100);
204
+ }
205
+ throw new Error("Chrome did not open the requested Build page.");
206
+ }
207
+
208
+ const RUNTIME_READINESS_EXPRESSION = `(() => {
209
+ const visible = (element) => {
210
+ if (!element) return false;
211
+ const style = getComputedStyle(element);
212
+ const rect = element.getBoundingClientRect();
213
+ return style.display !== 'none' && style.visibility !== 'hidden' &&
214
+ Number(style.opacity || 1) > 0 && rect.width > 1 && rect.height > 1;
215
+ };
216
+ const preview = document.querySelector('iframe[title="App preview"]');
217
+ const progress = document.querySelector('[role="progressbar"]');
218
+ const runtimeError = document.querySelector('[data-agent-status="preview-error"]');
219
+ return {
220
+ documentReady: document.readyState === 'complete',
221
+ previewPresent: Boolean(preview),
222
+ previewVisible: visible(preview),
223
+ progressVisible: visible(progress),
224
+ runtimeErrorVisible: visible(runtimeError),
225
+ title: document.title || ''
226
+ };
227
+ })()`;
228
+
229
+ function isRuntimeReady(state) {
230
+ return Boolean(
231
+ state?.documentReady &&
232
+ state?.previewPresent &&
233
+ state?.previewVisible &&
234
+ !state?.progressVisible &&
235
+ !state?.runtimeErrorVisible,
236
+ );
237
+ }
238
+
239
+ function remoteArgumentText(argument) {
240
+ if (Object.hasOwn(argument || {}, "value")) {
241
+ const value = argument.value;
242
+ return typeof value === "string" ? value : JSON.stringify(value);
243
+ }
244
+ return String(argument?.description || argument?.type || "");
245
+ }
246
+
247
+ async function runChromeReview({ executable, url, screenshotPath, profileDir, waitMs }) {
248
+ const args = [
249
+ "--headless=new",
250
+ "--no-first-run",
251
+ "--no-default-browser-check",
252
+ "--disable-background-networking",
253
+ "--disable-component-update",
254
+ "--disable-sync",
255
+ "--hide-scrollbars",
256
+ "--enable-logging=stderr",
257
+ "--remote-debugging-pipe",
258
+ `--user-data-dir=${profileDir}`,
259
+ "--window-size=1440,1100",
260
+ url,
261
+ ];
262
+ const child = spawn(executable, args, {
263
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
264
+ });
265
+ let stdout = "";
266
+ let stderr = "";
267
+ const consoleLines = [];
268
+ const append = (current, chunk) => `${current}${chunk}`.slice(-2_000_000);
269
+ child.stdout.on("data", (chunk) => {
270
+ stdout = append(stdout, chunk);
271
+ });
272
+ child.stderr.on("data", (chunk) => {
273
+ stderr = append(stderr, chunk);
274
+ });
275
+ const closed = new Promise((resolve, reject) => {
276
+ child.once("error", reject);
277
+ child.once("close", (code, signal) => {
278
+ resolve({ code: Number(code ?? -1), signal: signal || null });
279
+ });
280
+ });
281
+ const timer = setTimeout(() => child.kill("SIGTERM"), Math.min(waitMs + 30_000, 58_000));
282
+ try {
283
+ const cdp = createCdpPipe(child);
284
+ cdp.onEvent((message) => {
285
+ if (message.method === "Runtime.consoleAPICalled") {
286
+ const values = Array.isArray(message.params?.args)
287
+ ? message.params.args.map(remoteArgumentText).filter(Boolean)
288
+ : [];
289
+ consoleLines.push(
290
+ `[console.${message.params?.type || "log"}] ${values.join(" ")}`,
291
+ );
292
+ } else if (message.method === "Runtime.exceptionThrown") {
293
+ consoleLines.push(
294
+ `[exception] ${message.params?.exceptionDetails?.exception?.description || message.params?.exceptionDetails?.text || "Uncaught exception"}`,
295
+ );
296
+ } else if (message.method === "Log.entryAdded") {
297
+ const entry = message.params?.entry;
298
+ consoleLines.push(
299
+ `[${entry?.level || "log"}] ${entry?.text || ""}${entry?.url ? ` (${entry.url})` : ""}`,
300
+ );
301
+ } else if (message.method === "Target.attachedToTarget") {
302
+ const childSessionId = String(message.params?.sessionId || "");
303
+ if (childSessionId) {
304
+ Promise.all([
305
+ cdp.send("Runtime.enable", {}, childSessionId),
306
+ cdp.send("Log.enable", {}, childSessionId),
307
+ ]).catch(() => {});
308
+ }
309
+ }
310
+ });
311
+ const target = await waitForPageTarget(cdp, url);
312
+ const attached = await cdp.send("Target.attachToTarget", {
313
+ targetId: target.targetId,
314
+ flatten: true,
315
+ });
316
+ const sessionId = String(attached.sessionId || "");
317
+ if (!sessionId) throw new Error("Chrome did not attach to the Build page.");
318
+ await Promise.all([
319
+ cdp.send("Page.enable", {}, sessionId),
320
+ cdp.send("Runtime.enable", {}, sessionId),
321
+ cdp.send("Log.enable", {}, sessionId),
322
+ ]);
323
+ await cdp.send(
324
+ "Target.setAutoAttach",
325
+ {
326
+ autoAttach: true,
327
+ waitForDebuggerOnStart: false,
328
+ flatten: true,
329
+ },
330
+ sessionId,
331
+ );
332
+ const deadline = Date.now() + waitMs;
333
+ let firstReadyAt = null;
334
+ let readiness = null;
335
+ do {
336
+ const evaluated = await cdp.send(
337
+ "Runtime.evaluate",
338
+ { expression: RUNTIME_READINESS_EXPRESSION, returnByValue: true },
339
+ sessionId,
340
+ );
341
+ readiness = evaluated.result?.value || null;
342
+ if (!firstReadyAt && isRuntimeReady(readiness)) {
343
+ firstReadyAt = new Date().toISOString();
344
+ }
345
+ const remaining = deadline - Date.now();
346
+ if (remaining > 0) await delay(Math.min(250, remaining));
347
+ } while (Date.now() < deadline);
348
+ const screenshot = await cdp.send(
349
+ "Page.captureScreenshot",
350
+ { format: "png", fromSurface: true, captureBeyondViewport: false },
351
+ sessionId,
352
+ );
353
+ if (screenshot.data) {
354
+ writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
355
+ }
356
+ await cdp.send("Browser.close").catch(() => {});
357
+ const closeResult = await Promise.race([
358
+ closed,
359
+ delay(5_000).then(() => {
360
+ child.kill("SIGTERM");
361
+ return closed;
362
+ }),
363
+ ]);
364
+ return {
365
+ ...closeResult,
366
+ stdout,
367
+ stderr,
368
+ consoleLines: consoleLines.slice(-MAX_CAPTURED_LOG_LINES),
369
+ runtimeReadiness: {
370
+ ...(readiness || {}),
371
+ ready: isRuntimeReady(readiness),
372
+ firstReadyAt,
373
+ },
374
+ };
375
+ } finally {
376
+ clearTimeout(timer);
377
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
378
+ }
379
+ }
380
+
381
+ function captureConsoleEvidence(stderr, cdpLines = []) {
382
+ const chromeLines = String(stderr || "")
383
+ .split(/\r?\n/)
384
+ .filter((line) =>
385
+ /(?:INFO:CONSOLE|Uncaught|Failed to load resource)/i.test(line),
386
+ )
387
+ .slice(-MAX_CAPTURED_LOG_LINES);
388
+ return [...new Set([...cdpLines, ...chromeLines])].slice(
389
+ -MAX_CAPTURED_LOG_LINES,
390
+ );
391
+ }
392
+
393
+ export async function runManagedBuildReview({ options, authToken, buildId }) {
394
+ const waitMs = boundedWaitMs(options.adminWaitMs);
395
+ const executable = findChromeExecutable(options.adminBrowserPath);
396
+ let outputDir;
397
+ if (options.adminOutputDir) {
398
+ const outputRoot = path.resolve(options.adminOutputDir);
399
+ mkdirSync(outputRoot, { recursive: true });
400
+ outputDir = mkdtempSync(path.join(outputRoot, `build-${buildId}-review-`));
401
+ } else {
402
+ outputDir = mkdtempSync(
403
+ path.join(os.tmpdir(), `lumine-build-${buildId}-review-`),
404
+ );
405
+ }
406
+ const profileDir = mkdtempSync(path.join(os.tmpdir(), "lumine-chrome-profile-"));
407
+ const screenshotPath = path.join(outputDir, "runtime.png");
408
+ const receiptPath = path.join(outputDir, "review.json");
409
+ const appUrl = `${String(options.siteUrl).replace(/\/$/, "")}/app/${buildId}`;
410
+ const beforeVersion = await loadPublishedVersion({
411
+ apiUrl: options.apiUrl,
412
+ buildId,
413
+ timeoutMs: options.timeoutMs,
414
+ authToken,
415
+ });
416
+ const startedAt = new Date().toISOString();
417
+ let browser;
418
+ try {
419
+ browser = await runChromeReview({
420
+ executable,
421
+ url: appUrl,
422
+ screenshotPath,
423
+ profileDir,
424
+ waitMs,
425
+ });
426
+ } finally {
427
+ rmSync(profileDir, { recursive: true, force: true });
428
+ }
429
+ const afterVersion = await loadPublishedVersion({
430
+ apiUrl: options.apiUrl,
431
+ buildId,
432
+ timeoutMs: options.timeoutMs,
433
+ authToken,
434
+ });
435
+ const screenshotBytes = existsSync(screenshotPath)
436
+ ? Number(statSync(screenshotPath).size || 0)
437
+ : 0;
438
+ const confirmed =
439
+ browser.code === 0 &&
440
+ screenshotBytes > 0 &&
441
+ browser.runtimeReadiness?.ready === true &&
442
+ beforeVersion === afterVersion;
443
+ const receipt = {
444
+ schemaVersion: 2,
445
+ status: confirmed ? "confirmed" : "failed",
446
+ reviewMethod: "runtime",
447
+ buildId,
448
+ appUrl,
449
+ publishedArtifactVersionId: beforeVersion,
450
+ versionAfterReview: afterVersion,
451
+ versionStable: beforeVersion === afterVersion,
452
+ startedAt,
453
+ completedAt: new Date().toISOString(),
454
+ waitMs,
455
+ browser: {
456
+ executable,
457
+ exitCode: browser.code,
458
+ signal: browser.signal,
459
+ runtimeReadiness: browser.runtimeReadiness,
460
+ },
461
+ screenshot: {
462
+ path: screenshotPath,
463
+ bytes: screenshotBytes,
464
+ },
465
+ console: captureConsoleEvidence(browser.stderr, browser.consoleLines),
466
+ };
467
+ writeAdminJsonFile(receiptPath, receipt);
468
+ if (!confirmed) {
469
+ const error = validationError(
470
+ beforeVersion !== afterVersion
471
+ ? "The Build was republished during review; review the new artifact before commenting."
472
+ : browser.runtimeReadiness?.runtimeErrorVisible
473
+ ? "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",
476
+ );
477
+ error.data = {
478
+ ok: false,
479
+ status: "validation_error",
480
+ error: {
481
+ code: error.code,
482
+ message: error.message,
483
+ details: { receiptPath, screenshotPath },
484
+ },
485
+ };
486
+ throw error;
487
+ }
488
+ return {
489
+ ok: true,
490
+ status: "success",
491
+ data: { review: receipt, receiptPath, screenshotPath },
492
+ };
493
+ }