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.
- 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 +21 -3
- package/dist/utils/api.js +379 -550
- package/dist/utils/frontend-preview-service.d.ts +12 -2
- package/dist/utils/frontend-preview-service.js +214 -24
- 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 +111 -1
- package/dist/utils/preview-screenshot-adapter.js +990 -7
- 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 +12 -23
- 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,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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 };
|