返回 DeepSeek-Reasonix
topicbar-session-actions.test.tsx
根目录 / desktop / frontend / src / __tests__ / topicbar-session-actions.test.tsx
1 // Run: tsx src/__tests__/topicbar-session-actions.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { TopicbarSessionActions } from "../components/TopicbarSessionActions";
8 import { LocaleProvider } from "../lib/i18n";
9
10 let passed = 0;
11 let failed = 0;
12
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22
23 function flushTimers(): Promise<void> {
24 return new Promise((resolve) => setTimeout(resolve, 0));
25 }
26
27 async function waitFor(label: string, predicate: () => boolean) {
28 for (let attempt = 0; attempt < 30; attempt += 1) {
29 await act(async () => {
30 await flushTimers();
31 });
32 if (predicate()) return;
33 }
34 throw new Error(`timed out waiting for ${label}`);
35 }
36
37 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
38 pretendToBeVisual: true,
39 url: "http://localhost/",
40 });
41 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
42 globalThis.window = dom.window as unknown as Window & typeof globalThis;
43 globalThis.document = dom.window.document;
44 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
45 globalThis.Node = dom.window.Node;
46 globalThis.HTMLElement = dom.window.HTMLElement;
47 globalThis.Event = dom.window.Event;
48 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
49 globalThis.MouseEvent = dom.window.MouseEvent;
50 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
51 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
52 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
53
54 function press(target: Element, key: string) {
55 target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
56 }
57
58 console.log("\ntopicbar direct session actions and export navigation");
59 const rootElement = document.getElementById("root")!;
60 const root = createRoot(rootElement);
61 const calls: string[] = [];
62 Object.defineProperty(navigator, "clipboard", { configurable: true, value: {
63 writeText: async (value: string) => { calls.push(`copy:${value}`); },
64 } });
65
66 async function render(terminalEnabled = true, sessionHasContent = true, tabID = "one") {
67 await act(async () => {
68 root.render(<LocaleProvider><TopicbarSessionActions
69 key={tabID} sessionHasContent={sessionHasContent}
70 getSessionMarkdown={async () => "# Session"}
71 exportSession={(format) => { calls.push(`export:${format}`); }}
72 toggleTerminal={() => { calls.push("terminal"); }} terminalOpen={false}
73 terminalEnabled={terminalEnabled} prefetchTerminal={() => { calls.push("prefetch"); }}
74 openSessionSummary={() => { calls.push("summary"); }} tasksOpen={false}
75 /></LocaleProvider>);
76 });
77 }
78 await render();
79 const buttons = Array.from(rootElement.querySelectorAll<HTMLButtonElement>("button"));
80 ok(buttons.length === 4 && !rootElement.querySelector('[role="menu"]'), "all four session actions are directly available without opening a menu");
81 const [copy, trigger, terminal, summary] = buttons as [HTMLButtonElement, HTMLButtonElement, HTMLButtonElement, HTMLButtonElement];
82 await act(async () => { copy.click(); terminal.click(); summary.click(); });
83 ok(calls.includes("copy:# Session") && calls.includes("terminal") && calls.includes("summary"), "one click invokes each direct action, including asynchronous copy");
84 await act(async () => { terminal.focus(); });
85 ok(calls.includes("prefetch"), "focusing the terminal action prefetches its panel");
86
87 async function openExport(key?: string) {
88 await act(async () => { if (key) press(trigger, key); else trigger.click(); });
89 await waitFor("export formats", () => rootElement.querySelector('[role="menu"]') !== null);
90 return Array.from(rootElement.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
91 }
92 let items = await openExport();
93 ok(items.length === 5 && document.activeElement === items[0], "one export click opens all five formats and focuses the first");
94 await act(async () => { press(items[0]!, "ArrowUp"); });
95 ok(document.activeElement === items[4], "ArrowUp wraps to the last format");
96 await act(async () => { press(items[4]!, "Home"); });
97 ok(document.activeElement === items[0], "Home focuses the first format");
98 await act(async () => { press(items[0]!, "ArrowDown"); });
99 ok(document.activeElement === items[1], "ArrowDown advances to the next format");
100 await act(async () => { press(items[1]!, "End"); });
101 ok(document.activeElement === items[4], "End focuses the last format");
102 await act(async () => { press(items[4]!, "Escape"); });
103 ok(!rootElement.querySelector('[role="menu"]') && document.activeElement === trigger, "Escape closes export and restores trigger focus");
104 items = await openExport("ArrowUp");
105 ok(document.activeElement === items[4], "ArrowUp on the export trigger opens at the final format");
106 await act(async () => { items[4]!.click(); });
107 ok(calls.includes("export:diagnostic") && !rootElement.querySelector('[role="menu"]') && document.activeElement === trigger, "format selection dispatches diagnostic export, closes the menu, and restores focus");
108 for (const [index, format] of ["markdown", "json", "pdf"].entries()) {
109 items = await openExport("ArrowDown");
110 await act(async () => { items[index]!.click(); });
111 ok(calls.includes(`export:${format}`), `${format} export retains its callback`);
112 }
113 await openExport();
114 await act(async () => { summary.focus(); });
115 ok(!rootElement.querySelector('[role="menu"]') && document.activeElement === summary, "moving focus outside export dismisses it without stealing focus");
116 await openExport();
117 await act(async () => { document.body.dispatchEvent(new dom.window.MouseEvent("pointerdown", { bubbles: true })); });
118 ok(!rootElement.querySelector('[role="menu"]'), "pointer interaction outside dismisses export");
119 await openExport();
120 await render(true, true, "two");
121 ok(!rootElement.querySelector('[role="menu"]'), "switching sessions clears the previous export menu");
122 await render(false, false, "two");
123 const disabledButtons = Array.from(rootElement.querySelectorAll<HTMLButtonElement>("button"));
124 ok(disabledButtons[1]!.disabled && disabledButtons[2]!.disabled, "empty sessions disable export and remote surfaces disable terminal");
125 const callCount = calls.length;
126 await act(async () => { disabledButtons[1]!.click(); disabledButtons[2]!.click(); disabledButtons[2]!.focus(); });
127 ok(calls.length === callCount && !rootElement.querySelector('[role="menu"]'), "disabled actions neither run nor prefetch");
128 await act(async () => { root.unmount(); });
129 dom.window.close();
130 console.log(`\n${passed} passed, ${failed} failed`);
131 if (failed > 0) process.exit(1);
132
132 lines Plain Text