@antelopejs/dms-ai 0.2.1 → 0.4.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 (87) hide show
  1. package/README.md +64 -205
  2. package/dist/constants/frontend-module.d.ts +2 -0
  3. package/dist/constants/frontend-module.js +6 -1
  4. package/dist/constants/frontend-module.js.map +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/lifecycle/skill-sources.d.ts +1 -1
  8. package/dist/lifecycle/skill-sources.js +29 -13
  9. package/dist/lifecycle/skill-sources.js.map +1 -1
  10. package/frontend-vue/app/chat/components/BlockPreviewCard.vue +172 -0
  11. package/frontend-vue/app/chat/components/BlockPreviewModal.vue +324 -0
  12. package/frontend-vue/app/chat/components/BlockPreviewThumb.vue +141 -0
  13. package/frontend-vue/app/chat/components/MessageList.vue +20 -0
  14. package/frontend-vue/app/chat/components/SketchFrame.vue +59 -0
  15. package/frontend-vue/app/chat/composables/conversation-events.ts +23 -1
  16. package/frontend-vue/app/chat/composables/useBlockPreviewComponent.ts +15 -0
  17. package/frontend-vue/app/chat/composables/useDashboardScheme.ts +42 -0
  18. package/frontend-vue/app/chat/constants/block-preview.ts +27 -0
  19. package/frontend-vue/app/chat/constants/conversation.ts +1 -0
  20. package/frontend-vue/app/chat/constants/protocol.ts +1 -0
  21. package/frontend-vue/app/chat/constants/sketch.ts +105 -0
  22. package/frontend-vue/app/chat/types/conversation.ts +11 -1
  23. package/frontend-vue/app/chat/types/protocol.ts +56 -0
  24. package/frontend-vue/app/chat/utils/block-preview.ts +145 -0
  25. package/frontend-vue/app/chat/utils/conversation-messages.ts +23 -1
  26. package/frontend-vue/app/chat/utils/render-items.ts +13 -2
  27. package/frontend-vue/app/chat/utils/sketch.ts +56 -0
  28. package/frontend-vue/app/chat/utils/tool-lexicon.ts +7 -0
  29. package/frontend-vue/app/components/ActivityDetail.vue +11 -13
  30. package/frontend-vue/app/components/ChangesView.vue +56 -49
  31. package/frontend-vue/app/plugins/ai.client.ts +16 -6
  32. package/frontend-vue/app/runtime/assistant-activity.ts +38 -21
  33. package/frontend-vue/app/runtime/sidecar-status.ts +78 -56
  34. package/frontend-vue/app/views/ChangeSetPanel.vue +13 -18
  35. package/frontend-vue/app/views/UnavailableState.vue +1 -1
  36. package/frontend-vue/app/views/composables/useAiData.ts +116 -0
  37. package/frontend-vue/app/views/composables/useAssistant.ts +8 -2
  38. package/frontend-vue/app/views/composables/useChangeSetActions.ts +7 -6
  39. package/frontend-vue/app/views/utils/change-sets.ts +0 -7
  40. package/frontend-vue/i18n/locales/dms-ai-panel-en-GB.json +23 -1
  41. package/frontend-vue/i18n/locales/dms-ai-panel-fr-FR.json +23 -1
  42. package/frontend-vue/package.json +1 -1
  43. package/frontend-vue/tests/assistant-activity.test.ts +69 -0
  44. package/frontend-vue/tests/chat/block-preview.test.ts +448 -0
  45. package/frontend-vue/tests/sidecar-guest.test.ts +1 -1
  46. package/frontend-vue/tests/sidecar-status.test.ts +93 -15
  47. package/frontend-vue/tests/support/dms-frontend-module.ts +9 -0
  48. package/frontend-vue/tests/views/api-data-reads.test.ts +445 -0
  49. package/frontend-vue/tests/views/use-assistant.test.ts +53 -0
  50. package/frontend-vue/vitest.config.ts +10 -0
  51. package/package.json +20 -6
  52. package/sidecar/dist/constants/mcp.js +2 -0
  53. package/sidecar/dist/constants/mcp.js.map +1 -1
  54. package/sidecar/dist/constants/preview.d.ts +39 -0
  55. package/sidecar/dist/constants/preview.js +55 -0
  56. package/sidecar/dist/constants/preview.js.map +1 -0
  57. package/sidecar/dist/constants/tool-kinds.js +2 -0
  58. package/sidecar/dist/constants/tool-kinds.js.map +1 -1
  59. package/sidecar/dist/mcp/tool-definitions.js +5 -0
  60. package/sidecar/dist/mcp/tool-definitions.js.map +1 -1
  61. package/sidecar/dist/mcp/tools/preview-blocks.d.ts +65 -0
  62. package/sidecar/dist/mcp/tools/preview-blocks.js +176 -0
  63. package/sidecar/dist/mcp/tools/preview-blocks.js.map +1 -0
  64. package/sidecar/dist/mcp/types.d.ts +3 -0
  65. package/sidecar/dist/prompts/agent.d.ts +1 -1
  66. package/sidecar/dist/prompts/agent.js +3 -2
  67. package/sidecar/dist/prompts/agent.js.map +1 -1
  68. package/sidecar/dist/protocol/events.d.ts +313 -0
  69. package/sidecar/dist/protocol/events.js +9 -0
  70. package/sidecar/dist/protocol/events.js.map +1 -1
  71. package/sidecar/dist/protocol/preview.d.ts +130 -0
  72. package/sidecar/dist/protocol/preview.js +91 -0
  73. package/sidecar/dist/protocol/preview.js.map +1 -0
  74. package/sidecar/dist/server/chat-events.d.ts +3 -0
  75. package/sidecar/dist/server/chat-events.js +21 -0
  76. package/sidecar/dist/server/chat-events.js.map +1 -1
  77. package/sidecar/dist/server/ws.js +3 -1
  78. package/sidecar/dist/server/ws.js.map +1 -1
  79. package/sidecar/dist/state/types.d.ts +3 -1
  80. package/sidecar/dist/state/types.js +1 -0
  81. package/sidecar/dist/state/types.js.map +1 -1
  82. package/sidecar/package.json +1 -1
  83. package/skills/dms-ai/SKILL.md +30 -0
  84. package/skills/dms-ai-code-mode/SKILL.md +51 -0
  85. package/skills/dms-builder-safe/SKILL.md +9 -2
  86. package/frontend-vue/app/views/composables/useAiRequest.ts +0 -89
  87. package/skills/dms-builder-page/SKILL.md +0 -80
