@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,434 @@
1
+ import type { Size } from "../core/index.js";
2
+ import type { PageLike } from "./page.js";
3
+
4
+ /*
5
+ * Everything here that is handed to `page.evaluate` runs in the browser: Playwright serializes the
6
+ * function source, so these functions must not reference anything outside their own body. They
7
+ * only collect facts; every judgement happens in Node (`checks.ts`, `geometry.ts`), where it is
8
+ * unit-tested. `installPageTools` puts the shared helpers and the recorders under one page global.
9
+ */
10
+
11
+ /** A box relative to the shell root's padding box (where `position: absolute` islands anchor). */
12
+ export interface RelativeBox {
13
+ left: number;
14
+ top: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ /** A `keydown` that reached the kit's listener on `window` (bubble phase), where VS Code's host page listens. */
20
+ export interface RecordedKey {
21
+ key: string;
22
+ code: string;
23
+ ctrlKey: boolean;
24
+ metaKey: boolean;
25
+ shiftKey: boolean;
26
+ altKey: boolean;
27
+ defaultPrevented: boolean;
28
+ }
29
+
30
+ /** The facts the kit reads about one element. */
31
+ export interface ElementFacts {
32
+ tag: string;
33
+ description: string;
34
+ attributes: Record<string, string>;
35
+ text: string;
36
+ visible: boolean;
37
+ /** The native `disabled` property (a disabled `<button>` loses focus, `aria-disabled` keeps it). */
38
+ disabled: boolean;
39
+ }
40
+
41
+ /** The helpers and recorders `installPageTools` keeps under the page global. */
42
+ export interface PageTools {
43
+ /** `keydown`s that reached `window`, where VS Code's host page listens. */
44
+ keys: RecordedKey[];
45
+ /**
46
+ * `keydown`s seen on `document` after every listener registered before the kit's (the shell's,
47
+ * the modeler's), so `defaultPrevented` is final even where the shell stops the key.
48
+ */
49
+ documentKeys: RecordedKey[];
50
+ errors: string[];
51
+ injected: Element[];
52
+ cleanups: Array<() => void>;
53
+ armedRoot?: HTMLElement;
54
+ frame(): Promise<void>;
55
+ settle(): Promise<void>;
56
+ visible(element: Element): boolean;
57
+ container(root: HTMLElement): Size;
58
+ box(element: Element, root: HTMLElement): RelativeBox;
59
+ describe(element: Element): string;
60
+ facts(element: Element): ElementFacts;
61
+ }
62
+
63
+ export type ToolWindow = Window & { __modelerShellConformance?: PageTools };
64
+
65
+ /** Installs the in-page helpers once per document (idempotent; a navigation drops them). */
66
+ export const installPageTools = (_: null): void => {
67
+ const scope = window as ToolWindow;
68
+ if (scope.__modelerShellConformance) return;
69
+ const modifierKeys = ["Control", "Meta", "Shift", "Alt", "AltGraph", "CapsLock", "Fn"];
70
+ const frame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
71
+ const borders = (element: Element) => {
72
+ const style = getComputedStyle(element);
73
+ return {
74
+ left: parseFloat(style.borderLeftWidth) || 0,
75
+ top: parseFloat(style.borderTopWidth) || 0,
76
+ right: parseFloat(style.borderRightWidth) || 0,
77
+ bottom: parseFloat(style.borderBottomWidth) || 0,
78
+ };
79
+ };
80
+ const visible = (element: Element): boolean => {
81
+ if (!element.isConnected) return false;
82
+ if (typeof element.checkVisibility === "function") {
83
+ if (!element.checkVisibility({ visibilityProperty: true })) return false;
84
+ } else {
85
+ if (element.closest("[hidden]")) return false;
86
+ for (let node: Element | null = element; node; node = node.parentElement) {
87
+ if (getComputedStyle(node).display === "none") return false;
88
+ }
89
+ if (getComputedStyle(element).visibility === "hidden") return false;
90
+ }
91
+ const rect = element.getBoundingClientRect();
92
+ return rect.width > 0 && rect.height > 0;
93
+ };
94
+ const describe = (element: Element): string => {
95
+ const id = element.id ? `#${element.id}` : "";
96
+ const classes = [...element.classList]
97
+ .slice(0, 3)
98
+ .map((name) => `.${name}`)
99
+ .join("");
100
+ const chrome = element.getAttribute("data-chrome");
101
+ return `${element.tagName.toLowerCase()}${id}${classes}${chrome ? `[data-chrome="${chrome}"]` : ""}`;
102
+ };
103
+ const tools: PageTools = {
104
+ keys: [],
105
+ documentKeys: [],
106
+ errors: [],
107
+ injected: [],
108
+ cleanups: [],
109
+ frame,
110
+ async settle() {
111
+ await frame();
112
+ await frame();
113
+ const running =
114
+ typeof document.getAnimations === "function"
115
+ ? document.getAnimations().filter((animation) => {
116
+ const end = animation.effect?.getComputedTiming().endTime;
117
+ return animation.playState === "running" && typeof end === "number" && Number.isFinite(end);
118
+ })
119
+ : [];
120
+ if (running.length > 0) {
121
+ await Promise.race([
122
+ Promise.allSettled(running.map((animation) => animation.finished)),
123
+ new Promise((resolve) => setTimeout(resolve, 2000)),
124
+ ]);
125
+ }
126
+ await frame();
127
+ },
128
+ visible,
129
+ container(root) {
130
+ const rect = root.getBoundingClientRect();
131
+ const border = borders(root);
132
+ return {
133
+ width: rect.width - border.left - border.right,
134
+ height: rect.height - border.top - border.bottom,
135
+ };
136
+ },
137
+ box(element, root) {
138
+ const rect = element.getBoundingClientRect();
139
+ const origin = root.getBoundingClientRect();
140
+ const border = borders(root);
141
+ return {
142
+ left: rect.left - origin.left - border.left,
143
+ top: rect.top - origin.top - border.top,
144
+ width: rect.width,
145
+ height: rect.height,
146
+ };
147
+ },
148
+ describe,
149
+ facts(element) {
150
+ const attributes: Record<string, string> = {};
151
+ for (const name of element.getAttributeNames()) attributes[name] = element.getAttribute(name) ?? "";
152
+ return {
153
+ tag: element.tagName.toLowerCase(),
154
+ description: describe(element),
155
+ attributes,
156
+ text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
157
+ visible: visible(element),
158
+ disabled: Boolean((element as HTMLButtonElement).disabled),
159
+ };
160
+ },
161
+ };
162
+ const recorder = (into: RecordedKey[]) => (event: KeyboardEvent) => {
163
+ if (modifierKeys.includes(event.key)) return;
164
+ into.push({
165
+ key: event.key,
166
+ code: event.code,
167
+ ctrlKey: event.ctrlKey,
168
+ metaKey: event.metaKey,
169
+ shiftKey: event.shiftKey,
170
+ altKey: event.altKey,
171
+ defaultPrevented: event.defaultPrevented,
172
+ });
173
+ };
174
+ document.addEventListener("keydown", recorder(tools.documentKeys));
175
+ window.addEventListener("keydown", recorder(tools.keys));
176
+ window.addEventListener("error", (event) => {
177
+ tools.errors.push(event.message || String(event.error));
178
+ });
179
+ window.addEventListener("unhandledrejection", (event) => {
180
+ tools.errors.push(`unhandled rejection: ${String(event.reason)}`);
181
+ });
182
+ scope.__modelerShellConformance = tools;
183
+ };
184
+
185
+ /** Runs `pageFunction` in the page after making sure the helpers are installed. */
186
+ export async function inPage<Arg, Result>(
187
+ page: PageLike,
188
+ pageFunction: (arg: Arg) => Result | Promise<Result>,
189
+ arg: Arg,
190
+ ): Promise<Result> {
191
+ await page.evaluate(installPageTools, null);
192
+ return page.evaluate(pageFunction, arg);
193
+ }
194
+
195
+ /** Two frames plus every finite running animation (pop-ins, slide-ins), capped at 2s. */
196
+ export const settleInPage = (_: null): Promise<void> =>
197
+ (window as ToolWindow).__modelerShellConformance!.settle();
198
+
199
+ export function settle(page: PageLike): Promise<void> {
200
+ return inPage(page, settleInPage, null);
201
+ }
202
+
203
+ /** The controls the kit hovers, clicks or focuses, found inside the shell root. */
204
+ export type ChromeTarget =
205
+ | "menu"
206
+ | "palette-entry"
207
+ | "palette-tab-stop"
208
+ | "zoom-out"
209
+ | "zoom-actual"
210
+ | "zoom-in"
211
+ | "zoom-fit"
212
+ | "canvas";
213
+
214
+ export const CHROME_TARGET_SELECTORS: Readonly<Record<ChromeTarget, string>> = {
215
+ menu: '[data-chrome="menu"]',
216
+ "palette-entry": '[data-chrome="palette"] [data-action]',
217
+ "palette-tab-stop": '[data-chrome="palette"] [data-action][tabindex="0"]',
218
+ "zoom-out": '[data-chrome="zoom"] [data-zoom="out"]',
219
+ "zoom-actual": '[data-chrome="zoom"] [data-zoom="actual"]',
220
+ "zoom-in": '[data-chrome="zoom"] [data-zoom="in"]',
221
+ "zoom-fit": '[data-chrome="zoom"] [data-zoom="fit"]',
222
+ canvas: ".djs-container > svg",
223
+ };
224
+
225
+ export interface TargetFacts {
226
+ /** Viewport coordinates of the centre, for `page.mouse`. */
227
+ client: { x: number; y: number };
228
+ box: RelativeBox;
229
+ /** `aria-label`, else the text. */
230
+ label: string;
231
+ }
232
+
233
+ /** The first visible match of `selector` inside the root; `null` when there is none. */
234
+ export const targetProbe = ({ root, selector }: { root: string; selector: string }): TargetFacts | null => {
235
+ const tools = (window as ToolWindow).__modelerShellConformance!;
236
+ const rootElement = document.querySelector<HTMLElement>(root);
237
+ if (!rootElement) return null;
238
+ const element = [...rootElement.querySelectorAll(selector)].find((candidate) => tools.visible(candidate));
239
+ if (!element) return null;
240
+ const rect = element.getBoundingClientRect();
241
+ return {
242
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
243
+ box: tools.box(element, rootElement),
244
+ label: (element.getAttribute("aria-label") ?? element.textContent ?? "").replace(/\s+/g, " ").trim(),
245
+ };
246
+ };
247
+
248
+ export async function findTarget(
249
+ page: PageLike,
250
+ root: string,
251
+ target: ChromeTarget,
252
+ ): Promise<TargetFacts | null> {
253
+ return inPage(page, targetProbe, { root, selector: CHROME_TARGET_SELECTORS[target] });
254
+ }
255
+
256
+ /** Focuses the first match of `selector` inside the root; the description of the focused element. */
257
+ export const focusProbe = ({ root, selector }: { root: string; selector: string }): string | null => {
258
+ const tools = (window as ToolWindow).__modelerShellConformance!;
259
+ const element = document.querySelector(root)?.querySelector<HTMLElement | SVGElement>(selector);
260
+ if (!element) return null;
261
+ element.focus();
262
+ return document.activeElement === element ? tools.describe(element) : null;
263
+ };
264
+
265
+ export async function focusTarget(
266
+ page: PageLike,
267
+ root: string,
268
+ target: ChromeTarget,
269
+ ): Promise<string | null> {
270
+ return inPage(page, focusProbe, { root, selector: CHROME_TARGET_SELECTORS[target] });
271
+ }
272
+
273
+ /** A visible popup with `role` inside the root (the dropdown, the chrome tooltip). */
274
+ export interface OverlayFacts {
275
+ box: RelativeBox;
276
+ text: string;
277
+ /** How many visible elements carry the role. */
278
+ count: number;
279
+ id: string;
280
+ }
281
+
282
+ export const overlayProbe = ({ root, role }: { root: string; role: string }): OverlayFacts | null => {
283
+ const tools = (window as ToolWindow).__modelerShellConformance!;
284
+ const rootElement = document.querySelector<HTMLElement>(root);
285
+ if (!rootElement) return null;
286
+ const shown = [...rootElement.querySelectorAll(`[role="${role}"]`)].filter((element) =>
287
+ tools.visible(element),
288
+ );
289
+ const first = shown[0];
290
+ if (!first) return null;
291
+ return {
292
+ box: tools.box(first, rootElement),
293
+ text: (first.textContent ?? "").replace(/\s+/g, " ").trim(),
294
+ count: shown.length,
295
+ id: first.id,
296
+ };
297
+ };
298
+
299
+ export async function findOverlay(
300
+ page: PageLike,
301
+ root: string,
302
+ role: "menu" | "tooltip",
303
+ ): Promise<OverlayFacts | null> {
304
+ return inPage(page, overlayProbe, { root, role });
305
+ }
306
+
307
+ export interface PressFacts {
308
+ /** The readout as a whole percentage; `null` without a readout. */
309
+ zoom: number | null;
310
+ keys: RecordedKey[];
311
+ documentKeys: RecordedKey[];
312
+ }
313
+
314
+ /** After a key press: settles, then reads the readout and takes the keys `window` and `document` saw. */
315
+ export const afterPressProbe = async ({ root }: { root: string }): Promise<PressFacts> => {
316
+ const tools = (window as ToolWindow).__modelerShellConformance!;
317
+ await tools.settle();
318
+ const readout = document
319
+ .querySelector(root)
320
+ ?.querySelector('[data-chrome="zoom"] [data-zoom="actual"]')?.textContent;
321
+ const digits = (readout ?? "").replace(/[^0-9]/g, "");
322
+ return {
323
+ zoom: digits ? Number(digits) : null,
324
+ keys: tools.keys.splice(0),
325
+ documentKeys: tools.documentKeys.splice(0),
326
+ };
327
+ };
328
+
329
+ /** Drops what `window` saw so far. */
330
+ export const clearKeysProbe = (_: null): void => {
331
+ const tools = (window as ToolWindow).__modelerShellConformance!;
332
+ tools.keys.splice(0);
333
+ tools.documentKeys.splice(0);
334
+ };
335
+
336
+ export type InjectedKind = "input" | "textarea" | "contenteditable" | "modal" | "popover" | "focus-target";
337
+
338
+ /**
339
+ * Adds a stand-in for what modelers render inside the shell (text fields: an input, a textarea, an
340
+ * editable label; a modal dialog, a popover, a plain focus target), inside the root or, once it is
341
+ * gone, the body; focuses it.
342
+ */
343
+ export const injectProbe = ({ root, kind }: { root: string; kind: InjectedKind }): boolean => {
344
+ const tools = (window as ToolWindow).__modelerShellConformance!;
345
+ const parent = document.querySelector(root) ?? tools.armedRoot ?? document.body;
346
+ const host = parent.isConnected ? parent : document.body;
347
+ const element =
348
+ kind === "input" || kind === "textarea"
349
+ ? document.createElement(kind)
350
+ : (document.createElement("div") as HTMLElement);
351
+ element.setAttribute("data-modeler-shell-conformance", kind);
352
+ element.style.cssText = "position:absolute;left:0;top:0;width:40px;height:20px;";
353
+ if (kind === "modal" || kind === "popover") {
354
+ element.setAttribute("role", "dialog");
355
+ element.setAttribute("aria-label", "Conformance stand-in");
356
+ if (kind === "modal") element.setAttribute("aria-modal", "true");
357
+ }
358
+ if (kind === "contenteditable") element.setAttribute("contenteditable", "true");
359
+ else if (kind !== "input" && kind !== "textarea") element.tabIndex = -1;
360
+ host.append(element);
361
+ tools.injected.push(element);
362
+ element.focus();
363
+ return document.activeElement === element;
364
+ };
365
+
366
+ export const removeInjectedProbe = (_: null): void => {
367
+ const tools = (window as ToolWindow).__modelerShellConformance!;
368
+ for (const element of tools.injected.splice(0)) element.remove();
369
+ for (const cleanup of tools.cleanups.splice(0)) cleanup();
370
+ };
371
+
372
+ /** Makes the canvas `<svg>` handle `key` (`preventDefault`), as a modeler's own canvas binding would. */
373
+ export const canvasHandlesProbe = ({ root, key }: { root: string; key: string }): boolean => {
374
+ const tools = (window as ToolWindow).__modelerShellConformance!;
375
+ const svg = document.querySelector(root)?.querySelector(".djs-container > svg");
376
+ if (!svg) return false;
377
+ const listener = (event: Event) => {
378
+ if ((event as KeyboardEvent).key === key) event.preventDefault();
379
+ };
380
+ svg.addEventListener("keydown", listener);
381
+ tools.cleanups.push(() => svg.removeEventListener("keydown", listener));
382
+ return true;
383
+ };
384
+
385
+ /** Remembers the root right before `destroy` (its class is gone afterwards) and clears the recorders. */
386
+ export const armCleanupProbe = ({ root }: { root: string }): boolean => {
387
+ const tools = (window as ToolWindow).__modelerShellConformance!;
388
+ const rootElement = document.querySelector<HTMLElement>(root);
389
+ if (rootElement) tools.armedRoot = rootElement;
390
+ tools.errors.splice(0);
391
+ tools.keys.splice(0);
392
+ tools.documentKeys.splice(0);
393
+ return Boolean(rootElement);
394
+ };
395
+
396
+ /** What the shell left behind after `destroy`. */
397
+ export interface LeftoverFacts {
398
+ /** Elements (other than `<html>`) with a `cd-shell*` class. */
399
+ classedElements: string[];
400
+ /** `cd-shell*` classes on `<html>`. */
401
+ documentClasses: string[];
402
+ /** Elements still carrying `data-chrome`. */
403
+ chromeElements: string[];
404
+ /** The remembered root's `class` and attributes. */
405
+ rootClasses: string[];
406
+ rootAttributes: string[];
407
+ errors: string[];
408
+ }
409
+
410
+ export const leftoverProbe = (_: null): LeftoverFacts => {
411
+ const tools = (window as ToolWindow).__modelerShellConformance!;
412
+ const ours = (element: Element) => element.hasAttribute("data-modeler-shell-conformance");
413
+ const shellClass = (name: string) => name.startsWith("cd-shell");
414
+ const root = tools.armedRoot;
415
+ return {
416
+ classedElements: [...document.querySelectorAll("[class]")]
417
+ .filter((element) => element !== document.documentElement && !ours(element))
418
+ .filter((element) => [...element.classList].some(shellClass))
419
+ .map((element) => tools.describe(element)),
420
+ documentClasses: [...document.documentElement.classList].filter(shellClass),
421
+ chromeElements: [...document.querySelectorAll("[data-chrome]")].map((element) => tools.describe(element)),
422
+ rootClasses: root ? [...root.classList].filter(shellClass) : [],
423
+ rootAttributes: root ? root.getAttributeNames() : [],
424
+ errors: [...tools.errors],
425
+ };
426
+ };
427
+
428
+ /** Puts a foreign page's `<html>` in a mode, as a themed host page around an embedded modeler. */
429
+ export const hostPageModeProbe = ({ theme, kontrast }: { theme: string; kontrast: string | null }): void => {
430
+ const html = document.documentElement;
431
+ html.setAttribute("data-cd-theme", theme);
432
+ if (kontrast) html.setAttribute("data-cd-kontrast", kontrast);
433
+ else html.removeAttribute("data-cd-kontrast");
434
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The part of Playwright's `Page` the kit drives, typed structurally: the kit has no runtime
3
+ * dependency on Playwright, the consumer's e2e suite passes its own `page`.
4
+ */
5
+ export interface PageLike {
6
+ evaluate<R, Arg>(pageFunction: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>;
7
+ setViewportSize(size: { width: number; height: number }): Promise<void>;
8
+ keyboard: { press(key: string): Promise<void> };
9
+ mouse: {
10
+ click(x: number, y: number): Promise<void>;
11
+ dblclick(x: number, y: number): Promise<void>;
12
+ move(x: number, y: number): Promise<void>;
13
+ down(): Promise<void>;
14
+ up(): Promise<void>;
15
+ };
16
+ waitForTimeout(timeout: number): Promise<void>;
17
+ }
@@ -0,0 +1,237 @@
1
+ import type { Appearance, ColorThemeSetting, Theme } from "../core/index.js";
2
+ import {
3
+ connectThemeBridge,
4
+ currentAppearance,
5
+ renderWebviewHtml,
6
+ type ColorThemeLike,
7
+ type DisposableLike,
8
+ type VsCodeLike,
9
+ type WebviewPanelLike,
10
+ } from "../vscode/extension/index.js";
11
+
12
+ /*
13
+ * Runs in the page, in front of a modeler's webview bundle: a stand-in for the VS Code around it,
14
+ * so a modeler's e2e suite runs the conformance kit for the `ide` host without VS Code.
15
+ */
16
+
17
+ /** `vscode.ColorThemeKind`: 1 Light, 2 Dark, 3 High Contrast, 4 High Contrast Light. */
18
+ export type ColorThemeKind = 1 | 2 | 3 | 4;
19
+
20
+ /** The body classes VS Code's webview host page sets per color theme (High Contrast Light keeps both). */
21
+ const BODY_CLASSES: Readonly<Record<ColorThemeKind, readonly string[]>> = {
22
+ 1: ["vscode-light"],
23
+ 2: ["vscode-dark"],
24
+ 3: ["vscode-high-contrast"],
25
+ 4: ["vscode-high-contrast-light", "vscode-high-contrast"],
26
+ };
27
+ const ALL_BODY_CLASSES = Object.values(BODY_CLASSES).flat();
28
+ const REDUCE_MOTION_BODY_CLASS = "vscode-reduce-motion";
29
+
30
+ /**
31
+ * The rules of VS Code's default webview stylesheet the shell's own-document reset must beat
32
+ * (implementation notes, "VS Code: host hygiene"), with deliberately foreign values so a leak shows.
33
+ */
34
+ const VSCODE_DEFAULT_STYLES = `@layer vscode-default {
35
+ body { margin: 0; padding: 0 20px; color: currentColor; font-family: serif; font-size: 17px; }
36
+ kbd { box-shadow: inset 0 -1px 0 currentColor; vertical-align: middle; padding: 1px 3px; border-radius: 3px; font-family: serif; }
37
+ code { font-family: serif; padding: 1px 3px; border-radius: 4px; }
38
+ a:focus, input:focus, select:focus, textarea:focus { outline: 1px solid -webkit-focus-ring-color; outline-offset: -1px; }
39
+ }`;
40
+
41
+ /** The default setting key the stub answers when `colorTheme` is given without one. */
42
+ export const STUB_SETTING_KEY = "miragon.modeler.colorTheme";
43
+
44
+ /** The VS Code color theme that requests `appearance` (guide §13.3). */
45
+ export function colorThemeKindOf(appearance: Appearance): ColorThemeKind {
46
+ if (appearance.kontrast) return appearance.theme === "dark" ? 3 : 4;
47
+ return appearance.theme === "dark" ? 2 : 1;
48
+ }
49
+
50
+ export interface VsCodeWebviewStubOptions {
51
+ /** What VS Code requests: its color theme, as the appearance the kit asks for. */
52
+ requested: Appearance;
53
+ /** The modes the modeler declares (what its extension passes to the theme bridge). */
54
+ themes: readonly Theme[];
55
+ /** `<html lang>`; default `"en"`. */
56
+ lang?: string | undefined;
57
+ /** The value of the modeler's `colorTheme` setting (from its dark-mode release on). */
58
+ colorTheme?: ColorThemeSetting | undefined;
59
+ /** The full setting key the stub answers and the bridge listens to; default `STUB_SETTING_KEY`. */
60
+ settingKey?: string | undefined;
61
+ /** VS Code's `workbench.reduceMotion` (the body class `vscode-reduce-motion`). */
62
+ reduceMotion?: boolean | undefined;
63
+ /** What `getState()` of the stubbed `acquireVsCodeApi()` returns at first. */
64
+ state?: unknown;
65
+ }
66
+
67
+ /** The stubbed `acquireVsCodeApi()`. */
68
+ export interface StubbedVsCodeApi {
69
+ getState(): unknown;
70
+ setState(state: unknown): void;
71
+ postMessage(message: unknown): void;
72
+ }
73
+
74
+ export interface VsCodeWebviewStub {
75
+ readonly kind: ColorThemeKind;
76
+ /** What VS Code does on a color theme change: body classes first, then the extension's bridge posts. */
77
+ setColorThemeKind(kind: ColorThemeKind): void;
78
+ /** Changes the `colorTheme` setting, as the user does in the settings editor. */
79
+ setColorTheme(setting: ColorThemeSetting | undefined): void;
80
+ setReduceMotion(reduceMotion: boolean): void;
81
+ /** Messages the bridge posted that have not reached the window yet. */
82
+ pendingMessages(): number;
83
+ /** What the webview posted with `acquireVsCodeApi().postMessage`. */
84
+ readonly posted: readonly unknown[];
85
+ /** The webview state as the last `setState` left it. */
86
+ state(): unknown;
87
+ }
88
+
89
+ /** The page globals the stub sets. */
90
+ export type StubWindow = Window & {
91
+ /** The stub of `installVsCodeWebviewStub`, for a Playwright suite to drive. */
92
+ __modelerShellVsCodeStub?: VsCodeWebviewStub;
93
+ acquireVsCodeApi?: () => StubbedVsCodeApi;
94
+ };
95
+
96
+ /**
97
+ * The stub's options from a query string (`theme`, `kontrast=hoch`, `colorTheme`, `reduceMotion`),
98
+ * the counterpart of `vsCodeStubSearch` of `@miragon/modeler-shell/testing`.
99
+ */
100
+ export function stubOptionsFromSearch(
101
+ search: string,
102
+ ): Pick<VsCodeWebviewStubOptions, "requested" | "colorTheme" | "reduceMotion"> {
103
+ const params = new URLSearchParams(search);
104
+ const theme: Theme = params.get("theme") === "dark" ? "dark" : "light";
105
+ const setting = params.get("colorTheme");
106
+ return {
107
+ requested: params.get("kontrast") === "hoch" ? { theme, kontrast: "hoch" } : { theme },
108
+ colorTheme: setting === "automatic" || setting === "light" || setting === "dark" ? setting : undefined,
109
+ reduceMotion: params.has("reduceMotion"),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Stands in for VS Code around the page: its default stylesheet in `@layer vscode-default`, `<html>`
115
+ * with the attributes `renderWebviewHtml` writes before the first paint, `<body>` with the host
116
+ * page's theme (and reduce-motion) classes, `acquireVsCodeApi()`, and the real `connectThemeBridge`
117
+ * running against a fake `vscode` namespace whose panel posts to this window. Call it before the
118
+ * modeler's webview code runs; also exposed as `window.__modelerShellVsCodeStub`.
119
+ */
120
+ export function installVsCodeWebviewStub(options: VsCodeWebviewStubOptions): VsCodeWebviewStub {
121
+ const settingKey = options.settingKey ?? STUB_SETTING_KEY;
122
+ const scope = window as StubWindow;
123
+ let kind = colorThemeKindOf(options.requested);
124
+ let setting = options.colorTheme;
125
+ const themeListeners = new Set<(theme: ColorThemeLike) => unknown>();
126
+ const configurationListeners = new Set<
127
+ (event: { affectsConfiguration(section: string): boolean }) => unknown
128
+ >();
129
+ const vscode: VsCodeLike = {
130
+ window: {
131
+ get activeColorTheme() {
132
+ return { kind };
133
+ },
134
+ onDidChangeActiveColorTheme(listener) {
135
+ themeListeners.add(listener);
136
+ return { dispose: () => themeListeners.delete(listener) };
137
+ },
138
+ },
139
+ workspace: {
140
+ getConfiguration: (section?: string) => ({
141
+ get: <T>(key: string): T | undefined =>
142
+ (section ? `${section}.${key}` : key) === settingKey ? (setting as T | undefined) : undefined,
143
+ }),
144
+ onDidChangeConfiguration(listener) {
145
+ configurationListeners.add(listener);
146
+ return { dispose: () => configurationListeners.delete(listener) };
147
+ },
148
+ },
149
+ };
150
+ const none: DisposableLike = { dispose: () => undefined };
151
+ let posted = 0;
152
+ let delivered = 0;
153
+ // Registered before the modeler's listeners, so it counts a message before the shell applies it.
154
+ window.addEventListener("message", () => delivered++);
155
+ const panel: WebviewPanelLike = {
156
+ webview: {
157
+ postMessage(message) {
158
+ posted++;
159
+ window.postMessage(message, "*");
160
+ },
161
+ },
162
+ visible: true,
163
+ onDidChangeViewState: () => none,
164
+ onDidDispose: () => none,
165
+ };
166
+ const bridgeOptions = { themes: options.themes, settingKey };
167
+
168
+ const defaults = document.createElement("style");
169
+ defaults.id = "_defaultStyles";
170
+ defaults.textContent = VSCODE_DEFAULT_STYLES;
171
+ document.head.prepend(defaults);
172
+
173
+ const html = new DOMParser().parseFromString(
174
+ renderWebviewHtml({
175
+ cspSource: window.location.origin,
176
+ scriptUri: "webview.js",
177
+ title: document.title,
178
+ lang: options.lang ?? "en",
179
+ appearance: currentAppearance(vscode, bridgeOptions),
180
+ }),
181
+ "text/html",
182
+ ).documentElement;
183
+ for (const name of html.getAttributeNames()) {
184
+ if (name === "class") document.documentElement.classList.add(...html.classList);
185
+ else document.documentElement.setAttribute(name, html.getAttribute(name) ?? "");
186
+ }
187
+
188
+ const setBodyClasses = (next: ColorThemeKind): void => {
189
+ document.body.classList.remove(...ALL_BODY_CLASSES);
190
+ document.body.classList.add(...BODY_CLASSES[next]);
191
+ };
192
+ setBodyClasses(kind);
193
+ document.body.classList.toggle(REDUCE_MOTION_BODY_CLASS, options.reduceMotion === true);
194
+ connectThemeBridge(vscode, panel, bridgeOptions);
195
+
196
+ let state = options.state;
197
+ const sent: unknown[] = [];
198
+ let acquired = false;
199
+ scope.acquireVsCodeApi = () => {
200
+ if (acquired) throw new Error("An instance of the VS Code API has already been acquired");
201
+ acquired = true;
202
+ return {
203
+ getState: () => state,
204
+ setState(next) {
205
+ state = next;
206
+ },
207
+ postMessage(message) {
208
+ sent.push(message);
209
+ },
210
+ };
211
+ };
212
+
213
+ const stub: VsCodeWebviewStub = {
214
+ get kind() {
215
+ return kind;
216
+ },
217
+ setColorThemeKind(next) {
218
+ kind = next;
219
+ setBodyClasses(next);
220
+ for (const listener of [...themeListeners]) listener({ kind: next });
221
+ },
222
+ setColorTheme(next) {
223
+ setting = next;
224
+ for (const listener of [...configurationListeners]) {
225
+ listener({ affectsConfiguration: (section) => settingKey.startsWith(section) });
226
+ }
227
+ },
228
+ setReduceMotion(reduceMotion) {
229
+ document.body.classList.toggle(REDUCE_MOTION_BODY_CLASS, reduceMotion);
230
+ },
231
+ pendingMessages: () => posted - delivered,
232
+ posted: sent,
233
+ state: () => state,
234
+ };
235
+ scope.__modelerShellVsCodeStub = stub;
236
+ return stub;
237
+ }
@@ -0,0 +1,2 @@
1
+ // "@miragon/modeler-shell/testing/vscode-stub": a stand-in for VS Code around a webview test page.
2
+ export * from "./testing/vscode-stub.js";
package/src/testing.ts ADDED
@@ -0,0 +1,2 @@
1
+ // "@miragon/modeler-shell/testing": the Playwright-driven shell conformance kit (Playwright injected).
2
+ export * from "./testing/index.js";