返回 DeepSeek-Reasonix
session-takeover-ui.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-takeover-ui.test.tsx
1 // Run: tsx src/__tests__/session-takeover-ui.test.tsx
2
3 import React from "react";
4 import { JSDOM } from "jsdom";
5 import { act } from "react";
6 import type { AppBindings } from "../lib/bridge";
7 import { activeLeaseBlockedTab } from "../lib/tabMetaRefresh";
8 import type { TabMeta } from "../lib/types";
9 import { installDesktopHostStub } from "./desktopHostStub";
10
11 let passed = 0;
12 let failed = 0;
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 console.log("\nSession takeover UI lifecycle");
24 const tabMetas = [
25 { id: "tab-a", remote: false, runtime: { phase: "lease_blocked", epoch: "a", issue: { code: "session_lease_held", message: "held", retryable: true } } },
26 { id: "tab-b", remote: false, runtime: { phase: "ready", epoch: "b" } },
27 ] as TabMeta[];
28 ok(activeLeaseBlockedTab(tabMetas, "tab-b") === undefined, "background lease conflicts do not arm the active tab");
29 ok(activeLeaseBlockedTab(tabMetas, "tab-a")?.id === "tab-a", "the active lease-blocked tab exposes takeover");
30 const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
31 pretendToBeVisual: true,
32 url: "http://localhost/",
33 });
34 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
35 globalThis.window = dom.window as unknown as Window & typeof globalThis;
36 globalThis.document = dom.window.document;
37 globalThis.Node = dom.window.Node;
38 globalThis.Element = dom.window.Element;
39 globalThis.HTMLElement = dom.window.HTMLElement;
40 globalThis.Event = dom.window.Event;
41 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
42 globalThis.MouseEvent = dom.window.MouseEvent;
43 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
44
45 let rejectTakeover: ((reason?: unknown) => void) | null = null;
46 installDesktopHostStub(({ main: { App: {
47 async QuerySessionTakeover() {
48 return { available: true, sessionPath: "/session.jsonl", holder: "serve", remoteAttached: true, running: true, mirrored: false };
49 },
50 TakeoverSession() {
51 return new Promise<void>((_resolve, reject) => { rejectTakeover = reject; });
52 },
53 } as Partial<AppBindings> as AppBindings } }).main.App);
54
55 const [{ createRoot }, { LocaleProvider }, { RemoteReclaimBanner }, { SessionTakeoverDialog }] = await Promise.all([
56 import("react-dom/client"),
57 import("../lib/i18n"),
58 import("../components/RemoteReclaimBanner"),
59 import("../components/SessionTakeoverDialog"),
60 ]);
61
62 const host = document.getElementById("root");
63 if (!host) throw new Error("missing root");
64 const root = createRoot(host);
65 const reclaimed: string[] = [];
66 const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
67 const renderBanner = (tabId: string, busyTabId: string | null = null) => act(async () => {
68 root.render(<LocaleProvider><RemoteReclaimBanner tabId={tabId} busyTabId={busyTabId} onReclaim={(id) => reclaimed.push(id)} /></LocaleProvider>);
69 });
70
71 await renderBanner("tab-a");
72 await act(async () => document.querySelector<HTMLButtonElement>(".banner button")?.click());
73 await renderBanner("tab-b");
74 await act(async () => document.querySelector<HTMLButtonElement>(".banner button")?.click());
75 ok(reclaimed.length === 0, "switching A to B requires B's own first confirmation click");
76 await act(async () => document.querySelector<HTMLButtonElement>(".banner button")?.click());
77 ok(reclaimed.length === 1 && reclaimed[0] === "tab-b", "second click reclaims only the armed tab");
78 await renderBanner("tab-b", "tab-a");
79 ok(document.querySelector<HTMLButtonElement>(".banner button")?.disabled === true, "any reclaim in progress disables the active reclaim button");
80
81 let closes = 0;
82 await act(async () => {
83 root.render(<LocaleProvider><SessionTakeoverDialog tabId="tab-a" onClose={() => { closes += 1; }} /></LocaleProvider>);
84 await flush();
85 });
86 await act(async () => {
87 await flush();
88 });
89 const waitButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".session-takeover-dialog button"))
90 .find((button) => button.classList.contains("btn--primary"));
91 await act(async () => waitButton?.click());
92 await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })));
93 ok(closes === 0 && document.querySelector(".session-takeover-dialog") !== null, "busy Escape is consumed without closing the takeover dialog");
94 await act(async () => {
95 rejectTakeover?.(new Error("injected takeover failure"));
96 await Promise.resolve();
97 });
98 ok(document.body.textContent?.includes("injected takeover failure") === true, "action failure remains visible and restores the dialog");
99 await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })));
100 ok(closes === 1, "Escape closes again after a failed action leaves busy state");
101
102 await act(async () => root.unmount());
103 dom.window.close();
104 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
105 if (failed > 0) process.exit(1);
106
106 lines Plain Text