返回 DeepSeek-Reasonix
session-submission-lifecycle.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-submission-lifecycle.test.tsx
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 { useSessionSubmission } from "../lib/useSessionSubmission";
6 import { useSessionOperations } from "../app-runtime/useSessionOperations";
7 import type { SubmissionPorts, SubmissionResource } from "../app-runtime/sessionSubmissionOwner";
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 const root = createRoot(document.getElementById("root")!);
12 const { createSubmissionPorts } = await import("../app-runtime/desktopSubmissionAdapter");
13 function deferred() {
14 let resolve!: () => void; let reject!: (error: Error) => void;
15 const promise = new Promise<void>((yes, no) => { resolve = yes; reject = no; });
16 return { promise, resolve, reject };
17 }
18 let goalGate: ReturnType<typeof deferred> | undefined, profileGate: ReturnType<typeof deferred> | undefined;
19 const calls: unknown[][] = [];
20 const ports: SubmissionPorts = {
21 send: async (...args) => { calls.push(["send", ...args]); },
22 clearUndo: tab => { calls.push(["undo", tab]); },
23 setGoal: async (...args) => { calls.push(["goal", ...args]); await goalGate?.promise; },
24 patchGoal: (...args) => { calls.push(["patch", ...args]); },
25 profile: async tab => { calls.push(["profile", tab]); await profileGate?.promise; return true; },
26 };
27 let commands!: ReturnType<typeof useSessionSubmission>;
28 function Probe({ tab, gen, draft, readOnly }: { tab: string; gen: number; draft: boolean; readOnly: boolean }) {
29 const resources: SubmissionResource[] = ["A", "B"].map(tabId => ({ target: { tabId, sessionKey: tabId + gen },
30 ready: true, remote: false, unavailable: readOnly ? "read-only" : "", goalDraft: draft, collaboration: "normal", approval: "ask" }));
31 const target = resources.find(source => source.target.tabId === tab)!.target;
32 const operations = useSessionOperations({ visible: target, resources: resources.map(source => source.target) });
33 commands = useSessionSubmission({ target, resources, operations, ports, missingSource: "missing" });
34 return null;
35 }
36 const paint = (tab = "A", gen = 1, draft = false, readOnly = false) => act(async () => root.render(<Probe tab={tab} gen={gen} draft={draft} readOnly={readOnly} />));
37 try {
38 const adapterCalls: unknown[][] = [];
39 const adapter = createSubmissionPorts({
40 send: async (...args) => { adapterCalls.push(["send", ...args]); },
41 setGoal: async (...args) => { adapterCalls.push(["set", ...args]); },
42 clearGoal: async (...args) => { adapterCalls.push(["clear", ...args]); },
43 clearUndo: () => {}, patchGoal: () => {}, profile: async () => true,
44 });
45 await adapter.setGoal("B", "goal", false); await adapter.setGoal("A", "", false);
46 await adapter.send("B", "display", " raw bytes ", undefined, { goal: "goal", collaborationMode: "normal", toolApprovalMode: "ask" });
47 assert.deepEqual(adapterCalls, [["set", "B", "goal"], ["clear", "A"], ["send", "B", "display", " raw bytes ", undefined, undefined,
48 { goal: "goal", collaborationMode: "normal", toolApprovalMode: "ask" }]], "runtime adapter preserves explicit Controller targets, original-text slot and atomic Goal payload");
49 await paint(); profileGate = deferred();
50 const first = commands.submit("A", " display ", " provider bytes ");
51 await paint("B"); profileGate.resolve(); await first;
52 assert.deepEqual(calls, [["profile", "A"], ["undo", "A"], ["send", "A", "display", "provider bytes", undefined, undefined]], "ordinary continuation uses the source resource and preserves established trim semantics");
53
54 calls.length = 0; profileGate = deferred(); await paint();
55 const replaced = commands.submit("A", "stale");
56 await paint("A", 2); profileGate.resolve(); await replaced;
57 assert.deepEqual(calls, [["profile", "A"]], "replacement receives no stale undo invalidation or submit");
58
59 calls.length = 0; profileGate = undefined; goalGate = deferred(); await paint();
60 const goal = commands.submit("A", "/goal source goal");
61 await paint("A", 2); goalGate.resolve(); await goal;
62 assert.deepEqual(calls, [["goal", "A", "source goal", false]], "old Goal completion cannot patch or submit to a replacement");
63
64 calls.length = 0; goalGate = deferred(); await paint();
65 const rejected = commands.applyGoal("bad goal");
66 goalGate.reject(Error("activation failed")); await assert.rejects(rejected, /activation failed/);
67 assert.deepEqual(calls, [["goal", "A", "bad goal", false]], "failed activation leaves UI profile and undo untouched");
68
69 calls.length = 0; goalGate = undefined; await paint("A", 1, true);
70 const structured = { display: "skill", input: "/skill input", invocations: [{ name: "skill", kind: "skill" as const, offset: 0 }] };
71 await commands.submit("A", " goal text ", " /skill input ", structured);
72 assert.deepEqual(calls, [["undo", "A"], ["send", "A", "goal text", "/skill input", structured,
73 { goal: "goal text", collaborationMode: "normal", toolApprovalMode: "ask" }], ["patch", "A", "goal text"]], "structured Goal uses one atomic source send and unchanged invocation bytes");
74 calls.length = 0;
75 await commands.submit("A", " goal text ", " task bytes ");
76 assert.equal(calls[1][3], "/goal task bytes", "ordinary first Goal retains its existing prefix");
77
78 calls.length = 0; await paint();
79 await commands.submit("A", "/goal pause"); await commands.submit("A", "/goal resume");
80 assert.deepEqual(calls.filter(call => call[0] === "goal" || call[0] === "patch"), [], "pause and resume preserve Goal before backend command handling");
81 assert.deepEqual(calls.filter(call => call[0] === "send").map(call => call[3]), ["/goal pause", "/goal resume"]);
82 calls.length = 0; await commands.applyGoalForTab("B", " target goal "); await commands.applyGoalForTab("B", "");
83 assert.deepEqual(calls, [["goal", "B", "target goal", false], ["patch", "B", "target goal"], ["goal", "B", "", false], ["patch", "B", ""]], "activation and clear patch only their explicit source after backend success");
84
85 calls.length = 0; await paint();
86 await commands.submit("A", "/goal --deep --research preserve flags");
87 assert.deepEqual(calls, [["patch", "A", "preserve flags"], ["undo", "A"],
88 ["send", "A", "/goal --deep --research preserve flags", "/goal --deep --research preserve flags", undefined, undefined]], "legacy Goal flags remain backend-visible and do not invoke separate activation");
89
90 calls.length = 0; await paint("A", 1, false, true);
91 await assert.rejects(commands.commitThenSend("A", "direct"), /read-only/);
92 assert.deepEqual(calls, [], "read-only source preserves undo and sends nothing");
93 await paint(); profileGate = deferred();
94 const disposed = commands.submit("A", "disposed");
95 await act(async () => root.unmount()); profileGate.resolve(); await disposed;
96 commands.commitThenSend("A", "after-unmount");
97 assert.deepEqual(calls, [["profile", "A"]], "unmount revokes both continuation and committed direct entry");
98 console.log("session submission lifecycle: source continuations, Goal atomicity/bytes, replacement, failure, read-only and disposal passed");
99 } finally { dom.window.close(); }
100
100 lines Plain Text