返回 DeepSeek-Reasonix
approval-submit-outcome.test.tsx
根目录 / desktop / frontend / src / __tests__ / approval-submit-outcome.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 { useSessionOperations } from "../app-runtime/useSessionOperations";
6 import { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands";
7 import type { PromptPorts } from "../app-runtime/sessionPromptExecutor";
8 import { stateOwnsInteraction } from "../lib/interactionOwnership";
9 import { sessionIdentityStableKey } from "../lib/sessionIdentity";
10 import { CommandCancelled } from "../lib/commandOutcome";
11 import { resolvePromptForSession } from "../lib/exactPromptSubmit";
12 import { sessionObservationDiagnostics } from "../lib/sessionObservationDiagnostics";
13
14 const dom = new JSDOM("<div id='root'></div>");
15 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
16 const root = createRoot(document.getElementById("root")!);
17 const meta = { label: "test", ready: true, eventChannel: "test", cwd: "/workspace",
18 session: { hostId: "local", sessionId: "session-A" }, sessionGeneration: 0 };
19 const approval = { id: "1", tool: "write_file", subject: "temporary render script", kind: "write_access",
20 turnId: "turn-A", runtimeEpoch: "runtime-A", generation: 1, permissionRevision: 1,
21 write_access: { directories: ["/tmp/render-tools"] } };
22 const target = { tabId: "A", sessionKey: sessionIdentityStableKey(meta) };
23 let commands!: ReturnType<typeof useSessionPromptCommands>;
24 let calls = 0;
25 let submit: () => void | Promise<void> = () => { calls++; };
26 const ports: PromptPorts = {
27 isPromptCurrentForTab: candidate => stateOwnsInteraction({ meta, approval }, candidate),
28 approveForTab: candidate => resolvePromptForSession({
29 ResolvePromptForSession: async received => {
30 assert.equal(received.sessionGeneration, 0, "fresh binding generation reaches the host unchanged");
31 await submit();
32 },
33 ResolvePromptForTab: async () => { throw new Error("unexpected legacy route"); },
34 PendingPromptIdentitiesForTab: async () => [],
35 }, candidate, { allow: true }),
36 resolvePlanForTab: () => submit(), resolveRecoveryForTab: () => submit(),
37 answerQuestionForTab: async () => {}, answerMCPForTab: () => {},
38 setCollaborationModeForTab: async () => {}, clearGoalForTab: async () => {},
39 setRemoteComposerProfile: async () => [], patchComposerProfile: () => {}, notePlanMode: () => {},
40 drainRemoteApprovals: () => {}, rememberRevision: () => {},
41 };
42 function Probe({ ready, resourceKey }: { ready: boolean; resourceKey: string }) {
43 const operations = useSessionOperations({ visible: target, resources: [{ ...target, sessionKey: resourceKey }] });
44 commands = useSessionPromptCommands({ target, session: ready ? meta.session : undefined,
45 sessionGeneration: ready ? meta.sessionGeneration : undefined, approval,
46 remote: false, goal: "", toolApprovalMode: "workspace-write", ports, operations,
47 reportError: error => { throw error; } });
48 return null;
49 }
50 async function paint(ready = true, resourceKey = target.sessionKey) {
51 await act(async () => root.render(<Probe ready={ready} resourceKey={resourceKey} />));
52 }
53 try {
54 await paint(false);
55 await assert.rejects(commands.handleApprovalAnswer(true, false, false), CommandCancelled,
56 "missing session binding must not masquerade as an accepted approval");
57 assert.equal(calls, 0);
58 await paint(true, "replaced-session");
59 await assert.rejects(commands.handleApprovalAnswer(true, false, false), CommandCancelled,
60 "a cancelled operation must release the card's submission lock");
61 assert.equal(calls, 0, "a stale card never authorizes the replacement session");
62 await paint();
63 await commands.handleApprovalAnswer(true, false, false);
64 assert.equal(calls, 1, "retry after the authoritative binding arrives submits once");
65
66 let reject!: (error: Error) => void;
67 let entered!: () => void;
68 const started = new Promise<void>(resolve => { entered = resolve; });
69 const pending = new Promise<void>((_resolve, fail) => { reject = fail; });
70 submit = () => { entered(); return pending; };
71 let settled = false;
72 const result = commands.handleApprovalAnswer(true, false, false);
73 const checked = assert.rejects(result, /transport unavailable/);
74 void result.then(() => { settled = true; }, () => { settled = true; });
75 await started;
76 await new Promise(resolve => setTimeout(resolve, 0));
77 assert.equal(settled, false, "the card waits for the actual approval transport outcome");
78 reject(new Error("transport unavailable"));
79 await checked;
80 const trace = sessionObservationDiagnostics("session-id:session-A", "A");
81 assert.ok(trace.events.some(e => e.action === "prompt-transport" && e.status === "accepted" && e.prompt?.bindingGeneration === 0));
82 assert.ok(trace.events.some(e => e.action === "prompt-transport" && e.status === "rejected:unavailable"));
83 assert.ok(!JSON.stringify(trace).includes("transport unavailable"), "diagnostics keep closed error classes, not raw errors");
84 assert.equal(sessionObservationDiagnostics("session-id:session-A", "B").events.length, 0, "diagnostics stay with the submitting tab");
85 console.log("approval outcomes: missing/stale binding, safe retry and transport failure passed");
86 } finally {
87 await act(async () => root.unmount());
88 dom.window.close();
89 }
90
90 lines Plain Text