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