@miragon/modeler-shell 0.0.0-stage → 0.1.0

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 (126) hide show
  1. package/API.md +1902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  6. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  7. package/dist/chunk-5JSZ4VGA.js +551 -0
  8. package/dist/chunk-5JSZ4VGA.js.map +1 -0
  9. package/dist/chunk-6LQOFACK.js +1576 -0
  10. package/dist/chunk-6LQOFACK.js.map +1 -0
  11. package/dist/chunk-N4QXFF3A.js +23 -0
  12. package/dist/chunk-N4QXFF3A.js.map +1 -0
  13. package/dist/chunk-RJRFR6N3.js +136 -0
  14. package/dist/chunk-RJRFR6N3.js.map +1 -0
  15. package/dist/core.cjs +619 -0
  16. package/dist/core.cjs.map +1 -0
  17. package/dist/core.d.cts +171 -0
  18. package/dist/core.d.ts +171 -0
  19. package/dist/core.js +91 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/diagram-js.cjs +1115 -0
  22. package/dist/diagram-js.cjs.map +1 -0
  23. package/dist/diagram-js.d.cts +163 -0
  24. package/dist/diagram-js.d.ts +163 -0
  25. package/dist/diagram-js.js +867 -0
  26. package/dist/diagram-js.js.map +1 -0
  27. package/dist/index.cjs +2175 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +20 -0
  30. package/dist/index.d.ts +20 -0
  31. package/dist/index.js +103 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/keymap-CObQE6q6.d.cts +151 -0
  34. package/dist/keymap-DORzHnK-.d.ts +151 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +914 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +2364 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +165 -0
  47. package/dist/testing.d.ts +165 -0
  48. package/dist/testing.js +2297 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/types-B6NfkiUG.d.cts +66 -0
  51. package/dist/types-B6NfkiUG.d.ts +66 -0
  52. package/dist/types-DnZn9CPw.d.ts +284 -0
  53. package/dist/types-OuFlqZp0.d.cts +284 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +2198 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +82 -0
  63. package/dist/vscode-webview.d.ts +82 -0
  64. package/dist/vscode-webview.js +250 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/fit.ts +205 -0
  69. package/src/core/index.ts +84 -0
  70. package/src/core/keymap.ts +275 -0
  71. package/src/core/layout.ts +69 -0
  72. package/src/core/messages.ts +88 -0
  73. package/src/core/sizes.ts +85 -0
  74. package/src/core/types.ts +84 -0
  75. package/src/core/zoom.ts +74 -0
  76. package/src/core.ts +2 -0
  77. package/src/diagram-js/connect.ts +75 -0
  78. package/src/diagram-js/index.ts +16 -0
  79. package/src/diagram-js/module.ts +20 -0
  80. package/src/diagram-js/palette-skin.ts +267 -0
  81. package/src/diagram-js/palette.css +269 -0
  82. package/src/diagram-js/service.ts +240 -0
  83. package/src/diagram-js/types.ts +67 -0
  84. package/src/diagram-js/viewport.ts +564 -0
  85. package/src/diagram-js.ts +2 -0
  86. package/src/dom/element.ts +51 -0
  87. package/src/dom/hint.ts +51 -0
  88. package/src/dom/icons.ts +309 -0
  89. package/src/dom/index.ts +29 -0
  90. package/src/dom/inspector.ts +35 -0
  91. package/src/dom/keys.ts +59 -0
  92. package/src/dom/ledger.ts +87 -0
  93. package/src/dom/legend.ts +85 -0
  94. package/src/dom/menu.ts +393 -0
  95. package/src/dom/mount.ts +461 -0
  96. package/src/dom/problems.ts +72 -0
  97. package/src/dom/shell-dom.css +966 -0
  98. package/src/dom/status.ts +72 -0
  99. package/src/dom/tooltip.ts +237 -0
  100. package/src/dom/types.ts +277 -0
  101. package/src/dom/zoom-panel.ts +154 -0
  102. package/src/index.ts +3 -0
  103. package/src/shell.css +6 -0
  104. package/src/testing/cases.ts +1012 -0
  105. package/src/testing/checks.ts +832 -0
  106. package/src/testing/facts.ts +587 -0
  107. package/src/testing/geometry.ts +472 -0
  108. package/src/testing/harness.ts +138 -0
  109. package/src/testing/index.ts +15 -0
  110. package/src/testing/page-tools.ts +434 -0
  111. package/src/testing/page.ts +17 -0
  112. package/src/testing/vscode-stub.ts +237 -0
  113. package/src/testing-vscode-stub.ts +2 -0
  114. package/src/testing.ts +2 -0
  115. package/src/vscode/extension/html.ts +107 -0
  116. package/src/vscode/extension/index.ts +8 -0
  117. package/src/vscode/extension/reveal.ts +9 -0
  118. package/src/vscode/extension/theme-bridge.ts +85 -0
  119. package/src/vscode/extension/types.ts +36 -0
  120. package/src/vscode/protocol.ts +31 -0
  121. package/src/vscode/webview/appearance.ts +141 -0
  122. package/src/vscode/webview/index.ts +8 -0
  123. package/src/vscode/webview/mount.ts +76 -0
  124. package/src/vscode/webview/state.ts +116 -0
  125. package/src/vscode-extension.ts +2 -0
  126. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,1012 @@
