返回 DeepSeek-Reasonix
session-control-commands.test.ts
根目录 / desktop / frontend / src / __tests__ / session-control-commands.test.ts
1 import assert from "node:assert/strict";
2 import React, { act } from "react";
3 import { createRoot } from "react-dom/client";
4 import { JSDOM } from "jsdom";
5 import { useSessionOperations, type SessionResource } from "../app-runtime/useSessionOperations";
6 import { useSessionControlCommands } from "../app-runtime/useSessionControlCommands";
7 import { sessionIdentityKey } from "../app-runtime/sessionTarget";
8
9 const dom = new JSDOM("<div id='root'></div>");
10 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
11 // Remote tab metas carry the canonical id as a top-level `sessionId` with no
12 // SessionRef (desktop/remote_projects.go), and their generation is a pump
13 // reconnect counter. The runtime identity must still fence sessions apart.
14 const remoteHost = { hostId: "gpu-box" };
15 assert.notEqual(
16 sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionGeneration: 3 }),
17 sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-b", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionGeneration: 3 }),
18 "canonical remote session IDs must fence reused remote tabs separately",
19 );
20 assert.equal(
21 sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", sessionGeneration: 3 }),
22 sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a", sessionGeneration: 4 }),
23 "a remote pump reconnect (generation bump) does not change the session identity",
24 );
25 assert.notEqual(
26 sessionIdentityKey({ tabId: "remote", remote: remoteHost, sessionId: "session-a" }),
27 sessionIdentityKey({ tabId: "remote", remote: { hostId: "other-box" }, sessionId: "session-a" }),
28 "the same session id on two hosts stays two identities",
29 );
30 assert.notEqual(
31 sessionIdentityKey({ tabId: "remote", sessionId: "session-a" }),
32 sessionIdentityKey({ tabId: "remote", sessionId: "session-b" }),
33 "a compatibility sessionId without a host still keys the session, not the tab",
34 );
35 const legacyLocalKey = sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3 });
36 assert.equal(
37 sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3, sessionId: "" }),
38 legacyLocalKey,
39 "legacy local sessions keep their path+generation identity when no ID exists",
40 );
41 assert.notEqual(
42 sessionIdentityKey({ tabId: "local", sessionPath: "", sessionGeneration: 3, sessionId: "local-v3" }),
43 legacyLocalKey,
44 "canonical local sessions use their immutable ID only when the v3 identity is present",
45 );
46 const resource = (tabId: string, generation = 1): SessionResource => ({ tabId,
47 sessionKey: sessionIdentityKey({ tabId, sessionPath: `/${tabId}`, sessionGeneration: generation }) });
48 const a = resource("A"), b = resource("B");
49 const calls: string[] = [], errors: string[] = [];
50 let finish: ((value: boolean) => void) | undefined;
51 let started: (() => void) | undefined;
52 let delayed = false;
53 let cancelError: string | undefined;
54 let commands!: ReturnType<typeof useSessionControlCommands>;
55 function Probe({ visible = a, resources = [a, b] }: { visible?: SessionResource; resources?: SessionResource[] }) {
56 const operations = useSessionOperations({ visible, resources });
57 commands = useSessionControlCommands({ activeTabId: visible.tabId, resources, operations,
58 showToast: message => errors.push(message), clearWorkspaceConflict() {}, ports: {
59 cancel: async () => ({ discardedItemIds: [] }), cancelForTab: async tab => {
60 calls.push(`stop:${tab}`);
61 return { discardedItemIds: [], error: cancelError };
62 },
63 acceptDelivery: async () => {}, disconnectRemote: async () => {},
64 cancelJobForTab: async (tab, job) => {
65 calls.push(`${tab}:${job}`);
66 if (!delayed) return true;
67 const result = new Promise<boolean>(resolve => { finish = resolve; });
68 started?.();
69 return result;
70 }, refreshBackgroundRuntimes: async () => { calls.push("refresh"); },
71 } });
72 return null;
73 }
74 const root = createRoot(document.getElementById("root")!);
75 const paint = (resources = [a, b], visible = a) => act(async () => root.render(React.createElement(Probe, { resources, visible })));
76 try {
77 await paint();
78 const retained = commands.cancelRuntimeJob;
79 assert.equal(await retained("B", "background"), true);
80 assert.deepEqual(calls, ["B:background"], "background cancellation reaches its source port without taking active UI ownership");
81 assert.equal(await retained("A", "active"), true);
82 assert.deepEqual(calls.slice(1), ["A:active", "refresh"]);
83 assert.equal(await retained("missing", "gone"), false);
84 assert.equal(calls.length, 3, "removed resources never reach the bridge");
85
86 cancelError = "cancellation transport failed";
87 await assert.rejects(commands.handleStopActive(), /cancellation transport failed/,
88 "decision Stop rejects the controller's failure receipt so its lock can recover");
89 assert.equal(calls[calls.length - 1], "stop:A");
90 cancelError = undefined;
91 const stop = commands.handleStopActive();
92 await paint([a, b], b);
93 await stop;
94 assert.equal(calls[calls.length - 1], "stop:A", "Stop captures its source before a navigation commit");
95 await paint();
96
97 delayed = true;
98 let entered = new Promise<void>(resolve => { started = resolve; });
99 const stale = retained("B", "old-generation");
100 await entered;
101 await paint([a, resource("B", 2)]);
102 finish!(true);
103 assert.equal(await stale, false, "replacement invalidates an in-flight cancellation result");
104 assert.equal(calls[calls.length - 1], "B:old-generation", "stale results cannot refresh replacement UI");
105
106 entered = new Promise<void>(resolve => { started = resolve; });
107 const switched = retained("B", "current-generation");
108 await entered;
109 await paint([a, resource("B", 2)], resource("B", 2));
110 finish!(true);
111 assert.equal(await switched, true, "switching visible tabs does not cancel a live source operation");
112 assert.deepEqual(errors, []);
113 await act(async () => root.unmount());
114 const count = calls.length;
115 await retained("B", "unmounted");
116 assert.equal(calls.length, count, "retained commands are inert after unmount");
117 console.log("session control commands: canonical background target, active target, replacement, navigation and disposal passed");
118 } finally { dom.window.close(); }
119
119 lines TYPESCRIPT