tessera-learn 0.2.1 → 0.2.3

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.
Files changed (30) hide show
  1. package/AGENTS.md +280 -916
  2. package/README.md +3 -3
  3. package/dist/{audit-BNrvFHq_.js → audit--fSWIOgK.js} +156 -33
  4. package/dist/{audit-BNrvFHq_.js.map → audit--fSWIOgK.js.map} +1 -1
  5. package/dist/{build-commands-BWnATKat.js → build-commands-Qyrlsp3n.js} +2 -2
  6. package/dist/{build-commands-BWnATKat.js.map → build-commands-Qyrlsp3n.js.map} +1 -1
  7. package/dist/{inline-config-Dudu5r8w.js → inline-config-DqAKsCNl.js} +2 -2
  8. package/dist/{inline-config-Dudu5r8w.js.map → inline-config-DqAKsCNl.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +33 -18
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +0 -2
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -2
  15. package/dist/{plugin-diNZaDJK.js → plugin-B-aiL9-V.js} +2 -2
  16. package/dist/{plugin-diNZaDJK.js.map → plugin-B-aiL9-V.js.map} +1 -1
  17. package/package.json +11 -8
  18. package/src/components/FillInTheBlank.svelte +3 -27
  19. package/src/components/Matching.svelte +4 -26
  20. package/src/components/MultipleChoice.svelte +8 -27
  21. package/src/components/QuestionShell.svelte +35 -0
  22. package/src/components/Sorting.svelte +4 -26
  23. package/src/plugin/a11y/audit.ts +239 -39
  24. package/src/plugin/a11y-cli.ts +1 -4
  25. package/src/plugin/cli.ts +2 -3
  26. package/src/plugin/course-root.ts +37 -9
  27. package/src/plugin/validate-cli.ts +10 -4
  28. package/src/runtime/adapters/cmi5.ts +5 -14
  29. package/src/runtime/adapters/index.ts +41 -38
  30. package/src/runtime/adapters/scorm12.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tessera-learn",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web), your choice of components.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -28,6 +28,9 @@
28
28
  "author": "Derek Redmond <derek.redmond@redmondelearning.ca>",
29
29
  "license": "MIT",
30
30
  "type": "module",
31
+ "publishConfig": {
32
+ "provenance": true
33
+ },
31
34
  "files": [
32
35
  "dist",
33
36
  "src",
@@ -76,8 +79,8 @@
76
79
  "acorn": "^8.16.0",
77
80
  "archiver": "^8.0.0",
78
81
  "json5": "^2.0.0",
79
- "svelte": "^5.56.0",
80
- "vite": "^8.0.14"
82
+ "svelte": "^5.56.2",
83
+ "vite": "^8.0.16"
81
84
  },
82
85
  "peerDependencies": {
83
86
  "@axe-core/playwright": ">=4",
@@ -92,14 +95,14 @@
92
95
  }
93
96
  },
94
97
  "devDependencies": {
95
- "@types/node": "^25.9.1",
96
- "@vitest/coverage-v8": "^4.1.7",
98
+ "@types/node": "^25.9.2",
99
+ "@vitest/coverage-v8": "^4.1.8",
97
100
  "jsdom": "^29.0.1",
98
101
  "scorm-again": "3.0.5",
99
- "svelte-check": "^4.4.8",
100
- "tsdown": "^0.22.1",
102
+ "svelte-check": "^4.6.0",
103
+ "tsdown": "^0.22.2",
101
104
  "typescript": "^6.0.3",
102
- "vitest": "^4.1.7"
105
+ "vitest": "^4.1.8"
103
106
  },
