返回 DeepSeek-Reasonix
extension-surface.test.tsx
根目录 / desktop / frontend / src / __tests__ / extension-surface.test.tsx
1 // Run: tsx src/__tests__/extension-surface.test.tsx
2 // Stage 8b2: extension_surface / extension_status wire events → per-tab
3 // controller state, ExtensionCard / ExtensionFormDialog rendering and wiring.
4
5 import { JSDOM } from "jsdom";
6 import { registerHooks } from "node:module";
7 import React from "react";
8 import { act } from "react";
9 import { createRoot } from "react-dom/client";
10
11 // ExtensionCard lazy-loads the shared MarkdownRenderer, which transitively
12 // imports katex CSS (and SVG assets); tsx has no asset loader, so redirect
13 // those specifiers to an empty-string stub, the way Vite would handle them.
14 registerHooks({
15 resolve(specifier, context, nextResolve) {
16 if (specifier.endsWith(".css") || specifier.endsWith(".svg")) {
17 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
18 }
19 return nextResolve(specifier, context);
20 },
21 });
22
23 import { LocaleProvider } from "../lib/i18n";
24 import type { WireEvent, WireExtensionCard, WireExtensionSurface } from "../lib/types";
25 import {
26 acceptsExtensionGeneration,
27 initialState,
28 reducer,
29 type ExtensionItem,
30 } from "../lib/useController";
31 import { ExtensionCard } from "../components/ExtensionCard";
32 import { ExtensionFormDialog } from "../components/ExtensionFormDialog";
33
34 let passed = 0;
35 let failed = 0;
36
37 function ok(value: boolean, label: string) {
38 if (value) {
39 process.stdout.write(` PASS ${label}\n`);
40 passed += 1;
41 } else {
42 process.stdout.write(` FAIL ${label}\n`);
43 failed += 1;
44 }
45 }
46
47 // State is not exported from useController; derive it from the reducer.
48 type ControllerState = Parameters<typeof reducer>[0];
49
50 // ── Reducer / store logic ────────────────────────────────────────────────────
51
52 function surfaceEvent(partial: Partial<WireExtensionSurface> & Pick<WireExtensionSurface, "kind">, eventKind?: "extension_surface" | "extension_status"): WireEvent {
53 return {
54 kind: eventKind ?? "extension_surface",
55 extension: { pluginId: "alpha", surfaceId: "s1", ...partial },
56 };
57 }
58
59 function extensionItems(s: ControllerState): ExtensionItem[] {
60 return s.items.filter((it): it is ExtensionItem => it.kind === "extension");
61 }
62
63 console.log("\nExtension surface reducer");
64
65 ok(acceptsExtensionGeneration(undefined, 3) && acceptsExtensionGeneration(3, 3) && acceptsExtensionGeneration(3, 4), "accepts new/equal/newer generations");
66 ok(!acceptsExtensionGeneration(5, 4), "rejects an older generation");
67 ok(acceptsExtensionGeneration(5, undefined), "events without a generation always pass");
68
69 {
70 let s: ControllerState = { ...initialState };
71 const ev = surfaceEvent({ kind: "status", status: { label: "working", detail: "half", severity: "warn", progress: 0.5 }, generation: 2 }, "extension_status");
72 s = reducer(s, { type: "event", e: ev });
73 const entry = s.extensionStatuses["alpha:s1"];
74 ok(Boolean(entry) && entry.label === "working" && entry.severity === "warn" && entry.progress === 0.5, "extension_status upserts a status entry");
75 ok(s.extensionGenerations["alpha:s1"] === 2, "accepted generation is recorded");
76
77 // A second plugin's status keeps the first; a same-key publish replaces it.
78 s = reducer(s, { type: "event", e: { kind: "extension_status", extension: { pluginId: "beta", surfaceId: "s1", kind: "status", status: { label: "beta" } } } });
79 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "status", status: { label: "done", severity: "info" }, generation: 3 }, "extension_status") });
80 ok(Object.keys(s.extensionStatuses).length === 2, "statuses key on pluginId:surfaceId");
81 ok(s.extensionStatuses["alpha:s1"]?.label === "done", "same-key status replaces in place");
82
83 // extension_surface carrying kind=status reduces identically.
84 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "status", status: { label: "again" }, generation: 4 }) });
85 ok(s.extensionStatuses["alpha:s1"]?.label === "again", "extension_surface kind=status also updates the entry");
86 }
87
88 {
89 let s: ControllerState = { ...initialState };
90 const card: WireExtensionCard = { title: "Report", text: "v1", fields: [{ key: "k", value: "v" }], progress: 0.25, actions: [{ actionId: "run", label: "Run" }] };
91 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card, generation: 5 }) });
92 ok(extensionItems(s).length === 1 && extensionItems(s)[0].card.title === "Report", "card appends one transcript item");
93 const firstId = extensionItems(s)[0].id;
94
95 // Same surface re-published: replace in place, no duplicate.
96 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card: { ...card, text: "v2" }, generation: 6 }) });
97 ok(extensionItems(s).length === 1 && extensionItems(s)[0].id === firstId && extensionItems(s)[0].card.text === "v2", "card re-publish replaces in place");
98
99 // Stale generation: dropped entirely.
100 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card: { ...card, text: "stale" }, generation: 3 }) });
101 ok(extensionItems(s)[0].card.text === "v2" && s.extensionGenerations["alpha:s1"] === 6, "stale card generation is dropped");
102
103 // Equal generation is a legitimate re-publish.
104 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card: { ...card, text: "v3" }, generation: 6 }) });
105 ok(extensionItems(s)[0].card.text === "v3", "equal generation replaces");
106
107 // A different surface id appends its own card.
108 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", surfaceId: "s2", card: { title: "Other" } }) });
109 ok(extensionItems(s).length === 2, "different surface id appends a separate card");
110 }
111
112 {
113 let s: ControllerState = { ...initialState };
114 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 9, form: { title: "Setup", fields: [{ key: "name", kind: "input", required: true }] } }) });
115 ok(s.extensionForm?.pluginId === "alpha" && s.extensionForm.form.title === "Setup", "form surface arms the pending form");
116 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", generation: 4, form: { title: "Stale", fields: [] } }) });
117 ok(s.extensionForm?.form.title === "Setup", "stale form generation is dropped");
118 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f2", generation: 10, form: { title: "Next", fields: [] } }) });
119 ok(s.extensionForm?.surfaceId === "f2", "a new form replaces the pending one");
120 s = reducer(s, { type: "clearExtensionForm" });
121 ok(s.extensionForm === undefined, "clearExtensionForm dismisses the form");
122 }
123
124 {
125 let s: ControllerState = { ...initialState };
126 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "notification", surfaceId: "n1", notification: { title: "Heads up", body: "b", severity: "warn" } }) });
127 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "notification", surfaceId: "n2", notification: { title: "Second" } }) });
128 ok(s.extensionNotifications.length === 2 && s.extensionNotifications[0].severity === "warn", "notifications queue for the toast drain");
129 ok(s.extensionNotifications[0].id !== s.extensionNotifications[1].id, "notification ids are unique");
130 s = reducer(s, { type: "extension_notifications_drained" });
131 ok(s.extensionNotifications.length === 0, "drain clears the notification queue");
132 }
133
134 {
135 let s: ControllerState = { ...initialState };
136 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "status", status: { label: "working" }, generation: 2 }, "extension_status") });
137 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "notification", notification: { title: "n" } }) });
138 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "form", surfaceId: "f1", form: { fields: [] } }) });
139 s = reducer(s, { type: "controller_rebuilt" });
140 ok(
141 Object.keys(s.extensionStatuses).length === 0 && s.extensionForm === undefined && s.extensionNotifications.length === 0 && Object.keys(s.extensionGenerations).length === 0,
142 "controller_rebuilt clears extension state and the generation fence",
143 );
144 // Post-rebuild generations restart from zero: an old high generation must not block.
145 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card: { title: "fresh" }, generation: 1 }) });
146 ok(extensionItems(s).length === 1, "post-rebuild surfaces flow with fresh generations");
147 }
148
149 {
150 let s: ControllerState = { ...initialState };
151 s = reducer(s, { type: "user", text: "hello", seq: 0 });
152 ok(s.pendingUser === "hello", "optimistic user bubble pending");
153 s = reducer(s, { type: "event", e: surfaceEvent({ kind: "card", card: { title: "bg" } }) });
154 ok(s.pendingUser === "hello", "extension events never flush the optimistic user bubble");
155 }
156
157 {
158 const s: ControllerState = { ...initialState };
159 const next = reducer(s, { type: "event", e: surfaceEvent({ kind: "mystery" }) });
160 ok(next === s, "unknown surface kinds leave state untouched");
161 const missingPayload = reducer(s, { type: "event", e: { kind: "extension_surface" } });
162 ok(missingPayload === s, "events without an extension payload leave state untouched");
163 }
164
165 // ── Components ───────────────────────────────────────────────────────────────
166
167 console.log("\nExtensionCard / ExtensionFormDialog components");
168
169 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
170 pretendToBeVisual: true,
171 url: "http://localhost/",
172 });
173 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
174 globalThis.window = dom.window as unknown as Window & typeof globalThis;
175 globalThis.document = dom.window.document;
176 globalThis.Node = dom.window.Node;
177 globalThis.Element = dom.window.Element;
178 globalThis.HTMLElement = dom.window.HTMLElement;
179 globalThis.Event = dom.window.Event;
180 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
181 globalThis.MouseEvent = dom.window.MouseEvent;
182 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
183 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
184 // tsx loads react-dom before any DOM exists, so isInputEventSupported is false
185 // and text-input onChange rides the IE polyfill: it only synthesizes change
186 // for the watched (focused) element on keyup/keydown, via attachEvent.
187 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
188 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
189
190 // setTextInput drives a controlled React text input under the polyfill path:
191 // focus (starts the polyfill's value watcher), bypass React's value tracker
192 // with the prototype setter, then keyup to synthesize the change event.
193 function setTextInput(dom: JSDOM, input: HTMLInputElement, value: string) {
194 input.focus();
195 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
196 setter?.call(input, value);
197 input.dispatchEvent(new dom.window.KeyboardEvent("keyup", { key: ".", bubbles: true }));
198 }
199
200 async function flush(ms = 30) {
201 await new Promise((resolve) => setTimeout(resolve, ms));
202 }
203
204 const invokeCalls: Array<{ tabId: string; name: string; args: Record<string, string> }> = [];
205 let invokeResult: string | Error = "Completed!";
206 (dom.window as unknown as { go: unknown }).go = {
207 main: {
208 App: {
209 InvokeExtensionAction: async (tabId: string, name: string, args: Record<string, string>) => {
210 invokeCalls.push({ tabId, name, args });
211 if (invokeResult instanceof Error) throw invokeResult;
212 return invokeResult;
213 },
214 },
215 },
216 };
217
218 const cardItem: ExtensionItem = {
219 kind: "extension",
220 id: "x0",
221 surfaceKey: "alpha:c1",
222 pluginId: "alpha",
223 surfaceId: "c1",
224 generation: 3,
225 card: {
226 title: "Weekly report",
227 markdown: "**bold** body",
228 fields: [
229 { key: "range", value: "7d" },
230 { key: "rows", value: "42" },
231 ],
232 progress: 0.4,
233 actions: [{ actionId: "run", label: "Run now" }],
234 },
235 };
236
237 {
238 const container = document.createElement("div");
239 document.body.appendChild(container);
240 const root = createRoot(container);
241 await act(async () => {
242 root.render(
243 <LocaleProvider>
244 <ExtensionCard item={cardItem} tabId="tab-1" />
245 </LocaleProvider>,
246 );
247 await flush(120);
248 });
249
250 ok(container.textContent?.includes("Weekly report") === true, "card renders the title");
251 ok(container.querySelector(".extension-card__plugin")?.textContent === "alpha", "card badges the plugin id");
252 ok(container.querySelectorAll(".extension-card__field").length === 2, "card renders the key-value grid");
253 ok(container.querySelector(".extension-card__progress")?.getAttribute("aria-valuenow") === "40", "card renders progress as an accessible progressbar");
254 ok(container.querySelector(".extension-card__body")?.querySelector("strong") !== null || container.textContent?.includes("bold") === true, "card renders markdown through the shared renderer");
255
256 const runButton = Array.from(container.querySelectorAll<HTMLButtonElement>(".extension-card__actions button")).find((b) => b.textContent === "Run now");
257 await act(async () => {
258 runButton?.click();
259 await flush();
260 });
261 ok(invokeCalls.length === 1 && invokeCalls[0].tabId === "tab-1" && invokeCalls[0].name === "/alpha:run", "action click invokes /<plugin>:<action> on the tab");
262 ok(container.querySelector(".extension-card__result")?.textContent === "Completed!", "action result message renders inline");
263 ok(container.querySelector(".extension-card__result--error") === null, "successful result is not styled as an error");
264
265 invokeResult = new Error("sidecar exploded");
266 await act(async () => {
267 runButton?.click();
268 await flush();
269 });
270 ok(invokeCalls.length === 2, "failed action still reached the bridge");
271 ok(container.querySelector(".extension-card__result--error")?.textContent === "sidecar exploded", "action failure renders an inline error");
272
273 await act(async () => root.unmount());
274 container.remove();
275 }
276
277 // ── ExtensionFormDialog ──────────────────────────────────────────────────────
278
279 {
280 const submitted: Array<Record<string, unknown>> = [];
281 let cancels = 0;
282 const container = document.createElement("div");
283 document.body.appendChild(container);
284 const root = createRoot(container);
285 const surface = {
286 pluginId: "alpha",
287 surfaceId: "f1",
288 generation: 9,
289 form: {
290 title: "Configure sync",
291 fields: [
292 { key: "agree", label: "I agree", kind: "confirm" },
293 { key: "name", label: "Name", kind: "input", required: true },
294 { key: "mode", label: "Mode", kind: "select", options: ["fast", "safe"], default: "fast" },
295 { key: "scopes", label: "Scopes", kind: "multiselect", options: ["mail", "cal"], required: true },
296 ],
297 },
298 };
299 await act(async () => {
300 root.render(
301 <LocaleProvider>
302 <ExtensionFormDialog surface={surface} onSubmit={(values) => submitted.push(values)} onCancel={() => { cancels += 1; }} />
303 </LocaleProvider>,
304 );
305 await flush();
306 });
307
308 ok(container.textContent?.includes("Configure sync") === true, "form renders its title");
309 ok(container.querySelector(".prompt-shelf__badge")?.textContent === "alpha", "form badges the plugin id");
310 const submitButton = () => Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((b) => b.textContent === "Submit");
311 ok(submitButton()?.disabled === true, "required fields keep submit disabled");
312
313 const nameInput = container.querySelector<HTMLInputElement>(".extension-form__input");
314 await act(async () => {
315 if (nameInput) setTextInput(dom, nameInput, "wen");
316 await flush();
317 });
318 ok(submitButton()?.disabled === true, "still disabled while the required multiselect is empty");
319
320 const calOption = Array.from(container.querySelectorAll<HTMLButtonElement>(".extension-form__options button")).find((b) => b.textContent === "cal");
321 await act(async () => {
322 calOption?.click();
323 await flush();
324 });
325 ok(submitButton()?.disabled === false, "submit enables once required fields are filled");
326
327 await act(async () => {
328 submitButton()?.click();
329 await flush();
330 });
331 const values = submitted[0];
332 ok(
333 submitted.length === 1 &&
334 values?.agree === false &&
335 values?.name === "wen" &&
336 values?.mode === "fast" &&
337 Array.isArray(values?.scopes) &&
338 (values.scopes as string[]).join(",") === "cal",
339 "submit delivers typed values (bool / string / select default / multiselect)",
340 );
341
342 await act(async () => {
343 document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
344 await flush();
345 });
346 ok(cancels === 1, "Escape cancels the form");
347
348 await act(async () => root.unmount());
349 container.remove();
350 }
351
352 console.log(`\n${passed} passed, ${failed} failed`);
353 dom.window.close();
354 if (failed > 0) process.exit(1);
355
355 lines Plain Text