vigthoria-cli 1.13.31 → 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.
Files changed (51) hide show
  1. package/README.md +11 -56
  2. package/SECURITY_HARDENING.md +2 -4
  3. package/dist/commands/auth.js +16 -14
  4. package/dist/commands/cancel.d.ts +1 -0
  5. package/dist/commands/cancel.js +21 -4
  6. package/dist/commands/chat.d.ts +13 -0
  7. package/dist/commands/chat.js +170 -14
  8. package/dist/commands/fork.js +8 -3
  9. package/dist/commands/history.js +8 -3
  10. package/dist/commands/legion.js +1 -1
  11. package/dist/commands/replay.js +8 -3
  12. package/dist/commands/update-registration.js +63 -206
  13. package/dist/commands/v4-menu.d.ts +0 -1
  14. package/dist/commands/v4-menu.js +8 -6
  15. package/dist/commands/v4-registration.js +0 -2
  16. package/dist/commands/v4.js +9 -4
  17. package/dist/commands/wallet.d.ts +1 -1
  18. package/dist/commands/wallet.js +3 -3
  19. package/dist/index.js +2 -3
  20. package/dist/utils/agent-stream-state.d.ts +7 -0
  21. package/dist/utils/agent-stream-state.js +31 -0
  22. package/dist/utils/agentRunOutcome.js +5 -1
  23. package/dist/utils/api.d.ts +21 -3
  24. package/dist/utils/api.js +379 -550
  25. package/dist/utils/frontend-preview-service.d.ts +12 -2
  26. package/dist/utils/frontend-preview-service.js +214 -24
  27. package/dist/utils/javascript-syntax.d.ts +19 -0
  28. package/dist/utils/javascript-syntax.js +35 -0
  29. package/dist/utils/localTestMode.js +1 -1
  30. package/dist/utils/mutation-journal.d.ts +10 -0
  31. package/dist/utils/mutation-journal.js +104 -76
  32. package/dist/utils/network-policy.js +7 -10
  33. package/dist/utils/preview-screenshot-adapter.d.ts +111 -1
  34. package/dist/utils/preview-screenshot-adapter.js +990 -7
  35. package/dist/utils/release-install.d.ts +0 -1
  36. package/dist/utils/release-install.js +44 -7
  37. package/dist/utils/requestIntent.d.ts +1 -1
  38. package/dist/utils/requestIntent.js +17 -3
  39. package/dist/utils/tools.js +21 -15
  40. package/dist/utils/update-policy.d.ts +0 -4
  41. package/dist/utils/update-policy.js +5 -9
  42. package/dist/utils/v3-agent-client.js +25 -11
  43. package/install.ps1 +30 -50
  44. package/install.sh +12 -23
  45. package/package.json +4 -2
  46. package/release-policy.json +1 -5
  47. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +6 -7
  48. package/scripts/release/install-release.mjs +5 -4
  49. package/scripts/release/publish-cli-release.mjs +21 -8
  50. package/scripts/release/test-balanced-model-live.sh +4 -1
  51. package/scripts/release/validate-live-service-gates.sh +27 -5
@@ -1,7 +1,9 @@
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';
6
+ import WebSocket from 'ws';
5
7
  import { redactSensitiveText, safeChildProcessEnv } from './secret-policy.js';
6
8
  import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from './runtime-temp.js';
7
9
  const SCREENSHOT_WIDTH = 800;
@@ -11,7 +13,35 @@ const BROWSER_TIMEOUT_MS = 30_000;
11
13
  // Chromium creates short-lived files below TMPDIR while starting. Keep enough
12
14
  // room below the 108-byte POSIX sockaddr_un ceiling without rejecting normal
13
15
  // per-user Vigthoria roots (for example ~/.vigthoria/tmp/browser-*/scratch).
