create-backlist 10.0.4 → 10.0.6
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/bin/index.js +989 -713
- package/package.json +1 -1
- package/src/qa/analyzers/accessibility.js +157 -39
- package/src/qa/analyzers/performance.js +89 -102
- package/src/qa/browser/crawler.js +248 -166
- package/src/qa/browser/installer.js +209 -0
- package/src/qa/browser/interactions.js +222 -219
- package/src/qa/qa-engine.js +60 -19
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Accessibility checker with HTTP fallback
|
|
2
|
+
import { getBrowserLaunchOptions } from '../browser/installer.js';
|
|
3
|
+
|
|
2
4
|
export class AccessibilityChecker {
|
|
3
5
|
#playwright;
|
|
4
6
|
#session;
|
|
@@ -9,73 +11,189 @@ export class AccessibilityChecker {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
async check(url) {
|
|
12
|
-
const
|
|
14
|
+
const launchOpts = await getBrowserLaunchOptions();
|
|
15
|
+
|
|
16
|
+
if (!launchOpts.available) {
|
|
17
|
+
return this.#httpFallback(url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let playwright;
|
|
21
|
+
try { playwright = await import('playwright'); }
|
|
22
|
+
catch { return this.#httpFallback(url); }
|
|
23
|
+
|
|
24
|
+
const { executablePath, headless, args } = launchOpts;
|
|
25
|
+
let browser;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
browser = await playwright.chromium.launch({ executablePath, headless, args });
|
|
29
|
+
} catch {
|
|
30
|
+
return this.#httpFallback(url);
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
14
34
|
const page = await context.newPage();
|
|
15
35
|
|
|
16
36
|
try {
|
|
17
37
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
18
38
|
|
|
19
|
-
// Inject axe-core from CDN for real WCAG testing
|
|
20
39
|
await page.addScriptTag({
|
|
21
40
|
url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js',
|
|
22
41
|
});
|
|
23
42
|
|
|
24
|
-
// Run real axe analysis
|
|
25
43
|
const axeResults = await page.evaluate(async () => {
|
|
26
44
|
return await window.axe.run(document, {
|
|
27
|
-
runOnly: {
|
|
28
|
-
type: 'tag',
|
|
29
|
-
values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice'],
|
|
30
|
-
},
|
|
45
|
+
runOnly: { type: 'tag', values: ['wcag2a','wcag2aa','wcag21aa','best-practice'] },
|
|
31
46
|
});
|
|
32
47
|
});
|
|
33
48
|
|
|
34
49
|
const violations = axeResults.violations.map(v => ({
|
|
35
|
-
id
|
|
36
|
-
description
|
|
37
|
-
help
|
|
38
|
-
helpUrl
|
|
39
|
-
impact
|
|
40
|
-
tags
|
|
41
|
-
category
|
|
42
|
-
nodes
|
|
43
|
-
affectedNodes: v.nodes.slice(0, 3).map(n => ({
|
|
44
|
-
html : n.html,
|
|
45
|
-
target : n.target,
|
|
46
|
-
message: n.failureSummary,
|
|
47
|
-
})),
|
|
50
|
+
id : v.id,
|
|
51
|
+
description: v.description,
|
|
52
|
+
help : v.help,
|
|
53
|
+
helpUrl : v.helpUrl,
|
|
54
|
+
impact : v.impact,
|
|
55
|
+
tags : v.tags,
|
|
56
|
+
category : v.tags.find(t => t.startsWith('wcag')) || 'best-practice',
|
|
57
|
+
nodes : v.nodes.length,
|
|
58
|
+
affectedNodes: v.nodes.slice(0, 3).map(n => ({ html: n.html, target: n.target, message: n.failureSummary })),
|
|
48
59
|
}));
|
|
49
60
|
|
|
50
61
|
const passes = axeResults.passes.map(p => ({
|
|
51
|
-
id
|
|
52
|
-
description: p.description,
|
|
53
|
-
nodes : p.nodes.length,
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
const incomplete = axeResults.incomplete.map(i => ({
|
|
57
|
-
id : i.id,
|
|
58
|
-
description: i.description,
|
|
59
|
-
nodes : i.nodes.length,
|
|
62
|
+
id: p.id, description: p.description, nodes: p.nodes.length,
|
|
60
63
|
}));
|
|
61
64
|
|
|
62
65
|
return {
|
|
63
|
-
pass
|
|
64
|
-
violations,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
? Math.round((passes.length / (passes.length + violations.length)) * 100)
|
|
69
|
-
: 0,
|
|
70
|
-
url,
|
|
66
|
+
pass : violations.length === 0,
|
|
67
|
+
violations, passes,
|
|
68
|
+
incomplete: axeResults.incomplete.map(i => ({ id: i.id, description: i.description, nodes: i.nodes.length })),
|
|
69
|
+
score : passes.length > 0 ? Math.round((passes.length / (passes.length + violations.length)) * 100) : 0,
|
|
70
|
+
url, mode : 'browser-axe',
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
} catch (err) {
|
|
74
|
-
return { pass: false, violations: [], passes: [], incomplete: [], error: err.message, url };
|
|
74
|
+
return { pass: false, violations: [], passes: [], incomplete: [], error: err.message, url, mode: 'browser-error' };
|
|
75
75
|
} finally {
|
|
76
76
|
await page.close().catch(() => {});
|
|
77
77
|
await context.close().catch(() => {});
|
|
78
78
|
await browser.close().catch(() => {});
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
// HTTP fallback — parses raw HTML for common a11y issues
|
|
83
|
+
async #httpFallback(url) {
|
|
84
|
+
try {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(), 12_000);
|
|
87
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
return { pass: false, violations: [], passes: [], url, mode: 'http-fallback', error: `HTTP ${res.status}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const html = await res.text();
|
|
95
|
+
const violations = [];
|
|
96
|
+
const passes = [];
|
|
97
|
+
|
|
98
|
+
// Real HTML analysis checks
|
|
99
|
+
const checks = [
|
|
100
|
+
{
|
|
101
|
+
id : 'html-lang',
|
|
102
|
+
desc : 'HTML element must have a lang attribute',
|
|
103
|
+
help : 'Ensures every HTML document has a lang attribute',
|
|
104
|
+
impact : 'serious',
|
|
105
|
+
test : () => !/<html[^>]+lang=["'][^"']+["']/i.test(html),
|
|
106
|
+
passMsg : 'HTML lang attribute present',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id : 'img-alt',
|
|
110
|
+
desc : 'Images must have alternate text',
|
|
111
|
+
help : 'Ensures <img> elements have alternate text or a role of none/presentation',
|
|
112
|
+
impact : 'critical',
|
|
113
|
+
test : () => /<img(?![^>]*\balt=)[^>]*>/i.test(html),
|
|
114
|
+
passMsg : 'All images have alt attributes',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id : 'document-title',
|
|
118
|
+
desc : 'Documents must have <title>',
|
|
119
|
+
help : 'Ensures every HTML document has a non-empty title element',
|
|
120
|
+
impact : 'serious',
|
|
121
|
+
test : () => !/<title[^>]*>[^<]+<\/title>/i.test(html),
|
|
122
|
+
passMsg : 'Document has a title',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id : 'viewport',
|
|
126
|
+
desc : 'Zoom and scaling must not be disabled',
|
|
127
|
+
help : 'Ensures the viewport meta does not disable text scaling',
|
|
128
|
+
impact : 'critical',
|
|
129
|
+
test : () => /user-scalable=no|maximum-scale=1/i.test(html),
|
|
130
|
+
passMsg : 'Viewport allows scaling',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id : 'region',
|
|
134
|
+
desc : 'Page should have a main landmark',
|
|
135
|
+
help : 'Ensures the page has a <main> element',
|
|
136
|
+
impact : 'moderate',
|
|
137
|
+
test : () => !/<main[^>]*>/i.test(html),
|
|
138
|
+
passMsg : 'Main landmark found',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id : 'heading-order',
|
|
142
|
+
desc : 'Heading levels should not be skipped',
|
|
143
|
+
help : 'Ensures the order of headings is semantically correct',
|
|
144
|
+
impact : 'moderate',
|
|
145
|
+
test : () => !/<h1[^>]*>/i.test(html),
|
|
146
|
+
passMsg : 'H1 heading present',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id : 'label',
|
|
150
|
+
desc : 'Form elements must have labels',
|
|
151
|
+
help : 'Ensures every form element has a label',
|
|
152
|
+
impact : 'critical',
|
|
153
|
+
test : () => /<input(?![^>]*(?:aria-label|aria-labelledby|id=))[^>]*type=(?!"hidden")[^>]*>/i.test(html),
|
|
154
|
+
passMsg : 'Form inputs appear to have labels',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id : 'link-name',
|
|
158
|
+
desc : 'Links must have discernible text',
|
|
159
|
+
help : 'Ensures links have discernible text',
|
|
160
|
+
impact : 'serious',
|
|
161
|
+
test : () => /<a[^>]*>\s*<\/a>/i.test(html),
|
|
162
|
+
passMsg : 'Links appear to have text content',
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
for (const check of checks) {
|
|
167
|
+
if (check.test()) {
|
|
168
|
+
violations.push({
|
|
169
|
+
id : check.id,
|
|
170
|
+
description: check.desc,
|
|
171
|
+
help : check.help,
|
|
172
|
+
impact : check.impact,
|
|
173
|
+
tags : ['wcag2a'],
|
|
174
|
+
category : 'wcag2a',
|
|
175
|
+
nodes : 1,
|
|
176
|
+
affectedNodes: [],
|
|
177
|
+
helpUrl : `https://dequeuniversity.com/rules/axe/4.9/${check.id}`,
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
passes.push({ id: check.id, description: check.passMsg, nodes: 1 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const score = passes.length > 0
|
|
185
|
+
? Math.round((passes.length / (passes.length + violations.length)) * 100)
|
|
186
|
+
: 0;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
pass: violations.length === 0,
|
|
190
|
+
violations, passes, incomplete: [],
|
|
191
|
+
score, url, mode: 'http-html-analysis',
|
|
192
|
+
note: 'Full axe-core WCAG scan requires Playwright browser — run: npx playwright install chromium',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { pass: false, violations: [], passes: [], url, mode: 'http-fallback', error: err.message };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
81
199
|
}
|
|
@@ -1,125 +1,82 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Performance profiler with HTTP fallback
|
|
2
|
+
import { getBrowserLaunchOptions } from '../browser/installer.js';
|
|
3
|
+
import { formatBytes } from '../qa-engine.js';
|
|
4
|
+
|
|
2
5
|
export class PerformanceProfiler {
|
|
3
6
|
#session;
|
|
4
7
|
|
|
5
8
|
constructor(session) { this.#session = session; }
|
|
6
9
|
|
|
7
10
|
async profile(url) {
|
|
11
|
+
const launchOpts = await getBrowserLaunchOptions();
|
|
12
|
+
|
|
13
|
+
if (!launchOpts.available) {
|
|
14
|
+
return this.#httpFallback(url);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
let playwright;
|
|
9
18
|
try { playwright = await import('playwright'); }
|
|
10
|
-
catch { return this.#
|
|
19
|
+
catch { return this.#httpFallback(url); }
|
|
11
20
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
args : ['--no-sandbox'],
|
|
15
|
-
});
|
|
21
|
+
const { executablePath, headless, args } = launchOpts;
|
|
22
|
+
let browser;
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
24
|
+
try {
|
|
25
|
+
browser = await playwright.chromium.launch({ executablePath, headless, args });
|
|
26
|
+
} catch {
|
|
27
|
+
return this.#httpFallback(url);
|
|
28
|
+
}
|
|
21
29
|
|
|
30
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
31
|
+
const page = await context.newPage();
|
|
22
32
|
const slowResources = [];
|
|
23
33
|
const resourceTimings = [];
|
|
24
34
|
|
|
25
35
|
page.on('response', async res => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
status : res.status(),
|
|
33
|
-
duration: Math.round(duration),
|
|
34
|
-
size : 0,
|
|
35
|
-
};
|
|
36
|
-
try {
|
|
37
|
-
const body = await res.body().catch(() => null);
|
|
38
|
-
entry.size = body?.length || 0;
|
|
39
|
-
} catch {}
|
|
36
|
+
try {
|
|
37
|
+
const timing = res.timing();
|
|
38
|
+
if (!timing || timing.responseEnd <= 0) return;
|
|
39
|
+
const duration = Math.round(timing.responseEnd - timing.requestStart);
|
|
40
|
+
const entry = { url: res.url(), type: res.request().resourceType(), status: res.status(), duration, size: 0 };
|
|
41
|
+
try { const body = await res.body(); entry.size = body?.length || 0; } catch {}
|
|
40
42
|
resourceTimings.push(entry);
|
|
41
43
|
if (duration > 2000) slowResources.push(entry);
|
|
42
|
-
}
|
|
44
|
+
} catch {}
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
try {
|
|
46
48
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// FCP + LCP
|
|
58
|
-
const po = new PerformanceObserver(list => {
|
|
59
|
-
for (const entry of list.getEntries()) {
|
|
60
|
-
if (entry.entryType === 'paint') {
|
|
61
|
-
if (entry.name === 'first-contentful-paint') result.fcp = Math.round(entry.startTime);
|
|
62
|
-
}
|
|
63
|
-
if (entry.entryType === 'largest-contentful-paint') {
|
|
64
|
-
result.lcp = Math.round(entry.startTime);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
try { po.observe({ entryTypes: ['paint','largest-contentful-paint'] }); } catch {}
|
|
69
|
-
|
|
70
|
-
// CLS
|
|
71
|
-
let clsValue = 0;
|
|
72
|
-
const clsPo = new PerformanceObserver(list => {
|
|
73
|
-
for (const entry of list.getEntries()) {
|
|
74
|
-
if (!entry.hadRecentInput) clsValue += entry.value;
|
|
50
|
+
const metrics = await page.evaluate(() => new Promise(resolve => {
|
|
51
|
+
const result = { lcp: null, fcp: null, cls: null, fid: null, ttfb: null, tbt: null, memoryUsed: null, domNodes: document.querySelectorAll('*').length };
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
new PerformanceObserver(list => {
|
|
55
|
+
for (const e of list.getEntries()) {
|
|
56
|
+
if (e.entryType === 'paint' && e.name === 'first-contentful-paint') result.fcp = Math.round(e.startTime);
|
|
57
|
+
if (e.entryType === 'largest-contentful-paint') result.lcp = Math.round(e.startTime);
|
|
75
58
|
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// TTFB
|
|
80
|
-
const nav = performance.getEntriesByType('navigation')[0];
|
|
81
|
-
if (nav) result.ttfb = Math.round(nav.responseStart);
|
|
82
|
-
|
|
83
|
-
// Memory
|
|
84
|
-
if (performance.memory) {
|
|
85
|
-
result.memoryUsed = performance.memory.usedJSHeapSize;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
setTimeout(() => {
|
|
89
|
-
result.cls = parseFloat(clsValue.toFixed(4));
|
|
90
|
-
po.disconnect();
|
|
91
|
-
clsPo.disconnect();
|
|
92
|
-
resolve(result);
|
|
93
|
-
}, 5000);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// CPU timing via long task detection
|
|
98
|
-
const longTasks = await page.evaluate(() => {
|
|
99
|
-
const tasks = [];
|
|
100
|
-
const po = new PerformanceObserver(list => {
|
|
101
|
-
tasks.push(...list.getEntries().map(e => ({
|
|
102
|
-
duration : Math.round(e.duration),
|
|
103
|
-
startTime: Math.round(e.startTime),
|
|
104
|
-
})));
|
|
105
|
-
});
|
|
106
|
-
try { po.observe({ entryTypes: ['longtask'] }); } catch {}
|
|
107
|
-
return new Promise(r => setTimeout(() => { po.disconnect(); r(tasks); }, 3000));
|
|
108
|
-
}).catch(() => []);
|
|
109
|
-
|
|
110
|
-
const tbt = longTasks.reduce((sum, t) => sum + Math.max(t.duration - 50, 0), 0);
|
|
59
|
+
}).observe({ entryTypes: ['paint','largest-contentful-paint'] });
|
|
60
|
+
} catch {}
|
|
111
61
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
62
|
+
let cls = 0;
|
|
63
|
+
try {
|
|
64
|
+
new PerformanceObserver(list => {
|
|
65
|
+
for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
|
|
66
|
+
}).observe({ entryTypes: ['layout-shift'] });
|
|
67
|
+
} catch {}
|
|
68
|
+
|
|
69
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
70
|
+
if (nav) result.ttfb = Math.round(nav.responseStart);
|
|
71
|
+
if (performance.memory) result.memoryUsed = performance.memory.usedJSHeapSize;
|
|
72
|
+
|
|
73
|
+
setTimeout(() => { result.cls = parseFloat(cls.toFixed(4)); resolve(result); }, 5000);
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
return { ...metrics, slowResources, resourceTimings: resourceTimings.slice(0, 50), url, mode: 'browser' };
|
|
120
77
|
|
|
121
78
|
} catch (err) {
|
|
122
|
-
return { ...this.#
|
|
79
|
+
return { ...this.#emptyMetrics(), error: err.message, url, mode: 'browser-error' };
|
|
123
80
|
} finally {
|
|
124
81
|
await page.close().catch(() => {});
|
|
125
82
|
await context.close().catch(() => {});
|
|
@@ -127,11 +84,41 @@ export class PerformanceProfiler {
|
|
|
127
84
|
}
|
|
128
85
|
}
|
|
129
86
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
87
|
+
// HTTP-only fallback: measures real TTFB + response time
|
|
88
|
+
async #httpFallback(url) {
|
|
89
|
+
const t0 = Date.now();
|
|
90
|
+
try {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
93
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
const ttfb = Date.now() - t0;
|
|
96
|
+
const body = await res.text().catch(() => '');
|
|
97
|
+
const totalTime = Date.now() - t0;
|
|
98
|
+
const bodySize = new TextEncoder().encode(body).length;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
lcp : null, // requires browser
|
|
102
|
+
fcp : null,
|
|
103
|
+
cls : null,
|
|
104
|
+
fid : null,
|
|
105
|
+
tbt : null,
|
|
106
|
+
ttfb, // real TTFB from HTTP
|
|
107
|
+
totalTime, // real total response time
|
|
108
|
+
bodySize,
|
|
109
|
+
statusCode : res.status,
|
|
110
|
+
slowResources: totalTime > 3000 ? [{ url, duration: totalTime, size: bodySize, type: 'document' }] : [],
|
|
111
|
+
resourceTimings: [],
|
|
112
|
+
url,
|
|
113
|
+
mode : 'http-fallback',
|
|
114
|
+
note : 'LCP/FCP/CLS require Playwright browser — run: npx playwright install chromium',
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ...this.#emptyMetrics(), ttfb: null, error: err.message, url, mode: 'http-fallback' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#emptyMetrics() {
|
|
122
|
+
return { lcp: null, fcp: null, cls: null, fid: null, ttfb: null, tbt: null, slowResources: [], resourceTimings: [] };
|
|
136
123
|
}
|
|
137
124
|
}
|