inspectra-engine 0.0.0-stage → 1.0.2
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/CHANGELOG.md +58 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +132 -2
- package/bin/inspectra.mjs +68 -0
- package/client/index.d.mts +85 -0
- package/client/index.mjs +2 -0
- package/compare.js +439 -0
- package/dom-compare.js +263 -0
- package/examples/README.md +105 -0
- package/examples/figma-scan.json +12 -0
- package/examples/png-metadata.json +5 -0
- package/examples/runner-scan.json +7 -0
- package/node/analysis-worker.mjs +33 -0
- package/node/dev-config.mjs +18 -0
- package/node/event-stream.mjs +10 -0
- package/node/index.d.mts +114 -0
- package/node/index.mjs +100 -0
- package/node/job-manager.mjs +172 -0
- package/node/png-highlights.mjs +30 -0
- package/node/runner.mjs +135 -0
- package/package.json +136 -4
- package/pages.json +8 -0
- package/scan.js +27 -0
- package/scanner/README.md +70 -0
- package/scanner/accessibility.mjs +10 -0
- package/scanner/browser-snapshot.js +128 -0
- package/scanner/figma-snapshot.mjs +221 -0
- package/scanner/legacy-image-tools.cjs +14 -0
- package/scanner/ocr/local.mjs +52 -0
- package/scanner/ocr/vision.swift +29 -0
- package/scanner/png-regions.mjs +53 -0
- package/scanner/png-snapshot.mjs +192 -0
- package/scripts/check-build.mjs +12 -0
- package/server.js +361 -0
- package/ui/compare-2.html +946 -0
- package/ui/compare-3.html +1222 -0
- package/ui/compare.html +1408 -0
- package/ui/index.html +136 -0
- package/ui-check.js +1493 -0
package/node/runner.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { sendEvent } from './event-stream.mjs';
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { ScanJobs, secret, integer, terminal } from './job-manager.mjs';
|
|
8
|
+
|
|
9
|
+
function authorized(header, token) {
|
|
10
|
+
const value = typeof header === 'string' ? header : '';
|
|
11
|
+
const expected = `Bearer ${token}`;
|
|
12
|
+
const received = Buffer.from(value), wanted = Buffer.from(expected);
|
|
13
|
+
return received.length === wanted.length && timingSafeEqual(received, wanted);
|
|
14
|
+
}
|
|
15
|
+
function json(res, status, data) {
|
|
16
|
+
res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data));
|
|
17
|
+
}
|
|
18
|
+
async function body(req) {
|
|
19
|
+
if (req.headers['content-type']?.split(';')[0] !== 'application/json') throw new TypeError('Content-Type must be application/json');
|
|
20
|
+
let size = 0; const chunks = [];
|
|
21
|
+
for await (const chunk of req) {
|
|
22
|
+
size += chunk.length;
|
|
23
|
+
if (size > 6 * 1024 * 1024) throw Object.assign(new Error('Request body too large'), { status: 413 });
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
}
|
|
26
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
27
|
+
if (size > 65536 && input?.baseline?.type !== 'png') throw Object.assign(new Error('Non-PNG request body too large'), { status: 413 });
|
|
28
|
+
return input;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Local-only HTTP adapter; tokens never appear in URLs or scan artifacts. */
|
|
32
|
+
export async function startLocalRunner({ port = 4317, allowedOrigins = [], bootstrapTTL = 900000, signal, ...jobOptions } = {}) {
|
|
33
|
+
integer(port, 'port', 0, 65535); integer(bootstrapTTL, 'bootstrapTTL', 100, 900000);
|
|
34
|
+
if (!Array.isArray(allowedOrigins)) throw new TypeError('allowedOrigins must be an array');
|
|
35
|
+
const origins = new Set(allowedOrigins.map(origin => {
|
|
36
|
+
const url = new URL(origin);
|
|
37
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin) throw new TypeError('Allowed origins must be exact HTTP(S) origins');
|
|
38
|
+
return origin;
|
|
39
|
+
}));
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'inspectra-runner-'));
|
|
42
|
+
let jobs;
|
|
43
|
+
try { jobs = new ScanJobs({ ...jobOptions, root }); }
|
|
44
|
+
catch (error) { await rm(root, { recursive: true, force: true }); throw error; }
|
|
45
|
+
const token = secret(), expiresAt = Date.now() + bootstrapTTL;
|
|
46
|
+
let origin, closing;
|
|
47
|
+
const server = createServer(async (req, res) => {
|
|
48
|
+
res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
49
|
+
try {
|
|
50
|
+
if (req.headers.host !== new URL(origin).host) return json(res, 403, { error: 'Invalid Host' });
|
|
51
|
+
const frontend = req.headers.origin;
|
|
52
|
+
if (frontend && !origins.has(frontend)) return json(res, 403, { error: 'Origin not allowed' });
|
|
53
|
+
if (!frontend && req.headers['sec-fetch-site'] && req.headers['sec-fetch-site'] !== 'none') return json(res, 403, { error: 'Explicit Origin required' });
|
|
54
|
+
if (frontend) {
|
|
55
|
+
res.setHeader('Access-Control-Allow-Origin', frontend); res.setHeader('Vary', 'Origin');
|
|
56
|
+
res.setHeader('Access-Control-Expose-Headers', 'X-Preview-Side, X-Preview-Timestamp');
|
|
57
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Last-Event-ID');
|
|
58
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
|
|
59
|
+
}
|
|
60
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
|
|
61
|
+
const url = new URL(req.url, origin);
|
|
62
|
+
if (url.search) return json(res, 400, { error: 'Query parameters are not accepted; use Authorization headers' });
|
|
63
|
+
if (req.method === 'POST' && url.pathname === '/sessions') {
|
|
64
|
+
if (Date.now() >= expiresAt || !authorized(req.headers.authorization, token)) return json(res, 401, { error: 'Invalid or expired runner token' });
|
|
65
|
+
const input = await body(req);
|
|
66
|
+
if (Date.now() >= expiresAt) return json(res, 401, { error: 'Runner token expired' });
|
|
67
|
+
const job = jobs.create(input);
|
|
68
|
+
return json(res, 202, { ...jobs.status(job), token: job.token });
|
|
69
|
+
}
|
|
70
|
+
const match = /^\/sessions\/([a-f0-9-]+)(?:\/(events|preview|issues|report|baseline\.png|target\.png|baseline\.matches\.svg|target\.matches\.svg))?$/.exec(url.pathname);
|
|
71
|
+
const job = match && jobs.jobs.get(match[1]);
|
|
72
|
+
if (!job || Date.now() >= job.expiresAt || !authorized(req.headers.authorization, job.token)) return json(res, 401, { error: 'Invalid or expired scan token' });
|
|
73
|
+
const action = match[2];
|
|
74
|
+
if (req.method === 'GET' && !action) return json(res, 200, jobs.status(job));
|
|
75
|
+
if (req.method === 'DELETE' && !action) { jobs.cancel(job); return json(res, 202, jobs.status(job)); }
|
|
76
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
|
|
77
|
+
if (action === 'events') {
|
|
78
|
+
if (job.listeners.size >= 4) return json(res, 429, { error: 'Event subscriber limit reached' });
|
|
79
|
+
const cursor = Number(req.headers['last-event-id'] ?? 0);
|
|
80
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > job.sequence) throw new TypeError('Invalid Last-Event-ID');
|
|
81
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
82
|
+
res.flushHeaders();
|
|
83
|
+
const send = (data, end) => sendEvent(res, data, end);
|
|
84
|
+
if (cursor < (job.events[0]?.id ?? job.sequence + 1) - 1) send(`event: replay-gap\ndata: ${JSON.stringify(jobs.status(job))}\n\n`);
|
|
85
|
+
for (const event of job.events) if (event.id > cursor) send(event.data);
|
|
86
|
+
if (terminal(job.state)) return res.end();
|
|
87
|
+
job.listeners.add(send);
|
|
88
|
+
const heartbeat = setInterval(() => send(': keepalive\n\n'), 15000);
|
|
89
|
+
const expiry = setTimeout(() => res.end(), Math.max(0, job.expiresAt - Date.now()));
|
|
90
|
+
res.on('close', () => { clearInterval(heartbeat); clearTimeout(expiry); job.listeners.delete(send); });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action === 'preview') {
|
|
94
|
+
if (!job.frame) { res.writeHead(204); return res.end(); }
|
|
95
|
+
res.writeHead(200, { 'Content-Type': job.frame.contentType, 'X-Preview-Side': job.frame.side, 'X-Preview-Timestamp': job.frame.timestamp });
|
|
96
|
+
return res.end(job.frame.data);
|
|
97
|
+
}
|
|
98
|
+
if (job.state !== 'completed') return json(res, 409, { error: 'Scan has no completed report', state: job.state });
|
|
99
|
+
if (action === 'issues') return json(res, 200, { issues: job.result.issues, diagnostics: job.result.analysis.diagnostics });
|
|
100
|
+
if (action === 'report') return json(res, 200, job.result);
|
|
101
|
+
if (action === 'baseline.matches.svg' || action === 'target.matches.svg') {
|
|
102
|
+
const artifact = job.result.artifacts[action === 'baseline.matches.svg' ? 'baselineHighlights' : 'targetHighlights'];
|
|
103
|
+
if (!artifact) return json(res, 404, { error: 'No matched-region highlight artifact for this source' });
|
|
104
|
+
const data = await readFile(artifact);
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Security-Policy': "default-src 'none'; img-src data:; style-src 'none'" }); return res.end(data);
|
|
106
|
+
}
|
|
107
|
+
if (action === 'baseline.png' || action === 'target.png') {
|
|
108
|
+
const data = await readFile(job.result.artifacts[action === 'baseline.png' ? 'baselineScreenshot' : 'targetScreenshot']);
|
|
109
|
+
res.writeHead(200, { 'Content-Type': 'image/png' }); return res.end(data);
|
|
110
|
+
}
|
|
111
|
+
json(res, 404, { error: 'Endpoint not found' });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!res.headersSent) json(res, error.status ?? (error instanceof TypeError || error instanceof SyntaxError ? 400 : 500), { error: error.message });
|
|
114
|
+
else res.destroy();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
server.requestTimeout = 10000; server.headersTimeout = 10000; server.maxConnections = 64;
|
|
118
|
+
const close = () => {
|
|
119
|
+
if (!closing) closing = (async () => {
|
|
120
|
+
signal?.removeEventListener('abort', abort);
|
|
121
|
+
const stopped = new Promise(resolve => server.close(resolve));
|
|
122
|
+
await jobs.close(); server.closeAllConnections(); await stopped;
|
|
123
|
+
await rm(root, { recursive: true, force: true });
|
|
124
|
+
})();
|
|
125
|
+
return closing;
|
|
126
|
+
};
|
|
127
|
+
const abort = () => { void close(); };
|
|
128
|
+
try {
|
|
129
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
130
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
131
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
132
|
+
if (signal?.aborted) { await close(); signal.throwIfAborted(); }
|
|
133
|
+
return { origin, token, expiresAt, close };
|
|
134
|
+
} catch (error) { await close(); throw error; }
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,138 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inspectra-engine",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Playwright UI testing and accessibility toolkit for Node.js. Compare URL, Figma and PNG baselines using Shivax, with DOM snapshots, layout measurements and JSON reports.",
|
|
5
|
+
"homepage": "https://gitlab.com/urveshgohil.developer/inspectra",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://gitlab.com/urveshgohil.developer/inspectra.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://gitlab.com/urveshgohil.developer/inspectra/-/issues"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@axe-core/playwright": "^4.13.0",
|
|
15
|
+
"cspell": "^10.3.6",
|
|
16
|
+
"lighthouse": "^13.5.0",
|
|
17
|
+
"playwright": "^1.64.0",
|
|
18
|
+
"shivax": "^0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@playwright/test": "^1.64.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "playwright test",
|
|
25
|
+
"test:headed": "playwright test --headed",
|
|
26
|
+
"report": "playwright show-report",
|
|
27
|
+
"scan": "node scan.js",
|
|
28
|
+
"ui": "node --watch server.js",
|
|
29
|
+
"test:ui-check": "node --test --test-concurrency=1 tests/ui-check/regression.mjs tests/ui-check/dynamic-content.mjs",
|
|
30
|
+
"test:dynamic-content": "node --test --test-concurrency=1 tests/ui-check/dynamic-content.mjs",
|
|
31
|
+
"test:extraction": "node --test --test-concurrency=1 tests/extraction/snapshot.mjs",
|
|
32
|
+
"build": "node scripts/check-build.mjs",
|
|
33
|
+
"test:integration": "node --test --test-concurrency=1 tests/integration/*.mjs",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"test:runner": "node --test --test-concurrency=1 tests/integration/runner.mjs",
|
|
36
|
+
"test:figma": "node --test --test-concurrency=1 tests/integration/figma.mjs",
|
|
37
|
+
"test:png": "node --test --test-concurrency=1 tests/integration/png.mjs"
|
|
38
|
+
},
|
|
39
|
+
"main": "./node/index.mjs",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"types": "./node/index.d.mts",
|
|
43
|
+
"import": "./node/index.mjs"
|
|
44
|
+
},
|
|
45
|
+
"./node": {
|
|
46
|
+
"types": "./node/index.d.mts",
|
|
47
|
+
"import": "./node/index.mjs"
|
|
48
|
+
},
|
|
49
|
+
"./client": {
|
|
50
|
+
"types": "./client/index.d.mts",
|
|
51
|
+
"import": "./client/index.mjs"
|
|
52
|
+
},
|
|
53
|
+
"./ui-check.js": "./ui-check.js",
|
|
54
|
+
"./compare.js": "./compare.js",
|
|
55
|
+
"./dom-compare.js": "./dom-compare.js",
|
|
56
|
+
"./scanner/browser-snapshot.js": "./scanner/browser-snapshot.js"
|
|
57
|
+
},
|
|
58
|
+
"bin": {
|
|
59
|
+
"inspectra": "./bin/inspectra.mjs"
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"node/",
|
|
63
|
+
"client/",
|
|
64
|
+
"bin/",
|
|
65
|
+
"scanner/",
|
|
66
|
+
"ui-check.js",
|
|
67
|
+
"compare.js",
|
|
68
|
+
"dom-compare.js",
|
|
69
|
+
"scan.js",
|
|
70
|
+
"server.js",
|
|
71
|
+
"ui/",
|
|
72
|
+
"pages.json",
|
|
73
|
+
"scripts/check-build.mjs",
|
|
74
|
+
"examples/",
|
|
75
|
+
"CHANGELOG.md",
|
|
76
|
+
"NOTICE"
|
|
77
|
+
],
|
|
78
|
+
"keywords": [
|
|
79
|
+
"inspectra",
|
|
80
|
+
"visynq",
|
|
81
|
+
"shivax",
|
|
82
|
+
"playwright",
|
|
83
|
+
"nodejs",
|
|
84
|
+
"javascript",
|
|
85
|
+
"typescript",
|
|
86
|
+
"qa",
|
|
87
|
+
"quality-assurance",
|
|
88
|
+
"test-automation",
|
|
89
|
+
"browser-automation",
|
|
90
|
+
"web-automation",
|
|
91
|
+
"web-testing",
|
|
92
|
+
"frontend-testing",
|
|
93
|
+
"ui-testing",
|
|
94
|
+
"ui-inspection",
|
|
95
|
+
"ui-audit",
|
|
96
|
+
"ui-comparison",
|
|
97
|
+
"visual-testing",
|
|
98
|
+
"visual-regression",
|
|
99
|
+
"visual-regression-testing",
|
|
100
|
+
"design-qa",
|
|
101
|
+
"design-validation",
|
|
102
|
+
"design-comparison",
|
|
103
|
+
"figma",
|
|
104
|
+
"figma-comparison",
|
|
105
|
+
"figma-to-web",
|
|
106
|
+
"screenshot-testing",
|
|
107
|
+
"screenshot-comparison",
|
|
108
|
+
"screenshot-capture",
|
|
109
|
+
"dom-extraction",
|
|
110
|
+
"dom-snapshot",
|
|
111
|
+
"ui-snapshot",
|
|
112
|
+
"semantic-comparison",
|
|
113
|
+
"layout-testing",
|
|
114
|
+
"typography",
|
|
115
|
+
"spacing",
|
|
116
|
+
"responsive-testing",
|
|
117
|
+
"viewport-testing",
|
|
118
|
+
"accessibility",
|
|
119
|
+
"a11y",
|
|
120
|
+
"accessibility-testing",
|
|
121
|
+
"wcag",
|
|
122
|
+
"axe-core",
|
|
123
|
+
"spelling",
|
|
124
|
+
"spell-check",
|
|
125
|
+
"cspell",
|
|
126
|
+
"png",
|
|
127
|
+
"ocr",
|
|
128
|
+
"tesseract",
|
|
129
|
+
"json-report",
|
|
130
|
+
"local-scanner",
|
|
131
|
+
"developer-tools"
|
|
132
|
+
],
|
|
133
|
+
"author": "Urvesh Gohil",
|
|
134
|
+
"license": "Apache-2.0",
|
|
135
|
+
"engines": {
|
|
136
|
+
"node": ">=22.15"
|
|
137
|
+
}
|
|
138
|
+
}
|
package/pages.json
ADDED
package/scan.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Usage: node scan.js <url>
|
|
2
|
+
// Runs the locally built axe.js (repo root) against a page using Playwright.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { chromium } = require('playwright');
|
|
6
|
+
|
|
7
|
+
const url = process.argv[2];
|
|
8
|
+
if (!url) {
|
|
9
|
+
console.error('Usage: node scan.js <url>');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
(async () => {
|
|
14
|
+
const browser = await chromium.launch();
|
|
15
|
+
const page = await browser.newPage();
|
|
16
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
17
|
+
await page.addScriptTag({ path: process.env.AXE_PATH || path.join(__dirname, '..', 'axe-core', 'axe.js') });
|
|
18
|
+
const results = await page.evaluate(() => window.axe.run());
|
|
19
|
+
console.log(`Scanned: ${results.url}`);
|
|
20
|
+
console.log(`Violations: ${results.violations.length}, Incomplete: ${results.incomplete.length}, Passes: ${results.passes.length}\n`);
|
|
21
|
+
for (const v of results.violations) {
|
|
22
|
+
console.log(`[${v.impact}] ${v.id} - ${v.help} (${v.nodes.length} nodes)`);
|
|
23
|
+
console.log(` ${v.helpUrl}`);
|
|
24
|
+
v.nodes.slice(0, 3).forEach(n => console.log(` - ${n.target.join(' ')}`));
|
|
25
|
+
}
|
|
26
|
+
await browser.close();
|
|
27
|
+
})().catch(e => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Playwright UI extraction (Phase 2)
|
|
2
|
+
|
|
3
|
+
Use this adapter from a local Node.js runner. It reuses `inspectPage`, `settlePage`,
|
|
4
|
+
`VIEWPORTS`, page creation and full-page screenshots from `ui-check.js`.
|
|
5
|
+
Existing scanner endpoints and comparison results retain their existing format.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { captureUISnapshot, captureUIPage, captureUISnapshotPair } from './browser-snapshot.js';
|
|
9
|
+
|
|
10
|
+
const scope = { kind: 'company', id: 'your-workspace-id' };
|
|
11
|
+
const snapshot = await captureUISnapshot(page, { scope });
|
|
12
|
+
const capture = await captureUIPage(page, { scope, screenshotPath: './target.png' });
|
|
13
|
+
const pair = await captureUISnapshotPair({
|
|
14
|
+
baselineUrl: 'http://localhost:3000/baseline',
|
|
15
|
+
targetUrl: 'http://localhost:3000/target',
|
|
16
|
+
scope,
|
|
17
|
+
viewport: { width: 1440, height: 900 },
|
|
18
|
+
deviceScaleFactor: 1,
|
|
19
|
+
screenshots: { baseline: './baseline.png', target: './target.png' },
|
|
20
|
+
});
|
|
21
|
+
// pair.baseline.snapshot and pair.target.snapshot are Shivax UISnapshot v1.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`captureUISnapshot` returns only the strict Shivax contract. `captureUIPage` also
|
|
25
|
+
returns a separate `metadata` object containing DOM tags/paths, approximate
|
|
26
|
+
accessible names, sibling order/links, text glyph bounds, flex/grid properties,
|
|
27
|
+
signed sibling edge distances, media dimensions/readiness and scroll metrics.
|
|
28
|
+
These fields cannot be added to Shivax v1, whose validator rejects extra keys.
|
|
29
|
+
Node IDs are capture-local; DOM paths use child indices and never CSS classes.
|
|
30
|
+
Wrapper changes are retained in the hierarchy; this adapter does no matching.
|
|
31
|
+
|
|
32
|
+
All geometry uses unrounded CSS pixels in document coordinates. DPR is metadata,
|
|
33
|
+
not a geometry multiplier. Bounds are axis-aligned rendered boxes, including
|
|
34
|
+
transforms; computed padding/margins describe CSS lengths. Unresolved values
|
|
35
|
+
such as `line-height: normal` stay unknown. Measurements include provenance and
|
|
36
|
+
confidence. Accessible names use a partial DOM-based algorithm and are marked
|
|
37
|
+
estimated (quality annotation 0.8, not a calibrated probability). Roles use
|
|
38
|
+
explicit ARIA roles and common native HTML semantics, not a full accessibility tree.
|
|
39
|
+
Sibling edge distances can be negative for overlap or different layout rows.
|
|
40
|
+
|
|
41
|
+
Captures disable animations and scroll to the top after the existing lazy-load
|
|
42
|
+
scroll pass. Snapshot mode disables legacy opacity-forcing fallbacks so hidden
|
|
43
|
+
content stays hidden. Offscreen document nodes are retained; hidden and fully
|
|
44
|
+
overflow-clipped elements are omitted. Partial clipping retains the full rendered
|
|
45
|
+
box. Pseudo-elements, shadow DOM, iframe contents and arbitrary clip-path masks
|
|
46
|
+
are not extracted. Both URLs use the same Chromium instance, viewport and DPR,
|
|
47
|
+
with separate contexts (cookies/storage are not shared). An existing page uses
|
|
48
|
+
the browser settings supplied by its caller.
|
|
49
|
+
|
|
50
|
+
Options shared by all capture APIs:
|
|
51
|
+
|
|
52
|
+
- `scope` is required: `{ kind: 'company' | 'personal', id: string }`. No captures
|
|
53
|
+
are cached, merged or persisted automatically. Never reuse scope IDs across owners.
|
|
54
|
+
- `includeText: false` omits text, accessible names and glyph bounds. Input values
|
|
55
|
+
are never read; visible page text can still contain personal information.
|
|
56
|
+
- `ignoreSelectors: string[]` excludes selected subtrees.
|
|
57
|
+
- `maxNodes` defaults to 10,000; overflow throws instead of silently truncating.
|
|
58
|
+
- `timeout` defaults to 15,000 ms per readiness/navigation operation, not a total
|
|
59
|
+
scan deadline. Existing settling also has bounded network/image waits.
|
|
60
|
+
- All document images and loaded fonts must finish successfully. `requiredAssets`
|
|
61
|
+
is an array of CSS selectors for additional required assets: selectors must
|
|
62
|
+
resolve, media must have current frame data, and CSS background URLs must load.
|
|
63
|
+
Broken/missing assets produce errors. Dynamic sites must expose an appropriate
|
|
64
|
+
ready state before capture; stabilization does not freeze application data.
|
|
65
|
+
|
|
66
|
+
Run `npm run test:extraction` and `npm run test:ui-check`. Extraction tests validate
|
|
67
|
+
against the built sibling Shivax package without editing it. Set `SHIVAX_ENTRY`
|
|
68
|
+
to an alternative built module path/URL when testing outside this workspace.
|
|
69
|
+
This JavaScript package has no configured build step; use `node --check` for the
|
|
70
|
+
changed modules. No dependencies or comparison algorithms were added.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
2
|
+
|
|
3
|
+
export const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
|
|
4
|
+
|
|
5
|
+
/** The same WCAG A/AA scan used by the existing accessibility workflow. */
|
|
6
|
+
export async function scanAccessibility(page, { exclude = [], bestPractice = false } = {}) {
|
|
7
|
+
const builder = new AxeBuilder({ page }).withTags(bestPractice ? [...WCAG_TAGS, 'best-practice'] : WCAG_TAGS);
|
|
8
|
+
for (const selector of exclude) builder.exclude(selector);
|
|
9
|
+
return builder.analyze();
|
|
10
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Local Node.js adapter. Do not bundle this module into application/browser code.
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
import { inspectPage, settlePage, VIEWPORTS, createInspectionPage, capturePageScreenshot } from "../ui-check.js";
|
|
4
|
+
|
|
5
|
+
function captureOptions(options) {
|
|
6
|
+
const { scope, maxNodes = 10000, includeText = true, timeout = 15000, ignoreSelectors = [], requiredAssets = [] } = options;
|
|
7
|
+
if (!scope || !["company", "personal"].includes(scope.kind) || typeof scope.id !== "string" || !scope.id.trim() || scope.id.length > 256) {
|
|
8
|
+
throw new TypeError("An explicit company/personal scope and id are required");
|
|
9
|
+
}
|
|
10
|
+
if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10000) throw new TypeError("maxNodes must be between 1 and 10000");
|
|
11
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new TypeError("timeout must be positive");
|
|
12
|
+
if (typeof includeText !== "boolean") throw new TypeError("includeText must be boolean");
|
|
13
|
+
for (const list of [ignoreSelectors, requiredAssets]) {
|
|
14
|
+
if (!Array.isArray(list) || list.some((s) => typeof s !== "string" || !s.trim())) throw new TypeError("Asset/ignore selectors must be nonempty strings");
|
|
15
|
+
}
|
|
16
|
+
return { scope: { kind: scope.kind, id: scope.id }, maxNodes, includeText, timeout, ignoreSelectors, requiredAssets };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function prepare(page, options) {
|
|
20
|
+
await settlePage(page, true, options.timeout, false);
|
|
21
|
+
// Legacy settling is best effort. Snapshot capture requires fonts and images to
|
|
22
|
+
// succeed and exposes failures, instead of returning apparently complete data.
|
|
23
|
+
await page.waitForFunction(({ requiredAssets }) => {
|
|
24
|
+
const required = requiredAssets.flatMap((selector) => [...document.querySelectorAll(selector)]);
|
|
25
|
+
if (requiredAssets.some((selector) => !document.querySelector(selector))) return false;
|
|
26
|
+
return document.fonts.status === "loaded" && [...document.images, ...required].every((el) => {
|
|
27
|
+
if (el instanceof HTMLImageElement) return el.complete && (!el.currentSrc || el.naturalWidth > 0);
|
|
28
|
+
if (el instanceof HTMLMediaElement) return el.readyState >= 2;
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
}, { requiredAssets: options.requiredAssets }, { timeout: options.timeout });
|
|
32
|
+
const failures = await page.evaluate(async ({ requiredAssets, timeout }) => {
|
|
33
|
+
await document.fonts.ready;
|
|
34
|
+
const failed = [...document.fonts].filter((f) => f.status === "error").map((f) => `font: ${f.family}`);
|
|
35
|
+
const images = requiredAssets.flatMap((s) => [...document.querySelectorAll(s)])
|
|
36
|
+
.flatMap((el) => [...getComputedStyle(el).backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]));
|
|
37
|
+
await Promise.all(images.map((url) => new Promise((resolve) => {
|
|
38
|
+
const img = new Image();
|
|
39
|
+
const timer = setTimeout(() => { failed.push("Required background image timed out"); resolve(); }, timeout);
|
|
40
|
+
img.onload = img.onerror = (event) => { clearTimeout(timer); if (event.type === "error") failed.push("Required background image failed"); resolve(); };
|
|
41
|
+
img.src = url;
|
|
42
|
+
})));
|
|
43
|
+
window.scrollTo({ left: 0, top: 0, behavior: "instant" });
|
|
44
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
45
|
+
return failed;
|
|
46
|
+
}, { requiredAssets: options.requiredAssets, timeout: options.timeout });
|
|
47
|
+
if (failures.length) throw new Error(`Snapshot assets failed: ${failures.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function extract(page, options) {
|
|
51
|
+
return page.evaluate(inspectPage, { ignore: options.ignoreSelectors.join(","), snapshot: options });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns only the strict Shivax UISnapshot v1 contract. No persistence or comparison. */
|
|
55
|
+
export async function captureUISnapshot(page, options = {}) {
|
|
56
|
+
const config = captureOptions(options);
|
|
57
|
+
await prepare(page, config);
|
|
58
|
+
return (await extract(page, config)).snapshot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Metadata is separate because Shivax v1 rejects fields outside its contract. */
|
|
62
|
+
export async function captureUIPage(page, options = {}) {
|
|
63
|
+
const config = captureOptions(options);
|
|
64
|
+
await prepare(page, config);
|
|
65
|
+
const capture = await extract(page, config);
|
|
66
|
+
if (options.uiChecks) {
|
|
67
|
+
const { inspectUICheckPage } = await import('../ui-check.js');
|
|
68
|
+
capture.uiInspection = await inspectUICheckPage(page, config.ignoreSelectors);
|
|
69
|
+
}
|
|
70
|
+
if (options.screenshotPath) {
|
|
71
|
+
const size = await capturePageScreenshot(page, options.screenshotPath);
|
|
72
|
+
capture.screenshot = { path: options.screenshotPath, ...size };
|
|
73
|
+
}
|
|
74
|
+
return capture;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Validate runner settings without launching Chromium. */
|
|
78
|
+
export function validateCaptureOptions({ viewport = VIEWPORTS.desktop, deviceScaleFactor = 1, ...options } = {}) {
|
|
79
|
+
captureOptions(options);
|
|
80
|
+
if (!viewport || !Number.isInteger(viewport.width) || !Number.isInteger(viewport.height) || viewport.width <= 0 || viewport.height <= 0) throw new TypeError("A positive integer viewport is required");
|
|
81
|
+
if (!Number.isFinite(deviceScaleFactor) || deviceScaleFactor < 0.01 || deviceScaleFactor > 100) throw new TypeError("Invalid deviceScaleFactor");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Same Chromium instance/settings; independent fresh contexts for the two URLs. */
|
|
85
|
+
export async function captureUISnapshotPair({ baselineUrl, targetUrl, viewport = VIEWPORTS.desktop, deviceScaleFactor = 1, signal, accessibility = false, onEvent, onPage, baselineCapture, ...options } = {}) {
|
|
86
|
+
validateCaptureOptions({ ...options, viewport, deviceScaleFactor });
|
|
87
|
+
if ((!baselineUrl && !baselineCapture) || !targetUrl) throw new TypeError("baselineUrl and targetUrl are required");
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
onEvent?.({ type: "state", state: "loading", side: baselineCapture ? "target" : "baseline" });
|
|
90
|
+
const browser = await chromium.launch({ headless: true });
|
|
91
|
+
const abort = () => { void browser.close().catch(() => {}); };
|
|
92
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
93
|
+
try {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
const captures = {};
|
|
96
|
+
for (const [kind, url] of [["baseline", baselineUrl], ["target", targetUrl]]) {
|
|
97
|
+
signal?.throwIfAborted();
|
|
98
|
+
if (kind === "baseline" && baselineCapture) { captures.baseline = baselineCapture; continue; }
|
|
99
|
+
const page = await createInspectionPage(browser, viewport, deviceScaleFactor);
|
|
100
|
+
let stopPreview;
|
|
101
|
+
try {
|
|
102
|
+
stopPreview = await onPage?.(page, kind);
|
|
103
|
+
onEvent?.({ type: "state", state: "loading", side: kind });
|
|
104
|
+
page.on("domcontentloaded", () => onEvent?.({ type: "page", name: "domcontentloaded", side: kind }));
|
|
105
|
+
page.on("requestfailed", request => onEvent?.({ type: "page", name: "requestfailed", side: kind, error: request.failure()?.errorText }));
|
|
106
|
+
await page.goto(url, { waitUntil: "load", timeout: options.timeout ?? 15000 });
|
|
107
|
+
onEvent?.({ type: "state", state: "extracting", side: kind });
|
|
108
|
+
captures[kind] = await captureUIPage(page, { ...options, screenshotPath: options.screenshots?.[kind] });
|
|
109
|
+
onEvent?.({ type: "snapshot", side: kind, nodes: captures[kind].snapshot.nodes.length });
|
|
110
|
+
if (accessibility) {
|
|
111
|
+
const { scanAccessibility } = await import("./accessibility.mjs");
|
|
112
|
+
captures[kind].accessibility = await scanAccessibility(page, { exclude: options.ignoreSelectors });
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
await stopPreview?.();
|
|
116
|
+
await page.context().close().catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
return captures;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
signal?.throwIfAborted();
|
|
123
|
+
throw error;
|
|
124
|
+
} finally {
|
|
125
|
+
signal?.removeEventListener("abort", abort);
|
|
126
|
+
await browser.close();
|
|
127
|
+
}
|
|
128
|
+
}
|