@@ -0,0 +1,448 @@
1
+ // @vitest-environment jsdom
2
+ import { mount, type VueWrapper } from "@vue/test-utils";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { defineComponent, h, nextTick, type PropType, ref } from "vue";
5
+ import BlockPreviewCard from "../../app/chat/components/BlockPreviewCard.vue";
6
+ import { useConversation } from "../../app/chat/composables/useConversation";
7
+ import { CHAT_I18N_KEY } from "../../app/chat/composables/useChatI18n";
8
+ import type { ConversationMessage } from "../../app/chat/types/conversation";
9
+ import type { BlockPreview } from "../../app/chat/types/protocol";
10
+ import {
11
+ countBlocks,
12
+ previewTabs,
13
+ referenceView,
14
+ type ReferenceView,
15
+ } from "../../app/chat/utils/block-preview";
16
+ import { buildUserMessage } from "../../app/chat/utils/conversation-messages";
17
+ import {
18
+ buildSketchDocument,
19
+ sketchTokensCss,
20
+ } from "../../app/chat/utils/sketch";
21
+ import { createTestI18n } from "../support/i18n";
22
+ import { PANEL_STUBS } from "../support/stubs";
23
+
24
+ const CONVERSATION_ID = "conv-1";
25
+ const MOCKUP_NAME = "mockup.png";
26
+ const MOCKUP_DATA_URL = "data:image/png;base64,AAAA";
27
+
28
+ const PREVIEW: BlockPreview = {
29
+ id: "preview-1",
30
+ title: "Revenue card",
31
+ description: "Same layout; the trend sits under the value.",
32
+ tree: {
33
+ type: "Section",
34
+ children: [
35
+ { type: "KpiCard", options: { label: "Revenue" } },
36
+ { type: "KpiCard", options: { label: "Orders" } },
37
+ ],
38
+ },
39
+ timestampMs: 1_000,
40
+ };
41
+
42
+ const WITH_VARIANTS: BlockPreview = {
43
+ ...PREVIEW,
44
+ variants: [
45
+ { label: "Compact", tree: [{ type: "KpiCard", options: { size: "sm" } }] },
46
+ ],
47
+ };
48
+
49
+ const SKETCH = {
50
+ html: '<article class="card"><h3>Revenue</h3><p>12 400 €</p></article>',
51
+ css: ".card { background: var(--sketch-surface); }",
52
+ caption: "Revenue card with a sparkline",
53
+ height: 240,
54
+ };
55
+
56
+ const DMS_VS_SKETCH: BlockPreview = {
57
+ id: "preview-2",
58
+ title: "Revenue card: DMS or custom",
59
+ label: "DMS KpiCard",
60
+ tree: { type: "KpiCard", options: { label: "Revenue" } },
61
+ variants: [{ label: "Custom sketch", sketch: SKETCH }],
62
+ timestampMs: 2_000,
63
+ };
64
+
65
+ const SKETCH_ONLY: BlockPreview = {
66
+ id: "preview-3",
67
+ title: "Custom revenue card",
68
+ sketch: SKETCH,
69
+ timestampMs: 3_000,
70
+ };
71
+
72
+ const UTabs = defineComponent({
73
+ name: "UTabs",
74
+ props: {
75
+ modelValue: { type: String, default: "" },
76
+ items: {
77
+ type: Array as PropType<{ value: string; label: string }[]>,
78
+ default: () => [],
79
+ },
80
+ },
81
+ emits: ["update:modelValue"],
82
+ setup:
83
+ (props, { emit, attrs }) =>
84
+ () =>
85
+ h(
86
+ "div",
87
+ { role: "tablist", "aria-label": attrs["aria-label"] },
88
+ props.items.map((item) =>
89
+ h(
90
+ "button",
91
+ {
92
+ role: "tab",
93
+ "aria-selected": item.value === props.modelValue,
94
+ onClick: () => emit("update:modelValue", item.value),
95
+ },
96
+ item.label,
97
+ ),
98
+ ),
99
+ ),
100
+ });
101
+
102
+ const DmsBlockPreview = defineComponent({
103
+ name: "DmsBlockPreview",
104
+ props: { tree: { type: [Object, Array], required: true } },
105
+ setup: (props) => () =>
106
+ h("div", { class: "dms-block-preview" }, JSON.stringify(props.tree)),
107
+ });
108
+
109
+ interface MountOptions {
110
+ preview?: BlockPreview;
111
+ reference?: ReferenceView | null;
112
+ hasPreviewComponent?: boolean;
113
+ }
114
+
115
+ function mountCard(options: MountOptions = {}): VueWrapper {
116
+ return mount(BlockPreviewCard, {
117
+ props: {
118
+ preview: options.preview ?? PREVIEW,
119
+ reference: options.reference ?? null,
120
+ },
121
+ global: {
122
+ stubs: { ...PANEL_STUBS, UTabs },
123
+ components:
124
+ options.hasPreviewComponent === false ? {} : { DmsBlockPreview },
125
+ provide: { [CHAT_I18N_KEY as symbol]: createTestI18n() },
126
+ },
127
+ });
128
+ }
129
+
130
+ async function openModal(wrapper: VueWrapper): Promise<void> {
131
+ await wrapper.find("button.bp-card").trigger("click");
132
+ }
133
+
134
+ describe("block preview helpers", () => {
135
+ it("counts nested blocks", () => {
136
+ expect(countBlocks(PREVIEW.tree)).toBe(3);
137
+ });
138
+
139
+ it("puts the proposed tree first, then one tab per variant", () => {
140
+ const tabs = previewTabs(WITH_VARIANTS);
141
+ expect(tabs.map((tab) => tab.label)).toEqual([null, "Compact"]);
142
+ expect(tabs[1].tree).toEqual(WITH_VARIANTS.variants?.[0].tree);
143
+ });
144
+
145
+ it("finds the mockup among the images attached in this session", () => {
146
+ const messages: ConversationMessage[] = [
147
+ buildUserMessage("like this", [
148
+ {
149
+ name: MOCKUP_NAME,
150
+ mimeType: "image/png",
151
+ size: 4,
152
+ dataUrl: MOCKUP_DATA_URL,
153
+ },
154
+ ]),
155
+ ];
156
+ expect(referenceView({ attachmentName: MOCKUP_NAME }, messages)).toEqual({
157
+ kind: "image",
158
+ src: MOCKUP_DATA_URL,
159
+ name: MOCKUP_NAME,
160
+ });
161
+ });
162
+
163
+ it("says the mockup is gone when only its name survived a reload", () => {
164
+ const messages = [
165
+ buildUserMessage("like this", [
166
+ { name: MOCKUP_NAME, mimeType: "image/png", size: 4 },
167
+ ]),
168
+ ];
169
+ expect(referenceView({ attachmentName: MOCKUP_NAME }, messages)).toEqual({
170
+ kind: "missing",
171
+ name: MOCKUP_NAME,
172
+ });
173
+ });
174
+
175
+ it("loads a same-origin path, links an external URL, ignores the rest", () => {
176
+ expect(referenceView({ url: "/media/a.png" }, [])?.kind).toBe("image");
177
+ expect(referenceView({ url: "https://x.example/a.png" }, [])?.kind).toBe(
178
+ "link",
179
+ );
180
+ expect(referenceView({ url: "//x.example/a.png" }, [])).toBeNull();
181
+ expect(referenceView({ url: "javascript:alert(1)" }, [])).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("block preview events", () => {
186
+ function conversation() {
187
+ const handlers: ((msg: unknown) => void)[] = [];
188
+ const result = useConversation({
189
+ activeId: ref(CONVERSATION_ID),
190
+ send: () => true,
191
+ onMessage: (handler) => {
192
+ handlers.push(handler);
193
+ return () => undefined;
194
+ },
195
+ });
196
+ const receive = (event: Record<string, unknown>) =>
197
+ handlers.forEach((handler) =>
198
+ handler({ conversationId: CONVERSATION_ID, ...event }),
199
+ );
200
+ return { ...result, receive };
201
+ }
202
+
203
+ it("appends a live preview once", () => {
204
+ const chat = conversation();
205
+ chat.receive({ type: "block_preview", preview: PREVIEW });
206
+ chat.receive({ type: "block_preview", preview: PREVIEW });
207
+ expect(chat.messages.value).toHaveLength(1);
208
+ expect(chat.messages.value[0]).toMatchObject({
209
+ role: "block_preview",
210
+ preview: PREVIEW,
211
+ });
212
+ });
213
+
214
+ it("restores a preview from the stored transcript", () => {
215
+ const chat = conversation();
216
+ chat.receive({
217
+ type: "conversation_snapshot",
218
+ messages: [
219
+ { role: "user", content: "show me", timestampMs: 1 },
220
+ {
221
+ role: "block_preview",
222
+ content: "",
223
+ preview: PREVIEW,
224
+ timestampMs: 2,
225
+ },
226
+ ],
227
+ });
228
+ expect(chat.messages.value.map((m) => m.role)).toEqual([
229
+ "user",
230
+ "block_preview",
231
+ ]);
232
+ });
233
+
234
+ it("keeps the preview when a running turn is replayed", () => {
235
+ const chat = conversation();
236
+ chat.receive({
237
+ type: "conversation_snapshot",
238
+ messages: [{ role: "user", content: "show me", timestampMs: 1 }],
239
+ });
240
+ chat.receive({ type: "block_preview", preview: PREVIEW });
241
+ chat.receive({ type: "run_resumed" });
242
+ expect(chat.messages.value.at(-1)?.role).toBe("block_preview");
243
+ });
244
+ });
245
+
246
+ describe("the block preview attachment", () => {
247
+ it("reads as a file: thumbnail, title, what it holds", () => {
248
+ const wrapper = mountCard({ preview: WITH_VARIANTS });
249
+ const card = wrapper.find("button.bp-card");
250
+ expect(card.attributes("aria-haspopup")).toBe("dialog");
251
+ expect(card.attributes("aria-label")).toBe(
252
+ "Open the block preview: Revenue card",
253
+ );
254
+ expect(wrapper.find(".bp-thumb .dms-block-preview").exists()).toBe(true);
255
+ expect(wrapper.find(".bp-card__meta").text()).toBe(
256
+ "Section · 3 blocks · 2 variants",
257
+ );
258
+ });
259
+
260
+ it("draws a schematic thumbnail when the DMS has no preview component", () => {
261
+ const wrapper = mountCard({ hasPreviewComponent: false });
262
+ expect(wrapper.findAll(".bp-thumb__cell")).toHaveLength(2);
263
+ });
264
+
265
+ it("opens a dialog rendering the real blocks", async () => {
266
+ const wrapper = mountCard();
267
+ expect(wrapper.find("[role=dialog]").exists()).toBe(false);
268
+ await openModal(wrapper);
269
+ const dialog = wrapper.find("[role=dialog]");
270
+ expect(dialog.text()).toContain("Revenue card");
271
+ expect(dialog.find(".bp-pane__stage .dms-block-preview").text()).toBe(
272
+ JSON.stringify(PREVIEW.tree),
273
+ );
274
+ expect(dialog.find(".bp-fallback").exists()).toBe(false);
275
+ });
276
+
277
+ it("shows the tree as JSON when the DMS cannot render it", async () => {
278
+ const wrapper = mountCard({ hasPreviewComponent: false });
279
+ await openModal(wrapper);
280
+ const fallback = wrapper.find(".bp-fallback");
281
+ expect(fallback.text()).toContain(
282
+ "This DMS version cannot draw block previews",
283
+ );
284
+ expect(JSON.parse(fallback.find("pre").text())).toEqual(PREVIEW.tree);
285
+ });
286
+
287
+ it("switches the rendered tree with the variant tabs", async () => {
288
+ const wrapper = mountCard({ preview: WITH_VARIANTS });
289
+ await openModal(wrapper);
290
+ const tabs = wrapper.findAll("[role=tab]");
291
+ expect(tabs.map((tab) => tab.text())).toEqual(["Proposed", "Compact"]);
292
+ await tabs[1].trigger("click");
293
+ expect(wrapper.find(".bp-pane__stage .dms-block-preview").text()).toBe(
294
+ JSON.stringify(WITH_VARIANTS.variants?.[0].tree),
295
+ );
296
+ });
297
+
298
+ it("puts the mockup beside the blocks", async () => {
299
+ const wrapper = mountCard({
300
+ preview: { ...PREVIEW, reference: { caption: "Your Figma frame" } },
301
+ reference: { kind: "image", src: MOCKUP_DATA_URL, name: MOCKUP_NAME },
302
+ });
303
+ await openModal(wrapper);
304
+ expect(wrapper.find(".bp-modal__panes").classes()).toContain("is-split");
305
+ const image = wrapper.find(".bp-pane__image");
306
+ expect(image.attributes("src")).toBe(MOCKUP_DATA_URL);
307
+ expect(image.attributes("alt")).toBe("Your Figma frame");
308
+ });
309
+
310
+ it("links an external mockup instead of loading it", async () => {
311
+ const href = "https://figma.example/frame.png";
312
+ const wrapper = mountCard({ reference: { kind: "link", href } });
313
+ await openModal(wrapper);
314
+ expect(wrapper.find(".bp-pane__image").exists()).toBe(false);
315
+ const link = wrapper.find(".bp-pane__link");
316
+ expect(link.attributes("href")).toBe(href);
317
+ expect(link.attributes("rel")).toBe("noopener noreferrer");
318
+ });
319
+ });
320
+
321
+ describe("sketch documents", () => {
322
+ const unset = () => "";
323
+
324
+ it("puts the CSP first, before anything the agent wrote", () => {
325
+ const doc = buildSketchDocument(
326
+ { html: "<p>x</p>", css: "p{}" },
327
+ sketchTokensCss("light", unset),
328
+ );
329
+ const head = doc.slice(doc.indexOf("<head>") + "<head>".length);
330
+ expect(head.startsWith('<meta http-equiv="Content-Security-Policy"')).toBe(
331
+ true,
332
+ );
333
+ expect(doc).toContain(
334
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:",
335
+ );
336
+ expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(
337
+ doc.indexOf("<p>x</p>"),
338
+ );
339
+ });
340
+
341
+ it("gives the sketch the dashboard's values, or its defaults", () => {
342
+ const read = (variable: string) =>
343
+ variable === "--ui-border" ? " #123456 " : "";
344
+ const css = sketchTokensCss("dark", read);
345
+ expect(css).toContain("--sketch-scheme:dark;");
346
+ expect(css).toContain("--sketch-border:#123456;");
347
+ expect(css).toContain("--sketch-surface:#10151a;");
348
+ });
349
+
350
+ it("never writes a value that could close the style element", () => {
351
+ const read = () => "red}</style><p>";
352
+ const css = sketchTokensCss("light", read);
353
+ expect(css).not.toContain("</style>");
354
+ expect(css).toContain("--sketch-border:#e2e7ea;");
355
+ });
356
+ });
357
+
358
+ describe("a custom sketch in the preview", () => {
359
+ afterEach(() => {
360
+ document.documentElement.classList.remove("dark");
361
+ });
362
+
363
+ async function openOnSketchTab(wrapper: VueWrapper): Promise<void> {
364
+ await openModal(wrapper);
365
+ await wrapper.findAll("[role=tab]")[1].trigger("click");
366
+ await nextTick();
367
+ }
368
+
369
+ it("tabs a DMS block against a custom sketch", async () => {
370
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
371
+ await openModal(wrapper);
372
+ const tabs = wrapper.findAll("[role=tab]");
373
+ expect(tabs.map((tab) => tab.text())).toEqual([
374
+ "DMS KpiCard",
375
+ "Custom sketch",
376
+ ]);
377
+ expect(wrapper.find(".bp-pane__stage .dms-block-preview").exists()).toBe(
378
+ true,
379
+ );
380
+ expect(wrapper.find("iframe").exists()).toBe(false);
381
+ await openOnSketchTab(wrapper);
382
+ expect(wrapper.find(".bp-pane__stage").exists()).toBe(false);
383
+ expect(wrapper.find(".bp-pane--sketch").text()).toContain(
384
+ "Custom sketch — not a DMS block",
385
+ );
386
+ expect(wrapper.find(".bp-pane__caption").text()).toBe(SKETCH.caption);
387
+ });
388
+
389
+ it("draws the sketch in a sandboxed frame with no script", async () => {
390
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
391
+ await openOnSketchTab(wrapper);
392
+ const frame = wrapper.find(".bp-pane--sketch iframe");
393
+ expect(frame.attributes("sandbox")).toBe("");
394
+ expect(frame.attributes("referrerpolicy")).toBe("no-referrer");
395
+ expect(frame.attributes("src")).toBeUndefined();
396
+ expect(frame.attributes("style")).toContain("height: 240px");
397
+ expect(frame.attributes("title")).toContain(SKETCH.caption);
398
+ const doc = frame.attributes("srcdoc") ?? "";
399
+ expect(doc).toContain('http-equiv="Content-Security-Policy"');
400
+ expect(doc).toContain(SKETCH.html);
401
+ expect(doc).toContain(SKETCH.css);
402
+ expect(doc).not.toMatch(/<script/i);
403
+ expect(doc).toContain("--sketch-scheme:light;");
404
+ });
405
+
406
+ it("follows the dashboard into dark mode", async () => {
407
+ document.documentElement.classList.add("dark");
408
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
409
+ await openOnSketchTab(wrapper);
410
+ const doc = wrapper.find("iframe").attributes("srcdoc") ?? "";
411
+ expect(doc).toContain("--sketch-scheme:dark;");
412
+ });
413
+
414
+ it("labels the attachment as holding a custom sketch", () => {
415
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
416
+ expect(wrapper.find(".bp-thumb__badge").text()).toBe("Custom sketch");
417
+ expect(wrapper.find(".bp-card__meta").text()).toBe(
418
+ "KpiCard · 1 block · 2 variants · custom sketch",
419
+ );
420
+ });
421
+
422
+ it("thumbnails a sketch-only preview with the sandboxed sketch", async () => {
423
+ const wrapper = mountCard({ preview: SKETCH_ONLY });
424
+ await nextTick();
425
+ const frame = wrapper.find(".bp-thumb iframe");
426
+ expect(frame.attributes("sandbox")).toBe("");
427
+ expect(wrapper.find(".bp-card__meta").text()).toBe("custom sketch");
428
+ });
429
+
430
+ it("still draws the sketch when the DMS cannot render blocks", async () => {
431
+ const wrapper = mountCard({
432
+ preview: { ...SKETCH_ONLY, tree: { type: "KpiCard" } },
433
+ hasPreviewComponent: false,
434
+ });
435
+ await openModal(wrapper);
436
+ await nextTick();
437
+ expect(wrapper.find(".bp-fallback").exists()).toBe(true);
438
+ expect(wrapper.find(".bp-pane--sketch iframe").exists()).toBe(true);
439
+ expect(wrapper.find(".bp-modal__panes").classes()).toContain("is-split");
440
+ });
441
+
442
+ it("keeps a sketch-only preview as a single tab", () => {
443
+ const restored = previewTabs(SKETCH_ONLY);
444
+ expect(restored).toHaveLength(1);
445
+ expect(restored[0].sketch).toEqual(SKETCH);
446
+ expect(restored[0].tree).toBeUndefined();
447
+ });
448
+ });
@@ -57,7 +57,7 @@ function loadPlugin(loggedIn: Ref<boolean>): PluginHarness {
57
57
  new Function(
58
58
  "require",
59
59
  "exports",
60
- "useUserSession",
60
+ "useDmsUserSession",
61
61
  "useAuthFetch",
62
62
  "useToast",
63
63
  "useDevReload",
@@ -1,39 +1,117 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { createSidecarStatusController } from "../app/runtime/sidecar-status";
2
+ import { computed, nextTick, type Ref, ref } from "vue";
3
+ import {
4
+ createSidecarStatusController,
5
+ type SidecarInfoPayload,
6
+ type SidecarInfoRead,
7
+ } from "../app/runtime/sidecar-status";
8
+
9
+ type Answer = SidecarInfoPayload | Error;
10
+
11
+ interface FakeRead extends SidecarInfoRead {
12
+ /** The polling interval the controller asks for. */
13
+ interval: Readonly<Ref<number>>;
14
+ reads: () => number;
15
+ }
16
+
17
+ /** `useApiData` as the controller uses it: each read takes the next answer. */
18
+ function fakeRead(answers: Answer[]) {
19
+ let opened: FakeRead | undefined;
20
+ const open = (interval: Readonly<Ref<number>>): SidecarInfoRead => {
21
+ const data = ref<SidecarInfoPayload | null>(null);
22
+ const error = ref<unknown>(null);
23
+ const status = ref<"idle" | "pending" | "done">("idle");
24
+ let count = 0;
25
+ const refresh = async (): Promise<void> => {
26
+ status.value = "pending";
27
+ await Promise.resolve();
28
+ const answer = answers[Math.min(count, answers.length - 1)];
29
+ count += 1;
30
+ if (answer instanceof Error) error.value = answer;
31
+ else {
32
+ data.value = answer ?? null;
33
+ error.value = null;
34
+ }
35
+ status.value = "done";
36
+ };
37
+ opened = {
38
+ data,
39
+ error,
40
+ pending: computed(() => status.value === "pending"),
41
+ execute: refresh,
42
+ refresh,
43
+ interval,
44
+ reads: () => count,
45
+ };
46
+ return opened;
47
+ };
48
+ return { open, read: () => opened! };
49
+ }
3
50
 
4
51
  describe("sidecar status", () => {
5
52
  it("reports connected from the status alone, and keeps no port or credential", async () => {
6
- const controller = createSidecarStatusController(async () => ({
7
- isRunning: true,
8
- }));
53
+ const { open, read } = fakeRead([{ isRunning: true }]);
54
+ const controller = createSidecarStatusController(open);
9
55
  expect(await controller.init()).toBe(true);
10
56
  expect(controller.getStatus()).toBe("connected");
57
+ expect(read().interval.value).toBe(0);
11
58
  expect(Object.keys(controller)).not.toContain("getPort");
12
59
  expect(Object.keys(controller)).not.toContain("getClientToken");
13
60
  controller.dispose();
14
61
  });
15
62
 
16
63
  it("re-probes when the channel drops, and reports connected once the sidecar answers", async () => {
17
- const fetchInfo = vi
18
- .fn()
19
- .mockResolvedValueOnce({ isRunning: true })
20
- .mockResolvedValueOnce({ isRunning: true });
64
+ const { open, read } = fakeRead([{ isRunning: true }]);
21
65
  const statuses: string[] = [];
22
- const controller = createSidecarStatusController(fetchInfo);
66
+ const controller = createSidecarStatusController(open);
23
67
  await controller.init();
24
68
  controller.subscribe((status) => statuses.push(status));
25
69
  controller.reportChannelDown();
26
70
  await vi.waitFor(() => expect(statuses.at(-1)).toBe("connected"));
27
71
  expect(statuses).toEqual(["connected", "reviving", "connected"]);
28
- expect(fetchInfo).toHaveBeenCalledTimes(2);
72
+ expect(read().reads()).toBe(2);
29
73
  controller.dispose();
30
74
  });
31
75
 
32
- it("does not show the assistant when spawning is disabled", async () => {
33
- const controller = createSidecarStatusController(async () => ({
34
- disabled: true,
35
- }));
36
- expect(await controller.init()).toBe(false);
76
+ it("polls through the read's interval, backing off while the sidecar comes back, and stops once it runs", async () => {
77
+ const { open, read } = fakeRead([
78
+ { isRunning: false },
79
+ new Error("Bad Gateway"),
80
+ { isRunning: true },
81
+ ]);
82
+ const controller = createSidecarStatusController(open);
83
+ expect(await controller.init()).toBe(true);
84
+ expect(controller.getStatus()).toBe("reviving");
85
+ expect(read().interval.value).toBe(500);
86
+ await read().refresh();
87
+ expect(read().interval.value).toBe(1000);
88
+ await read().refresh();
89
+ await nextTick();
90
+ expect(controller.getStatus()).toBe("connected");
91
+ expect(read().interval.value).toBe(0);
92
+ controller.dispose();
93
+ });
94
+
95
+ it("stops polling once the sidecar gives up", async () => {
96
+ const { open, read } = fakeRead([{ hasGivenUp: true }]);
97
+ const controller = createSidecarStatusController(open);
98
+ await controller.init();
99
+ expect(controller.getStatus()).toBe("unavailable");
100
+ expect(read().interval.value).toBe(0);
37
101
  controller.dispose();
38
102
  });
103
+
104
+ it("does not show the assistant when spawning is disabled, and polls nothing", async () => {
105
+ const { open, read } = fakeRead([{ disabled: true }]);
106
+ const controller = createSidecarStatusController(open);
107
+ expect(await controller.init()).toBe(false);
108
+ expect(read().interval.value).toBe(0);
109
+ });
110
+
111
+ it("does not show the assistant when the first probe fails", async () => {
112
+ const { open, read } = fakeRead([new Error("Not Found")]);
113
+ const controller = createSidecarStatusController(open);
114
+ expect(await controller.init()).toBe(false);
115
+ expect(read().interval.value).toBe(0);
116
+ });
39
117
  });
@@ -0,0 +1,9 @@
1
+ // The renderer's runtime (`#dms/frontend-module`), which only the generated
2
+ // application provides: the suites that import a view mock what they use.
3
+ export function useDmsRoute(): never {
4
+ throw new Error("Mock #dms/frontend-module in the suite that needs it");
5
+ }
6
+
7
+ export function useDmsRouter(): never {
8
+ throw new Error("Mock #dms/frontend-module in the suite that needs it");
9
+ }