tessera-learn 0.2.0 → 0.2.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/AGENTS.md +297 -535
- package/README.md +3 -3
- package/dist/{audit-BA5o0ick.js → audit-B9VHgVjk.js} +195 -42
- package/dist/{audit-BA5o0ick.js.map → audit-B9VHgVjk.js.map} +1 -1
- package/dist/{build-commands-C0OnV-Vg.js → build-commands-D127jw0J.js} +2 -2
- package/dist/{build-commands-C0OnV-Vg.js.map → build-commands-D127jw0J.js.map} +1 -1
- package/dist/{inline-config-CroQ-_2Y.js → inline-config-eHjv9XuA.js} +2 -2
- package/dist/{inline-config-CroQ-_2Y.js.map → inline-config-eHjv9XuA.js.map} +1 -1
- package/dist/plugin/cli.js +27 -9
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin-W_rk3Pit.js → plugin--8H9xQIl.js} +2 -2
- package/dist/{plugin-W_rk3Pit.js.map → plugin--8H9xQIl.js.map} +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +3 -27
- package/src/components/Matching.svelte +4 -26
- package/src/components/MultipleChoice.svelte +8 -27
- package/src/components/QuestionShell.svelte +35 -0
- package/src/components/Sorting.svelte +4 -26
- package/src/plugin/a11y/audit.ts +306 -45
- package/src/plugin/course-root.ts +37 -9
- package/src/runtime/App.svelte +20 -1
- package/src/runtime/adapters/cmi5.ts +5 -14
- package/src/runtime/adapters/index.ts +41 -38
- package/src/runtime/adapters/scorm12.ts +1 -1
- package/src/virtual.d.ts +6 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
5
5
|
import { questionId, shuffle } from './util.js';
|
|
6
|
-
import
|
|
6
|
+
import QuestionShell from './QuestionShell.svelte';
|
|
7
7
|
import ResultIcon from './ResultIcon.svelte';
|
|
8
8
|
import RetryButton from './RetryButton.svelte';
|
|
9
9
|
|
|
@@ -85,10 +85,7 @@
|
|
|
85
85
|
if (!inQuiz) {
|
|
86
86
|
initShuffle();
|
|
87
87
|
} else {
|
|
88
|
-
onMount(
|
|
89
|
-
initShuffle();
|
|
90
|
-
q.setRender(renderQuestion);
|
|
91
|
-
});
|
|
88
|
+
onMount(initShuffle);
|
|
92
89
|
}
|
|
93
90
|
|
|
94
91
|
function handleLeftClick(leftIndex) {
|
|
@@ -166,7 +163,7 @@
|
|
|
166
163
|
}
|
|
167
164
|
</script>
|
|
168
165
|
|
|
169
|
-
{
|
|
166
|
+
<QuestionShell {q} class="tessera-matching" aria-label={question}>
|
|
170
167
|
<p class="tessera-matching-question">{question}</p>
|
|
171
168
|
|
|
172
169
|
<div class="tessera-matching-grid">
|
|
@@ -268,28 +265,9 @@
|
|
|
268
265
|
{/if}
|
|
269
266
|
</div>
|
|
270
267
|
{/if}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
{#if !inQuiz}
|
|
274
|
-
<div class="tessera-matching" aria-label={question}>
|
|
275
|
-
{@render matchingContent()}
|
|
276
|
-
</div>
|
|
277
|
-
{/if}
|
|
278
|
-
|
|
279
|
-
{#snippet renderQuestion()}
|
|
280
|
-
<div class="tessera-matching" aria-label={question}>
|
|
281
|
-
{#if q.isLockedCorrect}
|
|
282
|
-
<LockedBanner />
|
|
283
|
-
{/if}
|
|
284
|
-
{@render matchingContent()}
|
|
285
|
-
</div>
|
|
286
|
-
{/snippet}
|
|
268
|
+
</QuestionShell>
|
|
287
269
|
|
|
288
270
|
<style>
|
|
289
|
-
.tessera-matching {
|
|
290
|
-
padding: var(--tessera-spacing-md) 0;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
271
|
.tessera-matching-question {
|
|
294
272
|
font-size: 1.125rem;
|
|
295
273
|
font-weight: 600;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { onMount } from 'svelte';
|
|
3
2
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
4
3
|
import { questionId } from './util.js';
|
|
5
|
-
import
|
|
4
|
+
import QuestionShell from './QuestionShell.svelte';
|
|
6
5
|
import RetryButton from './RetryButton.svelte';
|
|
7
6
|
|
|
8
7
|
let {
|
|
@@ -45,10 +44,6 @@
|
|
|
45
44
|
// `q.mode` is fixed for the lifetime of the widget; capture once.
|
|
46
45
|
const inQuiz = q.mode === 'quiz';
|
|
47
46
|
|
|
48
|
-
onMount(() => {
|
|
49
|
-
if (inQuiz) q.setRender(renderQuestion);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
47
|
function handleSelect(optIndex) {
|
|
53
48
|
if (q.locked) return;
|
|
54
49
|
selectedOption = optIndex;
|
|
@@ -73,7 +68,12 @@
|
|
|
73
68
|
}
|
|
74
69
|
</script>
|
|
75
70
|
|
|
76
|
-
|
|
71
|
+
<QuestionShell
|
|
72
|
+
{q}
|
|
73
|
+
class="tessera-mc"
|
|
74
|
+
role="radiogroup"
|
|
75
|
+
aria-labelledby="{groupId}-label"
|
|
76
|
+
>
|
|
77
77
|
<p class="tessera-mc-question" id="{groupId}-label">{question}</p>
|
|
78
78
|
|
|
79
79
|
<div class="tessera-mc-options">
|
|
@@ -127,28 +127,9 @@
|
|
|
127
127
|
<RetryButton onclick={() => q.retry()} />
|
|
128
128
|
{/if}
|
|
129
129
|
{/if}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{#if !inQuiz}
|
|
133
|
-
<div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
|
|
134
|
-
{@render mcContent()}
|
|
135
|
-
</div>
|
|
136
|
-
{/if}
|
|
137
|
-
|
|
138
|
-
{#snippet renderQuestion()}
|
|
139
|
-
<div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
|
|
140
|
-
{#if q.isLockedCorrect}
|
|
141
|
-
<LockedBanner />
|
|
142
|
-
{/if}
|
|
143
|
-
{@render mcContent()}
|
|
144
|
-
</div>
|
|
145
|
-
{/snippet}
|
|
130
|
+
</QuestionShell>
|
|
146
131
|
|
|
147
132
|
<style>
|
|
148
|
-
.tessera-mc {
|
|
149
|
-
padding: var(--tessera-spacing-md) 0;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
133
|
.tessera-mc-question {
|
|
153
134
|
font-size: 1.125rem;
|
|
154
135
|
font-weight: 600;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import LockedBanner from './LockedBanner.svelte';
|
|
4
|
+
|
|
5
|
+
// Shared dual-render wrapper for the question widgets: inline when
|
|
6
|
+
// standalone, a snippet the Quiz shell renders when inside a quiz.
|
|
7
|
+
let { q, class: className = '', children, ...rest } = $props();
|
|
8
|
+
|
|
9
|
+
const inQuiz = $derived(q.mode === 'quiz');
|
|
10
|
+
|
|
11
|
+
onMount(() => {
|
|
12
|
+
if (inQuiz) q.setRender(quizContent);
|
|
13
|
+
});
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
{#if !inQuiz}
|
|
17
|
+
<div class="tessera-question-shell {className}" {...rest}>
|
|
18
|
+
{@render children?.()}
|
|
19
|
+
</div>
|
|
20
|
+
{/if}
|
|
21
|
+
|
|
22
|
+
{#snippet quizContent()}
|
|
23
|
+
<div class="tessera-question-shell {className}" {...rest}>
|
|
24
|
+
{#if q.isLockedCorrect}
|
|
25
|
+
<LockedBanner />
|
|
26
|
+
{/if}
|
|
27
|
+
{@render children?.()}
|
|
28
|
+
</div>
|
|
29
|
+
{/snippet}
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.tessera-question-shell {
|
|
33
|
+
padding: var(--tessera-spacing-md) 0;
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
5
5
|
import { questionId, shuffle } from './util.js';
|
|
6
|
-
import
|
|
6
|
+
import QuestionShell from './QuestionShell.svelte';
|
|
7
7
|
import ResultIcon from './ResultIcon.svelte';
|
|
8
8
|
import RetryButton from './RetryButton.svelte';
|
|
9
9
|
|
|
@@ -77,10 +77,7 @@
|
|
|
77
77
|
if (!inQuiz) {
|
|
78
78
|
initQueue();
|
|
79
79
|
} else {
|
|
80
|
-
onMount(
|
|
81
|
-
initQueue();
|
|
82
|
-
q.setRender(renderQuestion);
|
|
83
|
-
});
|
|
80
|
+
onMount(initQueue);
|
|
84
81
|
}
|
|
85
82
|
|
|
86
83
|
let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
|
|
@@ -176,7 +173,7 @@
|
|
|
176
173
|
}
|
|
177
174
|
</script>
|
|
178
175
|
|
|
179
|
-
{
|
|
176
|
+
<QuestionShell {q} class="tessera-sorting" aria-label={question}>
|
|
180
177
|
<p class="tessera-sorting-question">{question}</p>
|
|
181
178
|
|
|
182
179
|
<!-- Card deck: shows the current card to be placed -->
|
|
@@ -321,28 +318,9 @@
|
|
|
321
318
|
</button>
|
|
322
319
|
</div>
|
|
323
320
|
{/if}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
{#if !inQuiz}
|
|
327
|
-
<div class="tessera-sorting" aria-label={question}>
|
|
328
|
-
{@render sortingContent()}
|
|
329
|
-
</div>
|
|
330
|
-
{/if}
|
|
331
|
-
|
|
332
|
-
{#snippet renderQuestion()}
|
|
333
|
-
<div class="tessera-sorting" aria-label={question}>
|
|
334
|
-
{#if q.isLockedCorrect}
|
|
335
|
-
<LockedBanner />
|
|
336
|
-
{/if}
|
|
337
|
-
{@render sortingContent()}
|
|
338
|
-
</div>
|
|
339
|
-
{/snippet}
|
|
321
|
+
</QuestionShell>
|
|
340
322
|
|
|
341
323
|
<style>
|
|
342
|
-
.tessera-sorting {
|
|
343
|
-
padding: var(--tessera-spacing-md) 0;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
324
|
.tessera-sorting-question {
|
|
347
325
|
font-size: 1.125rem;
|
|
348
326
|
font-weight: 600;
|
package/src/plugin/a11y/audit.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
3
5
|
import { generateManifest, readCourseConfig } from '../manifest.js';
|
|
4
6
|
import { normalizeA11y, type A11ySettings } from '../validation.js';
|
|
5
7
|
|
|
@@ -23,24 +25,35 @@ const IMPACT_RANK: Record<ImpactLevel, number> = {
|
|
|
23
25
|
// skips export packaging, and stubs xAPI while it's set. See plugin/index.ts.
|
|
24
26
|
export const AUDIT_ENV_FLAG = 'TESSERA_A11Y_AUDIT';
|
|
25
27
|
|
|
28
|
+
export interface AxeNodeDetail {
|
|
29
|
+
target: string;
|
|
30
|
+
html: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
interface AxeViolation {
|
|
27
35
|
id: string;
|
|
28
36
|
impact: ImpactLevel | null;
|
|
29
37
|
help: string;
|
|
30
38
|
helpUrl: string;
|
|
31
39
|
nodes: number;
|
|
40
|
+
elements: AxeNodeDetail[];
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
interface PageAuditResult {
|
|
35
44
|
index: number;
|
|
36
45
|
title: string;
|
|
37
46
|
violations: AxeViolation[];
|
|
47
|
+
loadFailed?: boolean;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
interface AuditReport {
|
|
41
51
|
standard: A11ySettings['standard'];
|
|
42
52
|
threshold: ImpactLevel;
|
|
43
53
|
pages: PageAuditResult[];
|
|
54
|
+
pagesAudited: number;
|
|
55
|
+
totalPages: number;
|
|
56
|
+
pagesFailedToLoad: number;
|
|
44
57
|
totalViolations: number;
|
|
45
58
|
failingViolations: number;
|
|
46
59
|
passed: boolean;
|
|
@@ -66,10 +79,210 @@ export function axeIgnoreRules(ignore: string[]): string[] {
|
|
|
66
79
|
);
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
const MAX_HTML_LENGTH = 200;
|
|
83
|
+
const MAX_ELEMENTS_SHOWN = 5;
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
export function mapNodeDetail(node: any): AxeNodeDetail {
|
|
87
|
+
const target = Array.isArray(node?.target)
|
|
88
|
+
? node.target.flat(Infinity).join(' ')
|
|
89
|
+
: String(node?.target ?? '');
|
|
90
|
+
const html = String(node?.html ?? '');
|
|
91
|
+
return {
|
|
92
|
+
target,
|
|
93
|
+
html:
|
|
94
|
+
html.length > MAX_HTML_LENGTH
|
|
95
|
+
? `${html.slice(0, MAX_HTML_LENGTH - 1)}…`
|
|
96
|
+
: html,
|
|
97
|
+
summary: String(node?.failureSummary ?? '')
|
|
98
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
99
|
+
.trim(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
export function mapViolation(v: any): AxeViolation {
|
|
105
|
+
return {
|
|
106
|
+
id: v.id,
|
|
107
|
+
impact: v.impact ?? null,
|
|
108
|
+
help: v.help,
|
|
109
|
+
helpUrl: v.helpUrl,
|
|
110
|
+
nodes: v.nodes.length,
|
|
111
|
+
elements: v.nodes.map(mapNodeDetail),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
69
115
|
export function isMissingBrowserError(message: string): boolean {
|
|
70
116
|
return /Executable doesn't exist|playwright install/i.test(message);
|
|
71
117
|
}
|
|
72
118
|
|
|
119
|
+
// A missing-deps failure also mentions `playwright install-deps`, so it matches
|
|
120
|
+
// isMissingBrowserError; detect it first to route to the `--with-deps` fix.
|
|
121
|
+
export function isMissingDepsError(message: string): boolean {
|
|
122
|
+
return /Host system is missing dependencies|missing dependencies to run browser/i.test(
|
|
123
|
+
message,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const INSTALL_CHROMIUM = 'pnpm exec playwright install chromium';
|
|
128
|
+
const PLAYWRIGHT_SPECS = ['playwright', '@playwright/test'] as const;
|
|
129
|
+
|
|
130
|
+
function reportManualInstall(lead: string): void {
|
|
131
|
+
console.error(
|
|
132
|
+
`\x1b[31m[tessera a11y]\x1b[0m ${lead}\n` +
|
|
133
|
+
` Install it once:\n` +
|
|
134
|
+
` ${INSTALL_CHROMIUM}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type SpawnFn = (
|
|
139
|
+
command: string,
|
|
140
|
+
args: string[],
|
|
141
|
+
options: SpawnOptions,
|
|
142
|
+
) => {
|
|
143
|
+
on(event: 'error', listener: (err: Error) => void): unknown;
|
|
144
|
+
on(event: 'exit', listener: (code: number | null) => void): unknown;
|
|
145
|
+
kill?(signal?: NodeJS.Signals): unknown;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function resolvePlaywrightBin():
|
|
149
|
+
| { command: string; args: string[] }
|
|
150
|
+
| undefined {
|
|
151
|
+
const require = createRequire(import.meta.url);
|
|
152
|
+
for (const spec of PLAYWRIGHT_SPECS) {
|
|
153
|
+
try {
|
|
154
|
+
const pkgPath = require.resolve(`${spec}/package.json`);
|
|
155
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
|
|
156
|
+
bin?: string | Record<string, string>;
|
|
157
|
+
};
|
|
158
|
+
const binRel =
|
|
159
|
+
typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.playwright;
|
|
160
|
+
if (!binRel) continue;
|
|
161
|
+
return {
|
|
162
|
+
command: process.execPath,
|
|
163
|
+
args: [resolve(dirname(pkgPath), binRel), 'install', 'chromium'],
|
|
164
|
+
};
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
173
|
+
|
|
174
|
+
// Run the workspace's own `playwright` bin with the current Node binary so the
|
|
175
|
+
// install is package-manager-agnostic. No --with-deps: it needs sudo on Linux.
|
|
176
|
+
export async function installChromium(
|
|
177
|
+
workspaceRoot: string,
|
|
178
|
+
spawnFn: SpawnFn = spawn,
|
|
179
|
+
timeoutMs: number = INSTALL_TIMEOUT_MS,
|
|
180
|
+
): Promise<boolean> {
|
|
181
|
+
const bin = resolvePlaywrightBin();
|
|
182
|
+
if (!bin) {
|
|
183
|
+
console.error(
|
|
184
|
+
`\x1b[31m[tessera a11y]\x1b[0m Could not locate the Playwright CLI to install Chromium.`,
|
|
185
|
+
);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Promise<boolean>((resolvePromise) => {
|
|
190
|
+
const child = spawnFn(bin.command, bin.args, {
|
|
191
|
+
stdio: 'inherit',
|
|
192
|
+
cwd: workspaceRoot,
|
|
193
|
+
});
|
|
194
|
+
let settled = false;
|
|
195
|
+
const finish = (ok: boolean) => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
settled = true;
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
resolvePromise(ok);
|
|
200
|
+
};
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
console.error(
|
|
203
|
+
`\x1b[31m[tessera a11y]\x1b[0m Chromium install timed out after ${Math.round(
|
|
204
|
+
timeoutMs / 60_000,
|
|
205
|
+
)} min; aborting.`,
|
|
206
|
+
);
|
|
207
|
+
child.kill?.('SIGKILL');
|
|
208
|
+
finish(false);
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
timer.unref?.();
|
|
211
|
+
child.on('error', (err) => {
|
|
212
|
+
console.error(
|
|
213
|
+
`\x1b[31m[tessera a11y]\x1b[0m Failed to start the Chromium install: ${err.message}`,
|
|
214
|
+
);
|
|
215
|
+
finish(false);
|
|
216
|
+
});
|
|
217
|
+
child.on('exit', (code) => finish(code === 0));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
222
|
+
type LaunchResult = { ok: true; browser: any } | { ok: false; code: number };
|
|
223
|
+
|
|
224
|
+
function reportLaunchFailure(message: string, isLinux: boolean): void {
|
|
225
|
+
console.error(
|
|
226
|
+
`\x1b[31m[tessera a11y]\x1b[0m Chromium is installed but failed to launch.\n` +
|
|
227
|
+
(isLinux
|
|
228
|
+
? ` Install system dependencies:\n pnpm exec playwright install --with-deps chromium\n`
|
|
229
|
+
: ``) +
|
|
230
|
+
` Original error: ${message}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Owns the catch → install → guarded-retry around chromium.launch(). The retry
|
|
235
|
+
// is guarded because a binary-only install can still fail to launch (on Linux,
|
|
236
|
+
// for want of system libs) rather than throw a raw error post-download.
|
|
237
|
+
export async function launchWithInstall({
|
|
238
|
+
launch,
|
|
239
|
+
install,
|
|
240
|
+
isLinux = process.platform === 'linux',
|
|
241
|
+
}: {
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
|
+
launch: () => Promise<any>;
|
|
244
|
+
install: () => Promise<boolean>;
|
|
245
|
+
isLinux?: boolean;
|
|
246
|
+
}): Promise<LaunchResult> {
|
|
247
|
+
try {
|
|
248
|
+
return { ok: true, browser: await launch() };
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
if (isMissingDepsError(message)) {
|
|
252
|
+
reportLaunchFailure(message, isLinux);
|
|
253
|
+
return { ok: false, code: 1 };
|
|
254
|
+
}
|
|
255
|
+
if (!isMissingBrowserError(message)) throw err;
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
"[tessera a11y] Chromium isn't installed for Playwright. Installing it once now…",
|
|
259
|
+
);
|
|
260
|
+
const installed = await install();
|
|
261
|
+
if (!installed) {
|
|
262
|
+
reportManualInstall("Chromium isn't installed for Playwright.");
|
|
263
|
+
return { ok: false, code: 1 };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
return { ok: true, browser: await launch() };
|
|
268
|
+
} catch (retryErr) {
|
|
269
|
+
const retryMessage =
|
|
270
|
+
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
271
|
+
if (
|
|
272
|
+
isMissingBrowserError(retryMessage) &&
|
|
273
|
+
!isMissingDepsError(retryMessage)
|
|
274
|
+
) {
|
|
275
|
+
reportManualInstall(
|
|
276
|
+
"Chromium still isn't installed after the install step.",
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
reportLaunchFailure(retryMessage, isLinux);
|
|
280
|
+
}
|
|
281
|
+
return { ok: false, code: 1 };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
73
286
|
// A violation with no impact is treated as failing rather than slipping the
|
|
74
287
|
// gate at every threshold.
|
|
75
288
|
function isFailing(v: AxeViolation, thresholdRank: number): boolean {
|
|
@@ -94,7 +307,7 @@ async function loadDeps(): Promise<
|
|
|
94
307
|
> {
|
|
95
308
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
309
|
let chromium: any;
|
|
97
|
-
for (const spec of
|
|
310
|
+
for (const spec of PLAYWRIGHT_SPECS) {
|
|
98
311
|
try {
|
|
99
312
|
const mod = (await tryImport(spec)) as { chromium?: unknown };
|
|
100
313
|
if (mod.chromium) {
|
|
@@ -136,7 +349,7 @@ export async function runAudit(
|
|
|
136
349
|
`\x1b[31m[tessera a11y]\x1b[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n` +
|
|
137
350
|
` Install them to run the runtime audit:\n` +
|
|
138
351
|
` pnpm add -D playwright @axe-core/playwright\n` +
|
|
139
|
-
`
|
|
352
|
+
` ${INSTALL_CHROMIUM}`,
|
|
140
353
|
);
|
|
141
354
|
return 1;
|
|
142
355
|
}
|
|
@@ -196,21 +409,12 @@ export async function runAudit(
|
|
|
196
409
|
return 1;
|
|
197
410
|
}
|
|
198
411
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
console.error(
|
|
206
|
-
`\x1b[31m[tessera a11y]\x1b[0m Chromium isn't installed for Playwright.\n` +
|
|
207
|
-
` Install it once:\n` +
|
|
208
|
-
` pnpm exec playwright install chromium`,
|
|
209
|
-
);
|
|
210
|
-
return 1;
|
|
211
|
-
}
|
|
212
|
-
throw err;
|
|
213
|
-
}
|
|
412
|
+
const launched = await launchWithInstall({
|
|
413
|
+
launch: () => chromium.launch(),
|
|
414
|
+
install: () => installChromium(workspaceRoot),
|
|
415
|
+
});
|
|
416
|
+
if (!launched.ok) return launched.code;
|
|
417
|
+
const browser = launched.browser;
|
|
214
418
|
const pages: PageAuditResult[] = [];
|
|
215
419
|
try {
|
|
216
420
|
// axe-core/playwright requires a page from an explicit context.
|
|
@@ -226,39 +430,54 @@ export async function runAudit(
|
|
|
226
430
|
const builder = new AxeBuilder({ page }).withTags(tags);
|
|
227
431
|
if (disableRules.length > 0) builder.disableRules(disableRules);
|
|
228
432
|
const out = await builder.analyze();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
433
|
+
return out.violations.map(mapViolation);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const recordPage = async (
|
|
437
|
+
index: number,
|
|
438
|
+
title: string,
|
|
439
|
+
): Promise<PageAuditResult> => {
|
|
440
|
+
const loadFailed = await page.evaluate(
|
|
441
|
+
() =>
|
|
442
|
+
document.getElementById('tessera-app')?.dataset.tesseraPageError ===
|
|
443
|
+
'true',
|
|
444
|
+
);
|
|
445
|
+
if (loadFailed) return { index, title, violations: [], loadFailed };
|
|
446
|
+
return { index, title, violations: await scan() };
|
|
237
447
|
};
|
|
238
448
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
449
|
+
const totalPages = manifest.pages.length;
|
|
450
|
+
const hasAuditHook = await page.evaluate(
|
|
451
|
+
() => typeof window.__tesseraAudit?.goToIndex === 'function',
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (!hasAuditHook) {
|
|
455
|
+
// No navigation hook — audit the entry only, but flag the reduced scope
|
|
456
|
+
// rather than passing it off as full coverage.
|
|
457
|
+
if (totalPages > 1) {
|
|
458
|
+
console.warn(
|
|
459
|
+
`\x1b[33m[tessera a11y]\x1b[0m Could not enumerate pages; auditing the entry page only ` +
|
|
460
|
+
`(1 of ${totalPages}). The report records the reduced scope.`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
pages.push(await recordPage(0, manifest.pages[0]?.title ?? '(entry)'));
|
|
247
464
|
} else {
|
|
248
|
-
for (let i = 0; i <
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
465
|
+
for (let i = 0; i < totalPages; i++) {
|
|
466
|
+
await page.evaluate(
|
|
467
|
+
(idx: number) => window.__tesseraAudit!.goToIndex(idx),
|
|
468
|
+
i,
|
|
469
|
+
);
|
|
252
470
|
await page.waitForFunction(
|
|
253
471
|
(idx: number) =>
|
|
254
|
-
document
|
|
255
|
-
.
|
|
256
|
-
[idx]?.getAttribute('aria-current') === 'page',
|
|
472
|
+
document.getElementById('tessera-app')?.dataset
|
|
473
|
+
.tesseraPageIndex === String(idx),
|
|
257
474
|
i,
|
|
258
475
|
{ timeout: 20_000 },
|
|
259
476
|
);
|
|
260
477
|
await page.waitForLoadState('networkidle');
|
|
261
|
-
pages.push(
|
|
478
|
+
pages.push(
|
|
479
|
+
await recordPage(i, manifest.pages[i]?.title ?? `Page ${i + 1}`),
|
|
480
|
+
);
|
|
262
481
|
}
|
|
263
482
|
}
|
|
264
483
|
} finally {
|
|
@@ -268,7 +487,9 @@ export async function runAudit(
|
|
|
268
487
|
const thresholdRank = IMPACT_RANK[threshold];
|
|
269
488
|
let totalViolations = 0;
|
|
270
489
|
let failingViolations = 0;
|
|
490
|
+
let pagesFailedToLoad = 0;
|
|
271
491
|
for (const p of pages) {
|
|
492
|
+
if (p.loadFailed) pagesFailedToLoad++;
|
|
272
493
|
for (const v of p.violations) {
|
|
273
494
|
totalViolations++;
|
|
274
495
|
if (isFailing(v, thresholdRank)) failingViolations++;
|
|
@@ -279,9 +500,12 @@ export async function runAudit(
|
|
|
279
500
|
standard: settings.standard,
|
|
280
501
|
threshold,
|
|
281
502
|
pages,
|
|
503
|
+
pagesAudited: pages.length,
|
|
504
|
+
totalPages: manifest.pages.length,
|
|
505
|
+
pagesFailedToLoad,
|
|
282
506
|
totalViolations,
|
|
283
507
|
failingViolations,
|
|
284
|
-
passed: failingViolations === 0,
|
|
508
|
+
passed: failingViolations === 0 && pagesFailedToLoad === 0,
|
|
285
509
|
};
|
|
286
510
|
const reportPath = resolve(projectRoot, 'a11y-report.json');
|
|
287
511
|
writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
@@ -305,6 +529,10 @@ export async function runAudit(
|
|
|
305
529
|
function printSummary(report: AuditReport, reportPath: string): void {
|
|
306
530
|
const thresholdRank = IMPACT_RANK[report.threshold];
|
|
307
531
|
for (const p of report.pages) {
|
|
532
|
+
if (p.loadFailed) {
|
|
533
|
+
console.log(`\x1b[31m ✗\x1b[0m ${p.title} — failed to load`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
308
536
|
if (p.violations.length === 0) {
|
|
309
537
|
console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
|
|
310
538
|
continue;
|
|
@@ -316,16 +544,49 @@ function printSummary(report: AuditReport, reportPath: string): void {
|
|
|
316
544
|
console.log(
|
|
317
545
|
` [${v.impact ?? 'n/a'}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? '' : 's'})`,
|
|
318
546
|
);
|
|
547
|
+
for (const el of v.elements.slice(0, MAX_ELEMENTS_SHOWN)) {
|
|
548
|
+
console.log(
|
|
549
|
+
`\x1b[90m → ${el.target || '(unknown element)'}\x1b[0m`,
|
|
550
|
+
);
|
|
551
|
+
if (el.summary) console.log(`\x1b[90m ${el.summary}\x1b[0m`);
|
|
552
|
+
}
|
|
553
|
+
const hidden = v.elements.length - MAX_ELEMENTS_SHOWN;
|
|
554
|
+
if (hidden > 0) {
|
|
555
|
+
console.log(
|
|
556
|
+
`\x1b[90m … and ${hidden} more — see a11y-report.json\x1b[0m`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
319
559
|
}
|
|
320
560
|
}
|
|
321
561
|
console.log(`\n[tessera a11y] Report written to ${reportPath}`);
|
|
562
|
+
if (report.pagesAudited < report.totalPages) {
|
|
563
|
+
console.log(
|
|
564
|
+
`\x1b[33m[tessera a11y] Covered ${report.pagesAudited} of ${report.totalPages} page(s)\x1b[0m — reduced scope, the rest were not audited.`,
|
|
565
|
+
);
|
|
566
|
+
} else if (report.pagesFailedToLoad > 0) {
|
|
567
|
+
const scanned = report.pagesAudited - report.pagesFailedToLoad;
|
|
568
|
+
console.log(
|
|
569
|
+
`[tessera a11y] Reached all ${report.totalPages} page(s); scanned ${scanned}, ${report.pagesFailedToLoad} failed to load.`,
|
|
570
|
+
);
|
|
571
|
+
} else {
|
|
572
|
+
console.log(`[tessera a11y] Covered all ${report.totalPages} page(s).`);
|
|
573
|
+
}
|
|
322
574
|
if (report.passed) {
|
|
323
575
|
console.log(
|
|
324
576
|
`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`,
|
|
325
577
|
);
|
|
326
578
|
} else {
|
|
579
|
+
const reasons: string[] = [];
|
|
580
|
+
if (report.failingViolations > 0) {
|
|
581
|
+
reasons.push(
|
|
582
|
+
`${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total)`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (report.pagesFailedToLoad > 0) {
|
|
586
|
+
reasons.push(`${report.pagesFailedToLoad} page(s) failed to load`);
|
|
587
|
+
}
|
|
327
588
|
console.log(
|
|
328
|
-
`\x1b[31m[tessera a11y] Failed\x1b[0m — ${
|
|
589
|
+
`\x1b[31m[tessera a11y] Failed\x1b[0m — ${reasons.join('; ')}.`,
|
|
329
590
|
);
|
|
330
591
|
}
|
|
331
592
|
}
|