@oooooooooo/ch0de 0.0.0-stage → 1.9.56
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/API-TESTING.md +76 -0
- package/DEBTAUR-INTEGRATION.md +71 -0
- package/EDITING.md +80 -0
- package/EXPERT-PANEL.md +16 -0
- package/INTEGRATIONS.md +75 -0
- package/LICENSE +21 -0
- package/MODELS-DEV-LICENSE +21 -0
- package/PROVIDER-CONNECTIONS.md +48 -0
- package/PROVIDERS.md +17 -0
- package/README.md +523 -2
- package/RELIABILITY.md +95 -0
- package/UX-1.3.md +83 -0
- package/UX-1.4.1.md +13 -0
- package/UX-1.4.md +48 -0
- package/UX-1.5.md +13 -0
- package/UX-1.6.md +7 -0
- package/UX-1.7.3.md +13 -0
- package/UX-1.7.4.md +13 -0
- package/UX-1.7.md +19 -0
- package/acceptance-check.js +27 -0
- package/agent-budget.js +4 -0
- package/api-testing.js +140 -0
- package/automatic-re.js +19 -0
- package/benchmarks/public-baselines.json +29 -0
- package/benchmarks/render-1.4.0.json +8 -0
- package/benchmarks/targets.js +11 -0
- package/browser-check.js +1062 -0
- package/build-progress.js +10 -0
- package/build-quality.js +49 -0
- package/chode.js +7937 -0
- package/debtaur-engine.js +305 -0
- package/debtaur-orchestration.js +456 -0
- package/debtaur-worker.cjs +76 -0
- package/doctor.js +24 -0
- package/edit-transactions.js +79 -0
- package/elapsed.js +28 -0
- package/evidence-ledger.js +26 -0
- package/expert-panel.cjs +42 -0
- package/helios-ledger.js +405 -0
- package/install.cjs +25 -0
- package/integrations.js +30 -0
- package/json-output.js +8 -0
- package/local-memory.js +613 -0
- package/local-models.js +52 -0
- package/mcp-client.js +50 -0
- package/memory.js +511 -0
- package/model-catalog.js +46 -0
- package/native-tools.js +27 -0
- package/nosignups.js +325 -0
- package/observation-progress.js +33 -0
- package/orchestrator.js +299 -0
- package/package.json +160 -6
- package/plan-z6.js +490 -0
- package/project-memory.js +27 -0
- package/provider-capacity.js +13 -0
- package/provider-catalog.js +246 -0
- package/provider-connect.js +66 -0
- package/provider-credentials.js +5 -0
- package/provider-directory.js +8 -0
- package/provider-directory.json +32300 -0
- package/provider-errors.js +11 -0
- package/provider-picker.js +188 -0
- package/provider-policy.js +4 -0
- package/provider-race.js +25 -0
- package/provider-request.js +45 -0
- package/provider-store.js +13 -0
- package/rea-environment.js +5 -0
- package/read-window.js +21 -0
- package/recovery-context.js +3 -0
- package/reliability-memory.js +38 -0
- package/reliability-services.js +34 -0
- package/reliability-store.js +16 -0
- package/request-deadline.js +4 -0
- package/serve-frontend.html +172 -0
- package/settings-reset.js +11 -0
- package/skill-catalog.js +16 -0
- package/task-group.js +22 -0
- package/terminal-graphics.js +39 -0
- package/terminal-logo.js +76 -0
- package/terminal-ui.js +79 -0
- package/tool-call-validation.js +26 -0
- package/tool-health.js +22 -0
- package/tool-history.js +5 -0
- package/toolbox.js +156 -0
- package/tui-provider-picker.js +134 -0
- package/tui-worker.js +32 -0
- package/web-context.js +232 -0
- package/wolfram-math.js +6 -0
- package/worker-transport.js +11 -0
- package/workspace-commands.js +45 -0
- package/workspace-log.js +56 -0
- package/workspace-store.js +7 -0
- package/workspace-tui.js +113 -0
- package/workspace-view.js +109 -0
- package/worktrees.js +18 -0
- package/z6-integration.js +227 -0
package/browser-check.js
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Zero-dependency browser verification for generated sites.
|
|
3
|
+
//
|
|
4
|
+
// A static HTML review cannot prove that a button works, that a script parses
|
|
5
|
+
// at runtime, or that the mobile layout does not overflow. This module serves
|
|
6
|
+
// the workspace over a short-lived local HTTP server, drives Chromium through
|
|
7
|
+
// the DevTools Protocol (Node 22+ global WebSocket), and reports observable
|
|
8
|
+
// failures that the existing repair loop can act on. Screenshots are saved for
|
|
9
|
+
// human inspection; canvas pixels are sampled too, so a transparent or uniform
|
|
10
|
+
// canvas cannot pass merely by existing. The checks themselves stay deterministic.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const zlib = require('zlib');
|
|
17
|
+
const { spawn, spawnSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
const MIME = {
|
|
20
|
+
'.html': 'text/html; charset=utf-8',
|
|
21
|
+
'.htm': 'text/html; charset=utf-8',
|
|
22
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
23
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
24
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
25
|
+
'.css': 'text/css; charset=utf-8',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.png': 'image/png',
|
|
29
|
+
'.jpg': 'image/jpeg',
|
|
30
|
+
'.jpeg': 'image/jpeg',
|
|
31
|
+
'.gif': 'image/gif',
|
|
32
|
+
'.webp': 'image/webp',
|
|
33
|
+
'.avif': 'image/avif',
|
|
34
|
+
'.ico': 'image/x-icon',
|
|
35
|
+
'.woff': 'font/woff',
|
|
36
|
+
'.woff2': 'font/woff2',
|
|
37
|
+
'.ttf': 'font/ttf',
|
|
38
|
+
'.otf': 'font/otf',
|
|
39
|
+
'.mp4': 'video/mp4',
|
|
40
|
+
'.webm': 'video/webm',
|
|
41
|
+
'.mp3': 'audio/mpeg',
|
|
42
|
+
'.wav': 'audio/wav',
|
|
43
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
44
|
+
'.xml': 'application/xml; charset=utf-8'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const VIEWPORTS = [
|
|
48
|
+
{ name: 'desktop', width: 1440, height: 900, mobile: false },
|
|
49
|
+
{ name: 'mobile', width: 390, height: 844, mobile: true }
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const INTERACTION_REQUEST = /\b(?:form|contact\s+form|submit|filter|calculator|todo|shopping|cart|toggle|interactive|click|sign\s*up|signup|booking|reservation|newsletter|login|dashboard|quiz|game|slider|dropdown|search\s+(?:box|field|input)|add\s+(?:item|task|to\s+cart)|create\s+(?:task|item|account))\b/i;
|
|
53
|
+
const PRIMARY_ACTION = /\b(?:add|create|submit|save|send|start|search|filter|apply|calculate|toggle|open|close|show|hide|play|pause|next|previous|book|reserve|subscribe|sign\s*up|log\s*in|update|generate|run|check)\b/i;
|
|
54
|
+
const DESTRUCTIVE_ACTION = /\b(?:delete|remove|reset|clear|cancel|buy|pay|purchase|logout|log\s*out|sign\s*out|download|unsubscribe|destroy|drop)\b/i;
|
|
55
|
+
|
|
56
|
+
function timeoutMs(value) {
|
|
57
|
+
const n = Number(value);
|
|
58
|
+
if (!Number.isFinite(n) || n <= 0) return 20000;
|
|
59
|
+
return Math.max(1000, Math.min(120000, Math.floor(n)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sleep(ms) {
|
|
63
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pathExists(file) {
|
|
67
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch (e) { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findBrowser() {
|
|
71
|
+
if (process.env.CHODE_CHROMIUM) {
|
|
72
|
+
if (pathExists(process.env.CHODE_CHROMIUM)) return process.env.CHODE_CHROMIUM;
|
|
73
|
+
}
|
|
74
|
+
const names = ['chromium', 'chromium-browser', 'google-chrome-stable', 'google-chrome', 'chrome', 'msedge', 'microsoft-edge'];
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], {
|
|
77
|
+
encoding: 'utf8', timeout: 3000, windowsHide: true
|
|
78
|
+
});
|
|
79
|
+
if (found.status === 0) {
|
|
80
|
+
const first = String(found.stdout || '').split(/\r?\n/).map(function (line) { return line.trim(); }).filter(Boolean)[0];
|
|
81
|
+
if (first && pathExists(first)) return first;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const known = process.platform === 'win32'
|
|
85
|
+
? [
|
|
86
|
+
path.join(process.env.PROGRAMFILES || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
87
|
+
path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
88
|
+
path.join(process.env['PROGRAMFILES(X86)'] || '', 'Microsoft', 'Edge', 'Application', 'msedge.exe')
|
|
89
|
+
]
|
|
90
|
+
: [
|
|
91
|
+
'/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable',
|
|
92
|
+
'/snap/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
93
|
+
];
|
|
94
|
+
for (const file of known) {
|
|
95
|
+
if (file && pathExists(file)) return file;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function available() {
|
|
101
|
+
return typeof WebSocket === 'function' && !!findBrowser();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function unavailableReason() {
|
|
105
|
+
if (typeof WebSocket !== 'function') return 'Node.js global WebSocket is unavailable';
|
|
106
|
+
if (!findBrowser()) return 'no supported Chromium/Chrome executable was found';
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function safeFile(root, urlPath) {
|
|
111
|
+
let decoded;
|
|
112
|
+
try { decoded = decodeURIComponent(String(urlPath || '/')); }
|
|
113
|
+
catch (e) { return null; }
|
|
114
|
+
decoded = decoded.split('?')[0].split('#')[0];
|
|
115
|
+
const rel = path.posix.normalize('/' + decoded.replace(/^\/+/, ''));
|
|
116
|
+
if (rel.split('/').includes('..')) return null;
|
|
117
|
+
const full = path.join(root, rel.replace(/^\/+/, ''));
|
|
118
|
+
const rootResolved = path.resolve(root);
|
|
119
|
+
const fullResolved = path.resolve(full);
|
|
120
|
+
if (fullResolved !== rootResolved && !fullResolved.startsWith(rootResolved + path.sep)) return null;
|
|
121
|
+
return fullResolved;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startServer(root, virtual) {
|
|
125
|
+
const pages = virtual || new Map();
|
|
126
|
+
const server = http.createServer(function (req, res) {
|
|
127
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
128
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
|
|
129
|
+
res.end('Method not allowed');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let pathname = '/';
|
|
133
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; } catch (e) {}
|
|
134
|
+
if (pathname === '/favicon.ico') {
|
|
135
|
+
res.writeHead(204, { 'cache-control': 'no-store' });
|
|
136
|
+
res.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (pages.has(pathname)) {
|
|
140
|
+
const body = Buffer.from(String(pages.get(pathname) || ''), 'utf8');
|
|
141
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': body.length, 'cache-control': 'no-store' });
|
|
142
|
+
if (req.method === 'HEAD') res.end(); else res.end(body);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let file = safeFile(root, pathname);
|
|
146
|
+
if (file) {
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(file)) file = null;
|
|
149
|
+
else if (fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
|
|
150
|
+
} catch (e) { file = null; }
|
|
151
|
+
}
|
|
152
|
+
if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
153
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
154
|
+
res.end('Not found');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let body;
|
|
158
|
+
try { body = fs.readFileSync(file); }
|
|
159
|
+
catch (e) {
|
|
160
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
161
|
+
res.end('Read failed');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(200, {
|
|
165
|
+
'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
|
166
|
+
'content-length': body.length,
|
|
167
|
+
'cache-control': 'no-store'
|
|
168
|
+
});
|
|
169
|
+
if (req.method === 'HEAD') res.end(); else res.end(body);
|
|
170
|
+
});
|
|
171
|
+
return new Promise(function (resolve, reject) {
|
|
172
|
+
server.once('error', reject);
|
|
173
|
+
server.listen(0, '127.0.0.1', function () {
|
|
174
|
+
const address = server.address();
|
|
175
|
+
resolve({ server: server, base: 'http://127.0.0.1:' + address.port });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function closeServer(server) {
|
|
181
|
+
return new Promise(function (resolve) {
|
|
182
|
+
if (!server) return resolve();
|
|
183
|
+
try { server.close(function () { resolve(); }); } catch (e) { resolve(); }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function launchBrowser(bin, userDataDir, waitMs) {
|
|
188
|
+
return new Promise(function (resolve, reject) {
|
|
189
|
+
const args = [
|
|
190
|
+
'--headless=new',
|
|
191
|
+
'--no-sandbox',
|
|
192
|
+
'--disable-gpu',
|
|
193
|
+
'--disable-dev-shm-usage',
|
|
194
|
+
'--hide-scrollbars',
|
|
195
|
+
'--mute-audio',
|
|
196
|
+
'--no-first-run',
|
|
197
|
+
'--no-default-browser-check',
|
|
198
|
+
'--disable-extensions',
|
|
199
|
+
'--disable-background-networking',
|
|
200
|
+
'--disable-component-update',
|
|
201
|
+
'--disable-sync',
|
|
202
|
+
'--metrics-recording-only',
|
|
203
|
+
'--safebrowsing-disable-auto-update',
|
|
204
|
+
'--user-data-dir=' + userDataDir,
|
|
205
|
+
'--remote-debugging-port=0',
|
|
206
|
+
'about:blank'
|
|
207
|
+
];
|
|
208
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
209
|
+
let stderr = '';
|
|
210
|
+
let settled = false;
|
|
211
|
+
const timer = setTimeout(function () {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
try { child.kill('SIGKILL'); } catch (e) {}
|
|
215
|
+
reject(new Error('Chromium did not expose a DevTools endpoint: ' + stderr.slice(-800)));
|
|
216
|
+
}, waitMs);
|
|
217
|
+
child.once('error', function (error) {
|
|
218
|
+
if (settled) return;
|
|
219
|
+
settled = true;
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
reject(error);
|
|
222
|
+
});
|
|
223
|
+
child.once('exit', function (code) {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
reject(new Error('Chromium exited before DevTools was ready (code ' + code + '): ' + stderr.slice(-800)));
|
|
228
|
+
});
|
|
229
|
+
child.stderr.on('data', function (chunk) {
|
|
230
|
+
stderr += String(chunk);
|
|
231
|
+
const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
|
232
|
+
if (match && !settled) {
|
|
233
|
+
settled = true;
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
resolve({ child: child, wsUrl: match[1] });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
class CDP {
|
|
242
|
+
constructor(wsUrl, waitMs) {
|
|
243
|
+
this.wsUrl = wsUrl;
|
|
244
|
+
this.waitMs = waitMs;
|
|
245
|
+
this.nextId = 0;
|
|
246
|
+
this.pending = new Map();
|
|
247
|
+
this.listeners = new Set();
|
|
248
|
+
this.events = [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
connect() {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const ws = new WebSocket(this.wsUrl);
|
|
254
|
+
this.ws = ws;
|
|
255
|
+
const timer = setTimeout(function () { reject(new Error('DevTools WebSocket connection timed out')); }, this.waitMs);
|
|
256
|
+
ws.onopen = () => { clearTimeout(timer); resolve(this); };
|
|
257
|
+
ws.onerror = () => { clearTimeout(timer); reject(new Error('DevTools WebSocket connection failed')); };
|
|
258
|
+
ws.onmessage = (event) => this._message(event);
|
|
259
|
+
ws.onclose = () => {
|
|
260
|
+
for (const item of this.pending.values()) {
|
|
261
|
+
clearTimeout(item.timer);
|
|
262
|
+
item.reject(new Error('DevTools connection closed'));
|
|
263
|
+
}
|
|
264
|
+
this.pending.clear();
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_message(event) {
|
|
270
|
+
let message;
|
|
271
|
+
try {
|
|
272
|
+
const raw = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
|
273
|
+
message = JSON.parse(raw);
|
|
274
|
+
} catch (e) { return; }
|
|
275
|
+
if (message.id && this.pending.has(message.id)) {
|
|
276
|
+
const item = this.pending.get(message.id);
|
|
277
|
+
this.pending.delete(message.id);
|
|
278
|
+
clearTimeout(item.timer);
|
|
279
|
+
if (message.error) item.reject(new Error(item.method + ': ' + (message.error.message || 'DevTools error')));
|
|
280
|
+
else item.resolve(message.result || {});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!message.method) return;
|
|
284
|
+
this.events.push(message);
|
|
285
|
+
for (const listener of Array.from(this.listeners)) {
|
|
286
|
+
if (listener.method !== message.method) continue;
|
|
287
|
+
if (listener.sessionId && listener.sessionId !== message.sessionId) continue;
|
|
288
|
+
this.listeners.delete(listener);
|
|
289
|
+
clearTimeout(listener.timer);
|
|
290
|
+
listener.resolve(message.params || {});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
send(method, params, sessionId) {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const id = ++this.nextId;
|
|
297
|
+
const timer = setTimeout(() => {
|
|
298
|
+
this.pending.delete(id);
|
|
299
|
+
reject(new Error(method + ' timed out'));
|
|
300
|
+
}, this.waitMs);
|
|
301
|
+
this.pending.set(id, { resolve: resolve, reject: reject, timer: timer, method: method });
|
|
302
|
+
const message = { id: id, method: method, params: params || {} };
|
|
303
|
+
if (sessionId) message.sessionId = sessionId;
|
|
304
|
+
try { this.ws.send(JSON.stringify(message)); }
|
|
305
|
+
catch (e) {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
this.pending.delete(id);
|
|
308
|
+
reject(e);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
waitFor(method, sessionId, waitMs) {
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const listener = {
|
|
316
|
+
method: method,
|
|
317
|
+
sessionId: sessionId || null,
|
|
318
|
+
resolve: resolve,
|
|
319
|
+
reject: reject,
|
|
320
|
+
timer: null
|
|
321
|
+
};
|
|
322
|
+
listener.timer = setTimeout(() => {
|
|
323
|
+
this.listeners.delete(listener);
|
|
324
|
+
reject(new Error(method + ' timed out'));
|
|
325
|
+
}, Math.max(1000, Number(waitMs) || this.waitMs));
|
|
326
|
+
this.listeners.add(listener);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
close() {
|
|
331
|
+
try { this.ws.close(); } catch (e) {}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function evaluate(cdp, sessionId, expression) {
|
|
336
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
337
|
+
expression: expression,
|
|
338
|
+
returnByValue: true,
|
|
339
|
+
awaitPromise: true,
|
|
340
|
+
userGesture: true
|
|
341
|
+
}, sessionId);
|
|
342
|
+
if (result.exceptionDetails) {
|
|
343
|
+
const description = result.exceptionDetails.exception && (result.exceptionDetails.exception.description || result.exceptionDetails.exception.value);
|
|
344
|
+
throw new Error(String(description || result.exceptionDetails.text || 'page evaluation failed').slice(0, 500));
|
|
345
|
+
}
|
|
346
|
+
return result.result ? result.result.value : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const STATE_EXPR = `(() => {
|
|
350
|
+
const parts = [document.documentElement.outerHTML, location.href];
|
|
351
|
+
for (const el of document.querySelectorAll('input,textarea,select')) {
|
|
352
|
+
parts.push(el.tagName + ':' + (el.type || '') + ':' + (el.checked ? '1' : '0') + ':' + (el.value || ''));
|
|
353
|
+
}
|
|
354
|
+
const s = parts.join('\\u0001');
|
|
355
|
+
let h = 2166136261;
|
|
356
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
357
|
+
return (h >>> 0) + ':' + s.length;
|
|
358
|
+
})()`;
|
|
359
|
+
|
|
360
|
+
const SNAPSHOT = `(() => {
|
|
361
|
+
const visible = (el) => {
|
|
362
|
+
if (!el || el.disabled) return false;
|
|
363
|
+
const style = getComputedStyle(el);
|
|
364
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
|
365
|
+
const rect = el.getBoundingClientRect();
|
|
366
|
+
return rect.width > 1 && rect.height > 1;
|
|
367
|
+
};
|
|
368
|
+
const nameOf = (el) => String(
|
|
369
|
+
el.getAttribute('aria-label') ||
|
|
370
|
+
el.getAttribute('title') ||
|
|
371
|
+
(el.labels && el.labels.length ? Array.from(el.labels).map(l => l.textContent).join(' ') : '') ||
|
|
372
|
+
el.textContent ||
|
|
373
|
+
el.value ||
|
|
374
|
+
el.getAttribute('name') ||
|
|
375
|
+
el.getAttribute('placeholder') ||
|
|
376
|
+
''
|
|
377
|
+
).replace(/\\s+/g, ' ').trim().slice(0, 120);
|
|
378
|
+
const labelFor = (el) => {
|
|
379
|
+
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title')) return true;
|
|
380
|
+
if (el.labels && el.labels.length) return true;
|
|
381
|
+
if (el.closest('label')) return true;
|
|
382
|
+
return false;
|
|
383
|
+
};
|
|
384
|
+
const fieldData = [...document.querySelectorAll('input,textarea,select')].filter(visible).map((el, index) => {
|
|
385
|
+
el.setAttribute('data-chode-field', String(index));
|
|
386
|
+
return {
|
|
387
|
+
index: index,
|
|
388
|
+
tag: el.tagName.toLowerCase(),
|
|
389
|
+
type: String(el.getAttribute('type') || '').toLowerCase(),
|
|
390
|
+
name: nameOf(el),
|
|
391
|
+
labelled: labelFor(el),
|
|
392
|
+
required: !!el.required
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
const buttons = [...document.querySelectorAll('button,[role="button"],input[type="submit"],input[type="button"]')].filter(visible).map((el, index) => {
|
|
396
|
+
el.setAttribute('data-chode-action', String(index));
|
|
397
|
+
return {
|
|
398
|
+
index: index,
|
|
399
|
+
tag: el.tagName.toLowerCase(),
|
|
400
|
+
type: String(el.getAttribute('type') || '').toLowerCase(),
|
|
401
|
+
name: nameOf(el),
|
|
402
|
+
disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true',
|
|
403
|
+
active: el.getAttribute('aria-pressed') === 'true' || !!(el.classList && el.classList.contains('active'))
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
const images = [...document.images].filter(visible).map((img) => ({
|
|
407
|
+
src: String(img.currentSrc || img.src || '').slice(0, 300),
|
|
408
|
+
alt: img.getAttribute('alt'),
|
|
409
|
+
broken: !!img.complete && img.naturalWidth === 0
|
|
410
|
+
}));
|
|
411
|
+
const missingAnchors = [];
|
|
412
|
+
for (const a of document.querySelectorAll('a[href^="#"]')) {
|
|
413
|
+
const href = a.getAttribute('href');
|
|
414
|
+
if (!href || href === '#') continue;
|
|
415
|
+
let id;
|
|
416
|
+
try { id = decodeURIComponent(href.slice(1)); } catch (e) { id = href.slice(1); }
|
|
417
|
+
if (!id) continue;
|
|
418
|
+
if (!document.getElementById(id) && !document.getElementsByName(id).length) missingAnchors.push(id);
|
|
419
|
+
}
|
|
420
|
+
const canvases = [...document.querySelectorAll('canvas')].filter((el) => {
|
|
421
|
+
const style = getComputedStyle(el);
|
|
422
|
+
const rect = el.getBoundingClientRect();
|
|
423
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0 && rect.width > 1 && rect.height > 1;
|
|
424
|
+
}).map((el, index) => {
|
|
425
|
+
const rect = el.getBoundingClientRect();
|
|
426
|
+
const item = { index: index, width: el.width, height: el.height, displayWidth: Math.round(rect.width), displayHeight: Math.round(rect.height), sample: null };
|
|
427
|
+
try {
|
|
428
|
+
const area = Number(el.width) * Number(el.height);
|
|
429
|
+
if (area > 0 && area <= 16000000) {
|
|
430
|
+
const ctx = el.getContext('2d');
|
|
431
|
+
if (ctx) {
|
|
432
|
+
const data = ctx.getImageData(0, 0, el.width, el.height).data;
|
|
433
|
+
const step = Math.max(1, Math.floor(Math.sqrt(area / 6000)));
|
|
434
|
+
let opaque = 0, pixels = 0, sum = 0, sumSq = 0, min = 255, max = 0;
|
|
435
|
+
const colors = new Set();
|
|
436
|
+
for (let y = 0; y < el.height; y += step) {
|
|
437
|
+
for (let x = 0; x < el.width; x += step) {
|
|
438
|
+
const i = (y * el.width + x) * 4;
|
|
439
|
+
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
|
|
440
|
+
if (a > 8) opaque++;
|
|
441
|
+
colors.add((r >> 4) + ',' + (g >> 4) + ',' + (b >> 4) + ',' + (a >> 6));
|
|
442
|
+
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
443
|
+
sum += l; sumSq += l * l; pixels++;
|
|
444
|
+
if (l < min) min = l;
|
|
445
|
+
if (l > max) max = l;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
item.sample = { kind: '2d', pixels: pixels, opaque: opaque, ratio: pixels ? opaque / pixels : 0, colors: colors.size, variance: pixels ? sumSq / pixels - Math.pow(sum / pixels, 2) : 0, range: max - min };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (error) { item.sample = { kind: 'unreadable', error: String(error && error.message || error).slice(0, 160) }; }
|
|
452
|
+
return item;
|
|
453
|
+
});
|
|
454
|
+
const root = document.documentElement;
|
|
455
|
+
return {
|
|
456
|
+
ready: document.readyState,
|
|
457
|
+
title: String(document.title || '').trim(),
|
|
458
|
+
bodyText: document.body ? String(document.body.innerText || '').replace(/\\s+/g, ' ').trim() : '',
|
|
459
|
+
forms: document.forms.length,
|
|
460
|
+
fields: fieldData,
|
|
461
|
+
buttons: buttons,
|
|
462
|
+
images: images,
|
|
463
|
+
missingAnchors: [...new Set(missingAnchors)],
|
|
464
|
+
canvases: canvases,
|
|
465
|
+
overflow: Math.max(root.scrollWidth, document.body ? document.body.scrollWidth : 0) - root.clientWidth,
|
|
466
|
+
href: location.href,
|
|
467
|
+
hash: (() => {
|
|
468
|
+
const parts = [root.outerHTML, location.href];
|
|
469
|
+
for (const el of document.querySelectorAll('input,textarea,select')) {
|
|
470
|
+
parts.push(el.tagName + ':' + (el.type || '') + ':' + (el.checked ? '1' : '0') + ':' + (el.value || ''));
|
|
471
|
+
}
|
|
472
|
+
const s = parts.join('\\u0001');
|
|
473
|
+
let h = 2166136261;
|
|
474
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
475
|
+
return (h >>> 0) + ':' + s.length;
|
|
476
|
+
})()
|
|
477
|
+
};
|
|
478
|
+
})()`;
|
|
479
|
+
|
|
480
|
+
const PREPARE = `((index) => {
|
|
481
|
+
const visible = (el) => {
|
|
482
|
+
if (!el || el.disabled) return false;
|
|
483
|
+
const style = getComputedStyle(el);
|
|
484
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
|
485
|
+
const rect = el.getBoundingClientRect();
|
|
486
|
+
return rect.width > 1 && rect.height > 1;
|
|
487
|
+
};
|
|
488
|
+
const setValue = (el, value) => {
|
|
489
|
+
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
|
490
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value') && Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
491
|
+
if (setter) setter.call(el, value); else el.value = value;
|
|
492
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
493
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
494
|
+
};
|
|
495
|
+
for (const el of [...document.querySelectorAll('input,textarea,select')].filter(visible)) {
|
|
496
|
+
const type = String(el.getAttribute('type') || '').toLowerCase();
|
|
497
|
+
if (type === 'hidden' || type === 'file' || type === 'password') continue;
|
|
498
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
499
|
+
if (!el.checked) el.click();
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (el.tagName === 'SELECT') {
|
|
503
|
+
if (el.options.length > 1 && !el.value) el.selectedIndex = 1;
|
|
504
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (el.value) continue;
|
|
508
|
+
let value = 'Test value';
|
|
509
|
+
if (type === 'email') value = 'test@example.com';
|
|
510
|
+
else if (type === 'url') value = 'https://example.com';
|
|
511
|
+
else if (type === 'tel') value = '555-0100';
|
|
512
|
+
else if (type === 'number') value = '2';
|
|
513
|
+
else if (type === 'date') value = new Date().toISOString().slice(0, 10);
|
|
514
|
+
else if (type === 'time') value = '12:00';
|
|
515
|
+
else if (type === 'search') value = 'test';
|
|
516
|
+
else if (el.tagName === 'TEXTAREA') value = 'This is a test message with enough detail to exercise the form.';
|
|
517
|
+
setValue(el, value);
|
|
518
|
+
}
|
|
519
|
+
const buttons = [...document.querySelectorAll('button,[role="button"],input[type="submit"],input[type="button"]')].filter(visible);
|
|
520
|
+
const target = buttons[index];
|
|
521
|
+
if (!target) return { ok: false, reason: 'button disappeared before the check' };
|
|
522
|
+
target.setAttribute('data-chode-action', String(index));
|
|
523
|
+
if (target.form && target.form.getAttribute('action')) {
|
|
524
|
+
const action = target.form.getAttribute('action');
|
|
525
|
+
if (/^(?:[a-z][\\w+.-]*:|\\/\\/)/i.test(action)) return { ok: false, reason: 'form action is external: ' + action.slice(0, 200) };
|
|
526
|
+
}
|
|
527
|
+
return { ok: true, label: String(target.getAttribute('aria-label') || target.textContent || target.value || '').replace(/\\s+/g, ' ').trim().slice(0, 100) };
|
|
528
|
+
})(INDEX)`;
|
|
529
|
+
|
|
530
|
+
const CHANGE_SELECT = `(() => {
|
|
531
|
+
const visible = (el) => {
|
|
532
|
+
const style = getComputedStyle(el);
|
|
533
|
+
const rect = el.getBoundingClientRect();
|
|
534
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 1 && rect.height > 1;
|
|
535
|
+
};
|
|
536
|
+
const select = [...document.querySelectorAll('select')].filter(visible).find((el) => el.options.length > 1);
|
|
537
|
+
if (!select) return { ok: false, reason: 'no select control' };
|
|
538
|
+
const before = select.selectedIndex;
|
|
539
|
+
select.selectedIndex = before === 0 ? 1 : 0;
|
|
540
|
+
select.dispatchEvent(new Event('input', { bubbles: true }));
|
|
541
|
+
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
542
|
+
return { ok: true };
|
|
543
|
+
})()`;
|
|
544
|
+
|
|
545
|
+
function paeth(a, b, c) {
|
|
546
|
+
const p = a + b - c;
|
|
547
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
548
|
+
if (pa <= pb && pa <= pc) return a;
|
|
549
|
+
if (pb <= pc) return b;
|
|
550
|
+
return c;
|
|
551
|
+
}
|
|
552
|
+
function decodePng(buffer) {
|
|
553
|
+
const data = Buffer.from(buffer || []);
|
|
554
|
+
if (data.length < 24 || data.readUInt32BE(0) !== 0x89504e47 || data.toString('ascii', 12, 16) !== 'IHDR') throw new Error('not a PNG');
|
|
555
|
+
let offset = 8, width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
|
556
|
+
const idat = [];
|
|
557
|
+
while (offset + 12 <= data.length) {
|
|
558
|
+
const length = data.readUInt32BE(offset);
|
|
559
|
+
const type = data.toString('ascii', offset + 4, offset + 8);
|
|
560
|
+
const body = data.subarray(offset + 8, offset + 8 + length);
|
|
561
|
+
offset += 12 + length;
|
|
562
|
+
if (type === 'IHDR') {
|
|
563
|
+
width = body.readUInt32BE(0); height = body.readUInt32BE(4); depth = body[8]; colorType = body[9]; interlace = body[12];
|
|
564
|
+
} else if (type === 'IDAT') {
|
|
565
|
+
idat.push(body);
|
|
566
|
+
} else if (type === 'IEND') break;
|
|
567
|
+
}
|
|
568
|
+
if (!width || !height || depth !== 8 || interlace !== 0) throw new Error('unsupported PNG layout');
|
|
569
|
+
const bpp = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType];
|
|
570
|
+
if (!bpp) throw new Error('unsupported PNG color type ' + colorType);
|
|
571
|
+
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
572
|
+
const stride = width * bpp;
|
|
573
|
+
if (raw.length < (stride + 1) * height) throw new Error('truncated PNG data');
|
|
574
|
+
const pixels = Buffer.alloc(width * height * 4);
|
|
575
|
+
let previous = Buffer.alloc(stride);
|
|
576
|
+
let at = 0;
|
|
577
|
+
for (let y = 0; y < height; y++) {
|
|
578
|
+
const filter = raw[at++];
|
|
579
|
+
const row = Buffer.from(raw.subarray(at, at + stride));
|
|
580
|
+
at += stride;
|
|
581
|
+
for (let x = 0; x < stride; x++) {
|
|
582
|
+
const left = x >= bpp ? row[x - bpp] : 0;
|
|
583
|
+
const up = previous[x];
|
|
584
|
+
const upLeft = x >= bpp ? previous[x - bpp] : 0;
|
|
585
|
+
if (filter === 1) row[x] = (row[x] + left) & 255;
|
|
586
|
+
else if (filter === 2) row[x] = (row[x] + up) & 255;
|
|
587
|
+
else if (filter === 3) row[x] = (row[x] + Math.floor((left + up) / 2)) & 255;
|
|
588
|
+
else if (filter === 4) row[x] = (row[x] + paeth(left, up, upLeft)) & 255;
|
|
589
|
+
else if (filter !== 0) throw new Error('unsupported PNG filter ' + filter);
|
|
590
|
+
}
|
|
591
|
+
for (let x = 0; x < width; x++) {
|
|
592
|
+
const source = x * bpp, target = (y * width + x) * 4;
|
|
593
|
+
if (colorType === 6) { pixels[target] = row[source]; pixels[target + 1] = row[source + 1]; pixels[target + 2] = row[source + 2]; pixels[target + 3] = row[source + 3]; }
|
|
594
|
+
else if (colorType === 2) { pixels[target] = row[source]; pixels[target + 1] = row[source + 1]; pixels[target + 2] = row[source + 2]; pixels[target + 3] = 255; }
|
|
595
|
+
else if (colorType === 4) { pixels[target] = row[source]; pixels[target + 1] = row[source]; pixels[target + 2] = row[source]; pixels[target + 3] = row[source + 1]; }
|
|
596
|
+
else { pixels[target] = row[source]; pixels[target + 1] = row[source]; pixels[target + 2] = row[source]; pixels[target + 3] = 255; }
|
|
597
|
+
}
|
|
598
|
+
previous = row;
|
|
599
|
+
}
|
|
600
|
+
return { width: width, height: height, pixels: pixels };
|
|
601
|
+
}
|
|
602
|
+
function imageStats(image) {
|
|
603
|
+
const width = image && image.width, height = image && image.height, pixels = image && image.pixels;
|
|
604
|
+
if (!width || !height || !pixels) throw new Error('empty image');
|
|
605
|
+
const step = Math.max(1, Math.floor(Math.sqrt(width * height / 6000)));
|
|
606
|
+
const colors = new Set();
|
|
607
|
+
let opaque = 0, samples = 0, sum = 0, sumSq = 0, min = 255, max = 0;
|
|
608
|
+
for (let y = 0; y < height; y += step) {
|
|
609
|
+
for (let x = 0; x < width; x += step) {
|
|
610
|
+
const i = (y * width + x) * 4;
|
|
611
|
+
const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2], a = pixels[i + 3];
|
|
612
|
+
if (a > 8) opaque++;
|
|
613
|
+
colors.add((r >> 4) + ',' + (g >> 4) + ',' + (b >> 4) + ',' + (a >> 6));
|
|
614
|
+
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
615
|
+
sum += l; sumSq += l * l; samples++;
|
|
616
|
+
if (l < min) min = l;
|
|
617
|
+
if (l > max) max = l;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { pixels: samples, opaque: opaque, ratio: samples ? opaque / samples : 0, colors: colors.size, variance: samples ? sumSq / samples - Math.pow(sum / samples, 2) : 0, range: max - min };
|
|
621
|
+
}
|
|
622
|
+
function visualVerdict(stats) {
|
|
623
|
+
if (!stats) return { blank: true, detail: 'no pixels were sampled' };
|
|
624
|
+
const blank = stats.opaque === 0 || (stats.colors <= 2 && stats.variance < 2 && stats.range < 8);
|
|
625
|
+
return { blank: blank, detail: stats.colors + ' colour samples, ' + Math.round((stats.ratio || 0) * 100) + '% opaque, luma spread ' + Math.round(stats.range || 0) };
|
|
626
|
+
}
|
|
627
|
+
async function captureCanvas(cdp, sessionId, index) {
|
|
628
|
+
const rect = await evaluate(cdp, sessionId, `(() => { const el = document.querySelectorAll('canvas')[${Number(index) || 0}]; if (!el) return null; el.scrollIntoView({ block: 'center', inline: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, scrollX: window.scrollX, scrollY: window.scrollY }; })()`);
|
|
629
|
+
if (!rect || rect.width < 2 || rect.height < 2) return null;
|
|
630
|
+
const width = Math.min(2000, Math.floor(rect.width)), height = Math.min(2000, Math.floor(rect.height));
|
|
631
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: true, clip: { x: rect.x + rect.scrollX, y: rect.y + rect.scrollY, width: width, height: height, scale: 1 } }, sessionId);
|
|
632
|
+
return decodePng(Buffer.from(shot.data || '', 'base64'));
|
|
633
|
+
}
|
|
634
|
+
async function visualChecks(cdp, sessionId, snapshot, label, viewport, add) {
|
|
635
|
+
const canvases = (snapshot && snapshot.canvases) || [];
|
|
636
|
+
for (const canvas of canvases) {
|
|
637
|
+
if (canvas.displayWidth < 80 || canvas.displayHeight < 80) continue;
|
|
638
|
+
const name = 'canvas ' + (canvas.index + 1) + ' (' + canvas.width + 'x' + canvas.height + ')';
|
|
639
|
+
let stats = canvas.sample && canvas.sample.kind === '2d' ? canvas.sample : null;
|
|
640
|
+
if (!stats) {
|
|
641
|
+
try { const image = await captureCanvas(cdp, sessionId, canvas.index); stats = image ? imageStats(image) : null; }
|
|
642
|
+
catch (error) { add('Browser visual: ' + label + ' (' + viewport.name + ') — ' + name, false, 'canvas pixels could not be inspected: ' + String((error && error.message) || error).slice(0, 220)); continue; }
|
|
643
|
+
}
|
|
644
|
+
const verdict = visualVerdict(stats);
|
|
645
|
+
add('Browser visual: ' + label + ' (' + viewport.name + ') — ' + name, !verdict.blank, verdict.blank ? 'canvas appears blank or uniform; ' + verdict.detail : verdict.detail);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function eventUrlMap(events) {
|
|
650
|
+
const map = new Map();
|
|
651
|
+
for (const event of events || []) {
|
|
652
|
+
if (event.method === 'Network.requestWillBeSent') {
|
|
653
|
+
map.set(event.params.requestId, event.params.request && event.params.request.url);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return map;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function resourcePath(url) {
|
|
660
|
+
try { return new URL(url).pathname; } catch (e) { return String(url || '').slice(0, 200); }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function ignoredResource(url) {
|
|
664
|
+
const text = String(url || '');
|
|
665
|
+
return /\/favicon\.ico(?:$|[?#])/i.test(text) || /\.map(?:$|[?#])/i.test(text);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function browserProblems(events, from) {
|
|
669
|
+
const slice = (events || []).slice(from || 0);
|
|
670
|
+
const urls = eventUrlMap(slice);
|
|
671
|
+
const problems = [];
|
|
672
|
+
const add = (kind, detail) => {
|
|
673
|
+
const text = kind + ': ' + detail;
|
|
674
|
+
if (!problems.includes(text)) problems.push(text);
|
|
675
|
+
};
|
|
676
|
+
for (const event of slice) {
|
|
677
|
+
const params = event.params || {};
|
|
678
|
+
if (event.method === 'Runtime.exceptionThrown') {
|
|
679
|
+
const details = params.exceptionDetails || {};
|
|
680
|
+
const exception = details.exception || {};
|
|
681
|
+
add('runtime error', String(exception.description || exception.value || details.text || 'uncaught exception').replace(/\s+/g, ' ').slice(0, 500));
|
|
682
|
+
} else if (event.method === 'Runtime.consoleAPICalled' && (params.type === 'error' || params.type === 'assert')) {
|
|
683
|
+
const args = (params.args || []).map((arg) => String(arg.value != null ? arg.value : (arg.description || arg.type || ''))).join(' ');
|
|
684
|
+
if (args.trim()) add('console error', args.replace(/\s+/g, ' ').slice(0, 500));
|
|
685
|
+
} else if (event.method === 'Log.entryAdded') {
|
|
686
|
+
const entry = params.entry || {};
|
|
687
|
+
const text = String(entry.text || '');
|
|
688
|
+
if (entry.level !== 'error') continue;
|
|
689
|
+
if (/Failed to load resource/i.test(text)) continue;
|
|
690
|
+
if (entry.source === 'javascript' || /(?:Uncaught|ReferenceError|TypeError|SyntaxError|RangeError|Error:)/i.test(text)) {
|
|
691
|
+
add('page error', text.replace(/\s+/g, ' ').slice(0, 500));
|
|
692
|
+
}
|
|
693
|
+
} else if (event.method === 'Network.responseReceived') {
|
|
694
|
+
const response = params.response || {};
|
|
695
|
+
const url = String(response.url || '');
|
|
696
|
+
if (response.status >= 400 && !ignoredResource(url)) {
|
|
697
|
+
add('HTTP ' + response.status, resourcePath(url));
|
|
698
|
+
}
|
|
699
|
+
} else if (event.method === 'Network.loadingFailed') {
|
|
700
|
+
const url = String(urls.get(params.requestId) || '');
|
|
701
|
+
const error = String(params.errorText || 'resource failed').replace(/\s+/g, ' ').slice(0, 300);
|
|
702
|
+
if (ignoredResource(url)) continue;
|
|
703
|
+
if (/ERR_ABORTED|ERR_BLOCKED_BY_CLIENT/i.test(error)) continue;
|
|
704
|
+
add('asset failed', (url ? resourcePath(url) + ' — ' : '') + error);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return problems;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function chooseActions(snapshot, request) {
|
|
711
|
+
const seen = new Set();
|
|
712
|
+
const actions = [];
|
|
713
|
+
const candidates = (snapshot.buttons || []).filter(function (button) {
|
|
714
|
+
if (button.disabled || button.type === 'reset') return false;
|
|
715
|
+
if (DESTRUCTIVE_ACTION.test(button.name || '')) return false;
|
|
716
|
+
return true;
|
|
717
|
+
});
|
|
718
|
+
const primary = candidates.filter(function (button) {
|
|
719
|
+
if (PRIMARY_ACTION.test(button.name || '')) return true;
|
|
720
|
+
return (snapshot.forms || 0) > 0 && (button.type === 'submit' || button.tag === 'button');
|
|
721
|
+
});
|
|
722
|
+
const idle = primary.filter(function (button) { return !button.active; });
|
|
723
|
+
const ordered = (idle.length ? idle : primary).slice().sort(function (a, buttonB) { return a.index - buttonB.index; });
|
|
724
|
+
for (const button of ordered) {
|
|
725
|
+
const key = button.index + ':' + button.name;
|
|
726
|
+
if (seen.has(key)) continue;
|
|
727
|
+
seen.add(key);
|
|
728
|
+
actions.push(button);
|
|
729
|
+
if (actions.length >= 2) break;
|
|
730
|
+
}
|
|
731
|
+
return actions;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function navigate(cdp, sessionId, url, waitMs) {
|
|
735
|
+
const loaded = cdp.waitFor('Page.loadEventFired', sessionId, waitMs);
|
|
736
|
+
let navigationError = '';
|
|
737
|
+
try {
|
|
738
|
+
const result = await cdp.send('Page.navigate', { url: url }, sessionId);
|
|
739
|
+
if (result.errorText) navigationError = String(result.errorText);
|
|
740
|
+
} catch (e) {
|
|
741
|
+
navigationError = String((e && e.message) || e);
|
|
742
|
+
}
|
|
743
|
+
let loadedOk = true;
|
|
744
|
+
try { await loaded; }
|
|
745
|
+
catch (e) { loadedOk = false; }
|
|
746
|
+
await sleep(250);
|
|
747
|
+
return { loaded: loadedOk, error: navigationError };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function capture(cdp, sessionId, file) {
|
|
751
|
+
await cdp.send('Page.bringToFront', {}, sessionId);
|
|
752
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false }, sessionId);
|
|
753
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
754
|
+
fs.writeFileSync(file, Buffer.from(shot.data || '', 'base64'));
|
|
755
|
+
return file;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function inspectViewport(cdp, sessionId, baseUrl, page, viewport, options, add) {
|
|
759
|
+
const label = page.label || page.path || 'page';
|
|
760
|
+
const captureStart = cdp.events.length;
|
|
761
|
+
const reported = new Set();
|
|
762
|
+
const unseen = function (problems) {
|
|
763
|
+
return (problems || []).filter(function (problem) {
|
|
764
|
+
if (reported.has(problem)) return false;
|
|
765
|
+
reported.add(problem);
|
|
766
|
+
return true;
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
770
|
+
width: viewport.width,
|
|
771
|
+
height: viewport.height,
|
|
772
|
+
deviceScaleFactor: 1,
|
|
773
|
+
mobile: !!viewport.mobile,
|
|
774
|
+
screenWidth: viewport.width,
|
|
775
|
+
screenHeight: viewport.height
|
|
776
|
+
}, sessionId);
|
|
777
|
+
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: !!viewport.mobile, maxTouchPoints: viewport.mobile ? 5 : 1 }, sessionId); } catch (e) {}
|
|
778
|
+
const nav = await navigate(cdp, sessionId, baseUrl, options.timeoutMs);
|
|
779
|
+
add('Browser loads: ' + label + ' (' + viewport.name + ')', nav.loaded && !nav.error, nav.error || (nav.loaded ? 'load event fired' : 'load event did not fire'));
|
|
780
|
+
let snapshot = null;
|
|
781
|
+
const shotPath = path.join(options.screenshotsDir, page.fileName + '-' + viewport.name + '.png');
|
|
782
|
+
try { snapshot = await evaluate(cdp, sessionId, SNAPSHOT); }
|
|
783
|
+
catch (e) { add('Browser DOM: ' + label + ' (' + viewport.name + ')', false, String((e && e.message) || e)); }
|
|
784
|
+
if (snapshot) {
|
|
785
|
+
add('Browser content: ' + label + ' (' + viewport.name + ')', snapshot.bodyText.length >= 1, snapshot.bodyText.length + ' visible characters; title ' + (snapshot.title ? JSON.stringify(snapshot.title.slice(0, 80)) : '(missing)'));
|
|
786
|
+
const unlabeled = (snapshot.fields || []).filter(function (field) { return !field.labelled; });
|
|
787
|
+
if ((snapshot.fields || []).length) {
|
|
788
|
+
add('Browser accessible controls: ' + label + ' (' + viewport.name + ')', unlabeled.length === 0,
|
|
789
|
+
unlabeled.length ? unlabeled.map(function (field) { return (field.tag || 'field') + (field.name ? ' "' + field.name + '"' : ''); }).join(', ') + ' need a visible label or aria-label' : 'all visible controls have a label');
|
|
790
|
+
}
|
|
791
|
+
const unlabeledButtons = (snapshot.buttons || []).filter(function (button) { return !button.name; });
|
|
792
|
+
if (unlabeledButtons.length) add('Browser accessible buttons: ' + label + ' (' + viewport.name + ')', false, unlabeledButtons.length + ' visible button(s) have no text or aria-label');
|
|
793
|
+
const broken = (snapshot.images || []).filter(function (image) { return image.broken; });
|
|
794
|
+
if (broken.length) add('Browser images: ' + label + ' (' + viewport.name + ')', false, broken.map(function (image) { return image.src; }).join(', '));
|
|
795
|
+
if ((snapshot.missingAnchors || []).length) add('Browser anchors: ' + label + ' (' + viewport.name + ')', false, 'missing target for #' + snapshot.missingAnchors.join(', #'));
|
|
796
|
+
if (viewport.mobile || viewport.name === 'desktop') {
|
|
797
|
+
add('Browser layout: ' + label + ' (' + viewport.name + ')', snapshot.overflow <= 2, snapshot.overflow > 2 ? 'horizontal overflow ' + snapshot.overflow + 'px' : 'no horizontal overflow');
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
await capture(cdp, sessionId, shotPath);
|
|
801
|
+
add('Browser screenshot: ' + label + ' (' + viewport.name + ')', true, path.relative(options.root, shotPath));
|
|
802
|
+
options.screenshots.push(shotPath);
|
|
803
|
+
} catch (e) {
|
|
804
|
+
add('Browser screenshot: ' + label + ' (' + viewport.name + ')', false, String((e && e.message) || e).slice(0, 300));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const interactionExpected = INTERACTION_REQUEST.test(String(options.request || ''));
|
|
808
|
+
let actionsRun = false;
|
|
809
|
+
if (snapshot) {
|
|
810
|
+
const actions = chooseActions(snapshot, options.request);
|
|
811
|
+
if (interactionExpected && !actions.length) {
|
|
812
|
+
const hasSelect = (snapshot.fields || []).some(function (field) { return field.tag === 'select'; });
|
|
813
|
+
if (!hasSelect) {
|
|
814
|
+
add('Browser interactions: ' + label, false, 'the request asks for interaction but no usable button or select was found');
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
for (const action of actions) {
|
|
818
|
+
actionsRun = true;
|
|
819
|
+
const interactionStart = cdp.events.length;
|
|
820
|
+
const reload = await navigate(cdp, sessionId, baseUrl, options.timeoutMs);
|
|
821
|
+
if (!reload.loaded) {
|
|
822
|
+
add('Browser interaction: ' + label + ' — ' + (action.name || 'button'), false, 'page did not reload for the interaction check');
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
let prepared;
|
|
826
|
+
try { prepared = await evaluate(cdp, sessionId, PREPARE.replace('INDEX', String(action.index))); }
|
|
827
|
+
catch (e) { prepared = { ok: false, reason: String((e && e.message) || e) }; }
|
|
828
|
+
if (!prepared || !prepared.ok) {
|
|
829
|
+
add('Browser interaction: ' + label + ' — ' + (prepared && prepared.label || action.name || 'button'), false, prepared && prepared.reason || 'could not prepare the interaction');
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const before = await evaluate(cdp, sessionId, '({ href: location.href, hash: ' + STATE_EXPR + ' })');
|
|
833
|
+
let clicked = true;
|
|
834
|
+
const dialogSeen = cdp.waitFor('Page.javascriptDialogOpening', sessionId, 1500).then(async function () {
|
|
835
|
+
try { await cdp.send('Page.handleJavaScriptDialog', { accept: true }, sessionId); } catch (e) {}
|
|
836
|
+
return true;
|
|
837
|
+
}).catch(function () { return false; });
|
|
838
|
+
try {
|
|
839
|
+
clicked = await evaluate(cdp, sessionId, '(() => { const el = document.querySelector("[data-chode-action=\\"' + String(action.index) + '\\"]"); if (!el) return false; el.focus(); el.click(); return true; })()');
|
|
840
|
+
} catch (e) { clicked = false; }
|
|
841
|
+
const sawDialog = await Promise.race([dialogSeen, sleep(700).then(function () { return false; })]);
|
|
842
|
+
await sleep(350);
|
|
843
|
+
let after = null;
|
|
844
|
+
try { after = await evaluate(cdp, sessionId, '({ href: location.href, hash: ' + STATE_EXPR + ' })'); }
|
|
845
|
+
catch (e) {}
|
|
846
|
+
const changed = !!(sawDialog || (after && (after.href !== before.href || after.hash !== before.hash)));
|
|
847
|
+
add('Browser interaction: ' + label + ' — ' + (action.name || 'button'), !!clicked && changed,
|
|
848
|
+
!clicked ? 'button could not be clicked' : sawDialog ? 'click opened a dialog' : changed ? 'click changed the page or URL' : 'click produced no visible change and no navigation');
|
|
849
|
+
const problems = unseen(browserProblems(cdp.events, interactionStart));
|
|
850
|
+
if (problems.length) add('Browser interaction errors: ' + label + ' — ' + (action.name || 'button'), false, problems.slice(0, 4).join(' | '));
|
|
851
|
+
}
|
|
852
|
+
if (interactionExpected && !actions.length) {
|
|
853
|
+
const hasSelect = (snapshot.fields || []).some(function (field) { return field.tag === 'select'; });
|
|
854
|
+
if (hasSelect) {
|
|
855
|
+
actionsRun = true;
|
|
856
|
+
const interactionStart = cdp.events.length;
|
|
857
|
+
const before = await evaluate(cdp, sessionId, STATE_EXPR);
|
|
858
|
+
let changed = false;
|
|
859
|
+
try { await evaluate(cdp, sessionId, CHANGE_SELECT); } catch (e) {}
|
|
860
|
+
await sleep(500);
|
|
861
|
+
try {
|
|
862
|
+
const after = await evaluate(cdp, sessionId, STATE_EXPR);
|
|
863
|
+
changed = after !== before;
|
|
864
|
+
} catch (e) {}
|
|
865
|
+
add('Browser interaction: ' + label + ' — select control', changed, changed ? 'selecting an option changed the page' : 'selecting an option produced no visible change');
|
|
866
|
+
const problems = unseen(browserProblems(cdp.events, interactionStart));
|
|
867
|
+
if (problems.length) add('Browser interaction errors: ' + label + ' — select control', false, problems.slice(0, 4).join(' | '));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (snapshot) {
|
|
872
|
+
let finalSnapshot = null;
|
|
873
|
+
try { finalSnapshot = await evaluate(cdp, sessionId, SNAPSHOT); }
|
|
874
|
+
catch (e) {}
|
|
875
|
+
if (actionsRun) {
|
|
876
|
+
try { await capture(cdp, sessionId, shotPath); }
|
|
877
|
+
catch (e) {}
|
|
878
|
+
}
|
|
879
|
+
if (finalSnapshot) {
|
|
880
|
+
try { await visualChecks(cdp, sessionId, finalSnapshot, label, viewport, add); }
|
|
881
|
+
catch (e) { add('Browser visual: ' + label + ' (' + viewport.name + ')', false, String((e && e.message) || e).slice(0, 300)); }
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const problems = unseen(browserProblems(cdp.events, captureStart));
|
|
885
|
+
if (problems.length) add('Browser errors: ' + label + ' (' + viewport.name + ')', false, problems.slice(0, 8).join(' | '));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function pageDescriptor(root, file) {
|
|
889
|
+
const abs = path.isAbsolute(file) ? file : path.join(root, file);
|
|
890
|
+
const rel = path.relative(root, abs);
|
|
891
|
+
return {
|
|
892
|
+
path: rel,
|
|
893
|
+
label: rel || path.basename(abs),
|
|
894
|
+
fileName: String(rel || path.basename(abs)).replace(/[^a-z0-9._-]+/gi, '-'),
|
|
895
|
+
urlPath: '/' + String(rel || path.basename(abs)).split(path.sep).map(encodeURIComponent).join('/')
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function run(options) {
|
|
900
|
+
options = options || {};
|
|
901
|
+
const root = path.resolve(options.root || process.cwd());
|
|
902
|
+
const checks = [];
|
|
903
|
+
const screenshots = [];
|
|
904
|
+
const add = function (check, ok, detail) {
|
|
905
|
+
checks.push({ check: check, ok: !!ok, detail: String(detail == null ? '' : detail).slice(0, 1500) });
|
|
906
|
+
};
|
|
907
|
+
const reason = unavailableReason();
|
|
908
|
+
if (process.env.CHODE_BROWSER_CHECK === '0') {
|
|
909
|
+
return { ok: true, skipped: true, reason: 'browser verification disabled by CHODE_BROWSER_CHECK=0', checks: [], failures: [], screenshots: [] };
|
|
910
|
+
}
|
|
911
|
+
if (reason) {
|
|
912
|
+
return { ok: true, skipped: true, reason: reason, checks: [], failures: [], screenshots: [] };
|
|
913
|
+
}
|
|
914
|
+
const pages = (options.pages || []).slice(0, Math.max(1, Math.min(5, Number(options.maxPages) || 3)));
|
|
915
|
+
if (!pages.length) return { ok: true, skipped: true, reason: 'no HTML page was available for browser verification', checks: [], failures: [], screenshots: [] };
|
|
916
|
+
const virtual = new Map();
|
|
917
|
+
const descriptors = [];
|
|
918
|
+
for (let i = 0; i < pages.length; i++) {
|
|
919
|
+
const page = pages[i];
|
|
920
|
+
if (page && typeof page.html === 'string') {
|
|
921
|
+
const key = '/__chode_virtual_' + i + '.html';
|
|
922
|
+
virtual.set(key, page.html);
|
|
923
|
+
descriptors.push({
|
|
924
|
+
path: page.name || ('virtual-' + i + '.html'),
|
|
925
|
+
label: page.label || page.name || ('virtual-' + i + '.html'),
|
|
926
|
+
fileName: String(page.name || ('virtual-' + i + '.html')).replace(/[^a-z0-9._-]+/gi, '-'),
|
|
927
|
+
urlPath: key
|
|
928
|
+
});
|
|
929
|
+
} else if (page && page.path) {
|
|
930
|
+
const descriptor = pageDescriptor(root, page.path);
|
|
931
|
+
if (descriptor.path && !descriptor.path.startsWith('..') && !path.isAbsolute(descriptor.path)) descriptors.push(descriptor);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (!descriptors.length) return { ok: true, skipped: true, reason: 'no in-workspace HTML page was available for browser verification', checks: [], failures: [], screenshots: [] };
|
|
935
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
936
|
+
const screenshotsDir = options.screenshotsDir || path.join(root, '.chode', 'visual', stamp);
|
|
937
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
938
|
+
const waitMs = timeoutMs(options.timeoutMs);
|
|
939
|
+
const browserPath = findBrowser();
|
|
940
|
+
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chode-browser-'));
|
|
941
|
+
let child = null;
|
|
942
|
+
let cdp = null;
|
|
943
|
+
let targetId = null;
|
|
944
|
+
let serverInfo = null;
|
|
945
|
+
const startedAt = Date.now();
|
|
946
|
+
try {
|
|
947
|
+
serverInfo = await startServer(root, virtual);
|
|
948
|
+
const browser = await launchBrowser(browserPath, userDataDir, Math.min(waitMs, 15000));
|
|
949
|
+
child = browser.child;
|
|
950
|
+
cdp = await new CDP(browser.wsUrl, waitMs).connect();
|
|
951
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
952
|
+
targetId = created.targetId;
|
|
953
|
+
const attached = await cdp.send('Target.attachToTarget', { targetId: targetId, flatten: true });
|
|
954
|
+
const sessionId = attached.sessionId;
|
|
955
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
956
|
+
await cdp.send('Runtime.enable', {}, sessionId);
|
|
957
|
+
await cdp.send('Log.enable', {}, sessionId);
|
|
958
|
+
await cdp.send('Network.enable', {}, sessionId);
|
|
959
|
+
for (const page of descriptors) {
|
|
960
|
+
const url = serverInfo.base + page.urlPath;
|
|
961
|
+
for (const viewport of VIEWPORTS) {
|
|
962
|
+
if (Date.now() - startedAt > Math.max(waitMs * 4, 60000)) {
|
|
963
|
+
add('Browser verification time limit: ' + page.label, false, 'stopped after ' + Math.round((Date.now() - startedAt) / 1000) + 's');
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
await inspectViewport(cdp, sessionId, url, page, viewport, {
|
|
968
|
+
root: root,
|
|
969
|
+
request: options.request || '',
|
|
970
|
+
timeoutMs: waitMs,
|
|
971
|
+
screenshotsDir: screenshotsDir,
|
|
972
|
+
screenshots: screenshots
|
|
973
|
+
}, add);
|
|
974
|
+
} catch (e) {
|
|
975
|
+
add('Browser verification: ' + page.label + ' (' + viewport.name + ')', false, String((e && e.message) || e).slice(0, 500));
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
} catch (e) {
|
|
980
|
+
add('Browser verification', false, String((e && e.message) || e).slice(0, 800));
|
|
981
|
+
} finally {
|
|
982
|
+
try { if (cdp && targetId) await cdp.send('Target.closeTarget', { targetId: targetId }); } catch (e) {}
|
|
983
|
+
try { if (cdp) cdp.close(); } catch (e) {}
|
|
984
|
+
try { if (child && child.exitCode === null) child.kill('SIGKILL'); } catch (e) {}
|
|
985
|
+
try { if (serverInfo && serverInfo.server) await closeServer(serverInfo.server); } catch (e) {}
|
|
986
|
+
try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch (e) {}
|
|
987
|
+
}
|
|
988
|
+
const failed = checks.filter(function (check) { return !check.ok; });
|
|
989
|
+
return {
|
|
990
|
+
ok: failed.length === 0,
|
|
991
|
+
skipped: false,
|
|
992
|
+
reason: '',
|
|
993
|
+
checks: checks,
|
|
994
|
+
failures: failed.map(function (check) { return check.check + (check.detail ? ' — ' + check.detail : ''); }),
|
|
995
|
+
screenshots: screenshots
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function htmlFiles(root, files) {
|
|
1000
|
+
const out = [];
|
|
1001
|
+
const seen = new Set();
|
|
1002
|
+
for (const file of files || []) {
|
|
1003
|
+
if (!/\.html?$/i.test(String(file))) continue;
|
|
1004
|
+
const abs = path.isAbsolute(file) ? file : path.join(root, file);
|
|
1005
|
+
const rel = path.relative(root, abs);
|
|
1006
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
1007
|
+
if (seen.has(rel)) continue;
|
|
1008
|
+
try { if (!fs.statSync(abs).isFile()) continue; } catch (e) { continue; }
|
|
1009
|
+
seen.add(rel);
|
|
1010
|
+
out.push(rel);
|
|
1011
|
+
}
|
|
1012
|
+
if (!out.length) {
|
|
1013
|
+
for (const name of ['index.html', 'Index.html', 'index.htm']) {
|
|
1014
|
+
const abs = path.join(root, name);
|
|
1015
|
+
try { if (fs.statSync(abs).isFile()) { out.push(name); break; } } catch (e) {}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return out;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function checkWorkspace(root, request, files, options) {
|
|
1022
|
+
options = options || {};
|
|
1023
|
+
if (options.browserCheck === false) return { ok: true, skipped: true, reason: 'browser verification disabled', checks: [], failures: [], screenshots: [] };
|
|
1024
|
+
const pages = htmlFiles(root, files).slice(0, Math.max(1, Math.min(5, Number(options.maxPages) || 3))).map(function (file) {
|
|
1025
|
+
return { path: file };
|
|
1026
|
+
});
|
|
1027
|
+
if (!pages.length) return { ok: true, skipped: true, reason: 'no HTML page was written, so there was nothing to exercise in a browser', checks: [], failures: [], screenshots: [] };
|
|
1028
|
+
return run({
|
|
1029
|
+
root: root,
|
|
1030
|
+
request: request,
|
|
1031
|
+
pages: pages,
|
|
1032
|
+
maxPages: options.maxPages || 3,
|
|
1033
|
+
timeoutMs: options.timeoutMs,
|
|
1034
|
+
screenshotsDir: options.screenshotsDir
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function checkHtml(html, options) {
|
|
1039
|
+
options = options || {};
|
|
1040
|
+
if (typeof html !== 'string' || !html.trim()) return { ok: true, skipped: true, reason: 'empty document', checks: [], failures: [], screenshots: [] };
|
|
1041
|
+
return run({
|
|
1042
|
+
root: options.root || process.cwd(),
|
|
1043
|
+
request: options.request || '',
|
|
1044
|
+
pages: [{ html: html, name: options.name || 'artifact.html', label: options.name || 'artifact.html' }],
|
|
1045
|
+
maxPages: 1,
|
|
1046
|
+
timeoutMs: options.timeoutMs,
|
|
1047
|
+
screenshotsDir: options.screenshotsDir
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
module.exports = {
|
|
1052
|
+
available: available,
|
|
1053
|
+
unavailableReason: unavailableReason,
|
|
1054
|
+
run: run,
|
|
1055
|
+
checkWorkspace: checkWorkspace,
|
|
1056
|
+
checkHtml: checkHtml,
|
|
1057
|
+
findBrowser: findBrowser,
|
|
1058
|
+
decodePng: decodePng,
|
|
1059
|
+
imageStats: imageStats,
|
|
1060
|
+
visualVerdict: visualVerdict,
|
|
1061
|
+
VIEWPORTS: VIEWPORTS
|
|
1062
|
+
};
|