返回 DeepSeek-Reasonix
session-draft-presentation.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-draft-presentation.test.tsx
1 import assert from "node:assert/strict";
2 import { register } from "node:module";
3 import React, { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { JSDOM } from "jsdom";
6
7 import type { SessionDraftSurface } from "../app-runtime/useSessionDraftSurface";
8 import { initialState } from "../lib/useController";
9 import { LocaleProvider, type Translator } from "../lib/i18n";
10 import { creationHeroVisible } from "../app-shell/draftPresentation";
11
12 register(new URL("../../scripts/svg-loader.mjs", import.meta.url));
13 const { ChatPaneRegion } = await import("../app-shell/ChatPaneRegion");
14 const { DraftTopicbarActions } = await import("../app-shell/DraftTopicbarActions");
15
16 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
17 Object.assign(globalThis, {
18 window: dom.window,
19 document: dom.window.document,
20 localStorage: dom.window.localStorage,
21 IS_REACT_ACT_ENVIRONMENT: true,
22 });
23
24 const surface: SessionDraftSurface = {
25 kind: "draft",
26 draft: {
27 id: "draft-project",
28 workspaceId: "workspace-project",
29 scope: "project",
30 workspaceRoot: "/workspace/project",
31 revision: 1,
32 contentJson: "{}",
33 settings: { model: "fixture/model", mode: "normal", toolApprovalMode: "ask", disabledMcp: {}, mcpOrder: [] },
34 status: "active",
35 updatedAt: 1,
36 },
37 content: { text: "", invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [] },
38 settings: { model: "fixture/model", mode: "normal", toolApprovalMode: "ask", disabledMcp: {}, mcpOrder: [] },
39 commands: [],
40 servers: [],
41 generation: 1,
42 editVersion: 0,
43 pendingTasks: 0,
44 preparingSubmission: false,
45 saveState: "saved",
46 };
47
48 const noop = () => {};
49 for (const emptyHero of [true, false]) {
50 assert.equal(creationHeroVisible(null, emptyHero), emptyHero, "formal sessions keep their own layout");
51 assert.equal(creationHeroVisible(surface, emptyHero), true, "healthy drafts own the welcome layout");
52 for (const failure of [
53 { saveState: "conflict" as const },
54 { saveState: "error" as const },
55 { taskError: "Attachment failed" },
56 { submissionError: "Startup failed" },
57 ]) {
58 assert.equal(creationHeroVisible({ ...surface, ...failure }, emptyHero), false,
59 "draft recovery is never collapsed by a hidden empty formal session");
60 }
61 }
62 const commands = { onPrompt: noop, onFork: noop, onLoadOlderHistory: async () => false, onLoadNewerHistory: async () => false, onSurfacePaintReady: noop };
63 const transcript = {
64 state: { ...initialState, meta: { ready: true, eventChannel: "fixture" } },
65 items: [], tabId: undefined, geometrySessionKey: "draft", footerHeight: 0,
66 invocationMetadata: undefined, surfaceCommitToken: undefined, liveStore: undefined,
67 transcriptHydrating: false, navigationDataReady: true, readOnly: false, controllerReady: false,
68 hydratePlaceholderActive: false, clearContextPending: false, availability: { kind: "ready", source: "history" } as const,
69 rewind: { stateActive: false, committing: false },
70 };
71 const draftActions = {
72 surface,
73 onUseSaved: noop,
74 onKeepLocal: noop,
75 onRetrySave: noop,
76 onDismissTaskError: noop,
77 onResume: noop,
78 onOpenSession: noop,
79 onCheckSubmission: noop,
80 };
81
82 const root = createRoot(document.getElementById("root")!);
83 try {
84 await act(async () => root.render(<LocaleProvider><ChatPaneRegion
85 transitioning={false} t={((key: string) => key) as Translator} imDetail={null} remote={undefined}
86 draft={draftActions} transcript={transcript} onRetryHistory={async () => {}} commands={commands}
87 /></LocaleProvider>));
88 assert.ok(document.querySelector(".main--draft-landing"), "a healthy draft uses the clean new-session landing surface");
89 assert.equal(document.querySelector(".session-draft-surface"), null, "healthy drafts do not render the management page");
90
91 await act(async () => root.render(<LocaleProvider><ChatPaneRegion
92 transitioning={false} t={((key: string) => key) as Translator} imDetail={null} remote={undefined}
93 draft={{ ...draftActions, surface: { ...surface, saveState: "conflict", conflict: surface.draft } }}
94 transcript={transcript} onRetryHistory={async () => {}} commands={commands}
95 /></LocaleProvider>));
96 assert.ok(document.querySelector(".session-draft-attention[role=alert]"), "a conflict keeps its recovery actions visible");
97 assert.equal(document.querySelectorAll(".session-draft-attention button").length, 2, "both conflict resolutions remain available");
98
99 let resumed = 0;
100 const runtimeFailure = "create session runtime: path unavailable";
101 await act(async () => root.render(<LocaleProvider><ChatPaneRegion
102 transitioning={false} t={((key: string) => key) as Translator} imDetail={null} remote={undefined}
103 draft={{ ...draftActions, onResume: () => { resumed++; }, surface: { ...surface,
104 submissionError: runtimeFailure, taskError: `Error: ${runtimeFailure}`,
105 operation: { operationId: "original", requestId: "request", draftId: surface.draft.id, submissionId: "submission", phase: "runtime_failed", revision: 1, updatedAt: 1,
106 canResume: true, canEdit: false, canCancel: true, canDiscard: false, error: runtimeFailure },
107 } }} transcript={transcript} onRetryHistory={async () => {}} commands={commands}
108 /></LocaleProvider>));
109 assert.equal(document.querySelector('[role="status"]')?.textContent, "draft.startFailed");
110 assert.equal(document.querySelectorAll(".session-draft-surface__error").length, 1, "one runtime error is rendered once");
111 assert.ok(!document.body.textContent?.includes("draft.retrySave"), "runtime recovery does not offer a save retry");
112 await act(async () => document.querySelector<HTMLButtonElement>(".session-draft-attention button")!.click());
113 assert.equal(resumed, 1, "runtime recovery retries the original submission");
114
115 let discarded = 0;
116 const mcpSelections: boolean[] = [];
117 const server = {
118 name: "fixture-mcp", transport: "stdio", status: "deferred", enabled: true, installed: true,
119 autoStart: true, tools: 0, toolCount: 0, prompts: 0, resources: 0, toolList: [],
120 };
121 await act(async () => root.render(<LocaleProvider><DraftTopicbarActions
122 t={((key: string) => key) as Translator}
123 draft={{ ...surface, servers: [server] }}
124 onSetMCPEnabled={(_server, enabled) => mcpSelections.push(enabled)}
125 onDiscard={() => { discarded++; }}
126 /></LocaleProvider>));
127 assert.equal(document.querySelector(".draft-topicbar-mcp summary")?.textContent, "1/1", "the compact control preserves MCP visibility");
128 await act(async () => document.querySelector<HTMLInputElement>(".draft-topicbar-mcp input")!.click());
129 await act(async () => document.querySelector<HTMLButtonElement>('button[aria-label="draft.discard"]')!.click());
130 assert.deepEqual(mcpSelections, [false], "the compact control preserves per-draft MCP selection");
131 assert.equal(discarded, 1, "the compact control preserves draft discard");
132
133 for (const phase of ["submitting", "discarding"]) {
134 await act(async () => root.render(<LocaleProvider><DraftTopicbarActions
135 t={((key: string) => key) as Translator}
136 draft={{ ...surface, preparingSubmission: true, discarding: phase === "discarding" }}
137 onSetMCPEnabled={() => {}} onDiscard={() => { discarded++; }}
138 /></LocaleProvider>));
139 const button = document.querySelector<HTMLButtonElement>(".draft-topicbar-actions button")!;
140 assert.equal(button.disabled, true);
141 assert.equal(button.getAttribute("aria-label"), phase === "discarding" ? "draft.discarding" : "draft.discardBlocked");
142 await act(async () => button.click());
143 assert.equal(discarded, 1, "locked button cannot dispatch another discard");
144 }
145
146 await act(async () => root.unmount());
147 console.log("PASS session draft presentation: clean landing and exceptional recovery controls");
148 } finally {
149 dom.window.close();
150 }
151
151 lines Plain Text