返回 DeepSeek-Reasonix
composer-authentication-recovery.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-authentication-recovery.test.tsx
1 // Run with the CSS stub loader and 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 { Composer } from "../components/Composer";
8 import { LocaleProvider } from "../lib/i18n";
9 import { ToastProvider } from "../lib/toast";
10 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
11 import { installDesktopHostStub } from "./desktopHostStub";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25
26 function eq(actual: unknown, expected: unknown, label: string) {
27 if (actual === expected) ok(true, label);
28 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
29 }
30
31 function flushTimers(): Promise<void> {
32 return new Promise((resolve) => setTimeout(resolve, 0));
33 }
34
35 async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
36 for (let i = 0; i < attempts; i++) {
37 if (check()) return;
38 await act(async () => {
39 await flushTimers();
40 });
41 }
42 }
43
44 class TestResizeObserver {
45 observe() {}
46 unobserve() {}
47 disconnect() {}
48 }
49
50 function installDom() {
51 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
52 pretendToBeVisual: true,
53 url: "http://localhost/",
54 });
55 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
56 globalThis.window = dom.window as unknown as Window & typeof globalThis;
57 globalThis.document = dom.window.document;
58 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
59 globalThis.Node = dom.window.Node;
60 globalThis.HTMLElement = dom.window.HTMLElement;
61 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.CustomEvent = dom.window.CustomEvent;
64 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
65 globalThis.InputEvent = dom.window.InputEvent;
66 globalThis.MouseEvent = dom.window.MouseEvent;
67 globalThis.File = dom.window.File;
68 globalThis.FileReader = dom.window.FileReader;
69 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
70 globalThis.MutationObserver = dom.window.MutationObserver;
71 globalThis.localStorage = dom.window.localStorage;
72 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
73 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
74 globalThis.ResizeObserver = TestResizeObserver;
75 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
76 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
77 Object.defineProperty(window, "matchMedia", {
78 configurable: true,
79 value: () => ({
80 matches: true,
81 media: "(prefers-reduced-motion: reduce)",
82 onchange: null,
83 addEventListener() {},
84 removeEventListener() {},
85 addListener() {},
86 removeListener() {},
87 dispatchEvent: () => false,
88 }),
89 });
90 return dom;
91 }
92
93 function installBridgeApp(methods: Record<string, unknown>) {
94 installDesktopHostStub({
95 Commands: async () => [],
96 Models: async () => [],
97 ModelsForTab: async () => [],
98 ...methods,
99 });
100 }
101
102 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
103 const rootEl = document.getElementById("root");
104 if (!rootEl) throw new Error("missing root");
105 const root = createRoot(rootEl);
106 let currentProps: Parameters<typeof Composer>[0] = {
107 running: false,
108 collaborationMode: "normal",
109 toolApprovalMode: "ask" as ToolApprovalMode,
110
111 goal: "",
112 cwd: "/repo",
113 modelLabel: "DeepSeek-R1",
114 imageInputEnabled: true,
115 tabId: "single-surface-tab",
116 sessionKey: "session:project:/repo:topic-a:session-a",
117 onSend: () => {},
118 onCancel: async () => ({ discardedItemIds: [] }),
119 onCycleMode: () => {},
120 onSetMode: () => {},
121 onSetCollaborationMode: (_mode: CollaborationMode) => {},
122 onSetToolApprovalMode: () => {},
123 onClearGoal: () => {},
124 onSwitchModel: () => {},
125 onSetEffort: () => {},
126
127 ready: true,
128 ...props,
129 };
130 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
131 currentProps = { ...currentProps, ...nextProps };
132 await act(async () => {
133 root.render(
134 <LocaleProvider>
135 <ToastProvider>
136 <div className="chat-pane">
137 <Composer {...currentProps} />
138 </div>
139 </ToastProvider>
140 </LocaleProvider>,
141 );
142 await flushTimers();
143 });
144 };
145 await paint();
146 return { root, rerender: paint };
147 }
148
149 function textarea(): HTMLTextAreaElement {
150 const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
151 if (!node) throw new Error("composer textarea did not render");
152 return node;
153 }
154
155 function sendButton(): HTMLButtonElement {
156 const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
157 if (!node) throw new Error("send button did not render");
158 return node;
159 }
160
161
162 const dom = installDom();
163 let resolveRetry: (() => void) | undefined;
164 installBridgeApp({ RetryAuthenticationForTab: async () => {
165 await new Promise<void>((resolve) => { resolveRetry = resolve; });
166 return { status: "ready" };
167 } });
168 const rejected = { status: "authentication_rejected" as const, providerName: "relay", modelRef: "relay/chat" };
169 const { root, rerender } = await renderComposer({
170 submitDisabled: true,
171 authentication: rejected,
172 insertRequest: { id: 1, text: "preserve my draft", mode: "insert" },
173 });
174 const originalDraft = textarea().value;
175 await waitFor(() => document.querySelectorAll(".composer-toolbar-send__configure").length === 2, 100);
176 const retry = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-toolbar-send__configure")).at(-1);
177 if (!retry) throw new Error("retry action missing");
178 await act(async () => { retry.click(); await flushTimers(); });
179 await rerender({ tabId: "other-tab", ready: false, authentication: undefined, submitDisabled: true });
180 await act(async () => { resolveRetry?.(); await flushTimers(); });
181 eq(sendButton().disabled, true, "late retry cannot enable another tab");
182 await rerender({ tabId: "single-surface-tab", ready: false, authentication: rejected, submitDisabled: true });
183 eq(sendButton().disabled, true, "retry cannot bypass controller readiness on the original tab");
184 await rerender({ ready: true, authentication: { status: "missing_credential", providerName: "replacement" }, submitDisabled: true });
185 eq(sendButton().disabled, true, "old retry cannot authorize a replacement connection");
186 await rerender({ authentication: { status: "ready" }, submitDisabled: false });
187 eq(sendButton().disabled, false, "authoritative ready state enables the preserved draft");
188 eq(textarea().value, originalDraft, "authentication recovery preserves draft text");
189 await act(async () => root.unmount());
190 dom.window.close();
191 console.log(`authentication recovery: ${passed} passed, ${failed} failed`);
192 if (failed) process.exitCode = 1;
193
193 lines Plain Text