1
+ import {
2
+ INVALID_STATUS_DELAY_MS,
3
+ TOOLTIP_DELAY_MS,
4
+ type Appearance,
5
+ type ShellHost,
6
+ type Size,
7
+ } from "../core/index.js";
8
+ import {
9
+ absentIslandProblems,
10
+ afterDestroyKeyProblem,
11
+ appearanceProblems,
12
+ ariaProblems,
13
+ createProblems,
14
+ harnessHookProblems,
15
+ isToolEntry,
16
+ keyReachProblem,
17
+ leftoverProblems,
18
+ lockedShapeProblems,
19
+ menuClosedProblems,
20
+ menuKeysAtDocumentProblems,
21
+ menuOpenProblems,
22
+ optionalIslandProblems,
23
+ paletteColorProblems,
24
+ paletteShortcutProblems,
25
+ readOnlyMenuProblems,
26
+ readOnlyPaletteProblems,
27
+ readOnlyStateProblems,
28
+ surroundProblems,
29
+ tabSequenceProblems,
30
+ type KeyReach,
31
+ type PaletteShortcutFacts,
32
+ type TabStop,
33
+ } from "./checks.js";
34
+ import {
35
+ MODE_ROLES,
36
+ activeStopProbe,
37
+ appearanceProbe,
38
+ ariaProbe,
39
+ canvasStateProbe,
40
+ menuEntriesProbe,
41
+ menuProbe,
42
+ paletteColorProbe,
43
+ paletteEntriesProbe,
44
+ paletteFocusProbe,
45
+ paletteShortcutProbe,
46
+ readOnlyStateProbe,
47
+ shapesProbe,
48
+ surroundProbe,
49
+ zoomButtonsProbe,
50
+ type AppearanceFacts,
51
+ type AriaFacts,
52
+ type MenuEntryFacts,
53
+ type MenuFacts,
54
+ type PaletteEntryState,
55
+ type ShapeFacts,
56
+ } from "./facts.js";
57
+ import {
58
+ DEFAULT_ROOT,
59
+ dropdownProblems,
60
+ freeRectangle,
61
+ geometryProblems,
62
+ measureChrome,
63
+ tooltipProblems,
64
+ type ChromeMeasurement,
65
+ type TooltipRow,
66
+ } from "./geometry.js";
67
+ import type {
68
+ ShellCaseOutcome,
69
+ ShellConformanceCase,
70
+ ShellConformanceHarness,
71
+ ShellSkip,
72
+ ShowHook,
73
+ ShowHooks,
74
+ } from "./harness.js";
75
+ import type { PageLike } from "./page.js";
76
+ import {
77
+ afterPressProbe,
78
+ armCleanupProbe,
79
+ canvasHandlesProbe,
80
+ clearKeysProbe,
81
+ findOverlay,
82
+ findTarget,
83
+ focusTarget,
84
+ hostPageModeProbe,
85
+ inPage,
86
+ injectProbe,
87
+ leftoverProbe,
88
+ removeInjectedProbe,
89
+ settle,
90
+ type ChromeTarget,
91
+ type InjectedKind,
92
+ type TargetFacts,
93
+ } from "./page-tools.js";
94
+
95
+ /** Wide, narrow and very narrow (guide §5.15); 900 is above, 700 below the low breakpoint. */
96
+ export const DEFAULT_SIZES: readonly Size[] = [
97
+ { width: 1280, height: 900 },
98
+ { width: 720, height: 700 },
99
+ { width: 420, height: 700 },
100
+ ];
101
+
102
+ const LIGHT: Appearance = { theme: "light" };
103
+
104
+ /** Shown in this order, so the legend is measured without and then with the pill. */
105
+ const SHOWN_ISLANDS = ["legend", "problems", "status", "inspector"] as const satisfies ReadonlyArray<
106
+ keyof ShowHooks
107
+ >;
108
+
109
+ /** How far the read-only case drags a shape (guide §13.5: no move). */
110
+ const LOCKED_DRAG_PX = 40;
111
+
112
+ /** Room for a modeler that places, selects or re-renders a frame or two after Enter. */
113
+ const CREATE_WAIT_MS = 1000;
114
+
115
+ /** Room after `INVALID_STATUS_DELAY_MS` for the read-only marks and the status box to show. */
116
+ const READ_ONLY_WAIT_MS = 1000;
117
+
118
+ /** Room for the palette's 150ms colour transition (`--cd-motion-fast`) and a frame on either side. */
119
+ const COLOR_WAIT_MS = 600;
120
+
121
+ const POLL_MS = 50;
122
+
123
+ /** Requested from the modeler by the appearance case (VS Code Dark, High Contrast, High Contrast Light). */
124
+ const REQUESTED_APPEARANCES: readonly Appearance[] = [
125
+ { theme: "dark" },
126
+ { theme: "dark", kontrast: "hoch" },
127
+ { theme: "light", kontrast: "hoch" },
128
+ ];
129
+
130
+ /** Room for the chrome tooltip's own fade after its 300ms hover delay. */
131
+ const HOVER_MARGIN_MS = 200;
132
+
133
+ /** A spot without chrome: the root's top-left corner lies outside every island (12px edge). */
134
+ const PARK = { x: 1, y: 1 } as const;
135
+
136
+ /** Every kind of text field a modeler renders: inspector and dialog fields, the label editor. */
137
+ const TEXT_FIELDS: ReadonlyArray<readonly [InjectedKind, string]> = [
138
+ ["input", "a text field"],
139
+ ["textarea", "a textarea"],
140
+ ["contenteditable", "an editable label"],
141
+ ];
142
+
143
+ const UNDO_REDO_PRESSES = [
144
+ "Control+KeyZ",
145
+ "Meta+KeyZ",
146
+ "Control+Shift+KeyZ",
147
+ "Meta+Shift+KeyZ",
148
+ "Control+KeyY",
149
+ ] as const;
150
+
151
+ const ONE_STEP_UP = 135;
152
+ const TWO_STEPS_UP = 182;
153
+ const ZOOM_LIMIT_MAX = 400;
154
+ const ZOOM_LIMIT_MIN = 20;
155
+
156
+ function sizeLabel(size: Size): string {
157
+ return `${size.width} × ${size.height}`;
158
+ }
159
+
160
+ function appearanceLabel(appearance: Appearance): string {
161
+ return appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
162
+ }
163
+
164
+ function fail(caseName: string, host: ShellHost, problems: readonly string[]): never {
165
+ const unique = [...new Set(problems)];
166
+ throw new Error(
167
+ `@miragon/modeler-shell conformance: ${caseName} (${host} host)\n${unique.map((problem) => `- ${problem}`).join("\n")}`,
168
+ );
169
+ }
170
+
171
+ function finish(
172
+ caseName: string,
173
+ host: ShellHost,
174
+ problems: readonly string[],
175
+ skipped: Iterable<ShellSkip>,
176
+ ): ShellCaseOutcome | void {
177
+ if (problems.length > 0) fail(caseName, host, problems);
178
+ const seen = new Set<string>();
179
+ const unique = [...skipped].filter((skip) => !seen.has(skip.reason) && seen.add(skip.reason));
180
+ return unique.length > 0 ? { skipped: unique } : undefined;
181
+ }
182
+
183
+ /** A harness that gives a hook it also declares absent contradicts itself: fail before opening anything. */
184
+ function assertHooksConsistent<P extends PageLike>(
185
+ caseName: string,
186
+ harness: ShellConformanceHarness<P>,
187
+ hooks: readonly ShowHook[],
188
+ ): void {
189
+ const problems = harnessHookProblems(harness, hooks);
190
+ if (problems.length > 0) fail(caseName, harness.host, problems);
191
+ }
192
+
193
+ /** A hook that is neither given nor declared absent. */
194
+ function unfed<P extends PageLike>(harness: ShellConformanceHarness<P>, hook: ShowHook): boolean {
195
+ return !harness.show?.[hook] && !harness.absent?.includes(hook);
196
+ }
197
+
198
+ function missingHooks<P extends PageLike>(harness: ShellConformanceHarness<P>, what: string): ShellSkip[] {
199
+ return SHOWN_ISLANDS.filter((island) => unfed(harness, island)).map((island) => ({
200
+ hook: island,
201
+ reason: `show.${island} is undefined: ${what} of the ${island} island not verified`,
202
+ }));
203
+ }
204
+
205
+ /** Reads until `done` holds or `timeoutMs` passed; the last value either way. */
206
+ async function pollUntil<T>(
207
+ page: PageLike,
208
+ read: () => Promise<T>,
209
+ done: (value: T) => boolean,
210
+ timeoutMs: number,
211
+ ): Promise<T> {
212
+ let value = await read();
213
+ for (let waited = 0; !done(value) && waited < timeoutMs; waited += POLL_MS) {
214
+ await page.waitForTimeout(POLL_MS);
215
+ value = await read();
216
+ }
217
+ return value;
218
+ }
219
+
220
+ /** The visible islands of an ARIA snapshot that a harness may declare absent. */
221
+ function visibleIslands(facts: AriaFacts): Set<string> {
222
+ const islands = new Set<string>();
223
+ for (const island of SHOWN_ISLANDS) if (facts[island]?.visible) islands.add(island);
224
+ return islands;
225
+ }
226
+
227
+ /** Hovers `target` long enough for its chrome tooltip; the hovered control, `null` when there is none. */
228
+ async function hover(page: PageLike, root: string, target: ChromeTarget): Promise<TargetFacts | null> {
229
+ const anchor = await findTarget(page, root, target);
230
+ if (!anchor) return null;
231
+ await page.mouse.move(PARK.x, PARK.y);
232
+ await page.mouse.move(anchor.client.x, anchor.client.y);
233
+ await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
234
+ await settle(page);
235
+ return anchor;
236
+ }
237
+
238
+ async function openMenuByClick(page: PageLike, root: string): Promise<boolean> {
239
+ const trigger = await findTarget(page, root, "menu");
240
+ if (!trigger) return false;
241
+ await page.mouse.click(trigger.client.x, trigger.client.y);
242
+ await settle(page);
243
+ return true;
244
+ }
245
+
246
+ async function closeByEscape(page: PageLike): Promise<void> {
247
+ await page.keyboard.press("Escape");
248
+ await settle(page);
249
+ }
250
+
251
+ /** Dropdown and tooltips of one measured state (guide §5.4, §5.6). */
252
+ async function popupGeometry(
253
+ page: PageLike,
254
+ root: string,
255
+ measurement: ChromeMeasurement,
256
+ ): Promise<string[]> {
257
+ const problems: string[] = [];
258
+ if (!(await openMenuByClick(page, root))) return ["dropdown: no visible menu trigger to open it"];
259
+ const dropdown = await findOverlay(page, root, "menu");
260
+ if (!dropdown) problems.push('dropdown: no visible role="menu" after clicking the trigger');
261
+ else problems.push(...dropdownProblems(dropdown.box, measurement.container));
262
+ await closeByEscape(page);
263
+
264
+ const rows: Array<[ChromeTarget, TooltipRow]> = [
265
+ ["menu", "top"],
266
+ ["palette-entry", measurement.paletteRow === "second-row" ? "second-row" : "top"],
267
+ ["zoom-in", "bottom"],
268
+ ];
269
+ for (const [target, row] of rows) {
270
+ const anchor = await hover(page, root, target);
271
+ if (!anchor) {
272
+ problems.push(`tooltip: no visible ${target} to hover`);
273
+ continue;
274
+ }
275
+ const tooltip = await findOverlay(page, root, "tooltip");
276
+ if (!tooltip) {
277
+ problems.push(`tooltip: none visible ${TOOLTIP_DELAY_MS + HOVER_MARGIN_MS}ms after hovering ${target}`);
278
+ continue;
279
+ }
280
+ if (tooltip.count > 1) problems.push(`tooltip: ${tooltip.count} tooltips visible at once`);
281
+ if (anchor.label && !tooltip.text.startsWith(anchor.label)) {
282
+ problems.push(`tooltip: hovering ${target} ("${anchor.label}") shows "${tooltip.text}"`);
283
+ }
284
+ problems.push(
285
+ ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container).map(
286
+ (problem) => `${problem} (on ${target})`,
287
+ ),
288
+ );
289
+ }
290
+ await page.mouse.move(PARK.x, PARK.y);
291
+ return problems;
292
+ }
293
+
294
+ const geometryCase: ShellConformanceCase = {
295
+ name: "geometry",
296
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
297
+ assertHooksConsistent("geometry", harness, SHOWN_ISLANDS);
298
+ const root = harness.root ?? DEFAULT_ROOT;
299
+ const problems: string[] = [];
300
+ for (const size of harness.sizes ?? DEFAULT_SIZES) {
301
+ const at = sizeLabel(size);
302
+ await harness.open(page, { size, requested: LIGHT });
303
+ await settle(page);
304
+ const check = async (state: string): Promise<ChromeMeasurement> => {
305
+ const measurement = await measureChrome(page, root);
306
+ problems.push(
307
+ ...[
308
+ ...geometryProblems(measurement, harness.host),
309
+ ...absentIslandProblems(new Set(Object.keys(measurement.islands)), harness.absent),
310
+ ].map((problem) => `[${at}, ${state}] ${problem}`),
311
+ );
312
+ return measurement;
313
+ };
314
+ const opened = await check("as opened");
315
+ problems.push(...(await popupGeometry(page, root, opened)).map((problem) => `[${at}] ${problem}`));
316
+ for (const island of SHOWN_ISLANDS) {
317
+ const show = harness.show?.[island];
318
+ if (!show) continue;
319
+ await show(page);
320
+ await settle(page);
321
+ await check(`after show.${island}`);
322
+ }
323
+ }
324
+ return finish("geometry", harness.host, problems, missingHooks(harness, "the geometry"));
325
+ },
326
+ };
327
+
328
+ async function menuState(page: PageLike, root: string): Promise<MenuFacts> {
329
+ const facts = await inPage(page, menuProbe, { root });
330
+ if (!facts) throw new Error(`@miragon/modeler-shell conformance: no element matches "${root}"`);
331
+ return facts;
332
+ }
333
+
334
+ /** Keyboard model of the menu (guide §5.4): Enter/↓ open on the first entry, ↑ on the last, wrap, Escape. */
335
+ async function menuKeyboardProblems(page: PageLike, root: string): Promise<string[]> {
336
+ const problems: string[] = [];
337
+ const openWith = async (key: string, focus: "first" | "last"): Promise<MenuFacts | undefined> => {
338
+ if (!(await focusTarget(page, root, "menu"))) {
339
+ problems.push("menu: the trigger cannot take focus");
340
+ return undefined;
341
+ }
342
+ await page.keyboard.press(key);
343
+ await settle(page);
344
+ const opened = await menuState(page, root);
345
+ problems.push(...menuOpenProblems(opened, focus).map((problem) => `${key} on the trigger: ${problem}`));
346
+ return opened;
347
+ };
348
+ const close = async (key: string) => {
349
+ await closeByEscape(page);
350
+ problems.push(
351
+ ...menuClosedProblems(await menuState(page, root), true).map(
352
+ (problem) => `${key}, then Escape: ${problem}`,
353
+ ),
354
+ );
355
+ };
356
+
357
+ const opened = await openWith("Enter", "first");
358
+ if (opened && opened.items.length > 1) {
359
+ const moves: Array<[string, (count: number) => number]> = [
360
+ ["ArrowUp", (count) => count - 1],
361
+ ["ArrowDown", () => 0],
362
+ ["End", (count) => count - 1],
363
+ ["Home", () => 0],
364
+ ];
365
+ await inPage(page, clearKeysProbe, null);
366
+ for (const [key, target] of moves) {
367
+ await page.keyboard.press(key);
368
+ const moved = await menuState(page, root);
369
+ const expected = target(moved.items.length);
370
+ if (moved.active !== expected) {
371
+ problems.push(
372
+ `menu: ${key} moves the focus to entry ${moved.active + 1}, expected entry ${expected + 1} of ${moved.items.length}`,
373
+ );
374
+ }
375
+ }
376
+ const { documentKeys } = await inPage(page, afterPressProbe, { root });
377
+ problems.push(
378
+ ...menuKeysAtDocumentProblems(
379
+ moves.map(([key]) => key),
380
+ documentKeys,
381
+ ),
382
+ );
383
+ }
384
+ if (opened) await close("Enter");
385
+ if (await openWith("ArrowDown", "first")) await close("ArrowDown");
386
+ if (await openWith("ArrowUp", "last")) await close("ArrowUp");
387
+ return problems;
388
+ }
389
+
390
+ /** Roving tabindex of the palette toolbar (guide §5.5): ← → Home End. */
391
+ async function paletteKeyboardProblems(page: PageLike, root: string): Promise<string[]> {
392
+ const before = await inPage(page, paletteFocusProbe, { root });
393
+ if (!before || before.entries.length < 2) return [];
394
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) {
395
+ return ['palette: no entry with tabindex="0" takes the focus'];
396
+ }
397
+ const problems: string[] = [];
398
+ const start = before.entries.indexOf(before.tabStops[0] ?? "");
399
+ const step = start < before.entries.length - 1 ? 1 : -1;
400
+ const moves: Array<[string, number]> = [
401
+ [step === 1 ? "ArrowRight" : "ArrowLeft", start + step],
402
+ ["End", before.entries.length - 1],
403
+ ["Home", 0],
404
+ ];
405
+ for (const [key, index] of moves) {
406
+ await page.keyboard.press(key);
407
+ const after = await inPage(page, paletteFocusProbe, { root });
408
+ const expected = before.entries[index];
409
+ if (!after || after.active !== expected) {
410
+ problems.push(`palette: ${key} focuses "${after?.active ?? "nothing"}", expected "${expected}"`);
411
+ } else if (after.tabStops.length !== 1 || after.tabStops[0] !== expected) {
412
+ problems.push(
413
+ `palette: after ${key} tabindex="0" is on ${after.tabStops.join(", ") || "no entry"}, expected "${expected}"`,
414
+ );
415
+ }
416
+ }
417
+ return problems;
418
+ }
419
+
420
+ /** Every palette entry by keyboard: a shortcut its tooltip shows is named in `aria-keyshortcuts`. */
421
+ async function paletteShortcutWalk(page: PageLike, root: string): Promise<string[]> {
422
+ const before = await inPage(page, paletteFocusProbe, { root });
423
+ if (!before || before.entries.length === 0) return [];
424
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) return [];
425
+ if (before.entries.length > 1) await page.keyboard.press("End");
426
+ await page.keyboard.press("Home");
427
+ const seen: PaletteShortcutFacts[] = [];
428
+ for (let index = 0; index < before.entries.length; index++) {
429
+ const facts = await inPage(page, paletteShortcutProbe, { root });
430
+ if (facts) seen.push(facts);
431
+ await page.keyboard.press("ArrowRight");
432
+ }
433
+ return paletteShortcutProblems(seen);
434
+ }
435
+
436
+ /** Tab from the menu trigger: the palette's one stop, then the canvas (guide §5.14). */
437
+ async function tabSequenceFromMenu(page: PageLike, root: string): Promise<string[]> {
438
+ if (!(await focusTarget(page, root, "menu"))) return [];
439
+ const stops: TabStop[] = [];
440
+ for (let step = 0; step < 3; step++) {
441
+ await page.keyboard.press("Tab");
442
+ const stop = await inPage(page, activeStopProbe, { root });
443
+ stops.push({ description: stop.description, kind: stop.kind as TabStop["kind"] });
444
+ }
445
+ return tabSequenceProblems(stops);
446
+ }
447
+
448
+ /** `aria-disabled` at 400 % and 20 % (guide §5.8), reached by clicking the buttons. */
449
+ async function zoomLimitProblems(page: PageLike, root: string): Promise<string[]> {
450
+ const problems: string[] = [];
451
+ const limits: Array<["in" | "out", ChromeTarget, number, number, "in" | "out"]> = [
452
+ ["in", "zoom-in", ZOOM_LIMIT_MAX, 12, "out"],
453
+ ["out", "zoom-out", ZOOM_LIMIT_MIN, 16, "in"],
454
+ ];
455
+ for (const [button, target, limit, maxClicks, other] of limits) {
456
+ const control = await findTarget(page, root, target);
457
+ if (!control) continue;
458
+ let buttons = await inPage(page, zoomButtonsProbe, { root });
459
+ for (
460
+ let click = 0;
461
+ click < maxClicks && buttons[button]?.attributes["aria-disabled"] !== "true";
462
+ click++
463
+ ) {
464
+ await page.mouse.click(control.client.x, control.client.y);
465
+ await settle(page);
466
+ buttons = await inPage(page, zoomButtonsProbe, { root });
467
+ }
468
+ const { zoom } = await inPage(page, afterPressProbe, { root });
469
+ const reached = buttons[button];
470
+ if (reached?.attributes["aria-disabled"] !== "true") {
471
+ problems.push(
472
+ `zoom: [data-zoom="${button}"] is not aria-disabled after ${maxClicks} clicks (readout ${zoom ?? "?"}%)`,
473
+ );
474
+ } else {
475
+ if (zoom !== limit) {
476
+ problems.push(`zoom: [data-zoom="${button}"] is disabled at ${zoom ?? "?"}%, expected at ${limit}%`);
477
+ }
478
+ if (reached.disabled) problems.push(`zoom: [data-zoom="${button}"] is natively disabled at the limit`);
479
+ }
480
+ if (buttons[other]?.attributes["aria-disabled"] === "true") {
481
+ problems.push(`zoom: [data-zoom="${other}"] is aria-disabled at ${zoom ?? "?"}%`);
482
+ }
483
+ if (buttons.actual?.attributes["aria-disabled"] === "true") {
484
+ problems.push("zoom: the readout is disabled at a limit");
485
+ }
486
+ }
487
+ await page.mouse.move(PARK.x, PARK.y);
488
+ return problems;
489
+ }
490
+
491
+ const ariaCase: ShellConformanceCase = {
492
+ name: "aria",
493
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
494
+ assertHooksConsistent("aria", harness, SHOWN_ISLANDS);
495
+ const root = harness.root ?? DEFAULT_ROOT;
496
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
497
+ await harness.open(page, { size, requested: LIGHT });
498
+ await settle(page);
499
+ const facts = await inPage(page, ariaProbe, { root });
500
+ if (!facts) fail("aria", harness.host, [`no element matches the shell root selector "${root}"`]);
501
+ const problems = ariaProblems(facts, harness.host);
502
+ problems.push(...(await menuKeyboardProblems(page, root)));
503
+ problems.push(...(await paletteKeyboardProblems(page, root)));
504
+ problems.push(...(await paletteShortcutWalk(page, root)));
505
+ problems.push(...(await tabSequenceFromMenu(page, root)));
506
+ problems.push(...(await zoomLimitProblems(page, root)));
507
+
508
+ const shown = new Set<string>();
509
+ for (const island of SHOWN_ISLANDS) {
510
+ const show = harness.show?.[island];
511
+ if (!show) continue;
512
+ await show(page);
513
+ shown.add(island);
514
+ }
515
+ let final: AriaFacts = facts;
516
+ if (shown.size > 0) {
517
+ await settle(page);
518
+ const withIslands = await inPage(page, ariaProbe, { root });
519
+ if (withIslands) {
520
+ final = withIslands;
521
+ problems.push(...optionalIslandProblems(withIslands, shown));
522
+ const known = new Set(problems);
523
+ problems.push(
524
+ ...ariaProblems(withIslands, harness.host)
525
+ .filter((problem) => !known.has(problem))
526
+ .map((problem) => `with every island shown: ${problem}`),
527
+ );
528
+ }
529
+ }
530
+ problems.push(...absentIslandProblems(visibleIslands(final), harness.absent));
531
+ return finish("aria", harness.host, problems, missingHooks(harness, "the roles and names"));
532
+ },
533
+ };
534
+
535
+ const keysCase: ShellConformanceCase = {
536
+ name: "keys",
537
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
538
+ const root = harness.root ?? DEFAULT_ROOT;
539
+ const host = harness.host;
540
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
541
+ await harness.open(page, { size, requested: LIGHT });
542
+ await settle(page);
543
+ const problems: string[] = [];
544
+
545
+ if (!(await focusTarget(page, root, "canvas"))) {
546
+ fail("keys", host, ["canvas: the diagram-js <svg> (.djs-container > svg) cannot take focus"]);
547
+ }
548
+ await inPage(page, clearKeysProbe, null);
549
+ let current = (await inPage(page, afterPressProbe, { root })).zoom;
550
+ if (current === null) fail("keys", host, ['zoom: no readout [data-zoom="actual"] showing a percentage']);
551
+
552
+ const press = async (
553
+ key: string,
554
+ reach: KeyReach | undefined,
555
+ zoom: number | "unchanged" | undefined,
556
+ context = "on the canvas",
557
+ ) => {
558
+ const before = current;
559
+ await page.keyboard.press(key);
560
+ let after = await inPage(page, afterPressProbe, { root });
561
+ const where = `${key} ${context}`;
562
+ if (reach) {
563
+ const problem = keyReachProblem(where, reach, host, after);
564
+ if (problem) problems.push(problem);
565
+ }
566
+ if (zoom === undefined) return;
567
+ const expected = zoom === "unchanged" ? before : zoom;
568
+ // a readout that has not moved yet may still be waiting for its frame; a wrong one stays wrong
569
+ for (let poll = 0; after.zoom === before && expected !== before && poll < 10; poll++) {
570
+ await page.waitForTimeout(50);
571
+ after = await inPage(page, afterPressProbe, { root });
572
+ }
573
+ if (after.zoom !== expected) {
574
+ problems.push(
575
+ zoom === "unchanged"
576
+ ? `${where} changed the zoom from ${before}% to ${after.zoom ?? "?"}%; it must not act`
577
+ : `${where} zoomed to ${after.zoom ?? "?"}% from ${before}%, expected ${expected}% (exactly one step)`,
578
+ );
579
+ }
580
+ current = after.zoom ?? current;
581
+ };
582
+ const inside = async (kind: InjectedKind, context: string, steps: () => Promise<void>) => {
583
+ if (await inPage(page, injectProbe, { root, kind })) await steps();
584
+ else problems.push(`cannot focus ${context} stand-in inside the root`);
585
+ await inPage(page, removeInjectedProbe, null);
586
+ };
587
+
588
+ await press("Control+Digit0", "handled", 100);
589
+ const pairs: Array<[string, string]> = [
590
+ ["Control+Equal", "Control+Minus"],
591
+ ["Control+Shift+Equal", "Control+Shift+Minus"],
592
+ ["Meta+Equal", "Meta+Minus"],
593
+ ["Meta+Shift+Equal", "Meta+Shift+Minus"],
594
+ ["Control+NumpadAdd", "Control+NumpadSubtract"],
595
+ ["Meta+NumpadAdd", "Meta+NumpadSubtract"],
596
+ ];
597
+ for (const [zoomIn, zoomOut] of pairs) {
598
+ await press(zoomIn, "handled", ONE_STEP_UP);
599
+ await press(zoomOut, "handled", 100);
600
+ }
601
+ for (const zoomToActual of ["Meta+Digit0", "Control+Numpad0"]) {
602
+ await press("Control+Equal", "handled", ONE_STEP_UP);
603
+ await press(zoomToActual, "handled", 100);
604
+ }
605
+ for (const key of ["Equal", "Minus", "Shift+Equal", "Shift+Minus", "NumpadAdd", "NumpadSubtract"]) {
606
+ await press(key, "free", "unchanged", "on the canvas without Cmd/Ctrl");
607
+ }
608
+ for (const key of ["Digit0", "Control+Alt+Equal", "Control+Alt+Minus", "Control+Shift+Digit1"]) {
609
+ await press(key, undefined, "unchanged");
610
+ }
611
+ await press("F1", "free", undefined);
612
+ await press("Escape", "free", undefined, "on the canvas without a menu or dialog");
613
+ if (await inPage(page, canvasHandlesProbe, { root, key: "F9" })) {
614
+ await press("F9", "canvas", undefined, "handled by the canvas");
615
+ }
616
+ await inPage(page, removeInjectedProbe, null);
617
+ if (host === "ide") {
618
+ // Guide §13.5: on the canvas, undo and redo use the document's history, never the canvas's.
619
+ for (const key of UNDO_REDO_PRESSES) {
620
+ await press(key, "free", undefined, "on the canvas (undo and redo belong to the VS Code document)");
621
+ }
622
+ }
623
+ // Guide §13.5: keys the canvas uses act on the canvas only, select all included; in VS Code an
624
+ // unhandled Cmd/Ctrl+A would select the webview's text instead.
625
+ for (const key of ["Control+KeyA", "Meta+KeyA"]) {
626
+ await press(key, "canvas", undefined, "on the canvas (select all acts on the canvas only)");
627
+ }
628
+
629
+ await press("Control+Equal", "handled", ONE_STEP_UP);
630
+ for (const [kind, field] of TEXT_FIELDS) {
631
+ await inside(kind, field, async () => {
632
+ await press("Control+Equal", "blocked", "unchanged", `in ${field}`);
633
+ await press("Control+Minus", "blocked", "unchanged", `in ${field}`);
634
+ await press("Shift+Digit1", "free", "unchanged", `in ${field}`);
635
+ await press("Control+KeyZ", "blocked", undefined, `in ${field}`);
636
+ await press("Meta+Shift+KeyZ", "blocked", undefined, `in ${field}`);
637
+ await press("Control+KeyY", "blocked", undefined, `in ${field}`);
638
+ });
639
+ }
640
+ await inside("modal", "a modal dialog", async () => {
641
+ await press("Control+Equal", "blocked", "unchanged", "with a modal dialog open");
642
+ await press("Shift+Digit1", "free", "unchanged", "with a modal dialog open");
643
+ await press("Escape", "blocked", undefined, "with a modal dialog open");
644
+ });
645
+ await inside("popover", "a popover", async () => {
646
+ await press("Control+Minus", "handled", 100, "in a popover");
647
+ await press("Control+Equal", "handled", ONE_STEP_UP, "in a popover");
648
+ await press("Escape", "blocked", undefined, "with a popover open");
649
+ });
650
+
651
+ if (await openMenuByClick(page, root)) {
652
+ await inPage(page, clearKeysProbe, null);
653
+ await press("Escape", "handled", undefined, "with the menu open");
654
+ if (await findOverlay(page, root, "menu")) problems.push("Escape with the menu open did not close it");
655
+ }
656
+
657
+ await focusTarget(page, root, "canvas");
658
+ await press("Control+Equal", "handled", TWO_STEPS_UP);
659
+ await page.keyboard.press("Shift+Digit1");
660
+ let fitted = await inPage(page, afterPressProbe, { root });
661
+ const reachProblem = keyReachProblem("Shift+Digit1 on the canvas", "handled", host, fitted);
662
+ if (reachProblem) problems.push(reachProblem);
663
+ for (let poll = 0; (fitted.zoom === null || fitted.zoom > 100) && poll < 20; poll++) {
664
+ await page.waitForTimeout(50);
665
+ fitted = await inPage(page, afterPressProbe, { root });
666
+ }
667
+ if (fitted.zoom === null || fitted.zoom > 100) {
668
+ problems.push(
669
+ `Shift+Digit1 on the canvas left the zoom at ${fitted.zoom ?? "?"}%, expected a fit (at most 100%)`,
670
+ );
671
+ }
672
+ return finish("keys", host, problems, []);
673
+ },
674
+ };
675
+
676
+ async function appearanceFacts(page: PageLike, root: string): Promise<AppearanceFacts | null> {
677
+ const probe = () => inPage(page, appearanceProbe, { root, roles: MODE_ROLES });
678
+ await hover(page, root, "zoom-in");
679
+ const withTooltip = await probe();
680
+ const opened = await openMenuByClick(page, root);
681
+ const withMenu = await probe();
682
+ if (opened) await closeByEscape(page);
683
+ await page.mouse.move(PARK.x, PARK.y);
684
+ if (!withTooltip || !withMenu) return withTooltip ?? withMenu;
685
+ const names = new Set(withTooltip.parts.map((part) => part.name));
686
+ return {
687
+ ...withMenu,
688
+ parts: [...withTooltip.parts, ...withMenu.parts.filter((part) => !names.has(part.name))],
689
+ };
690
+ }
691
+
692
+ /** The palette's tool colours in the current state, read until a running colour transition is done. */
693
+ async function paletteColors(page: PageLike, root: string): Promise<string[]> {
694
+ const read = async () => {
695
+ const facts = await inPage(page, paletteColorProbe, { root });
696
+ return facts ? paletteColorProblems(facts) : [];
697
+ };
698
+ return pollUntil(page, read, (problems) => problems.length === 0, COLOR_WAIT_MS);
699
+ }
700
+
701
+ /**
702
+ * Guide §5.5: every tool entry at rest, hovered in turn, and the first tool armed by a click
703
+ * (pressed and hovered, then pressed with the pointer away), Escape afterwards.
704
+ */
705
+ async function paletteColorWalk(page: PageLike, root: string): Promise<string[]> {
706
+ const problems: string[] = [];
707
+ await page.mouse.move(PARK.x, PARK.y);
708
+ problems.push(...(await paletteColors(page, root)));
709
+ const tools = (await inPage(page, paletteEntriesProbe, { root })).filter(
710
+ (entry) => isToolEntry(entry) && !entry.disabled,
711
+ );
712
+ for (const tool of tools) {
713
+ await page.mouse.move(tool.client.x, tool.client.y);
714
+ problems.push(...(await paletteColors(page, root)));
715
+ await page.mouse.move(PARK.x, PARK.y);
716
+ }
717
+ const armed =
718
+ tools.find((tool) => tool.action === "lasso-tool") ?? tools.find((tool) => tool.action.endsWith("-tool"));
719
+ if (armed) {
720
+ await page.mouse.click(armed.client.x, armed.client.y);
721
+ await settle(page);
722
+ const pressed = (await inPage(page, paletteEntriesProbe, { root })).find(
723
+ (entry) => entry.action === armed.action,
724
+ );
725
+ if (!pressed?.pressed) {
726
+ problems.push(`palette: clicking tool "${armed.action}" does not set aria-pressed="true" (guide §5.5)`);
727
+ }
728
+ problems.push(...(await paletteColors(page, root)));
729
+ await page.mouse.move(PARK.x, PARK.y);
730
+ problems.push(...(await paletteColors(page, root)));
731
+ await closeByEscape(page);
732
+ }
733
+ return [...new Set(problems)];
734
+ }
735
+
736
+ const appearanceCase: ShellConformanceCase = {
737
+ name: "appearance",
738
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
739
+ const root = harness.root ?? DEFAULT_ROOT;
740
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
741
+ const problems: string[] = [];
742
+ for (const requested of REQUESTED_APPEARANCES) {
743
+ const at = `[requested ${appearanceLabel(requested)}]`;
744
+ await harness.open(page, { size, requested });
745
+ if (harness.host === "embedded") {
746
+ // The foreign page itself in that mode: a themed page around the modeler (guide §13.3).
747
+ await inPage(page, hostPageModeProbe, {
748
+ theme: requested.theme,
749
+ kontrast: requested.kontrast ?? null,
750
+ });
751
+ }
752
+ await settle(page);
753
+ const facts = await appearanceFacts(page, root);
754
+ if (!facts) {
755
+ problems.push(`${at} no element matches the shell root selector "${root}"`);
756
+ continue;
757
+ }
758
+ const verdict = appearanceProblems(facts, harness.host, requested, harness.themes);
759
+ problems.push(...verdict.problems.map((problem) => `${at} ${problem}`));
760
+ const surround = await inPage(page, surroundProbe, { root });
761
+ if (surround) problems.push(...surroundProblems(surround).map((problem) => `${at} ${problem}`));
762
+ problems.push(...(await paletteColorWalk(page, root)).map((problem) => `${at} ${problem}`));
763
+ }
764
+ return finish("appearance", harness.host, problems, []);
765
+ },
766
+ };
767
+
768
+ /** The visible shapes on the canvas. */
769
+ function shapes(page: PageLike, root: string): Promise<ShapeFacts[]> {
770
+ return inPage(page, shapesProbe, { root });
771
+ }
772
+
773
+ /** Moves the palette's roving focus by keyboard to `action`; `false` when it cannot get there. */
774
+ async function focusPaletteEntry(page: PageLike, root: string, action: string): Promise<boolean> {
775
+ const before = await inPage(page, paletteFocusProbe, { root });
776
+ if (!before || !before.entries.includes(action)) return false;
777
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) return false;
778
+ await page.keyboard.press("Home");
779
+ for (let step = 0; step <= before.entries.length; step++) {
780
+ if ((await inPage(page, paletteFocusProbe, { root }))?.active === action) return true;
781
+ await page.keyboard.press("ArrowRight");
782
+ }
783
+ return false;
784
+ }
785
+
786
+ /** The first entry that creates something: outside the tools group and not a tool. */
787
+ function firstCreatingEntry(entries: readonly PaletteEntryState[]): PaletteEntryState | undefined {
788
+ return entries.find((entry) => !isToolEntry(entry));
789
+ }
790
+
791
+ /** Enter on `entry`; the shapes once a new one appeared or `CREATE_WAIT_MS` passed. */
792
+ async function enterOnEntry(
793
+ page: PageLike,
794
+ root: string,
795
+ entry: string,
796
+ before: readonly ShapeFacts[],
797
+ ): Promise<ShapeFacts[] | undefined> {
798
+ if (!(await focusPaletteEntry(page, root, entry))) return undefined;
799
+ await page.keyboard.press("Enter");
800
+ await settle(page);
801
+ const known = new Set(before.map((shape) => shape.id));
802
+ return pollUntil(
803
+ page,
804
+ () => shapes(page, root),
805
+ (after) => after.some((shape) => !known.has(shape.id)),
806
+ CREATE_WAIT_MS,
807
+ );
808
+ }
809
+
810
+ const createCase: ShellConformanceCase = {
811
+ name: "create",
812
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
813
+ const root = harness.root ?? DEFAULT_ROOT;
814
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
815
+ await harness.open(page, { size, requested: LIGHT });
816
+ await settle(page);
817
+ const entry = firstCreatingEntry(await inPage(page, paletteEntriesProbe, { root }));
818
+ // A notation whose palette only switches tools has nothing to place.
819
+ if (!entry) return undefined;
820
+ await page.mouse.move(PARK.x, PARK.y);
821
+ const free = freeRectangle(await measureChrome(page, root), harness.host);
822
+ const before = await shapes(page, root);
823
+ const after = await enterOnEntry(page, root, entry.action, before);
824
+ if (!after) {
825
+ fail("create", harness.host, [`create: the palette's roving focus does not reach "${entry.action}"`]);
826
+ }
827
+ const canvas = await inPage(page, canvasStateProbe, { root });
828
+ const problems = createProblems({ entry: entry.action, before, after, canvas, free });
829
+ await closeByEscape(page);
830
+ return finish("create", harness.host, problems, []);
831
+ },
832
+ };
833
+
834
+ /** The open menu's entries, opened by a click and closed again by Escape. */
835
+ async function menuEntries(page: PageLike, root: string): Promise<MenuEntryFacts[]> {
836
+ if (!(await openMenuByClick(page, root))) return [];
837
+ const entries = (await inPage(page, menuEntriesProbe, { root })) ?? [];
838
+ await closeByEscape(page);
839
+ return entries;
840
+ }
841
+
842
+ /** A shape whose centre a pointer reaches (no chrome above it), found again by its id. */
843
+ async function reachableShape(page: PageLike, root: string, id?: string): Promise<ShapeFacts | undefined> {
844
+ const all = await shapes(page, root);
845
+ return id === undefined ? all.find((shape) => shape.hit) : all.find((shape) => shape.id === id);
846
+ }
847
+
848
+ /** Guide §13.5, "Was der Schreibschutz sperrt", on one shape: select, drag, double-click, delete. */
849
+ async function lockedShapeChecks(page: PageLike, root: string, shape: ShapeFacts): Promise<string[]> {
850
+ await page.mouse.click(shape.client.x, shape.client.y);
851
+ await settle(page);
852
+ const selected = await inPage(page, canvasStateProbe, { root });
853
+
854
+ const before = (await reachableShape(page, root, shape.id)) ?? shape;
855
+ await page.mouse.move(before.client.x, before.client.y);
856
+ await page.mouse.down();
857
+ for (let step = 1; step <= 4; step++) {
858
+ await page.mouse.move(before.client.x + (LOCKED_DRAG_PX * step) / 4, before.client.y);
859
+ }
860
+ await page.mouse.up();
861
+ await settle(page);
862
+ const dragged = await reachableShape(page, root, shape.id);
863
+
864
+ const target = dragged ?? before;
865
+ await page.mouse.dblclick(target.client.x, target.client.y);
866
+ await settle(page);
867
+ const { textFields } = await inPage(page, canvasStateProbe, { root });
868
+ await closeByEscape(page);
869
+
870
+ const kept = { Delete: true, Backspace: true };
871
+ for (const key of ["Delete", "Backspace"] as const) {
872
+ const current = await reachableShape(page, root, shape.id);
873
+ if (!current) break;
874
+ // a key that deletes the selection must not miss it; a click on a selected shape deselects it
875
+ if (!current.selected) await page.mouse.click(current.client.x, current.client.y);
876
+ await focusTarget(page, root, "canvas");
877
+ await page.keyboard.press(key);
878
+ await settle(page);
879
+ kept[key] = (await reachableShape(page, root, shape.id)) !== undefined;
880
+ }
881
+ await page.mouse.move(PARK.x, PARK.y);
882
+ return lockedShapeProblems({
883
+ contextPadEntries: selected.contextPadEntries,
884
+ dragged: { before, after: dragged },
885
+ textFields,
886
+ kept,
887
+ });
888
+ }
889
+
890
+ const readOnlyCase: ShellConformanceCase = {
891
+ name: "readOnly",
892
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
893
+ assertHooksConsistent("readOnly", harness, ["invalid"]);
894
+ const invalid = harness.show?.invalid;
895
+ if (!invalid) {
896
+ if (harness.absent?.includes("invalid")) return undefined;
897
+ return {
898
+ skipped: [
899
+ {
900
+ hook: "invalid",
901
+ reason: "show.invalid is undefined: the read-only canvas of an invalid file was not verified",
902
+ },
903
+ ],
904
+ };
905
+ }
906
+ const root = harness.root ?? DEFAULT_ROOT;
907
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
908
+ const problems: string[] = [];
909
+ const skipped: ShellSkip[] = [];
910
+ await harness.open(page, { size, requested: LIGHT });
911
+ await settle(page);
912
+ const menuBefore = await menuEntries(page, root);
913
+
914
+ await invalid(page);
915
+ await page.waitForTimeout(INVALID_STATUS_DELAY_MS);
916
+ const state = await pollUntil(
917
+ page,
918
+ () => inPage(page, readOnlyStateProbe, { root }),
919
+ (facts) => facts === null || readOnlyStateProblems(facts).length === 0,
920
+ READ_ONLY_WAIT_MS,
921
+ );
922
+ if (!state) fail("readOnly", harness.host, [`no element matches the shell root selector "${root}"`]);
923
+ await settle(page);
924
+ problems.push(...readOnlyStateProblems(state));
925
+
926
+ const entries = await inPage(page, paletteEntriesProbe, { root });
927
+ problems.push(...readOnlyPaletteProblems(entries));
928
+ problems.push(...(await paletteColors(page, root)));
929
+ const creating = firstCreatingEntry(entries);
930
+ if (creating) {
931
+ const before = await shapes(page, root);
932
+ const after = await enterOnEntry(page, root, creating.action, before);
933
+ const known = new Set(before.map((shape) => shape.id));
934
+ const added = (after ?? []).filter((shape) => !known.has(shape.id));
935
+ if (added.length > 0) {
936
+ problems.push(
937
+ `read-only: Enter on palette entry "${creating.action}" added ${added.map((shape) => `"${shape.id}"`).join(", ")} (guide §13.5)`,
938
+ );
939
+ }
940
+ await closeByEscape(page);
941
+ }
942
+
943
+ problems.push(...readOnlyMenuProblems(menuBefore, await menuEntries(page, root)));
944
+
945
+ const shape = await reachableShape(page, root);
946
+ if (shape) {
947
+ problems.push(...(await lockedShapeChecks(page, root, shape)));
948
+ } else {
949
+ skipped.push({
950
+ hook: "open",
951
+ reason:
952
+ "the opened document shows no shape a pointer reaches: context pad, move, delete and label editor of the read-only canvas not verified",
953
+ });
954
+ }
955
+ return finish("readOnly", harness.host, problems, skipped);
956
+ },
957
+ };
958
+
959
+ const cleanupCase: ShellConformanceCase = {
960
+ name: "cleanup",
961
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
962
+ if (!harness.destroy) {
963
+ return {
964
+ skipped: [
965
+ {
966
+ hook: "destroy",
967
+ reason: "harness.destroy is undefined: the cleanup after destroy was not verified",
968
+ },
969
+ ],
970
+ };
971
+ }
972
+ const root = harness.root ?? DEFAULT_ROOT;
973
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
974
+ await harness.open(page, { size, requested: LIGHT });
975
+ await settle(page);
976
+ const armed = await inPage(page, armCleanupProbe, { root });
977
+ if (!armed) fail("cleanup", harness.host, [`no element matches the shell root selector "${root}"`]);
978
+ const problems: string[] = [];
979
+
980
+ // A hover leaves the tooltip timer pending, so a timer that outlives destroy shows up below.
981
+ const zoomIn = await findTarget(page, root, "zoom-in");
982
+ if (zoomIn) {
983
+ await page.mouse.move(PARK.x, PARK.y);
984
+ await page.mouse.move(zoomIn.client.x, zoomIn.client.y);
985
+ }
986
+ try {
987
+ await harness.destroy(page);
988
+ } catch (error) {
989
+ problems.push(`destroy threw: ${error instanceof Error ? error.message : String(error)}`);
990
+ }
991
+ await page.waitForTimeout(Math.max(INVALID_STATUS_DELAY_MS, TOOLTIP_DELAY_MS) + HOVER_MARGIN_MS);
992
+ await page.mouse.move(PARK.x, PARK.y);
993
+ await page.setViewportSize({ width: size.width - 40, height: size.height - 40 });
994
+ await settle(page);
995
+
996
+ await inPage(page, injectProbe, { root, kind: "focus-target" });
997
+ await inPage(page, clearKeysProbe, null);
998
+ for (const key of ["Control+Equal", "Control+Minus", "Control+Digit0", "Shift+Digit1"]) {
999
+ await page.keyboard.press(key);
1000
+ const { keys } = await inPage(page, afterPressProbe, { root });
1001
+ const problem = afterDestroyKeyProblem(key, keys);
1002
+ if (problem) problems.push(problem);
1003
+ }
1004
+ await inPage(page, removeInjectedProbe, null);
1005
+ problems.push(...leftoverProblems(await inPage(page, leftoverProbe, null), harness.host));
1006
+ return finish("cleanup", harness.host, problems, []);
1007
+ },
1008
+ };
1009
+
1010
+ export function createCases(): ShellConformanceCase[] {
1011
+ return [geometryCase, ariaCase, keysCase, createCase, readOnlyCase, appearanceCase, cleanupCase];
1012
+ }