104
107
  "scripts": {
105
108
  "build": "tsdown",
@@ -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 LockedBanner from './LockedBanner.svelte';
4
+ import QuestionShell from './QuestionShell.svelte';
6
5
  import ResultIcon from './ResultIcon.svelte';
7
6
  import RetryButton from './RetryButton.svelte';
8
7
 
@@ -56,10 +55,6 @@
56
55
  // `q.mode` is fixed for the lifetime of the widget; capture once.
57
56
  const inQuiz = q.mode === 'quiz';
58
57
 
59
- onMount(() => {
60
- if (inQuiz) q.setRender(renderQuestion);
61
- });
62
-
63
58
  function handleInput(e) {
64
59
  if (q.locked) return;
65
60
  inputValue = e.target.value;
@@ -79,7 +74,7 @@
79
74
  }
80
75
  </script>
81
76
 
82
- {#snippet fitbContent()}
77
+ <QuestionShell {q} class="tessera-fitb">
83
78
  <label class="tessera-fitb-question" for={inputId}>{question}</label>
84
79
 
85
80
  <div class="tessera-fitb-input-wrapper">
@@ -138,28 +133,9 @@
138
133
  {/if}
139
134
  </div>
140
135
  {/if}
141
- {/snippet}
142
-
143
- {#if !inQuiz}
144
- <div class="tessera-fitb">
145
- {@render fitbContent()}
146
- </div>
147
- {/if}
148
-
149
- {#snippet renderQuestion()}
150
- <div class="tessera-fitb">
151
- {#if q.isLockedCorrect}
152
- <LockedBanner />
153
- {/if}
154
- {@render fitbContent()}
155
- </div>
156
- {/snippet}
136
+ </QuestionShell>
157
137
 
158
138
  <style>
159
- .tessera-fitb {
160
- padding: var(--tessera-spacing-md) 0;
161
- }
162
-
163
139
  .tessera-fitb-question {
164
140
  display: block;
165
141
  font-size: 1.125rem;
@@ -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 LockedBanner from './LockedBanner.svelte';
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
- {#snippet matchingContent()}
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
- {/snippet}
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 LockedBanner from './LockedBanner.svelte';
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
- {#snippet mcContent()}
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
- {/snippet}
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 LockedBanner from './LockedBanner.svelte';
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
- {#snippet sortingContent()}
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
- {/snippet}
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;
@@ -1,13 +1,13 @@
1
- import { existsSync, writeFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { spawn, type SpawnOptions } from 'node:child_process';
2
+ import { 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
 
6
8
  export interface AuditOptions {
7
9
  /** Minimum violation impact that fails the run (CI gate). Default 'serious'. */
8
10
  threshold?: ImpactLevel;
9
- /** Force a fresh `vite build` even if dist/ exists. */
10
- rebuild?: boolean;
11
11
  }
12
12
 
13
13
  export type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';
@@ -23,12 +23,19 @@ const IMPACT_RANK: Record<ImpactLevel, number> = {
23
23
  // skips export packaging, and stubs xAPI while it's set. See plugin/index.ts.
24
24
  export const AUDIT_ENV_FLAG = 'TESSERA_A11Y_AUDIT';
25
25
 
26
+ export interface AxeNodeDetail {
27
+ target: string;
28
+ html: string;
29
+ summary: string;
30
+ }
31
+
26
32
  interface AxeViolation {
27
33
  id: string;
28
34
  impact: ImpactLevel | null;
29
35
  help: string;
30
36
  helpUrl: string;
31
37
  nodes: number;
38
+ elements: AxeNodeDetail[];
32
39
  }
33
40
 
34
41
  interface PageAuditResult {
@@ -70,10 +77,210 @@ export function axeIgnoreRules(ignore: string[]): string[] {
70
77
  );
71
78
  }
72
79
 
80
+ const MAX_HTML_LENGTH = 200;
81
+ const MAX_ELEMENTS_SHOWN = 5;
82
+
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ export function mapNodeDetail(node: any): AxeNodeDetail {
85
+ const target = Array.isArray(node?.target)
86
+ ? node.target.flat(Infinity).join(' ')
87
+ : String(node?.target ?? '');
88
+ const html = String(node?.html ?? '');
89
+ return {
90
+ target,
91
+ html:
92
+ html.length > MAX_HTML_LENGTH
93
+ ? `${html.slice(0, MAX_HTML_LENGTH - 1)}…`
94
+ : html,
95
+ summary: String(node?.failureSummary ?? '')
96
+ .replace(/\s*\n\s*/g, ' ')
97
+ .trim(),
98
+ };
99
+ }
100
+
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ export function mapViolation(v: any): AxeViolation {
103
+ return {
104
+ id: v.id,
105
+ impact: v.impact ?? null,
106
+ help: v.help,
107
+ helpUrl: v.helpUrl,
108
+ nodes: v.nodes.length,
109
+ elements: v.nodes.map(mapNodeDetail),
110
+ };
111
+ }
112
+
73
113
  export function isMissingBrowserError(message: string): boolean {
74
114
  return /Executable doesn't exist|playwright install/i.test(message);
75
115
  }
76
116
 
117
+ // A missing-deps failure also mentions `playwright install-deps`, so it matches
118
+ // isMissingBrowserError; detect it first to route to the `--with-deps` fix.
119
+ export function isMissingDepsError(message: string): boolean {
120
+ return /Host system is missing dependencies|missing dependencies to run browser/i.test(
121
+ message,
122
+ );
123
+ }
124
+
125
+ const INSTALL_CHROMIUM = 'pnpm exec playwright install chromium';
126
+ const PLAYWRIGHT_SPECS = ['playwright', '@playwright/test'] as const;
127
+
128
+ function reportManualInstall(lead: string): void {
129
+ console.error(
130
+ `\x1b[31m[tessera a11y]\x1b[0m ${lead}\n` +
131
+ ` Install it once:\n` +
132
+ ` ${INSTALL_CHROMIUM}`,
133
+ );
134
+ }
135
+
136
+ type SpawnFn = (
137
+ command: string,
138
+ args: string[],
139
+ options: SpawnOptions,
140
+ ) => {
141
+ on(event: 'error', listener: (err: Error) => void): unknown;
142
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
143
+ kill?(signal?: NodeJS.Signals): unknown;
144
+ };
145
+
146
+ function resolvePlaywrightBin():
147
+ | { command: string; args: string[] }
148
+ | undefined {
149
+ const require = createRequire(import.meta.url);
150
+ for (const spec of PLAYWRIGHT_SPECS) {
151
+ try {
152
+ const pkgPath = require.resolve(`${spec}/package.json`);
153
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
154
+ bin?: string | Record<string, string>;
155
+ };
156
+ const binRel =
157
+ typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.playwright;
158
+ if (!binRel) continue;
159
+ return {
160
+ command: process.execPath,
161
+ args: [resolve(dirname(pkgPath), binRel), 'install', 'chromium'],
162
+ };
163
+ } catch {
164
+ continue;
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
171
+
172
+ // Run the workspace's own `playwright` bin with the current Node binary so the
173
+ // install is package-manager-agnostic. No --with-deps: it needs sudo on Linux.
174
+ export async function installChromium(
175
+ workspaceRoot: string,
176
+ spawnFn: SpawnFn = spawn,
177
+ timeoutMs: number = INSTALL_TIMEOUT_MS,
178
+ ): Promise<boolean> {
179
+ const bin = resolvePlaywrightBin();
180
+ if (!bin) {
181
+ console.error(
182
+ `\x1b[31m[tessera a11y]\x1b[0m Could not locate the Playwright CLI to install Chromium.`,
183
+ );
184
+ return false;
185
+ }
186
+
187
+ return new Promise<boolean>((resolvePromise) => {
188
+ const child = spawnFn(bin.command, bin.args, {
189
+ stdio: 'inherit',
190
+ cwd: workspaceRoot,
191
+ });
192
+ let settled = false;
193
+ const finish = (ok: boolean) => {
194
+ if (settled) return;
195
+ settled = true;
196
+ clearTimeout(timer);
197
+ resolvePromise(ok);
198
+ };
199
+ const timer = setTimeout(() => {
200
+ console.error(
201
+ `\x1b[31m[tessera a11y]\x1b[0m Chromium install timed out after ${Math.round(
202
+ timeoutMs / 60_000,
203
+ )} min; aborting.`,
204
+ );
205
+ child.kill?.('SIGKILL');
206
+ finish(false);
207
+ }, timeoutMs);
208
+ timer.unref?.();
209
+ child.on('error', (err) => {
210
+ console.error(
211
+ `\x1b[31m[tessera a11y]\x1b[0m Failed to start the Chromium install: ${err.message}`,
212
+ );
213
+ finish(false);
214
+ });
215
+ child.on('exit', (code) => finish(code === 0));
216
+ });
217
+ }
218
+
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ type LaunchResult = { ok: true; browser: any } | { ok: false; code: number };
221
+
222
+ function reportLaunchFailure(message: string, isLinux: boolean): void {
223
+ console.error(
224
+ `\x1b[31m[tessera a11y]\x1b[0m Chromium is installed but failed to launch.\n` +
225
+ (isLinux
226
+ ? ` Install system dependencies:\n pnpm exec playwright install --with-deps chromium\n`
227
+ : ``) +
228
+ ` Original error: ${message}`,
229
+ );
230
+ }
231
+
232
+ // Owns the catch → install → guarded-retry around chromium.launch(). The retry
233
+ // is guarded because a binary-only install can still fail to launch (on Linux,
234
+ // for want of system libs) rather than throw a raw error post-download.
235
+ export async function launchWithInstall({
236
+ launch,
237
+ install,
238
+ isLinux = process.platform === 'linux',
239
+ }: {
240
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
241
+ launch: () => Promise<any>;
242
+ install: () => Promise<boolean>;
243
+ isLinux?: boolean;
244
+ }): Promise<LaunchResult> {
245
+ try {
246
+ return { ok: true, browser: await launch() };
247
+ } catch (err) {
248
+ const message = err instanceof Error ? err.message : String(err);
249
+ if (isMissingDepsError(message)) {
250
+ reportLaunchFailure(message, isLinux);
251
+ return { ok: false, code: 1 };
252
+ }
253
+ if (!isMissingBrowserError(message)) throw err;
254
+
255
+ console.log(
256
+ "[tessera a11y] Chromium isn't installed for Playwright. Installing it once now…",
257
+ );
258
+ const installed = await install();
259
+ if (!installed) {
260
+ reportManualInstall("Chromium isn't installed for Playwright.");
261
+ return { ok: false, code: 1 };
262
+ }
263
+
264
+ try {
265
+ return { ok: true, browser: await launch() };
266
+ } catch (retryErr) {
267
+ const retryMessage =
268
+ retryErr instanceof Error ? retryErr.message : String(retryErr);
269
+ if (
270
+ isMissingBrowserError(retryMessage) &&
271
+ !isMissingDepsError(retryMessage)
272
+ ) {
273
+ reportManualInstall(
274
+ "Chromium still isn't installed after the install step.",
275
+ );
276
+ } else {
277
+ reportLaunchFailure(retryMessage, isLinux);
278
+ }
279
+ return { ok: false, code: 1 };
280
+ }
281
+ }
282
+ }
283
+
77
284
  // A violation with no impact is treated as failing rather than slipping the
78
285
  // gate at every threshold.
79
286
  function isFailing(v: AxeViolation, thresholdRank: number): boolean {
@@ -98,7 +305,7 @@ async function loadDeps(): Promise<
98
305
  > {
99
306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
307
  let chromium: any;
101
- for (const spec of ['playwright', '@playwright/test']) {
308
+ for (const spec of PLAYWRIGHT_SPECS) {
102
309
  try {
103
310
  const mod = (await tryImport(spec)) as { chromium?: unknown };
104
311
  if (mod.chromium) {
@@ -140,7 +347,7 @@ export async function runAudit(
140
347
  `\x1b[31m[tessera a11y]\x1b[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n` +
141
348
  ` Install them to run the runtime audit:\n` +
142
349
  ` pnpm add -D playwright @axe-core/playwright\n` +
143
- ` pnpm exec playwright install chromium`,
350
+ ` ${INSTALL_CHROMIUM}`,
144
351
  );
145
352
  return 1;
146
353
  }
@@ -169,7 +376,6 @@ export async function runAudit(
169
376
 
170
377
  // A throwaway web build, kept out of dist/ so a real LMS export is untouched.
171
378
  const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
172
- const distHtml = resolve(auditDist, 'index.html');
173
379
 
174
380
  const prevEnv = process.env[AUDIT_ENV_FLAG];
175
381
  process.env[AUDIT_ENV_FLAG] = '1';
@@ -177,15 +383,13 @@ export async function runAudit(
177
383
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
384
  let server: any;
179
385
  try {
180
- if (options.rebuild || !existsSync(distHtml)) {
181
- console.log('[tessera a11y] Building course…');
182
- await vite.build(
183
- vite.mergeConfig(auditBaseConfig, {
184
- build: { outDir: auditDist, emptyOutDir: true },
185
- logLevel: 'warn',
186
- }),
187
- );
188
- }
386
+ console.log('[tessera a11y] Building course…');
387
+ await vite.build(
388
+ vite.mergeConfig(auditBaseConfig, {
389
+ build: { outDir: auditDist, emptyOutDir: true },
390
+ logLevel: 'warn',
391
+ }),
392
+ );
189
393
 
190
394
  server = await vite.preview({
191
395
  root: projectRoot,
@@ -200,21 +404,12 @@ export async function runAudit(
200
404
  return 1;
201
405
  }
202
406
 
203
- let browser;
204
- try {
205
- browser = await chromium.launch();
206
- } catch (err) {
207
- const message = err instanceof Error ? err.message : String(err);
208
- if (isMissingBrowserError(message)) {
209
- console.error(
210
- `\x1b[31m[tessera a11y]\x1b[0m Chromium isn't installed for Playwright.\n` +
211
- ` Install it once:\n` +
212
- ` pnpm exec playwright install chromium`,
213
- );
214
- return 1;
215
- }
216
- throw err;
217
- }
407
+ const launched = await launchWithInstall({
408
+ launch: () => chromium.launch(),
409
+ install: () => installChromium(workspaceRoot),
410
+ });
411
+ if (!launched.ok) return launched.code;
412
+ const browser = launched.browser;
218
413
  const pages: PageAuditResult[] = [];
219
414
  try {
220
415
  // axe-core/playwright requires a page from an explicit context.
@@ -230,14 +425,7 @@ export async function runAudit(
230
425
  const builder = new AxeBuilder({ page }).withTags(tags);
231
426
  if (disableRules.length > 0) builder.disableRules(disableRules);
232
427
  const out = await builder.analyze();
233
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
- return out.violations.map((v: any) => ({
235
- id: v.id,
236
- impact: v.impact ?? null,
237
- help: v.help,
238
- helpUrl: v.helpUrl,
239
- nodes: v.nodes.length,
240
- }));
428
+ return out.violations.map(mapViolation);
241
429
  };
242
430
 
243
431
  const recordPage = async (
@@ -351,6 +539,18 @@ function printSummary(report: AuditReport, reportPath: string): void {
351
539
  console.log(
352
540
  ` [${v.impact ?? 'n/a'}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? '' : 's'})`,
353
541
  );
542
+ for (const el of v.elements.slice(0, MAX_ELEMENTS_SHOWN)) {
543
+ console.log(
544
+ `\x1b[90m → ${el.target || '(unknown element)'}\x1b[0m`,
545
+ );
546
+ if (el.summary) console.log(`\x1b[90m ${el.summary}\x1b[0m`);
547
+ }
548
+ const hidden = v.elements.length - MAX_ELEMENTS_SHOWN;
549
+ if (hidden > 0) {
550
+ console.log(
551
+ `\x1b[90m … and ${hidden} more — see a11y-report.json\x1b[0m`,
552
+ );
553
+ }
354
554
  }
355
555
  }
356
556
  console.log(`\n[tessera a11y] Report written to ${reportPath}`);