vigthoria-cli 1.13.34 → 1.13.42
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 +11 -56
- package/SECURITY_HARDENING.md +2 -4
- package/dist/commands/auth.js +16 -14
- package/dist/commands/cancel.d.ts +1 -0
- package/dist/commands/cancel.js +21 -4
- package/dist/commands/chat.d.ts +13 -0
- package/dist/commands/chat.js +170 -14
- package/dist/commands/fork.js +8 -3
- package/dist/commands/history.js +8 -3
- package/dist/commands/legion.js +1 -1
- package/dist/commands/replay.js +8 -3
- package/dist/commands/update-registration.js +63 -206
- package/dist/commands/v4-menu.d.ts +0 -1
- package/dist/commands/v4-menu.js +8 -6
- package/dist/commands/v4-registration.js +0 -2
- package/dist/commands/v4.js +9 -4
- package/dist/commands/wallet.d.ts +1 -1
- package/dist/commands/wallet.js +3 -3
- package/dist/index.js +2 -3
- package/dist/utils/agent-stream-state.d.ts +7 -0
- package/dist/utils/agent-stream-state.js +31 -0
- package/dist/utils/agentRunOutcome.js +5 -1
- package/dist/utils/api.d.ts +19 -4
- package/dist/utils/api.js +322 -550
- package/dist/utils/frontend-preview-service.d.ts +10 -1
- package/dist/utils/frontend-preview-service.js +162 -17
- package/dist/utils/javascript-syntax.d.ts +19 -0
- package/dist/utils/javascript-syntax.js +35 -0
- package/dist/utils/localTestMode.js +1 -1
- package/dist/utils/mutation-journal.d.ts +10 -0
- package/dist/utils/mutation-journal.js +104 -76
- package/dist/utils/network-policy.js +7 -10
- package/dist/utils/preview-screenshot-adapter.d.ts +81 -0
- package/dist/utils/preview-screenshot-adapter.js +615 -38
- package/dist/utils/release-install.d.ts +0 -1
- package/dist/utils/release-install.js +44 -7
- package/dist/utils/requestIntent.d.ts +1 -1
- package/dist/utils/requestIntent.js +17 -3
- package/dist/utils/tools.js +21 -15
- package/dist/utils/update-policy.d.ts +0 -4
- package/dist/utils/update-policy.js +5 -9
- package/dist/utils/v3-agent-client.js +25 -11
- package/install.ps1 +30 -50
- package/install.sh +13 -24
- package/package.json +4 -2
- package/release-policy.json +1 -5
- package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +6 -7
- package/scripts/release/install-release.mjs +5 -4
- package/scripts/release/publish-cli-release.mjs +21 -8
- package/scripts/release/test-balanced-model-live.sh +4 -1
- package/scripts/release/validate-live-service-gates.sh +27 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
3
4
|
import * as path from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import WebSocket from 'ws';
|
|
@@ -12,7 +13,35 @@ const BROWSER_TIMEOUT_MS = 30_000;
|
|
|
12
13
|
// Chromium creates short-lived files below TMPDIR while starting. Keep enough
|
|
13
14
|
// room below the 108-byte POSIX sockaddr_un ceiling without rejecting normal
|
|
14
15
|
// per-user Vigthoria roots (for example ~/.vigthoria/tmp/browser-*/scratch).
|
|
15
|
-
|
|
16
|
+
// Microsoft Edge adds a longer internal singleton suffix than Chromium; on
|
|
17
|
+
// the production Linux host a 61-byte scratch path starts reliably while a
|
|
18
|
+
// 66-byte path aborts before exposing DevTools. Fail closed before that abort.
|
|
19
|
+
const POSIX_BROWSER_SCRATCH_MAX_BYTES = 61;
|
|
20
|
+
function createIsolatedBrowserEnvironment(environment, platform, browserProfile, browserScratch) {
|
|
21
|
+
const additions = {
|
|
22
|
+
TMPDIR: browserScratch,
|
|
23
|
+
TMP: browserScratch,
|
|
24
|
+
TEMP: browserScratch,
|
|
25
|
+
};
|
|
26
|
+
if (platform !== 'win32') {
|
|
27
|
+
const xdgDirectories = {
|
|
28
|
+
XDG_CONFIG_HOME: path.join(browserProfile, 'xdg-config'),
|
|
29
|
+
XDG_CACHE_HOME: path.join(browserProfile, 'xdg-cache'),
|
|
30
|
+
XDG_DATA_HOME: path.join(browserProfile, 'xdg-data'),
|
|
31
|
+
XDG_RUNTIME_DIR: path.join(browserProfile, 'xdg-runtime'),
|
|
32
|
+
};
|
|
33
|
+
for (const directory of Object.values(xdgDirectories)) {
|
|
34
|
+
fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
|
|
35
|
+
}
|
|
36
|
+
Object.assign(additions, xdgDirectories);
|
|
37
|
+
}
|
|
38
|
+
return safeChildProcessEnv(environment, additions);
|
|
39
|
+
}
|
|
40
|
+
export function assertPreviewBrowserSandboxUser(platform = process.platform, effectiveUserId = typeof process.getuid === 'function' ? process.getuid() : undefined) {
|
|
41
|
+
if (platform !== 'win32' && effectiveUserId === 0) {
|
|
42
|
+
throw new Error('browser proof requires an unprivileged OS account so the Chromium sandbox remains enabled; do not run the Vigthoria CLI as root');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
16
45
|
export function resolvePreviewBrowserExecutable(environment = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
17
46
|
const candidates = platform === 'win32'
|
|
18
47
|
? [
|
|
@@ -90,6 +119,13 @@ async function terminateBrowserProcess(child, platform) {
|
|
|
90
119
|
catch { /* group exited */ }
|
|
91
120
|
}
|
|
92
121
|
export const runPreviewBrowser = (executablePath, args, platform, environment = process.env) => new Promise((resolve, reject) => {
|
|
122
|
+
try {
|
|
123
|
+
assertPreviewBrowserSandboxUser(platform);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
reject(error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
93
129
|
const child = spawn(executablePath, [...args], {
|
|
94
130
|
stdio: 'ignore',
|
|
95
131
|
windowsHide: true,
|
|
@@ -240,6 +276,7 @@ function runtimeKeyDefinition(rawKey = '') {
|
|
|
240
276
|
space: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
|
|
241
277
|
spacebar: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
|
|
242
278
|
enter: { key: 'Enter', code: 'Enter', virtualKeyCode: 13, text: '\r' },
|
|
279
|
+
tab: { key: 'Tab', code: 'Tab', virtualKeyCode: 9 },
|
|
243
280
|
escape: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
|
|
244
281
|
esc: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
|
|
245
282
|
arrowup: { key: 'ArrowUp', code: 'ArrowUp', virtualKeyCode: 38 },
|
|
@@ -255,19 +292,53 @@ function runtimeKeyDefinition(rawKey = '') {
|
|
|
255
292
|
}
|
|
256
293
|
return null;
|
|
257
294
|
}
|
|
258
|
-
|
|
295
|
+
export function runtimeKeyChordDefinition(rawKey = '') {
|
|
296
|
+
const parts = rawKey.trim().toLowerCase().replace(/\s+/g, '').split('+').filter(Boolean);
|
|
297
|
+
if (!parts.length)
|
|
298
|
+
return null;
|
|
299
|
+
const base = parts.at(-1) || '';
|
|
300
|
+
const aliases = {
|
|
301
|
+
shift: { key: 'Shift', code: 'ShiftLeft', virtualKeyCode: 16, bit: 8 },
|
|
302
|
+
control: { key: 'Control', code: 'ControlLeft', virtualKeyCode: 17, bit: 2 },
|
|
303
|
+
ctrl: { key: 'Control', code: 'ControlLeft', virtualKeyCode: 17, bit: 2 },
|
|
304
|
+
alt: { key: 'Alt', code: 'AltLeft', virtualKeyCode: 18, bit: 1 },
|
|
305
|
+
meta: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
|
|
306
|
+
cmd: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
|
|
307
|
+
command: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
|
|
308
|
+
};
|
|
309
|
+
const modifiers = [];
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
for (const part of parts.slice(0, -1)) {
|
|
312
|
+
const modifier = aliases[part];
|
|
313
|
+
if (!modifier || seen.has(modifier.bit))
|
|
314
|
+
return null;
|
|
315
|
+
seen.add(modifier.bit);
|
|
316
|
+
modifiers.push(modifier);
|
|
317
|
+
}
|
|
318
|
+
const key = runtimeKeyDefinition(base);
|
|
319
|
+
if (!key || aliases[base])
|
|
320
|
+
return null;
|
|
321
|
+
return {
|
|
322
|
+
key,
|
|
323
|
+
modifiers,
|
|
324
|
+
modifierMask: modifiers.reduce((mask, modifier) => mask | modifier.bit, 0),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async function waitForRuntimeReady(client, sessionId, expectedUrl) {
|
|
259
328
|
const deadline = Date.now() + 8_000;
|
|
260
329
|
while (Date.now() < deadline) {
|
|
261
330
|
const evaluated = await client.send('Runtime.evaluate', {
|
|
262
|
-
expression: 'document.readyState',
|
|
331
|
+
expression: '({ readyState: document.readyState, href: location.href })',
|
|
263
332
|
returnByValue: true,
|
|
264
333
|
}, sessionId);
|
|
265
|
-
const
|
|
266
|
-
|
|
334
|
+
const observation = evaluated.result?.value || {};
|
|
335
|
+
const readyState = String(observation.readyState || '');
|
|
336
|
+
const currentUrl = String(observation.href || '');
|
|
337
|
+
if (currentUrl === expectedUrl && (readyState === 'interactive' || readyState === 'complete'))
|
|
267
338
|
return;
|
|
268
339
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
269
340
|
}
|
|
270
|
-
throw new Error('browser document did not become ready');
|
|
341
|
+
throw new Error('browser document did not become ready at the requested loopback URL');
|
|
271
342
|
}
|
|
272
343
|
async function readRuntimeBody(client, sessionId) {
|
|
273
344
|
const evaluated = await client.send('Runtime.evaluate', {
|
|
@@ -276,6 +347,343 @@ async function readRuntimeBody(client, sessionId) {
|
|
|
276
347
|
}, sessionId);
|
|
277
348
|
return normalizeRuntimeBody(evaluated.result?.value);
|
|
278
349
|
}
|
|
350
|
+
async function evaluateRuntimeValue(client, sessionId, expression) {
|
|
351
|
+
const evaluated = await client.send('Runtime.evaluate', {
|
|
352
|
+
expression,
|
|
353
|
+
awaitPromise: true,
|
|
354
|
+
returnByValue: true,
|
|
355
|
+
}, sessionId);
|
|
356
|
+
if (evaluated.exceptionDetails) {
|
|
357
|
+
const description = evaluated.exceptionDetails.exception?.description
|
|
358
|
+
|| evaluated.exceptionDetails.text
|
|
359
|
+
|| 'browser scenario evaluation failed';
|
|
360
|
+
throw new Error(normalizeRuntimeBody(description));
|
|
361
|
+
}
|
|
362
|
+
return evaluated.result?.value;
|
|
363
|
+
}
|
|
364
|
+
async function focusRuntimeSelector(client, sessionId, selector) {
|
|
365
|
+
await evaluateRuntimeValue(client, sessionId, `(() => {
|
|
366
|
+
const selector = ${JSON.stringify(selector)};
|
|
367
|
+
const element = document.querySelector(selector);
|
|
368
|
+
if (!element) throw new Error('selector not found: ' + selector);
|
|
369
|
+
if (typeof element.focus !== 'function') throw new Error('element is not focusable: ' + selector);
|
|
370
|
+
element.focus();
|
|
371
|
+
return true;
|
|
372
|
+
})()`);
|
|
373
|
+
}
|
|
374
|
+
async function dispatchRuntimeKey(client, sessionId, rawKey) {
|
|
375
|
+
const chord = runtimeKeyChordDefinition(rawKey);
|
|
376
|
+
if (!chord)
|
|
377
|
+
throw new Error(`unsupported browser interaction key: ${rawKey}`);
|
|
378
|
+
let activeModifiers = 0;
|
|
379
|
+
for (const modifier of chord.modifiers) {
|
|
380
|
+
activeModifiers |= modifier.bit;
|
|
381
|
+
await client.send('Input.dispatchKeyEvent', {
|
|
382
|
+
type: 'keyDown', key: modifier.key, code: modifier.code,
|
|
383
|
+
windowsVirtualKeyCode: modifier.virtualKeyCode,
|
|
384
|
+
nativeVirtualKeyCode: modifier.virtualKeyCode,
|
|
385
|
+
modifiers: activeModifiers,
|
|
386
|
+
}, sessionId);
|
|
387
|
+
}
|
|
388
|
+
await client.send('Input.dispatchKeyEvent', {
|
|
389
|
+
type: 'keyDown', key: chord.key.key, code: chord.key.code,
|
|
390
|
+
windowsVirtualKeyCode: chord.key.virtualKeyCode,
|
|
391
|
+
nativeVirtualKeyCode: chord.key.virtualKeyCode,
|
|
392
|
+
modifiers: chord.modifierMask,
|
|
393
|
+
...(chord.key.text && chord.modifierMask === 0
|
|
394
|
+
? { text: chord.key.text, unmodifiedText: chord.key.text }
|
|
395
|
+
: {}),
|
|
396
|
+
}, sessionId);
|
|
397
|
+
await client.send('Input.dispatchKeyEvent', {
|
|
398
|
+
type: 'keyUp', key: chord.key.key, code: chord.key.code,
|
|
399
|
+
windowsVirtualKeyCode: chord.key.virtualKeyCode,
|
|
400
|
+
nativeVirtualKeyCode: chord.key.virtualKeyCode,
|
|
401
|
+
modifiers: chord.modifierMask,
|
|
402
|
+
}, sessionId);
|
|
403
|
+
for (const modifier of [...chord.modifiers].reverse()) {
|
|
404
|
+
activeModifiers &= ~modifier.bit;
|
|
405
|
+
await client.send('Input.dispatchKeyEvent', {
|
|
406
|
+
type: 'keyUp', key: modifier.key, code: modifier.code,
|
|
407
|
+
windowsVirtualKeyCode: modifier.virtualKeyCode,
|
|
408
|
+
nativeVirtualKeyCode: modifier.virtualKeyCode,
|
|
409
|
+
modifiers: activeModifiers,
|
|
410
|
+
}, sessionId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
export async function applyRuntimeStepAction(client, sessionId, step) {
|
|
414
|
+
const selector = String(step.selector || '').trim();
|
|
415
|
+
const value = String(step.value || '');
|
|
416
|
+
if (step.action === 'wait') {
|
|
417
|
+
const milliseconds = Math.max(0, Math.min(Number(value || step.wait_ms) || 0, 2_000));
|
|
418
|
+
if (milliseconds)
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (step.action === 'reload') {
|
|
423
|
+
const currentUrl = String(await evaluateRuntimeValue(client, sessionId, 'location.href'));
|
|
424
|
+
await client.send('Page.reload', { ignoreCache: false }, sessionId);
|
|
425
|
+
await waitForRuntimeReady(client, sessionId, currentUrl);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (step.action === 'press') {
|
|
429
|
+
if (selector)
|
|
430
|
+
await focusRuntimeSelector(client, sessionId, selector);
|
|
431
|
+
await dispatchRuntimeKey(client, sessionId, value);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (step.action === 'assert')
|
|
435
|
+
return;
|
|
436
|
+
if (step.action === 'click'
|
|
437
|
+
&& (step.offset_x_ratio !== undefined || step.offset_y_ratio !== undefined)) {
|
|
438
|
+
const point = await evaluateRuntimeValue(client, sessionId, `(() => {
|
|
439
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
440
|
+
if (!element) throw new Error('selector not found: ' + ${JSON.stringify(selector)});
|
|
441
|
+
const rect = element.getBoundingClientRect();
|
|
442
|
+
if (!(rect.width > 0 && rect.height > 0)) throw new Error('click target has no layout box: ' + ${JSON.stringify(selector)});
|
|
443
|
+
const clamp = (value) => Math.max(0.01, Math.min(Number(value), 0.99));
|
|
444
|
+
return {
|
|
445
|
+
x: rect.left + rect.width * clamp(${JSON.stringify(step.offset_x_ratio ?? 0.5)}),
|
|
446
|
+
y: rect.top + rect.height * clamp(${JSON.stringify(step.offset_y_ratio ?? 0.5)}),
|
|
447
|
+
};
|
|
448
|
+
})()`);
|
|
449
|
+
await client.send('Input.dispatchMouseEvent', {
|
|
450
|
+
type: 'mouseMoved', x: Number(point.x), y: Number(point.y), button: 'none', buttons: 0,
|
|
451
|
+
}, sessionId);
|
|
452
|
+
await client.send('Input.dispatchMouseEvent', {
|
|
453
|
+
type: 'mousePressed', x: Number(point.x), y: Number(point.y),
|
|
454
|
+
button: 'left', buttons: 1, clickCount: 1,
|
|
455
|
+
}, sessionId);
|
|
456
|
+
await client.send('Input.dispatchMouseEvent', {
|
|
457
|
+
type: 'mouseReleased', x: Number(point.x), y: Number(point.y),
|
|
458
|
+
button: 'left', buttons: 0, clickCount: 1,
|
|
459
|
+
}, sessionId);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await evaluateRuntimeValue(client, sessionId, `(() => {
|
|
463
|
+
const step = ${JSON.stringify(step)};
|
|
464
|
+
const selector = String(step.selector || '');
|
|
465
|
+
const element = document.querySelector(selector);
|
|
466
|
+
if (!element) throw new Error('selector not found: ' + selector);
|
|
467
|
+
if (step.action === 'click') {
|
|
468
|
+
if (typeof element.click !== 'function') throw new Error('element is not clickable: ' + selector);
|
|
469
|
+
// HTMLElement.click() dispatches click handlers but does not reproduce
|
|
470
|
+
// the focus transfer of a real pointer click. That left the previously
|
|
471
|
+
// filled search input active when a scenario clicked a native select or
|
|
472
|
+
// button, producing a false keyboard/focus failure. Move focus first,
|
|
473
|
+
// as a trusted user click does, then dispatch the activation.
|
|
474
|
+
if (typeof element.focus === 'function') element.focus();
|
|
475
|
+
element.click();
|
|
476
|
+
} else if (step.action === 'fill') {
|
|
477
|
+
if (!('value' in element)) throw new Error('element cannot be filled: ' + selector);
|
|
478
|
+
element.focus();
|
|
479
|
+
element.value = String(step.value ?? '');
|
|
480
|
+
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: String(step.value ?? '') }));
|
|
481
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
482
|
+
} else if (step.action === 'select') {
|
|
483
|
+
if (!(element instanceof HTMLSelectElement)) throw new Error('element is not a select: ' + selector);
|
|
484
|
+
element.value = String(step.value ?? '');
|
|
485
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
486
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
487
|
+
} else if (step.action === 'focus') {
|
|
488
|
+
if (typeof element.focus !== 'function') throw new Error('element is not focusable: ' + selector);
|
|
489
|
+
element.focus();
|
|
490
|
+
}
|
|
491
|
+
return true;
|
|
492
|
+
})()`);
|
|
493
|
+
}
|
|
494
|
+
export async function waitForRuntimeActionTarget(client, sessionId, selector, scenarioDeadline) {
|
|
495
|
+
const targetDeadline = Math.min(scenarioDeadline, Date.now() + 3_000);
|
|
496
|
+
while (Date.now() < targetDeadline) {
|
|
497
|
+
const exists = Boolean(await evaluateRuntimeValue(client, sessionId, `Boolean(document.querySelector(${JSON.stringify(selector)}))`));
|
|
498
|
+
if (exists)
|
|
499
|
+
return;
|
|
500
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
501
|
+
}
|
|
502
|
+
throw new Error(`selector not found: ${selector}`);
|
|
503
|
+
}
|
|
504
|
+
export function runtimeAttributeExpectationFailures(step, snapshot) {
|
|
505
|
+
const failures = [];
|
|
506
|
+
const attribute = String(step.expect_attribute || '').trim();
|
|
507
|
+
const absentAttribute = String(step.expect_attribute_absent || '').trim();
|
|
508
|
+
if (attribute && absentAttribute && attribute.toLowerCase() === absentAttribute.toLowerCase()) {
|
|
509
|
+
return [`runtime contract cannot require attribute ${JSON.stringify(attribute)} to be both present and absent`];
|
|
510
|
+
}
|
|
511
|
+
if (step.expect_attribute_value !== undefined) {
|
|
512
|
+
const expectedValue = String(step.expect_attribute_value);
|
|
513
|
+
const expectedToken = expectedValue.trim().toLowerCase();
|
|
514
|
+
if (attribute.toLowerCase() === 'hidden' && ['false', '0', 'off', 'no', 'absent'].includes(expectedToken)) {
|
|
515
|
+
if (snapshot.attributeValue !== null) {
|
|
516
|
+
failures.push('HTML boolean attribute "hidden" should be absent for the visible/false state');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (attribute.toLowerCase() === 'hidden' && ['true', '1', 'on', 'yes', 'present', ''].includes(expectedToken)) {
|
|
520
|
+
if (snapshot.attributeValue === null) {
|
|
521
|
+
failures.push('HTML boolean attribute "hidden" should be present for the hidden/true state');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if (snapshot.attributeValue !== expectedValue) {
|
|
525
|
+
failures.push(`attribute ${JSON.stringify(attribute)} expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(snapshot.attributeValue)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (step.expect_attribute_token !== undefined) {
|
|
529
|
+
const expectedToken = String(step.expect_attribute_token).trim();
|
|
530
|
+
const actualTokens = String(snapshot.attributeValue || '').split(/\s+/).filter(Boolean);
|
|
531
|
+
if (!actualTokens.includes(expectedToken)) {
|
|
532
|
+
failures.push(`attribute ${JSON.stringify(attribute)} should contain token ${JSON.stringify(expectedToken)}; got ${JSON.stringify(snapshot.attributeValue)}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (step.expect_attribute_token_absent !== undefined) {
|
|
536
|
+
const absentToken = String(step.expect_attribute_token_absent).trim();
|
|
537
|
+
const actualTokens = String(snapshot.attributeValue || '').split(/\s+/).filter(Boolean);
|
|
538
|
+
if (actualTokens.includes(absentToken)) {
|
|
539
|
+
failures.push(`attribute ${JSON.stringify(attribute)} should not contain token ${JSON.stringify(absentToken)}; got ${JSON.stringify(snapshot.attributeValue)}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
else if (attribute && snapshot.attributeValue === null) {
|
|
543
|
+
failures.push(`attribute ${JSON.stringify(attribute)} should be present`);
|
|
544
|
+
}
|
|
545
|
+
if (absentAttribute && snapshot.absentAttributeValue !== null) {
|
|
546
|
+
failures.push(`attribute ${JSON.stringify(absentAttribute)} should be absent`);
|
|
547
|
+
}
|
|
548
|
+
return failures;
|
|
549
|
+
}
|
|
550
|
+
export function runtimeVisibilityExpectationFailures(step, snapshot) {
|
|
551
|
+
if (step.expect_visible === true && !snapshot.visible)
|
|
552
|
+
return ['expected visible'];
|
|
553
|
+
if ((step.expect_hidden === true || step.expect_visible === false) && snapshot.visible) {
|
|
554
|
+
if (snapshot.hiddenAttribute || snapshot.hiddenProperty) {
|
|
555
|
+
return [
|
|
556
|
+
`expected hidden, but authored CSS overrides the HTML hidden state (computed display: ${JSON.stringify(snapshot.computedDisplay || 'unknown')}); `
|
|
557
|
+
+ 'add [hidden] { display: none !important; } or scope the visible display rule to :not([hidden])',
|
|
558
|
+
];
|
|
559
|
+
}
|
|
560
|
+
return ['expected hidden'];
|
|
561
|
+
}
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
async function runtimeStepFailures(client, sessionId, step) {
|
|
565
|
+
const selector = String(step.expect_selector || step.selector || '').trim();
|
|
566
|
+
const focusWithinSelector = String(step.expect_focus_within || '').trim();
|
|
567
|
+
const snapshot = await evaluateRuntimeValue(client, sessionId, `(() => {
|
|
568
|
+
const selector = ${JSON.stringify(selector)};
|
|
569
|
+
const focusWithinSelector = ${JSON.stringify(focusWithinSelector)};
|
|
570
|
+
const nodes = selector ? Array.from(document.querySelectorAll(selector)) : [];
|
|
571
|
+
const element = nodes[0] || null;
|
|
572
|
+
const visible = element ? (() => {
|
|
573
|
+
const style = getComputedStyle(element);
|
|
574
|
+
const rect = element.getBoundingClientRect();
|
|
575
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
|
576
|
+
&& rect.width > 0 && rect.height > 0;
|
|
577
|
+
})() : false;
|
|
578
|
+
const attribute = ${JSON.stringify(String(step.expect_attribute || '').trim())};
|
|
579
|
+
const absentAttribute = ${JSON.stringify(String(step.expect_attribute_absent || '').trim())};
|
|
580
|
+
const focusRoot = focusWithinSelector ? document.querySelector(focusWithinSelector) : null;
|
|
581
|
+
const activeElement = document.activeElement;
|
|
582
|
+
const activeDescriptor = activeElement ? (() => {
|
|
583
|
+
const tag = String(activeElement.tagName || '').toLowerCase();
|
|
584
|
+
const id = activeElement.id ? '#' + activeElement.id : '';
|
|
585
|
+
const classes = activeElement.classList && activeElement.classList.length
|
|
586
|
+
? '.' + Array.from(activeElement.classList).slice(0, 3).join('.')
|
|
587
|
+
: '';
|
|
588
|
+
const label = activeElement.getAttribute && activeElement.getAttribute('aria-label');
|
|
589
|
+
return tag + id + classes + (label ? '[aria-label=' + JSON.stringify(label) + ']' : '');
|
|
590
|
+
})() : '<none>';
|
|
591
|
+
return {
|
|
592
|
+
count: nodes.length,
|
|
593
|
+
visible,
|
|
594
|
+
text: nodes.map((node) => String(
|
|
595
|
+
node instanceof HTMLElement ? node.innerText : (node.textContent || '')
|
|
596
|
+
)).join('\\n'),
|
|
597
|
+
canvasNonblank: element instanceof HTMLCanvasElement ? (() => {
|
|
598
|
+
try {
|
|
599
|
+
const context = element.getContext('2d', { willReadFrequently: true });
|
|
600
|
+
if (!context || element.width < 1 || element.height < 1) return false;
|
|
601
|
+
const pixels = context.getImageData(0, 0, element.width, element.height).data;
|
|
602
|
+
for (let offset = 3; offset < pixels.length; offset += 4) {
|
|
603
|
+
if (pixels[offset] !== 0) return true;
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
} catch { return false; }
|
|
607
|
+
})() : false,
|
|
608
|
+
value: element && 'value' in element ? String(element.value ?? '') : '',
|
|
609
|
+
attributeValue: element && attribute ? element.getAttribute(attribute) : null,
|
|
610
|
+
absentAttributeValue: element && absentAttribute ? element.getAttribute(absentAttribute) : null,
|
|
611
|
+
hiddenAttribute: Boolean(element && element.hasAttribute('hidden')),
|
|
612
|
+
hiddenProperty: Boolean(element && 'hidden' in element && element.hidden),
|
|
613
|
+
computedDisplay: element ? getComputedStyle(element).display : '',
|
|
614
|
+
focused: Boolean(element && element === document.activeElement),
|
|
615
|
+
focusWithin: Boolean(focusRoot && focusRoot.contains(document.activeElement)),
|
|
616
|
+
activeDescriptor,
|
|
617
|
+
};
|
|
618
|
+
})()`);
|
|
619
|
+
const failures = [];
|
|
620
|
+
if (step.expect_count !== undefined && snapshot.count !== Number(step.expect_count)) {
|
|
621
|
+
failures.push(`count expected ${Number(step.expect_count)}, got ${snapshot.count}`);
|
|
622
|
+
}
|
|
623
|
+
failures.push(...runtimeVisibilityExpectationFailures(step, snapshot));
|
|
624
|
+
if (step.expect_text !== undefined && !String(snapshot.text || '').includes(String(step.expect_text))) {
|
|
625
|
+
const actualText = String(snapshot.text || '').replace(/\s+/g, ' ').trim();
|
|
626
|
+
const boundedActual = actualText.length > 240 ? actualText.slice(0, 237) + '...' : actualText;
|
|
627
|
+
failures.push(`text missing ${JSON.stringify(String(step.expect_text))}; actual rendered text was ${JSON.stringify(boundedActual)}`);
|
|
628
|
+
}
|
|
629
|
+
if (step.expect_text_not !== undefined && String(snapshot.text || '').includes(String(step.expect_text_not))) {
|
|
630
|
+
const actualText = String(snapshot.text || '').replace(/\s+/g, ' ').trim();
|
|
631
|
+
const boundedActual = actualText.length > 240 ? actualText.slice(0, 237) + '...' : actualText;
|
|
632
|
+
failures.push(`text still contains forbidden pre-action value ${JSON.stringify(String(step.expect_text_not))}; actual rendered text was ${JSON.stringify(boundedActual)}`);
|
|
633
|
+
}
|
|
634
|
+
if (step.expect_canvas_nonblank === true && !snapshot.canvasNonblank) {
|
|
635
|
+
failures.push('expected canvas to contain rendered pixels, but it remained transparent/blank');
|
|
636
|
+
}
|
|
637
|
+
if (step.expect_value !== undefined && String(snapshot.value || '') !== String(step.expect_value)) {
|
|
638
|
+
failures.push(`value expected ${JSON.stringify(String(step.expect_value))}, got ${JSON.stringify(String(snapshot.value || ''))}`);
|
|
639
|
+
}
|
|
640
|
+
failures.push(...runtimeAttributeExpectationFailures(step, snapshot));
|
|
641
|
+
if (step.expect_focused === true && !snapshot.focused) {
|
|
642
|
+
failures.push(`expected ${JSON.stringify(selector)} to be focused; actual active element is ${JSON.stringify(snapshot.activeDescriptor)}`);
|
|
643
|
+
}
|
|
644
|
+
if (focusWithinSelector && !snapshot.focusWithin) {
|
|
645
|
+
failures.push(`focus is not within ${JSON.stringify(focusWithinSelector)}; actual active element is ${JSON.stringify(snapshot.activeDescriptor)}`);
|
|
646
|
+
}
|
|
647
|
+
return failures;
|
|
648
|
+
}
|
|
649
|
+
async function executeRuntimeScenario(client, sessionId, steps, timeoutMs) {
|
|
650
|
+
const failures = [];
|
|
651
|
+
let passed = 0;
|
|
652
|
+
const scenarioDeadline = Date.now() + Math.max(1_000, Math.min(timeoutMs, 120_000));
|
|
653
|
+
for (const [offset, step] of steps.entries()) {
|
|
654
|
+
const index = offset + 1;
|
|
655
|
+
try {
|
|
656
|
+
const actionSelector = String(step.selector || '').trim();
|
|
657
|
+
if (actionSelector && ['click', 'fill', 'select', 'focus', 'press'].includes(step.action)) {
|
|
658
|
+
await waitForRuntimeActionTarget(client, sessionId, actionSelector, scenarioDeadline);
|
|
659
|
+
}
|
|
660
|
+
await applyRuntimeStepAction(client, sessionId, step);
|
|
661
|
+
const waitMs = Math.max(0, Math.min(Number(step.wait_ms) || 0, 2_000));
|
|
662
|
+
if (waitMs && step.action !== 'wait')
|
|
663
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
664
|
+
let assertions = await runtimeStepFailures(client, sessionId, step);
|
|
665
|
+
const assertionDeadline = Math.min(scenarioDeadline, Date.now() + 3_000);
|
|
666
|
+
while (assertions.length > 0 && Date.now() < assertionDeadline) {
|
|
667
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
668
|
+
assertions = await runtimeStepFailures(client, sessionId, step);
|
|
669
|
+
}
|
|
670
|
+
if (assertions.length > 0) {
|
|
671
|
+
failures.push(`step ${index} (${step.action}): ${assertions.join('; ')}`);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
passed += 1;
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
failures.push(`step ${index} (${step.action}): ${normalizeRuntimeBody(error instanceof Error ? error.message : String(error))}`);
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
if (Date.now() >= scenarioDeadline && index < steps.length) {
|
|
681
|
+
failures.push(`scenario timed out after step ${index}`);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return { passed, failures };
|
|
686
|
+
}
|
|
279
687
|
async function captureRuntimePng(client, sessionId) {
|
|
280
688
|
const captured = await client.send('Page.captureScreenshot', {
|
|
281
689
|
format: 'png',
|
|
@@ -288,6 +696,7 @@ async function captureRuntimePng(client, sessionId) {
|
|
|
288
696
|
return Buffer.from(captured.data, 'base64');
|
|
289
697
|
}
|
|
290
698
|
export const runPreviewRuntimeBrowser = async (executablePath, args, platform, environment, screenshotPath, options) => {
|
|
699
|
+
assertPreviewBrowserSandboxUser(platform);
|
|
291
700
|
const child = spawn(executablePath, [...args], {
|
|
292
701
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
293
702
|
windowsHide: true,
|
|
@@ -319,7 +728,18 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
|
|
|
319
728
|
runtimeErrors.push(normalizeRuntimeBody(description));
|
|
320
729
|
}
|
|
321
730
|
if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
|
|
322
|
-
|
|
731
|
+
const entry = event.params.entry;
|
|
732
|
+
const entryUrl = String(entry.url || '').trim();
|
|
733
|
+
runtimeErrors.push(normalizeRuntimeBody(`${entry.text || 'Browser console error'}${entryUrl ? ` (${entryUrl})` : ''}`));
|
|
734
|
+
}
|
|
735
|
+
if (event.method === 'Network.responseReceived') {
|
|
736
|
+
const resourceType = String(event.params?.type || '');
|
|
737
|
+
const status = Number(event.params?.response?.status || 0);
|
|
738
|
+
const responseUrl = String(event.params?.response?.url || '').trim();
|
|
739
|
+
if (status >= 400
|
|
740
|
+
&& ['Document', 'Script', 'Fetch', 'XHR', 'Stylesheet'].includes(resourceType)) {
|
|
741
|
+
runtimeErrors.push(normalizeRuntimeBody(`HTTP ${status} ${resourceType} request: ${responseUrl || 'unknown URL'}`));
|
|
742
|
+
}
|
|
323
743
|
}
|
|
324
744
|
if (event.method === 'Network.loadingFailed') {
|
|
325
745
|
const resourceType = String(event.params?.type || '');
|
|
@@ -340,27 +760,28 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
|
|
|
340
760
|
}, sessionId);
|
|
341
761
|
const entryUrl = String(args.at(-1) || '');
|
|
342
762
|
await client.send('Page.navigate', { url: entryUrl }, sessionId);
|
|
343
|
-
await waitForRuntimeReady(client, sessionId);
|
|
763
|
+
await waitForRuntimeReady(client, sessionId, entryUrl);
|
|
344
764
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
345
765
|
const initialBodyText = await readRuntimeBody(client, sessionId);
|
|
346
766
|
const initialPng = await captureRuntimePng(client, sessionId);
|
|
347
|
-
const key = options.key ? runtimeKeyDefinition(options.key) : null;
|
|
348
|
-
if (options.key && !key)
|
|
349
|
-
throw new Error(`unsupported browser interaction key: ${options.key}`);
|
|
350
767
|
let action;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
768
|
+
const steps = Array.isArray(options.steps) ? options.steps.slice(0, 64) : [];
|
|
769
|
+
let interactionStepsPassed = 0;
|
|
770
|
+
let interactionFailures = [];
|
|
771
|
+
if (steps.length > 0) {
|
|
772
|
+
action = `scenario:${steps.length}`;
|
|
773
|
+
const scenario = await executeRuntimeScenario(client, sessionId, steps, Number(options.timeoutMs) || 30_000);
|
|
774
|
+
interactionStepsPassed = scenario.passed;
|
|
775
|
+
interactionFailures = scenario.failures;
|
|
776
|
+
}
|
|
777
|
+
else if (options.key) {
|
|
778
|
+
const chord = runtimeKeyChordDefinition(options.key);
|
|
779
|
+
if (!chord)
|
|
780
|
+
throw new Error(`unsupported browser interaction key: ${options.key}`);
|
|
781
|
+
action = `press:${chord.key.code}`;
|
|
782
|
+
await dispatchRuntimeKey(client, sessionId, options.key);
|
|
783
|
+
}
|
|
784
|
+
const waitMs = steps.length > 0 ? 250 : Math.max(250, Math.min(Number(options.waitMs) || 1_750, 5_000));
|
|
364
785
|
const deadline = Date.now() + waitMs;
|
|
365
786
|
let finalBodyText = initialBodyText;
|
|
366
787
|
while (Date.now() < deadline) {
|
|
@@ -380,6 +801,9 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
|
|
|
380
801
|
bodyChanged: finalBodyText !== initialBodyText,
|
|
381
802
|
visualChanged: !finalPng.equals(initialPng),
|
|
382
803
|
runtimeErrors: Array.from(new Set(runtimeErrors.filter(Boolean))).slice(0, 12),
|
|
804
|
+
interactionStepsRequested: steps.length,
|
|
805
|
+
interactionStepsPassed,
|
|
806
|
+
interactionFailures,
|
|
383
807
|
};
|
|
384
808
|
}
|
|
385
809
|
finally {
|
|
@@ -460,6 +884,126 @@ function isTransientBrowserFailure(result) {
|
|
|
460
884
|
return false;
|
|
461
885
|
return /(?:process exited with (?:SIG[A-Z]+|[1-9][0-9]*)|timed out|\bE(?:ACCES|PERM|BUSY|AGAIN)\b|resource temporarily unavailable)/i.test(result.error);
|
|
462
886
|
}
|
|
887
|
+
const PREVIEW_CONTENT_TYPES = {
|
|
888
|
+
'.css': 'text/css; charset=utf-8',
|
|
889
|
+
'.gif': 'image/gif',
|
|
890
|
+
'.html': 'text/html; charset=utf-8',
|
|
891
|
+
'.htm': 'text/html; charset=utf-8',
|
|
892
|
+
'.ico': 'image/x-icon',
|
|
893
|
+
'.jpeg': 'image/jpeg',
|
|
894
|
+
'.jpg': 'image/jpeg',
|
|
895
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
896
|
+
'.json': 'application/json; charset=utf-8',
|
|
897
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
898
|
+
'.png': 'image/png',
|
|
899
|
+
'.svg': 'image/svg+xml; charset=utf-8',
|
|
900
|
+
'.wasm': 'application/wasm',
|
|
901
|
+
'.webp': 'image/webp',
|
|
902
|
+
'.woff': 'font/woff',
|
|
903
|
+
'.woff2': 'font/woff2',
|
|
904
|
+
};
|
|
905
|
+
function isPathInside(rootPath, candidatePath) {
|
|
906
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
907
|
+
return relative === '' || (relative !== '..'
|
|
908
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
909
|
+
&& !path.isAbsolute(relative));
|
|
910
|
+
}
|
|
911
|
+
async function startLoopbackPreviewServer(entryAbsolutePath, requestedWorkspaceRoot) {
|
|
912
|
+
const entryPath = fs.realpathSync(entryAbsolutePath);
|
|
913
|
+
const workspaceRoot = fs.realpathSync(requestedWorkspaceRoot || path.dirname(entryPath));
|
|
914
|
+
if (!isPathInside(workspaceRoot, entryPath) || !fs.statSync(entryPath).isFile()) {
|
|
915
|
+
throw new Error('runtime preview entry is outside the authoritative workspace');
|
|
916
|
+
}
|
|
917
|
+
let server;
|
|
918
|
+
return new Promise((resolve, reject) => {
|
|
919
|
+
server = createServer((request, response) => {
|
|
920
|
+
try {
|
|
921
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
922
|
+
response.writeHead(405, { Allow: 'GET, HEAD' });
|
|
923
|
+
response.end();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
|
|
927
|
+
let decodedPath;
|
|
928
|
+
try {
|
|
929
|
+
decodedPath = decodeURIComponent(requestUrl.pathname);
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
response.writeHead(400);
|
|
933
|
+
response.end();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (decodedPath.includes('\0')) {
|
|
937
|
+
response.writeHead(400);
|
|
938
|
+
response.end();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
// Chromium requests /favicon.ico even when the document declares no
|
|
942
|
+
// icon. That optional browser decoration is not application runtime
|
|
943
|
+
// evidence. Answer the absent implicit request without hiding an
|
|
944
|
+
// explicitly provided favicon or any missing script/style/module.
|
|
945
|
+
if (decodedPath === '/favicon.ico') {
|
|
946
|
+
const explicitFavicon = path.resolve(workspaceRoot, 'favicon.ico');
|
|
947
|
+
if (!fs.existsSync(explicitFavicon)) {
|
|
948
|
+
response.writeHead(204, { 'Cache-Control': 'no-store' });
|
|
949
|
+
response.end();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const relativeRequest = decodedPath.replace(/^\/+/, '').split('/').join(path.sep);
|
|
954
|
+
const unresolved = path.resolve(workspaceRoot, relativeRequest);
|
|
955
|
+
if (!isPathInside(workspaceRoot, unresolved) || !fs.existsSync(unresolved)) {
|
|
956
|
+
response.writeHead(404);
|
|
957
|
+
response.end();
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const resolved = fs.realpathSync(unresolved);
|
|
961
|
+
if (!isPathInside(workspaceRoot, resolved) || !fs.statSync(resolved).isFile()) {
|
|
962
|
+
response.writeHead(404);
|
|
963
|
+
response.end();
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
response.writeHead(200, {
|
|
967
|
+
'Cache-Control': 'no-store',
|
|
968
|
+
'Content-Type': PREVIEW_CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream',
|
|
969
|
+
'X-Content-Type-Options': 'nosniff',
|
|
970
|
+
});
|
|
971
|
+
if (request.method === 'HEAD') {
|
|
972
|
+
response.end();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const stream = fs.createReadStream(resolved);
|
|
976
|
+
stream.once('error', () => response.destroy());
|
|
977
|
+
stream.pipe(response);
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
if (!response.headersSent)
|
|
981
|
+
response.writeHead(404);
|
|
982
|
+
response.end();
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
server.on('clientError', (_error, socket) => socket.destroy());
|
|
986
|
+
server.once('error', reject);
|
|
987
|
+
server.listen(0, '127.0.0.1', () => {
|
|
988
|
+
const address = server.address();
|
|
989
|
+
if (!address || typeof address.port !== 'number') {
|
|
990
|
+
server.close();
|
|
991
|
+
reject(new Error('loopback runtime preview server did not bind an ephemeral port'));
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const relativeEntry = path.relative(workspaceRoot, entryPath)
|
|
995
|
+
.split(path.sep)
|
|
996
|
+
.map((segment) => encodeURIComponent(segment))
|
|
997
|
+
.join('/');
|
|
998
|
+
resolve({
|
|
999
|
+
entryUrl: `http://127.0.0.1:${address.port}/${relativeEntry}`,
|
|
1000
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
1001
|
+
server.close((error) => error ? closeReject(error) : closeResolve());
|
|
1002
|
+
}),
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
463
1007
|
/** Dependency-free system-browser adapter. No browser download or archive
|
|
464
1008
|
* extractor is shipped, and only fixed operating-system installation paths
|
|
465
1009
|
* are eligible.
|
|
@@ -529,9 +1073,13 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
529
1073
|
}
|
|
530
1074
|
async captureRuntimeOnce(resolution, entryAbsolutePath, screenshotPath, options) {
|
|
531
1075
|
let browserProfile = null;
|
|
1076
|
+
let previewServer = null;
|
|
532
1077
|
let result;
|
|
533
1078
|
try {
|
|
534
|
-
|
|
1079
|
+
// Chromium creates process-singleton sockets below this directory. Keep
|
|
1080
|
+
// the managed leaf deliberately short so ordinary per-user temp roots
|
|
1081
|
+
// retain enough POSIX socket-path budget for real interaction proof.
|
|
1082
|
+
browserProfile = this.allocateTemp('br-', 128 * 1024 * 1024);
|
|
535
1083
|
const browserUserData = path.join(browserProfile, 'profile');
|
|
536
1084
|
const browserScratch = path.join(browserProfile, 'scratch');
|
|
537
1085
|
if (this.platform !== 'win32' && Buffer.byteLength(browserScratch) > POSIX_BROWSER_SCRATCH_MAX_BYTES) {
|
|
@@ -547,6 +1095,11 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
547
1095
|
if (error?.code !== 'ENOENT')
|
|
548
1096
|
throw error;
|
|
549
1097
|
}
|
|
1098
|
+
// Chromium blocks normal ES-module graphs loaded from file://. Use an
|
|
1099
|
+
// ephemeral HTTP origin bound exclusively to loopback so the proof sees
|
|
1100
|
+
// the same browser semantics as the user's local preview without
|
|
1101
|
+
// exposing the workspace on the network.
|
|
1102
|
+
previewServer = await startLoopbackPreviewServer(entryAbsolutePath, options.workspaceRoot);
|
|
550
1103
|
const args = [
|
|
551
1104
|
'--headless=new',
|
|
552
1105
|
'--disable-gpu',
|
|
@@ -559,17 +1112,13 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
559
1112
|
'--disable-sync',
|
|
560
1113
|
'--metrics-recording-only',
|
|
561
1114
|
'--proxy-server=http://127.0.0.1:9',
|
|
562
|
-
'--proxy-bypass-list
|
|
1115
|
+
'--proxy-bypass-list=127.0.0.1;localhost',
|
|
563
1116
|
'--remote-debugging-port=0',
|
|
564
1117
|
`--user-data-dir=${browserUserData}`,
|
|
565
1118
|
`--window-size=${SCREENSHOT_WIDTH},${SCREENSHOT_HEIGHT}`,
|
|
566
|
-
|
|
1119
|
+
previewServer.entryUrl,
|
|
567
1120
|
];
|
|
568
|
-
const browserEnvironment =
|
|
569
|
-
TMPDIR: browserScratch,
|
|
570
|
-
TMP: browserScratch,
|
|
571
|
-
TEMP: browserScratch,
|
|
572
|
-
});
|
|
1121
|
+
const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
|
|
573
1122
|
const observation = await this.runtimeRunner(resolution.executablePath, args, this.platform, browserEnvironment, screenshotPath, options);
|
|
574
1123
|
validatePng(screenshotPath);
|
|
575
1124
|
const runtimeErrors = observation.runtimeErrors.map((entry) => redactSensitiveText(entry)).slice(0, 12);
|
|
@@ -579,6 +1128,19 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
579
1128
|
const reasons = [];
|
|
580
1129
|
if (runtimeErrors.length > 0)
|
|
581
1130
|
reasons.push(`browser errors: ${runtimeErrors.join(' | ')}`);
|
|
1131
|
+
const unresolvedRenderedValues = Array.from(new Set(observation.finalBodyText.match(/\b(?:undefined|null|NaN)\s*(?:%|°(?:C|F)?\b|(?:px|rem|em|vh|vw|ms)\b)/gi) || [])).slice(0, 8);
|
|
1132
|
+
if (unresolvedRenderedValues.length > 0) {
|
|
1133
|
+
reasons.push(`rendered output exposes unresolved data values: ${unresolvedRenderedValues.join(', ')}`);
|
|
1134
|
+
}
|
|
1135
|
+
const stringifiedDomNodes = Array.from(new Set(observation.finalBodyText.match(/\[object\s+(?:HTML|SVG)[A-Za-z0-9]*Element\]/g) || [])).slice(0, 8);
|
|
1136
|
+
if (stringifiedDomNodes.length > 0) {
|
|
1137
|
+
reasons.push(`rendered output exposes stringified DOM nodes: ${stringifiedDomNodes.join(', ')}`);
|
|
1138
|
+
}
|
|
1139
|
+
const interactionFailures = (observation.interactionFailures || [])
|
|
1140
|
+
.map((entry) => redactSensitiveText(entry)).slice(0, 20);
|
|
1141
|
+
if (interactionFailures.length > 0) {
|
|
1142
|
+
reasons.push(`interaction failures: ${interactionFailures.join(' | ')}`);
|
|
1143
|
+
}
|
|
582
1144
|
if (!expectedTextMatched)
|
|
583
1145
|
reasons.push(`expected rendered text was not found after the interaction: ${redactSensitiveText(expectedText)}`);
|
|
584
1146
|
if (options.requireStateChange && !stateChanged) {
|
|
@@ -593,7 +1155,10 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
593
1155
|
bodyChanged: observation.bodyChanged,
|
|
594
1156
|
visualChanged: observation.visualChanged,
|
|
595
1157
|
runtimeErrors,
|
|
596
|
-
|
|
1158
|
+
interactionStepsRequested: Number(observation.interactionStepsRequested || 0),
|
|
1159
|
+
interactionStepsPassed: Number(observation.interactionStepsPassed || 0),
|
|
1160
|
+
interactionFailures,
|
|
1161
|
+
error: reasons.length > 0 ? `Loopback HTTP browser runtime proof failed: ${reasons.join('; ')}.` : undefined,
|
|
597
1162
|
};
|
|
598
1163
|
}
|
|
599
1164
|
catch (error) {
|
|
@@ -608,6 +1173,22 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
608
1173
|
};
|
|
609
1174
|
}
|
|
610
1175
|
finally {
|
|
1176
|
+
if (previewServer) {
|
|
1177
|
+
try {
|
|
1178
|
+
await previewServer.close();
|
|
1179
|
+
}
|
|
1180
|
+
catch (cleanupError) {
|
|
1181
|
+
try {
|
|
1182
|
+
fs.unlinkSync(screenshotPath);
|
|
1183
|
+
}
|
|
1184
|
+
catch { /* proof is invalid when cleanup is incomplete */ }
|
|
1185
|
+
result = {
|
|
1186
|
+
captured: false,
|
|
1187
|
+
passed: false,
|
|
1188
|
+
error: redactSensitiveText(`runtime preview server cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`),
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
611
1192
|
if (browserProfile) {
|
|
612
1193
|
try {
|
|
613
1194
|
this.releaseTemp(browserProfile);
|
|
@@ -672,11 +1253,7 @@ export class SystemBrowserScreenshotAdapter {
|
|
|
672
1253
|
'--allow-file-access-from-files',
|
|
673
1254
|
pathToFileURL(entryAbsolutePath).toString(),
|
|
674
1255
|
];
|
|
675
|
-
const browserEnvironment =
|
|
676
|
-
TMPDIR: browserScratch,
|
|
677
|
-
TMP: browserScratch,
|
|
678
|
-
TEMP: browserScratch,
|
|
679
|
-
});
|
|
1256
|
+
const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
|
|
680
1257
|
await this.runner(resolution.executablePath, args, this.platform, browserEnvironment);
|
|
681
1258
|
validatePng(screenshotPath);
|
|
682
1259
|
result = { captured: true };
|