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/server.js ADDED
@@ -0,0 +1,361 @@
1
+ // Local UI: paste a URL, get a WCAG accessibility report. Run: npm run ui
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { chromium } = require('playwright');
6
+
7
+ const PORT = process.env.PORT || 3000;
8
+
9
+ const REVIEWS_FILE = process.env.UI_REVIEWS_FILE || path.join(__dirname, 'visual-reports', 'reviews.json');
10
+ const REVIEW_STATUSES = ['issue', 'accepted', 'ignored'];
11
+ const reviewKey = (ref, tgt) => `${String(ref || '').trim()} -> ${String(tgt || '').trim()}`;
12
+ function readReviews() {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(REVIEWS_FILE, 'utf8'));
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+ function writeReviews(all) {
20
+ fs.mkdirSync(path.dirname(REVIEWS_FILE), { recursive: true });
21
+ const tmp = `${REVIEWS_FILE}.tmp`;
22
+ fs.writeFileSync(tmp, JSON.stringify(all, null, 2));
23
+ fs.renameSync(tmp, REVIEWS_FILE);
24
+ }
25
+
26
+ // Lighthouse-style scoring: weighted share of applicable rules that pass.
27
+ const WEIGHT = { critical: 10, serious: 7, moderate: 3, minor: 1 };
28
+ const LEVEL_A = ['wcag2a', 'wcag21a'];
29
+ const LEVEL_AA = ['wcag2aa', 'wcag21aa', 'wcag22aa'];
30
+
31
+ function ruleWeight(rule, failed) {
32
+ const impact = failed
33
+ ? rule.impact
34
+ : rule.nodes.flatMap(n => [...n.any, ...n.all]).map(c => c.impact).find(Boolean);
35
+ return WEIGHT[impact] || WEIGHT.moderate;
36
+ }
37
+
38
+ function score(violations, passes, tags) {
39
+ const hit = r => !tags || r.tags.some(t => tags.includes(t));
40
+ let pass = 0, total = 0, failed = 0, passed = 0;
41
+ for (const r of passes.filter(hit)) { const w = ruleWeight(r, false); pass += w; total += w; passed++; }
42
+ for (const r of violations.filter(hit)) { total += ruleWeight(r, true); failed++; }
43
+ return { score: total ? Math.round((pass / total) * 100) : null, passed, failed };
44
+ }
45
+
46
+ // Flatten a Lighthouse table cell into display text.
47
+ function cell(v) {
48
+ if (v == null) return '';
49
+ if (typeof v !== 'object') return String(v);
50
+ if (v.type === 'node') return v.snippet || v.selector || '';
51
+ if (v.type === 'source-location') return `${v.url}:${(v.line || 0) + 1}`;
52
+ if (v.type === 'link') return v.url || v.text || '';
53
+ if (v.type === 'code') return String(v.value);
54
+ if (v.type === 'numeric') return String(Math.round(v.value * 100) / 100);
55
+ if (v.url) return v.url;
56
+ if (v.value != null) return String(v.value);
57
+ if (v.text != null) return String(v.text);
58
+ return '';
59
+ }
60
+
61
+ function auditDetails(d) {
62
+ if (!d || !['table', 'opportunity', 'list'].includes(d.type)) return null;
63
+ if (d.type === 'list') return null;
64
+ const heads = (d.headings || []).filter(h => h.key).map(h => ({ key: h.key, label: cell(h.label) }));
65
+ const rows = (d.items || []).slice(0, 15).map(it => heads.map(h => cell(it[h.key])));
66
+ return rows.length ? { headings: heads.map(h => h.label), rows, total: (d.items || []).length } : null;
67
+ }
68
+
69
+ async function lighthouseScores(url, port) {
70
+ const { default: lighthouse } = await import('lighthouse');
71
+ const { default: agenticConfig } = await import('lighthouse/core/config/agentic-browsing-config.js');
72
+ const { lhr, report } = await lighthouse(
73
+ url,
74
+ {
75
+ port,
76
+ output: ['json', 'html'],
77
+ logLevel: 'error',
78
+ onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']
79
+ },
80
+ agenticConfig
81
+ );
82
+ const scores = {};
83
+ const categories = [];
84
+ for (const [k, c] of Object.entries(lhr.categories)) {
85
+ const audits = c.auditRefs
86
+ .filter(r => r.weight > 0 || r.group)
87
+ .map(r => {
88
+ const a = lhr.audits[r.id];
89
+ if (!a) return null;
90
+ const mode = a.scoreDisplayMode;
91
+ const status =
92
+ mode === 'notApplicable' ? 'na'
93
+ : mode === 'manual' ? 'manual'
94
+ : mode === 'informative' ? 'info'
95
+ : mode === 'error' ? 'error'
96
+ : a.score === 1 || a.score >= 0.9 ? 'pass'
97
+ : 'fail';
98
+ return {
99
+ id: a.id,
100
+ title: a.title,
101
+ description: a.description,
102
+ displayValue: a.displayValue || '',
103
+ score: a.score,
104
+ status,
105
+ group: r.group ? (lhr.categoryGroups?.[r.group]?.title || r.group) : '',
106
+ details: status === 'pass' ? null : auditDetails(a.details)
107
+ };
108
+ })
109
+ .filter(Boolean);
110
+ if (k === 'agentic-browsing') {
111
+ // Fraction category: passed / applicable audits.
112
+ const applicable = audits.filter(a => ['pass', 'fail'].includes(a.status));
113
+ scores[k] = { passed: applicable.filter(a => a.status === 'pass').length, total: applicable.length };
114
+ } else {
115
+ scores[k] = c.score == null ? null : Math.round(c.score * 100);
116
+ }
117
+ categories.push({ id: k, title: c.title, audits });
118
+ }
119
+ return { scores, categories, report: report[1] };
120
+ }
121
+
122
+ const reports = new Map(); // id -> full Lighthouse HTML report
123
+ let reportSeq = 0;
124
+
125
+ async function scan(url, bestPractice) {
126
+ const port = 9300 + Math.floor(Math.random() * 500);
127
+ const browser = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
128
+ try {
129
+ let lighthouse = null, lighthouseError = null, lighthouseCategories = null, reportId = null;
130
+ try {
131
+ const lh = await lighthouseScores(url, port);
132
+ lighthouse = lh.scores;
133
+ lighthouseCategories = lh.categories;
134
+ reportId = String(++reportSeq);
135
+ reports.set(reportId, lh.report);
136
+ if (reports.size > 10) reports.delete(reports.keys().next().value);
137
+ } catch (e) {
138
+ lighthouseError = String(e.message).split('\n')[0];
139
+ console.error('Lighthouse failed:', e.message);
140
+ }
141
+ const page = await (await browser.newContext()).newPage();
142
+ await page.goto(url, { waitUntil: 'load', timeout: 45000 });
143
+ const { scanAccessibility } = await import('./scanner/accessibility.mjs');
144
+ const r = await scanAccessibility(page, { bestPractice });
145
+ return {
146
+ url: r.url,
147
+ lighthouse,
148
+ lighthouseError,
149
+ lighthouseCategories,
150
+ reportId,
151
+ scores: {
152
+ overall: score(r.violations, r.passes),
153
+ a: score(r.violations, r.passes, LEVEL_A),
154
+ aa: score(r.violations, r.passes, LEVEL_AA)
155
+ },
156
+ counts: {
157
+ violations: r.violations.length,
158
+ incomplete: r.incomplete.length,
159
+ passes: r.passes.length
160
+ },
161
+ violations: r.violations.map(v => ({
162
+ id: v.id,
163
+ impact: v.impact,
164
+ help: v.help,
165
+ helpUrl: v.helpUrl,
166
+ tags: v.tags.filter(t => t.startsWith('wcag')),
167
+ nodes: v.nodes.slice(0, 10).map(n => ({
168
+ target: n.target.join(' '),
169
+ html: n.html,
170
+ fix: n.failureSummary
171
+ })),
172
+ totalNodes: v.nodes.length
173
+ }))
174
+ };
175
+ } finally {
176
+ await browser.close();
177
+ }
178
+ }
179
+
180
+ http
181
+ .createServer(async (req, res) => {
182
+ const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
183
+ const pathname = parsedUrl.pathname;
184
+
185
+ if (req.method === 'GET' && pathname === '/') {
186
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
187
+ return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'index.html')));
188
+ }
189
+ if (req.method === 'GET' && (pathname === '/compare' || pathname === '/compare.html')) {
190
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
191
+ return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare.html')));
192
+ }
193
+ if (req.method === 'GET' && (pathname === '/compare-2' || pathname === '/compare-2.html')) {
194
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
195
+ return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare-2.html')));
196
+ }
197
+ if (req.method === 'GET' && (pathname === '/compare-3' || pathname === '/compare-3.html')) {
198
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
199
+ return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare-3.html')));
200
+ }
201
+ if (req.method === 'GET' && pathname === '/api/last-compare-result') {
202
+ const resultPath = path.join(__dirname, 'visual-reports', 'result.json');
203
+ res.setHeader('Content-Type', 'application/json');
204
+ if (fs.existsSync(resultPath)) {
205
+ return res.end(fs.readFileSync(resultPath));
206
+ }
207
+ return res.end(JSON.stringify(null));
208
+ }
209
+ const reportFileMatch = req.method === 'GET' && pathname.match(/^\/visual-reports\/((?:runs\/[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-][a-zA-Z0-9_\-\.]*)$/);
210
+ if (reportFileMatch) {
211
+ const filename = reportFileMatch[1];
212
+ const filePath = path.join(__dirname, 'visual-reports', filename);
213
+ if (fs.existsSync(filePath)) {
214
+ const ext = path.extname(filename).toLowerCase();
215
+ const mimeMap = {
216
+ '.png': 'image/png',
217
+ '.json': 'application/json',
218
+ '.jpg': 'image/jpeg',
219
+ '.jpeg': 'image/jpeg',
220
+ '.webp': 'image/webp',
221
+ };
222
+ res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
223
+ res.setHeader('Cache-Control', 'no-cache');
224
+ return res.end(fs.readFileSync(filePath));
225
+ }
226
+ res.statusCode = 404;
227
+ return res.end('File not found');
228
+ }
229
+ const m = req.method === 'GET' && pathname.match(/^\/report\/(\d+)$/);
230
+ if (m) {
231
+ const html = reports.get(m[1]);
232
+ res.statusCode = html ? 200 : 404;
233
+ res.setHeader('Content-Type', 'text/html');
234
+ return res.end(html || 'Report expired. Run the scan again.');
235
+ }
236
+ if (req.method === 'POST' && pathname === '/scan') {
237
+ let body = '';
238
+ for await (const c of req) body += c;
239
+ res.setHeader('Content-Type', 'application/json');
240
+ try {
241
+ const { url, bestPractice } = JSON.parse(body);
242
+ if (!/^https?:\/\//i.test(url)) throw new Error('URL must start with http:// or https://');
243
+ res.end(JSON.stringify(await scan(url, bestPractice)));
244
+ } catch (e) {
245
+ res.statusCode = 400;
246
+ res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
247
+ }
248
+ return;
249
+ }
250
+ if (req.method === 'POST' && pathname === '/api/compare') {
251
+ let body = '';
252
+ for await (const c of req) body += c;
253
+ res.setHeader('Content-Type', 'application/json');
254
+ try {
255
+ const payload = JSON.parse(body);
256
+ const { compareVisuals } = await import('./compare.js');
257
+ const result = await compareVisuals(payload);
258
+ res.end(JSON.stringify(result));
259
+ } catch (e) {
260
+ res.statusCode = 400;
261
+ res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
262
+ }
263
+ return;
264
+ }
265
+ // Review decisions for UI Check findings, remembered per reference/target URL pair.
266
+ if (pathname === '/api/reviews') {
267
+ res.setHeader('Content-Type', 'application/json');
268
+ try {
269
+ if (req.method === 'GET') {
270
+ const key = reviewKey(parsedUrl.searchParams.get('ref'), parsedUrl.searchParams.get('tgt'));
271
+ return res.end(JSON.stringify(readReviews()[key] || {}));
272
+ }
273
+ if (req.method === 'POST') {
274
+ let body = '';
275
+ for await (const c of req) body += c;
276
+ const p = JSON.parse(body);
277
+ const fp = String(p.fingerprint || '');
278
+ if (!/^[a-z0-9]{1,32}$/.test(fp)) throw new Error('Invalid fingerprint');
279
+ const status = p.status == null ? null : String(p.status);
280
+ if (status !== null && !REVIEW_STATUSES.includes(status)) throw new Error('Invalid status');
281
+ const all = readReviews();
282
+ const key = reviewKey(p.referenceUrl, p.targetUrl);
283
+ const pair = (all[key] = all[key] || {});
284
+ if (status === null && p.note == null) {
285
+ delete pair[fp];
286
+ } else {
287
+ const prev = pair[fp] || {};
288
+ pair[fp] = {
289
+ status: status ?? prev.status ?? null,
290
+ note: p.note != null ? String(p.note).slice(0, 2000) : prev.note || '',
291
+ type: String(p.type || prev.type || '').slice(0, 200),
292
+ at: new Date().toISOString(),
293
+ };
294
+ }
295
+ writeReviews(all);
296
+ return res.end(JSON.stringify(pair));
297
+ }
298
+ res.statusCode = 405;
299
+ return res.end(JSON.stringify({ error: 'Method not allowed' }));
300
+ } catch (e) {
301
+ res.statusCode = 400;
302
+ return res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
303
+ }
304
+ }
305
+ if (req.method === 'POST' && pathname === '/api/ui-check') {
306
+ let body = '';
307
+ for await (const c of req) body += c;
308
+ res.setHeader('Content-Type', 'application/json');
309
+ try {
310
+ const p = JSON.parse(body);
311
+ const list = (v, sep) => String(v || '').split(sep).map((x) => x.trim()).filter(Boolean);
312
+ const { runUiCheck } = await import('./ui-check.js');
313
+ const result = await runUiCheck({
314
+ referenceUrl: String(p.referenceUrl || '').trim(),
315
+ targetUrl: String(p.targetUrl || '').trim(),
316
+ viewports: Array.isArray(p.viewports) && p.viewports.length ? p.viewports : ['desktop'],
317
+ checklist: list(p.checklist, '\n'),
318
+ customWords: list(p.customWords, /[\n,]/),
319
+ ignoreSelectors: list(p.ignoreSelectors, ','),
320
+ spelling: p.spelling === true,
321
+ styling: p.styling !== false,
322
+ pixelDiff: p.pixelDiff === true,
323
+ });
324
+ res.end(JSON.stringify(result));
325
+ } catch (e) {
326
+ res.statusCode = 400;
327
+ res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
328
+ }
329
+ return;
330
+ }
331
+ if (req.method === 'POST' && pathname === '/api/compare-dom') {
332
+ let body = '';
333
+ for await (const c of req) body += c;
334
+ res.setHeader('Content-Type', 'application/json');
335
+ try {
336
+ const p = JSON.parse(body);
337
+ const list = (v, sep) => String(v || '').split(sep).map((x) => x.trim()).filter(Boolean);
338
+ const referenceUrl = String(p.referenceUrl || '').trim();
339
+ const targetUrl = String(p.targetUrl || '').trim();
340
+ if (!referenceUrl || !targetUrl) throw new Error('Reference URL and Target URL are required');
341
+ const ignoreSelectors = list(p.ignoreSelectors, ',');
342
+ const viewport = {
343
+ width: Number(p.viewport?.width) || 1440,
344
+ height: Number(p.viewport?.height) || 900,
345
+ };
346
+ const { compareDom } = await import('./dom-compare.js');
347
+ const { spellCheckUrl } = await import('./ui-check.js');
348
+ const dom = await compareDom({ referenceUrl, actualUrl: targetUrl, viewport, tolerance: p.tolerance, ignoreSelectors });
349
+ const spelling = p.spelling === false ? null
350
+ : await spellCheckUrl({ url: targetUrl, viewport, customWords: list(p.customWords, /[\n,]/), ignoreSelectors });
351
+ res.end(JSON.stringify({ dom, spelling, referenceUrl, targetUrl, viewport }));
352
+ } catch (e) {
353
+ res.statusCode = 400;
354
+ res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
355
+ }
356
+ return;
357
+ }
358
+ res.statusCode = 404;
359
+ res.end('Not found');
360
+ })
361
+ .listen(PORT, '127.0.0.1', () => console.log(`Open http://127.0.0.1:${PORT}`));