14
- const POSIX_BROWSER_SCRATCH_MAX_BYTES = 76;
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
+ }
15
45
  export function resolvePreviewBrowserExecutable(environment = process.env, platform = process.platform, exists = fs.existsSync) {
16
46
  const candidates = platform === 'win32'
17
47
  ? [
@@ -89,6 +119,13 @@ async function terminateBrowserProcess(child, platform) {
89
119
  catch { /* group exited */ }
90
120
  }
91
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
+ }
92
129
  const child = spawn(executablePath, [...args], {
93
130
  stdio: 'ignore',
94
131
  windowsHide: true,
@@ -114,6 +151,666 @@ export const runPreviewBrowser = (executablePath, args, platform, environment =
114
151
  finish(new Error(`browser screenshot process exited with ${signal || code}`));
115
152
  });
116
153
  });
154
+ class PreviewCdpClient {
155
+ socket;
156
+ nextId = 1;
157
+ pending = new Map();
158
+ eventListeners = new Set();
159
+ constructor(socket) {
160
+ this.socket = socket;
161
+ socket.on('message', (data) => {
162
+ let message;
163
+ try {
164
+ message = JSON.parse(data.toString());
165
+ }
166
+ catch {
167
+ return;
168
+ }
169
+ if (typeof message.id === 'number') {
170
+ const request = this.pending.get(message.id);
171
+ if (!request)
172
+ return;
173
+ clearTimeout(request.timer);
174
+ this.pending.delete(message.id);
175
+ if (message.error)
176
+ request.reject(new Error(message.error.message || 'DevTools command failed'));
177
+ else
178
+ request.resolve(message.result || {});
179
+ return;
180
+ }
181
+ for (const listener of this.eventListeners)
182
+ listener(message);
183
+ });
184
+ socket.on('error', (error) => this.rejectPending(error));
185
+ socket.on('close', () => this.rejectPending(new Error('browser DevTools connection closed')));
186
+ }
187
+ onEvent(listener) {
188
+ this.eventListeners.add(listener);
189
+ return () => this.eventListeners.delete(listener);
190
+ }
191
+ send(method, params = {}, sessionId) {
192
+ const id = this.nextId++;
193
+ return new Promise((resolve, reject) => {
194
+ const timer = setTimeout(() => {
195
+ this.pending.delete(id);
196
+ reject(new Error(`browser DevTools command ${method} timed out`));
197
+ }, 7_500);
198
+ this.pending.set(id, { resolve, reject, timer });
199
+ this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }), (error) => {
200
+ if (!error)
201
+ return;
202
+ const request = this.pending.get(id);
203
+ if (!request)
204
+ return;
205
+ clearTimeout(request.timer);
206
+ this.pending.delete(id);
207
+ reject(error);
208
+ });
209
+ });
210
+ }
211
+ close() {
212
+ try {
213
+ this.socket.close();
214
+ }
215
+ catch { /* browser already exited */ }
216
+ }
217
+ rejectPending(error) {
218
+ for (const [id, request] of this.pending) {
219
+ clearTimeout(request.timer);
220
+ request.reject(error);
221
+ this.pending.delete(id);
222
+ }
223
+ }
224
+ }
225
+ function waitForDevToolsSocket(child) {
226
+ return new Promise((resolve, reject) => {
227
+ let buffer = '';
228
+ let settled = false;
229
+ const finish = (error, socketUrl) => {
230
+ if (settled)
231
+ return;
232
+ settled = true;
233
+ clearTimeout(timer);
234
+ child.stderr?.off('data', onData);
235
+ child.off('error', onError);
236
+ child.off('exit', onExit);
237
+ if (error)
238
+ reject(error);
239
+ else
240
+ resolve(socketUrl);
241
+ };
242
+ const onData = (chunk) => {
243
+ buffer = `${buffer}${chunk.toString()}`.slice(-16_384);
244
+ const match = buffer.match(/DevTools listening on (ws:\/\/[^\s]+)/i);
245
+ if (match)
246
+ finish(undefined, match[1]);
247
+ };
248
+ const onError = (error) => finish(error);
249
+ const onExit = (code, signal) => finish(new Error(`browser runtime process exited before DevTools was ready with ${signal || code}`));
250
+ const timer = setTimeout(() => finish(new Error('browser DevTools endpoint did not become ready')), 10_000);
251
+ child.stderr?.on('data', onData);
252
+ child.once('error', onError);
253
+ child.once('exit', onExit);
254
+ });
255
+ }
256
+ function connectDevTools(socketUrl) {
257
+ return new Promise((resolve, reject) => {
258
+ const socket = new WebSocket(socketUrl, { perMessageDeflate: false });
259
+ const timer = setTimeout(() => {
260
+ try {
261
+ socket.terminate();
262
+ }
263
+ catch { /* connection never opened */ }
264
+ reject(new Error('browser DevTools WebSocket connection timed out'));
265
+ }, 7_500);
266
+ socket.once('open', () => { clearTimeout(timer); resolve(socket); });
267
+ socket.once('error', (error) => { clearTimeout(timer); reject(error); });
268
+ });
269
+ }
270
+ function normalizeRuntimeBody(value) {
271
+ return redactSensitiveText(String(value || '').replace(/\s+/g, ' ').trim()).slice(0, 4_000);
272
+ }
273
+ function runtimeKeyDefinition(rawKey = '') {
274
+ const normalized = rawKey.trim().toLowerCase().replace(/\s+/g, '');
275
+ const fixed = {
276
+ space: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
277
+ spacebar: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
278
+ enter: { key: 'Enter', code: 'Enter', virtualKeyCode: 13, text: '\r' },
279
+ tab: { key: 'Tab', code: 'Tab', virtualKeyCode: 9 },
280
+ escape: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
281
+ esc: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
282
+ arrowup: { key: 'ArrowUp', code: 'ArrowUp', virtualKeyCode: 38 },
283
+ arrowdown: { key: 'ArrowDown', code: 'ArrowDown', virtualKeyCode: 40 },
284
+ arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', virtualKeyCode: 37 },
285
+ arrowright: { key: 'ArrowRight', code: 'ArrowRight', virtualKeyCode: 39 },
286
+ };
287
+ if (fixed[normalized])
288
+ return fixed[normalized];
289
+ if (/^[a-z0-9]$/.test(normalized)) {
290
+ const upper = normalized.toUpperCase();
291
+ return { key: normalized, code: /[a-z]/.test(normalized) ? `Key${upper}` : `Digit${normalized}`, virtualKeyCode: upper.charCodeAt(0), text: normalized };
292
+ }
293
+ return null;
294
+ }
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) {
328
+ const deadline = Date.now() + 8_000;
329
+ while (Date.now() < deadline) {
330
+ const evaluated = await client.send('Runtime.evaluate', {
331
+ expression: '({ readyState: document.readyState, href: location.href })',
332
+ returnByValue: true,
333
+ }, sessionId);
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'))
338
+ return;
339
+ await new Promise((resolve) => setTimeout(resolve, 100));
340
+ }
341
+ throw new Error('browser document did not become ready at the requested loopback URL');
342
+ }
343
+ async function readRuntimeBody(client, sessionId) {
344
+ const evaluated = await client.send('Runtime.evaluate', {
345
+ expression: 'document.body ? document.body.innerText : ""',
346
+ returnByValue: true,
347
+ }, sessionId);
348
+ return normalizeRuntimeBody(evaluated.result?.value);
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
+ }
687
+ async function captureRuntimePng(client, sessionId) {
688
+ const captured = await client.send('Page.captureScreenshot', {
689
+ format: 'png',
690
+ fromSurface: true,
691
+ captureBeyondViewport: false,
692
+ }, sessionId);
693
+ if (typeof captured.data !== 'string' || captured.data.length === 0) {
694
+ throw new Error('browser DevTools did not return screenshot bytes');
695
+ }
696
+ return Buffer.from(captured.data, 'base64');
697
+ }
698
+ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, environment, screenshotPath, options) => {
699
+ assertPreviewBrowserSandboxUser(platform);
700
+ const child = spawn(executablePath, [...args], {
701
+ stdio: ['ignore', 'ignore', 'pipe'],
702
+ windowsHide: true,
703
+ env: safeChildProcessEnv(environment),
704
+ detached: platform !== 'win32',
705
+ });
706
+ let client = null;
707
+ const runtimeErrors = [];
708
+ try {
709
+ const socketUrl = await waitForDevToolsSocket(child);
710
+ client = new PreviewCdpClient(await connectDevTools(socketUrl));
711
+ const targets = await client.send('Target.getTargets');
712
+ const pageTarget = Array.isArray(targets.targetInfos)
713
+ ? targets.targetInfos.find((target) => target?.type === 'page')
714
+ : null;
715
+ if (!pageTarget?.targetId)
716
+ throw new Error('browser DevTools did not expose a page target');
717
+ const attached = await client.send('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
718
+ const sessionId = String(attached.sessionId || '');
719
+ if (!sessionId)
720
+ throw new Error('browser DevTools did not attach to the page target');
721
+ client.onEvent((event) => {
722
+ if (event.sessionId !== sessionId)
723
+ return;
724
+ if (event.method === 'Runtime.exceptionThrown') {
725
+ const description = event.params?.exceptionDetails?.exception?.description
726
+ || event.params?.exceptionDetails?.text
727
+ || 'Uncaught runtime exception';
728
+ runtimeErrors.push(normalizeRuntimeBody(description));
729
+ }
730
+ if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
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
+ }
743
+ }
744
+ if (event.method === 'Network.loadingFailed') {
745
+ const resourceType = String(event.params?.type || '');
746
+ if (['Document', 'Script', 'Fetch', 'XHR', 'Stylesheet'].includes(resourceType)) {
747
+ runtimeErrors.push(normalizeRuntimeBody(`Failed ${resourceType} request: ${event.params?.errorText || 'unknown browser error'}`));
748
+ }
749
+ }
750
+ });
751
+ await client.send('Page.enable', {}, sessionId);
752
+ await client.send('Runtime.enable', {}, sessionId);
753
+ await client.send('Log.enable', {}, sessionId);
754
+ await client.send('Network.enable', {}, sessionId);
755
+ await client.send('Emulation.setDeviceMetricsOverride', {
756
+ width: SCREENSHOT_WIDTH,
757
+ height: SCREENSHOT_HEIGHT,
758
+ deviceScaleFactor: 1,
759
+ mobile: false,
760
+ }, sessionId);
761
+ const entryUrl = String(args.at(-1) || '');
762
+ await client.send('Page.navigate', { url: entryUrl }, sessionId);
763
+ await waitForRuntimeReady(client, sessionId, entryUrl);
764
+ await new Promise((resolve) => setTimeout(resolve, 250));
765
+ const initialBodyText = await readRuntimeBody(client, sessionId);
766
+ const initialPng = await captureRuntimePng(client, sessionId);
767
+ let action;
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));
785
+ const deadline = Date.now() + waitMs;
786
+ let finalBodyText = initialBodyText;
787
+ while (Date.now() < deadline) {
788
+ await new Promise((resolve) => setTimeout(resolve, 100));
789
+ finalBodyText = await readRuntimeBody(client, sessionId);
790
+ if (options.expectText && finalBodyText.includes(options.expectText))
791
+ break;
792
+ if (!options.expectText && options.requireStateChange && finalBodyText !== initialBodyText)
793
+ break;
794
+ }
795
+ const finalPng = await captureRuntimePng(client, sessionId);
796
+ fs.writeFileSync(screenshotPath, finalPng, { mode: 0o600 });
797
+ return {
798
+ action,
799
+ initialBodyText,
800
+ finalBodyText,
801
+ bodyChanged: finalBodyText !== initialBodyText,
802
+ visualChanged: !finalPng.equals(initialPng),
803
+ runtimeErrors: Array.from(new Set(runtimeErrors.filter(Boolean))).slice(0, 12),
804
+ interactionStepsRequested: steps.length,
805
+ interactionStepsPassed,
806
+ interactionFailures,
807
+ };
808
+ }
809
+ finally {
810
+ client?.close();
811
+ await terminateBrowserProcess(child, platform);
812
+ }
813
+ };
117
814
  function pngCrc32(data) {
118
815
  let crc = 0xffffffff;
119
816
  for (const byte of data) {
@@ -187,6 +884,126 @@ function isTransientBrowserFailure(result) {
187
884
  return false;
188
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);
189
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
+ }
190
1007
  /** Dependency-free system-browser adapter. No browser download or archive
191
1008
  * extractor is shipped, and only fixed operating-system installation paths
192
1009
  * are eligible.
@@ -198,13 +1015,15 @@ export class SystemBrowserScreenshotAdapter {
198
1015
  platform;
199
1016
  exists;
200
1017
  runner;
201
- constructor(environment = process.env, allocateTemp = createRuntimeTempDirectory, releaseTemp = removeRuntimeTempDirectory, platform = process.platform, exists = fs.existsSync, runner = runPreviewBrowser) {
1018
+ runtimeRunner;
1019
+ constructor(environment = process.env, allocateTemp = createRuntimeTempDirectory, releaseTemp = removeRuntimeTempDirectory, platform = process.platform, exists = fs.existsSync, runner = runPreviewBrowser, runtimeRunner = runPreviewRuntimeBrowser) {
202
1020
  this.environment = environment;
203
1021
  this.allocateTemp = allocateTemp;
204
1022
  this.releaseTemp = releaseTemp;
205
1023
  this.platform = platform;
206
1024
  this.exists = exists;
207
1025
  this.runner = runner;
1026
+ this.runtimeRunner = runtimeRunner;
208
1027
  }
209
1028
  async capture(entryAbsolutePath, screenshotPath) {
210
1029
  if (this.environment.VIGTHORIA_DISABLE_PREVIEW_SCREENSHOT === '1') {
@@ -227,6 +1046,174 @@ export class SystemBrowserScreenshotAdapter {
227
1046
  }
228
1047
  return lastResult;
229
1048
  }
1049
+ async captureRuntime(entryAbsolutePath, screenshotPath, options) {
1050
+ if (this.environment.VIGTHORIA_DISABLE_PREVIEW_SCREENSHOT === '1') {
1051
+ return { captured: false, passed: false, error: 'runtime screenshot capture explicitly unavailable' };
1052
+ }
1053
+ const resolution = resolvePreviewBrowserExecutable(this.environment, this.platform, this.exists);
1054
+ if (!resolution) {
1055
+ return {
1056
+ captured: false,
1057
+ passed: false,
1058
+ error: 'required system browser is unavailable; install Microsoft Edge, Chrome, or Chromium',
1059
+ };
1060
+ }
1061
+ let lastResult = {
1062
+ captured: false,
1063
+ passed: false,
1064
+ error: 'browser runtime proof was not attempted',
1065
+ };
1066
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1067
+ lastResult = await this.captureRuntimeOnce(resolution, entryAbsolutePath, screenshotPath, options);
1068
+ if (lastResult.passed || !isTransientBrowserFailure(lastResult))
1069
+ return lastResult;
1070
+ await new Promise((resolve) => setTimeout(resolve, 500));
1071
+ }
1072
+ return lastResult;
1073
+ }
1074
+ async captureRuntimeOnce(resolution, entryAbsolutePath, screenshotPath, options) {
1075
+ let browserProfile = null;
1076
+ let previewServer = null;
1077
+ let result;
1078
+ try {
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);
1083
+ const browserUserData = path.join(browserProfile, 'profile');
1084
+ const browserScratch = path.join(browserProfile, 'scratch');
1085
+ if (this.platform !== 'win32' && Buffer.byteLength(browserScratch) > POSIX_BROWSER_SCRATCH_MAX_BYTES) {
1086
+ throw new Error('managed browser temporary path is too long; configure a shorter dedicated VIGTHORIA_TEMP_DIR');
1087
+ }
1088
+ fs.mkdirSync(browserUserData, { recursive: false, mode: 0o700 });
1089
+ fs.mkdirSync(browserScratch, { recursive: false, mode: 0o700 });
1090
+ fs.mkdirSync(path.dirname(screenshotPath), { recursive: true, mode: 0o700 });
1091
+ try {
1092
+ fs.unlinkSync(screenshotPath);
1093
+ }
1094
+ catch (error) {
1095
+ if (error?.code !== 'ENOENT')
1096
+ throw error;
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);
1103
+ const args = [
1104
+ '--headless=new',
1105
+ '--disable-gpu',
1106
+ '--disable-dev-shm-usage',
1107
+ '--hide-scrollbars',
1108
+ '--no-first-run',
1109
+ '--no-default-browser-check',
1110
+ '--disable-background-networking',
1111
+ '--disable-component-update',
1112
+ '--disable-sync',
1113
+ '--metrics-recording-only',
1114
+ '--proxy-server=http://127.0.0.1:9',
1115
+ '--proxy-bypass-list=127.0.0.1;localhost',
1116
+ '--remote-debugging-port=0',
1117
+ `--user-data-dir=${browserUserData}`,
1118
+ `--window-size=${SCREENSHOT_WIDTH},${SCREENSHOT_HEIGHT}`,
1119
+ previewServer.entryUrl,
1120
+ ];
1121
+ const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
1122
+ const observation = await this.runtimeRunner(resolution.executablePath, args, this.platform, browserEnvironment, screenshotPath, options);
1123
+ validatePng(screenshotPath);
1124
+ const runtimeErrors = observation.runtimeErrors.map((entry) => redactSensitiveText(entry)).slice(0, 12);
1125
+ const expectedText = String(options.expectText || '').trim();
1126
+ const expectedTextMatched = !expectedText || observation.finalBodyText.includes(expectedText);
1127
+ const stateChanged = observation.bodyChanged || observation.visualChanged;
1128
+ const reasons = [];
1129
+ if (runtimeErrors.length > 0)
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
+ }
1144
+ if (!expectedTextMatched)
1145
+ reasons.push(`expected rendered text was not found after the interaction: ${redactSensitiveText(expectedText)}`);
1146
+ if (options.requireStateChange && !stateChanged) {
1147
+ reasons.push(`${observation.action || 'requested interaction'} produced no DOM-text or visual change`);
1148
+ }
1149
+ result = {
1150
+ captured: true,
1151
+ passed: reasons.length === 0,
1152
+ action: observation.action,
1153
+ initialBodyText: observation.initialBodyText,
1154
+ finalBodyText: observation.finalBodyText,
1155
+ bodyChanged: observation.bodyChanged,
1156
+ visualChanged: observation.visualChanged,
1157
+ runtimeErrors,
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,
1162
+ };
1163
+ }
1164
+ catch (error) {
1165
+ try {
1166
+ fs.unlinkSync(screenshotPath);
1167
+ }
1168
+ catch { /* no failed proof artifact */ }
1169
+ result = {
1170
+ captured: false,
1171
+ passed: false,
1172
+ error: redactSensitiveText(error instanceof Error ? error.message : String(error)),
1173
+ };
1174
+ }
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
+ }
1192
+ if (browserProfile) {
1193
+ try {
1194
+ this.releaseTemp(browserProfile);
1195
+ }
1196
+ catch {
1197
+ await new Promise((resolve) => setTimeout(resolve, 150));
1198
+ try {
1199
+ this.releaseTemp(browserProfile);
1200
+ }
1201
+ catch (cleanupError) {
1202
+ try {
1203
+ fs.unlinkSync(screenshotPath);
1204
+ }
1205
+ catch { /* proof is invalid when cleanup is incomplete */ }
1206
+ result = {
1207
+ captured: false,
1208
+ passed: false,
1209
+ error: redactSensitiveText(`browser profile cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`),
1210
+ };
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+ return result;
1216
+ }
230
1217
  async captureOnce(resolution, entryAbsolutePath, screenshotPath) {
231
1218
  let browserProfile = null;
232
1219
  let result;
@@ -266,11 +1253,7 @@ export class SystemBrowserScreenshotAdapter {
266
1253
  '--allow-file-access-from-files',
267
1254
  pathToFileURL(entryAbsolutePath).toString(),
268
1255
  ];
269
- const browserEnvironment = safeChildProcessEnv(this.environment, {
270
- TMPDIR: browserScratch,
271
- TMP: browserScratch,
272
- TEMP: browserScratch,
273
- });
1256
+ const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
274
1257
  await this.runner(resolution.executablePath, args, this.platform, browserEnvironment);
275
1258
  validatePng(screenshotPath);
276
1259
  result = { captured: true };