@abreen/tada 1.4.1 → 1.5.1

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 (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +48 -6
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +88 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +102 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +382 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +21 -4
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +554 -0
  98. package/src/trace/index.ts +258 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. package/types/jsdom.d.ts +9 -0
package/src/top/index.ts CHANGED
@@ -10,7 +10,7 @@ export default (window: Window) => {
10
10
  const mountParent =
11
11
  (window.document.getElementById(
12
12
  'to-top-container',
13
- ) as HTMLElement | null) ?? document.body;
13
+ ) as HTMLElement | null) ?? window.document.body;
14
14
 
15
15
  function createLink(parent: HTMLElement): HTMLAnchorElement {
16
16
  const link = window.document.createElement('a');
@@ -34,7 +34,7 @@ export default (window: Window) => {
34
34
  } else {
35
35
  history.replaceState(null, '', cleanUrl);
36
36
  }
37
- window.scrollTo({ top: 0 });
37
+ window.scrollTo({ top: 0, behavior: 'smooth' });
38
38
  const toc = window.document.querySelector('nav.toc') as HTMLElement | null;
39
39
  if (toc) {
40
40
  toc.scrollTop = 0;
@@ -0,0 +1,554 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import type { TraceManifest, TraceChunkEntry } from './types';
4
+ import mount from './index';
5
+
6
+ function makeManifest(overrides: Partial<TraceManifest> = {}): TraceManifest {
7
+ return {
8
+ totalSteps: 3,
9
+ chunkSize: 10,
10
+ sourceFile: 'Main.java',
11
+ source: 'public class Main {}',
12
+ lineToSteps: {},
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function makeChunk(
18
+ entries: Array<{ line?: number; stdout?: string; svg?: string }>,
19
+ ): TraceChunkEntry[] {
20
+ return entries.map(e => ({
21
+ line: e.line ?? 1,
22
+ stdout: e.stdout ?? '',
23
+ svg: e.svg ?? '<svg></svg>',
24
+ }));
25
+ }
26
+
27
+ const defaultManifest = makeManifest();
28
+ const defaultChunk = makeChunk([
29
+ { line: 1, stdout: '', svg: '<svg>step0</svg>' },
30
+ { line: 2, stdout: 'hello', svg: '<svg>step1</svg>' },
31
+ { line: 3, stdout: ' world', svg: '<svg>step2</svg>' },
32
+ ]);
33
+
34
+ function widgetHtml(manifestUrl = '/trace/manifest.json'): string {
35
+ return (
36
+ `<div class="trace-widget" data-trace-manifest="${manifestUrl}">` +
37
+ '<div class="trace-source">' +
38
+ '<span class="line-number" data-line="1">1</span>' +
39
+ '<span class="line-number" data-line="2">2</span>' +
40
+ '<span class="line-number" data-line="3">3</span>' +
41
+ '</div>' +
42
+ '<div class="trace-controls">' +
43
+ '<button class="trace-first">First</button>' +
44
+ '<button class="trace-prev">Prev</button>' +
45
+ '<span class="trace-step-counter"></span>' +
46
+ '<button class="trace-next">Next</button>' +
47
+ '<button class="trace-last">Last</button>' +
48
+ '</div>' +
49
+ '<pre class="trace-output"></pre>' +
50
+ '<div class="trace-diagram"></div>' +
51
+ '</div>'
52
+ );
53
+ }
54
+
55
+ function createWindow(html: string) {
56
+ const dom = new JSDOM(`<body>${html}</body>`, { url: 'http://localhost/' });
57
+ return dom.window;
58
+ }
59
+
60
+ let originalFetch: typeof globalThis.fetch;
61
+
62
+ function setupFetch(responses: Record<string, unknown>): void {
63
+ const map = new Map(Object.entries(responses));
64
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
65
+ const url = typeof input === 'string' ? input : input.toString();
66
+ const data = map.get(url);
67
+ if (data === undefined) {
68
+ throw new Error(`Unexpected fetch: ${url}`);
69
+ }
70
+ return { ok: true, json: async () => data } as Response;
71
+ }) as typeof fetch;
72
+ }
73
+
74
+ async function flush(): Promise<void> {
75
+ for (let i = 0; i < 10; i++) {
76
+ await new Promise(r => setTimeout(r, 0));
77
+ }
78
+ }
79
+
80
+ function setupDefaultFetch(): void {
81
+ setupFetch({
82
+ '/trace/manifest.json': defaultManifest,
83
+ '/trace/chunk-0.json': defaultChunk,
84
+ });
85
+ }
86
+
87
+ describe('trace', () => {
88
+ beforeEach(() => {
89
+ originalFetch = globalThis.fetch;
90
+ });
91
+
92
+ afterEach(() => {
93
+ globalThis.fetch = originalFetch;
94
+ });
95
+
96
+ test('returns early when no trace widgets exist', () => {
97
+ let fetchCalled = false;
98
+ globalThis.fetch = (async () => {
99
+ fetchCalled = true;
100
+ return {} as Response;
101
+ }) as unknown as typeof fetch;
102
+
103
+ const win = createWindow('<div>no widgets</div>');
104
+ mount(win);
105
+ expect(fetchCalled).toBe(false);
106
+ });
107
+
108
+ test('returns early when widget has no data-trace-manifest', async () => {
109
+ let fetchCalled = false;
110
+ globalThis.fetch = (async () => {
111
+ fetchCalled = true;
112
+ return {} as Response;
113
+ }) as unknown as typeof fetch;
114
+
115
+ const win = createWindow('<div class="trace-widget"></div>');
116
+ mount(win);
117
+ await flush();
118
+ expect(fetchCalled).toBe(false);
119
+ });
120
+
121
+ test('fetches manifest and first chunk on init', async () => {
122
+ const fetched: string[] = [];
123
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
124
+ const url = typeof input === 'string' ? input : input.toString();
125
+ fetched.push(url);
126
+ if (url.includes('manifest.json')) {
127
+ return { ok: true, json: async () => defaultManifest } as Response;
128
+ }
129
+ return { ok: true, json: async () => defaultChunk } as Response;
130
+ }) as typeof fetch;
131
+
132
+ const win = createWindow(widgetHtml());
133
+ mount(win);
134
+ await flush();
135
+
136
+ expect(fetched).toContain('/trace/manifest.json');
137
+ expect(fetched).toContain('/trace/chunk-0.json');
138
+ });
139
+
140
+ test('displays step counter as 1/N', async () => {
141
+ setupDefaultFetch();
142
+ const win = createWindow(widgetHtml());
143
+ mount(win);
144
+ await flush();
145
+
146
+ const counter = win.document.querySelector('.trace-step-counter');
147
+ expect(counter!.textContent).toBe('1/3');
148
+ });
149
+
150
+ test('step counter min-width matches total step digits', async () => {
151
+ const manifest = makeManifest({ totalSteps: 100 });
152
+ const chunk = makeChunk(Array.from({ length: 10 }, () => ({ line: 1 })));
153
+ setupFetch({
154
+ '/trace/manifest.json': manifest,
155
+ '/trace/chunk-0.json': chunk,
156
+ });
157
+
158
+ const win = createWindow(widgetHtml());
159
+ mount(win);
160
+ await flush();
161
+
162
+ const counter = win.document.querySelector(
163
+ '.trace-step-counter',
164
+ ) as HTMLElement;
165
+ // 3 digits * 2 + 1 = 7ch
166
+ expect(counter.style.minWidth).toBe('7ch');
167
+ });
168
+
169
+ test('first and prev buttons disabled at step 0', async () => {
170
+ setupDefaultFetch();
171
+ const win = createWindow(widgetHtml());
172
+ mount(win);
173
+ await flush();
174
+
175
+ const first = win.document.querySelector(
176
+ '.trace-first',
177
+ ) as HTMLButtonElement;
178
+ const prev = win.document.querySelector('.trace-prev') as HTMLButtonElement;
179
+ expect(first.disabled).toBe(true);
180
+ expect(prev.disabled).toBe(true);
181
+ });
182
+
183
+ test('next and last buttons enabled at step 0', async () => {
184
+ setupDefaultFetch();
185
+ const win = createWindow(widgetHtml());
186
+ mount(win);
187
+ await flush();
188
+
189
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
190
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
191
+ expect(next.disabled).toBe(false);
192
+ expect(last.disabled).toBe(false);
193
+ });
194
+
195
+ test('highlights the active source line', async () => {
196
+ setupDefaultFetch();
197
+ const win = createWindow(widgetHtml());
198
+ mount(win);
199
+ await flush();
200
+
201
+ const active = win.document.querySelector('.trace-line-active');
202
+ expect(active).not.toBeNull();
203
+ expect(active!.getAttribute('data-line')).toBe('1');
204
+ });
205
+
206
+ test('renders initial SVG in diagram', async () => {
207
+ setupDefaultFetch();
208
+ const win = createWindow(widgetHtml());
209
+ mount(win);
210
+ await flush();
211
+
212
+ const diagram = win.document.querySelector('.trace-diagram') as HTMLElement;
213
+ expect(diagram.innerHTML).toBe('<svg>step0</svg>');
214
+ });
215
+
216
+ test('renders initial stdout in output', async () => {
217
+ const chunk = makeChunk([
218
+ { line: 1, stdout: 'initial output', svg: '<svg></svg>' },
219
+ { line: 2, svg: '<svg></svg>' },
220
+ ]);
221
+ setupFetch({
222
+ '/trace/manifest.json': makeManifest({ totalSteps: 2 }),
223
+ '/trace/chunk-0.json': chunk,
224
+ });
225
+
226
+ const win = createWindow(widgetHtml());
227
+ mount(win);
228
+ await flush();
229
+
230
+ const output = win.document.querySelector('.trace-output') as HTMLElement;
231
+ expect(output.textContent).toBe('initial output');
232
+ });
233
+
234
+ test('clicking next advances to the next step', async () => {
235
+ setupDefaultFetch();
236
+ const win = createWindow(widgetHtml());
237
+ mount(win);
238
+ await flush();
239
+
240
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
241
+ next.click();
242
+ await flush();
243
+
244
+ const counter = win.document.querySelector('.trace-step-counter');
245
+ expect(counter!.textContent).toBe('2/3');
246
+ });
247
+
248
+ test('clicking next updates source highlight', async () => {
249
+ setupDefaultFetch();
250
+ const win = createWindow(widgetHtml());
251
+ mount(win);
252
+ await flush();
253
+
254
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
255
+ next.click();
256
+ await flush();
257
+
258
+ const active = win.document.querySelector('.trace-line-active');
259
+ expect(active!.getAttribute('data-line')).toBe('2');
260
+ });
261
+
262
+ test('clicking next updates SVG diagram', async () => {
263
+ setupDefaultFetch();
264
+ const win = createWindow(widgetHtml());
265
+ mount(win);
266
+ await flush();
267
+
268
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
269
+ next.click();
270
+ await flush();
271
+
272
+ const diagram = win.document.querySelector('.trace-diagram') as HTMLElement;
273
+ expect(diagram.innerHTML).toBe('<svg>step1</svg>');
274
+ });
275
+
276
+ test('output accumulates stdout across steps', async () => {
277
+ setupDefaultFetch();
278
+ const win = createWindow(widgetHtml());
279
+ mount(win);
280
+ await flush();
281
+
282
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
283
+ const output = win.document.querySelector('.trace-output') as HTMLElement;
284
+
285
+ next.click();
286
+ await flush();
287
+ expect(output.textContent).toBe('hello');
288
+
289
+ next.click();
290
+ await flush();
291
+ expect(output.textContent).toBe('hello world');
292
+ });
293
+
294
+ test('clicking last goes to the final step', async () => {
295
+ setupDefaultFetch();
296
+ const win = createWindow(widgetHtml());
297
+ mount(win);
298
+ await flush();
299
+
300
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
301
+ last.click();
302
+ await flush();
303
+
304
+ const counter = win.document.querySelector('.trace-step-counter');
305
+ expect(counter!.textContent).toBe('3/3');
306
+ });
307
+
308
+ test('next and last disabled at the last step', async () => {
309
+ setupDefaultFetch();
310
+ const win = createWindow(widgetHtml());
311
+ mount(win);
312
+ await flush();
313
+
314
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
315
+ last.click();
316
+ await flush();
317
+
318
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
319
+ expect(next.disabled).toBe(true);
320
+ expect(last.disabled).toBe(true);
321
+ });
322
+
323
+ test('first and prev enabled after advancing', async () => {
324
+ setupDefaultFetch();
325
+ const win = createWindow(widgetHtml());
326
+ mount(win);
327
+ await flush();
328
+
329
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
330
+ next.click();
331
+ await flush();
332
+
333
+ const first = win.document.querySelector(
334
+ '.trace-first',
335
+ ) as HTMLButtonElement;
336
+ const prev = win.document.querySelector('.trace-prev') as HTMLButtonElement;
337
+ expect(first.disabled).toBe(false);
338
+ expect(prev.disabled).toBe(false);
339
+ });
340
+
341
+ test('clicking first returns to step 0', async () => {
342
+ setupDefaultFetch();
343
+ const win = createWindow(widgetHtml());
344
+ mount(win);
345
+ await flush();
346
+
347
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
348
+ last.click();
349
+ await flush();
350
+
351
+ const first = win.document.querySelector(
352
+ '.trace-first',
353
+ ) as HTMLButtonElement;
354
+ first.click();
355
+ await flush();
356
+
357
+ const counter = win.document.querySelector('.trace-step-counter');
358
+ expect(counter!.textContent).toBe('1/3');
359
+ });
360
+
361
+ test('clicking prev goes back one step', async () => {
362
+ setupDefaultFetch();
363
+ const win = createWindow(widgetHtml());
364
+ mount(win);
365
+ await flush();
366
+
367
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
368
+ last.click();
369
+ await flush();
370
+
371
+ const prev = win.document.querySelector('.trace-prev') as HTMLButtonElement;
372
+ prev.click();
373
+ await flush();
374
+
375
+ const counter = win.document.querySelector('.trace-step-counter');
376
+ expect(counter!.textContent).toBe('2/3');
377
+ });
378
+
379
+ test('only one line is highlighted at a time', async () => {
380
+ setupDefaultFetch();
381
+ const win = createWindow(widgetHtml());
382
+ mount(win);
383
+ await flush();
384
+
385
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
386
+ next.click();
387
+ await flush();
388
+
389
+ const allActive = win.document.querySelectorAll('.trace-line-active');
390
+ expect(allActive.length).toBe(1);
391
+ expect(allActive[0].getAttribute('data-line')).toBe('2');
392
+ });
393
+
394
+ test('loads additional chunks when stepping across boundaries', async () => {
395
+ const manifest = makeManifest({ totalSteps: 4, chunkSize: 2 });
396
+ const chunk0 = makeChunk([
397
+ { line: 1, svg: '<svg>s0</svg>' },
398
+ { line: 2, svg: '<svg>s1</svg>' },
399
+ ]);
400
+ const chunk1 = makeChunk([
401
+ { line: 3, stdout: 'out', svg: '<svg>s2</svg>' },
402
+ { line: 1, svg: '<svg>s3</svg>' },
403
+ ]);
404
+ setupFetch({
405
+ '/trace/manifest.json': manifest,
406
+ '/trace/chunk-0.json': chunk0,
407
+ '/trace/chunk-1.json': chunk1,
408
+ });
409
+
410
+ const win = createWindow(widgetHtml());
411
+ mount(win);
412
+ await flush();
413
+
414
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
415
+ last.click();
416
+ await flush();
417
+
418
+ const counter = win.document.querySelector('.trace-step-counter');
419
+ expect(counter!.textContent).toBe('4/4');
420
+ const diagram = win.document.querySelector('.trace-diagram') as HTMLElement;
421
+ expect(diagram.innerHTML).toBe('<svg>s3</svg>');
422
+ });
423
+
424
+ test('does not re-fetch already loaded chunks', async () => {
425
+ const fetched: string[] = [];
426
+ const manifest = makeManifest({ totalSteps: 4, chunkSize: 2 });
427
+ const chunk0 = makeChunk([
428
+ { line: 1, svg: '<svg>s0</svg>' },
429
+ { line: 2, svg: '<svg>s1</svg>' },
430
+ ]);
431
+ const chunk1 = makeChunk([
432
+ { line: 3, svg: '<svg>s2</svg>' },
433
+ { line: 1, svg: '<svg>s3</svg>' },
434
+ ]);
435
+
436
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
437
+ const url = typeof input === 'string' ? input : input.toString();
438
+ fetched.push(url);
439
+ if (url.includes('manifest.json')) {
440
+ return { ok: true, json: async () => manifest } as Response;
441
+ }
442
+ if (url.includes('chunk-0')) {
443
+ return { ok: true, json: async () => chunk0 } as Response;
444
+ }
445
+ return { ok: true, json: async () => chunk1 } as Response;
446
+ }) as typeof fetch;
447
+
448
+ const win = createWindow(widgetHtml());
449
+ mount(win);
450
+ await flush();
451
+
452
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
453
+ last.click();
454
+ await flush();
455
+
456
+ const first = win.document.querySelector(
457
+ '.trace-first',
458
+ ) as HTMLButtonElement;
459
+ first.click();
460
+ await flush();
461
+
462
+ const chunk0Fetches = fetched.filter(u => u.includes('chunk-0'));
463
+ expect(chunk0Fetches.length).toBe(1);
464
+ });
465
+
466
+ test('initializes multiple widgets', async () => {
467
+ setupDefaultFetch();
468
+ const html = widgetHtml() + widgetHtml();
469
+ const win = createWindow(html);
470
+ mount(win);
471
+ await flush();
472
+
473
+ const counters = win.document.querySelectorAll('.trace-step-counter');
474
+ expect(counters.length).toBe(2);
475
+ expect(counters[0].textContent).toBe('1/3');
476
+ expect(counters[1].textContent).toBe('1/3');
477
+ });
478
+
479
+ test('arrow keys move focus between enabled buttons', async () => {
480
+ setupDefaultFetch();
481
+ const win = createWindow(widgetHtml());
482
+ mount(win);
483
+ await flush();
484
+
485
+ // Advance to step 1 so all four buttons are enabled
486
+ const nextBtn = win.document.querySelector(
487
+ '.trace-next',
488
+ ) as HTMLButtonElement;
489
+ nextBtn.click();
490
+ await flush();
491
+
492
+ const firstBtn = win.document.querySelector(
493
+ '.trace-first',
494
+ ) as HTMLButtonElement;
495
+ firstBtn.focus();
496
+
497
+ firstBtn.dispatchEvent(
498
+ new win.KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
499
+ );
500
+
501
+ expect(win.document.activeElement).not.toBe(firstBtn);
502
+ });
503
+
504
+ test('disabled buttons get tabIndex -1', async () => {
505
+ setupDefaultFetch();
506
+ const win = createWindow(widgetHtml());
507
+ mount(win);
508
+ await flush();
509
+
510
+ const first = win.document.querySelector(
511
+ '.trace-first',
512
+ ) as HTMLButtonElement;
513
+ const prev = win.document.querySelector('.trace-prev') as HTMLButtonElement;
514
+ expect(first.tabIndex).toBe(-1);
515
+ expect(prev.tabIndex).toBe(-1);
516
+ });
517
+
518
+ test('stays disabled when manifest fetch fails', async () => {
519
+ globalThis.fetch = (async () => {
520
+ return { ok: false, status: 404 } as Response;
521
+ }) as unknown as typeof fetch;
522
+
523
+ const win = createWindow(widgetHtml());
524
+ mount(win);
525
+ await flush();
526
+
527
+ const counter = win.document.querySelector('.trace-step-counter');
528
+ expect(counter!.textContent).toBe('');
529
+ });
530
+
531
+ test('stays disabled when manifest fetch throws', async () => {
532
+ globalThis.fetch = (async () => {
533
+ throw new TypeError('Failed to fetch');
534
+ }) as unknown as typeof fetch;
535
+
536
+ const win = createWindow(widgetHtml());
537
+ mount(win);
538
+ await flush();
539
+
540
+ const counter = win.document.querySelector('.trace-step-counter');
541
+ expect(counter!.textContent).toBe('');
542
+ });
543
+
544
+ test('at least one enabled button has tabIndex 0', async () => {
545
+ setupDefaultFetch();
546
+ const win = createWindow(widgetHtml());
547
+ mount(win);
548
+ await flush();
549
+
550
+ const next = win.document.querySelector('.trace-next') as HTMLButtonElement;
551
+ const last = win.document.querySelector('.trace-last') as HTMLButtonElement;
552
+ expect(next.tabIndex === 0 || last.tabIndex === 0).toBe(true);
553
+ });
554
+ });