返回 DeepSeek-Reasonix
composer-workspace-context.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-workspace-context.test.tsx
1 import assert from "node:assert/strict";
2 import React, { act } from "react";
3 import { createRoot, type Root } from "react-dom/client";
4 import { buildComposerSurface, type ComposerSurfaceInput } from "../app-shell/decisionFooterBuilders";
5 import { ComposerWorkspaceContextBar, type ComposerWorkspaceContext } from "../components/ComposerWorkspaceContextBar";
6 import { LocaleProvider } from "../lib/i18n";
7 import { ToastProvider } from "../lib/toast";
8 import { installBridgeApp, installDom } from "./composerInboxHarness";
9
10 const dom = installDom("en-US");
11 const calls = {
12 snapshots: 0,
13 switches: [] as string[],
14 checkouts: [] as string[],
15 creates: [] as string[],
16 history: 0,
17 globals: 0,
18 };
19 installBridgeApp({
20 GetProjectTreeSnapshot: async () => {
21 calls.snapshots += 1;
22 return {
23 revision: calls.snapshots,
24 projects: [
25 { key: "project:/repo", kind: "project", label: "Reasonix", root: "/repo" },
26 { key: "project:/other", kind: "project", label: "Other", root: "/other" },
27 ],
28 catalog: { state: "ready", revision: 1, indexed: 2, total: 2, repairPending: 0 },
29 indexed: 2,
30 total: 2,
31 indexingDone: true,
32 };
33 },
34 GitBranchesForTab: async () => ["main", "feature/other"],
35 GitCheckoutForTab: async (_tab: string, _root: string, name: string) => { calls.checkouts.push(name); },
36 GitCreateBranchForTab: async (_tab: string, _root: string, name: string) => { calls.creates.push(name); },
37 WorkspaceGitHistory: async () => {
38 calls.history += 1;
39 return [{ hash: "abcdef123456", author: "Ada", date: "2026-09-16T02:00:00Z", message: "Ship workspace context" }];
40 },
41 });
42
43 const baseContext: ComposerWorkspaceContext = {
44 scope: "project",
45 workspaceRoot: "/repo",
46 workspaceName: "Reasonix",
47 gitBranch: "feature/current",
48 tabId: "tab-a",
49 scopeKey: "project:/repo",
50 onSwitchWorkspace: async (path) => { if (path) calls.switches.push(path); },
51 onWorkWithoutProject: async () => { calls.globals += 1; },
52 onRefreshProjects: async () => {},
53 };
54
55 const noop = () => {};
56 const surfaceInput = {
57 base: { running: false },
58 view: {
59 hidden: false,
60 inert: false,
61 hero: true,
62 headline: "Welcome",
63 remote: false,
64 rewindCommitting: false,
65 messageActionPending: false,
66 decisionActive: false,
67 runtimeTransitioning: false,
68 controllerReady: true,
69 showContextWindowRing: false,
70 },
71 profile: { collaborationMode: "normal", toolApprovalMode: "ask", goal: "" },
72 router: { handleSend: noop, handleSteer: noop },
73 modes: { applyMode: noop, applyToolApprovalMode: noop },
74 goals: { setCollaborationModeFromUi: noop, clearGoalFromUi: noop, editGoalFromUi: noop },
75 remoteGoal: { pauseGoal: noop, resumeGoal: noop, setEffort: noop },
76 modelSwitch: { switchModelFromUi: noop },
77 inserts: { composerInsertRequest: undefined, selectedTextRequest: undefined },
78 control: { handleCancelActive: noop },
79 remoteComposer: { send: noop, cancel: noop, ready: true, profileReady: true, liveStore: undefined },
80 localLiveStore: undefined,
81 onInvocationMetadataChange: noop,
82 onCycleMode: noop,
83 transientDismissSignal: 0,
84 sessionKey: "session-a",
85 workspaceScopeKey: "project:/repo",
86 workspaceContext: baseContext,
87 fileRefRefreshKey: "",
88 guidance: null,
89 guidanceQueuePreviewItems: undefined,
90 } as unknown as ComposerSurfaceInput;
91 assert.equal(buildComposerSurface(surfaceInput).props.workspaceContext, baseContext, "empty sessions expose workspace selection");
92 const missingTab = { authentication: { status: "missing_credential", providerName: "relay" } };
93 assert.equal(buildComposerSurface({ ...surfaceInput, tab: missingTab }).props.submitDisabled, true, "missing credentials block unchanged settings");
94 assert.equal(buildComposerSurface({ ...surfaceInput, tab: { ...missingTab, modelSettingsPending: true } }).props.submitDisabled, false, "saved settings can reach backend apply-before-admission");
95 assert.equal(buildComposerSurface({ ...surfaceInput, tab: { ...missingTab, modelSettingsPending: true }, view: { ...surfaceInput.view, controllerReady: false } }).props.submitDisabled, true, "pending settings never bypass controller readiness");
96 assert.equal(buildComposerSurface({ ...surfaceInput, view: { ...surfaceInput.view, hero: false } }).props.workspaceContext, undefined, "established sessions use the compact follow-up composer");
97 const draftSurfaceInput = {
98 ...surfaceInput,
99 view: { ...surfaceInput.view, hero: false },
100 draft: {
101 surface: {
102 kind: "draft",
103 draft: { id: "draft-a", workspaceId: "workspace-a", scope: "project", workspaceRoot: "/repo", revision: 1, contentJson: "{}", settings: {}, status: "active", updatedAt: 1 },
104 content: { text: "", invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [] },
105 settings: { model: "fixture/model", mode: "normal", toolApprovalMode: "ask", disabledMcp: {}, mcpOrder: [] },
106 commands: [], servers: [], generation: 1, editVersion: 0, pendingTasks: 0, preparingSubmission: false, saveState: "saved",
107 },
108 captureSubmission: noop, releasePreparation: noop, flushPreparation: noop, submitFrom: noop,
109 updateSettingsFor: noop, cancelSubmission: noop, updateContentFor: noop, patchContentFor: noop,
110 isCurrentHandle: () => true, canEditHandle: () => true, trackTask: noop, reportTaskError: noop,
111 },
112 } as unknown as ComposerSurfaceInput;
113 const draftContext = buildComposerSurface(draftSurfaceInput).props.workspaceContext;
114 assert.equal(draftContext?.workspaceRoot, "/repo", "drafts keep workspace selection when the backing tab is not in its hero state");
115 assert.equal(draftContext?.scopeKey, "draft:workspace-a", "draft workspace actions use the draft owner identity");
116 assert.equal(draftContext?.tabId, undefined, "drafts cannot issue Git RPCs against the previous formal session");
117 assert.equal(draftContext?.gitBranch, undefined, "drafts do not display the previous formal session's branch");
118 for (const [scope, workspaceRoot, workspaceName] of [
119 ["project", "/other/project-b", "project-b"],
120 ["project", "D:\\Work\\TEST\\", "TEST"],
121 ["global", "", undefined],
122 ] as const) {
123 const owner = draftSurfaceInput.draft!;
124 const projected = buildComposerSurface({
125 ...draftSurfaceInput,
126 workspaceContext: { ...baseContext, remote: true },
127 draft: { ...owner, surface: { ...owner.surface!, draft: { ...owner.surface!.draft, scope, workspaceRoot } } },
128 }).props.workspaceContext;
129 assert.equal(projected?.scope, scope);
130 assert.equal(projected?.workspaceRoot, workspaceRoot);
131 assert.equal(projected?.workspaceName, workspaceName, "project labels belong to the draft, including Windows paths and global drafts");
132 assert.equal(projected?.remote, false);
133 assert.equal(projected?.tabId, undefined);
134 assert.equal(projected?.gitBranch, undefined);
135 assert.equal(projected?.onSwitchWorkspace, baseContext.onSwitchWorkspace, "draft project switching remains available");
136 }
137
138 const rootElement = document.getElementById("root");
139 assert(rootElement);
140 const root: Root = createRoot(rootElement);
141 let context = baseContext;
142 const flush = async () => {
143 await new Promise<void>((resolve) => setTimeout(resolve, 0));
144 await Promise.resolve();
145 };
146 const render = async (next?: Partial<ComposerWorkspaceContext>) => {
147 context = { ...context, ...next };
148 await act(async () => {
149 root.render(<LocaleProvider><ToastProvider><ComposerWorkspaceContextBar context={context} /></ToastProvider></LocaleProvider>);
150 await flush();
151 });
152 };
153 const click = async (element: Element | null) => {
154 assert(element);
155 await act(async () => {
156 element.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
157 await flush();
158 });
159 };
160 await render();
161 assert(document.querySelector('[role="toolbar"][aria-label="Current work context"]'));
162 assert(document.querySelector('button[aria-label="Project: Reasonix"]'));
163 assert(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
164
165 await click(document.querySelector('button[aria-label="Project: Reasonix"]'));
166 assert.equal(calls.snapshots, 1);
167 assert(document.querySelector('input[aria-label="Search workspaces"]'));
168 const projectItems = [...document.querySelectorAll('.composer-workspace-menu--projects [role="menuitem"]')];
169 assert(projectItems.some((item) => item.textContent?.includes("Reasonix")));
170 assert(projectItems.some((item) => item.textContent?.includes("Other")));
171 await click(projectItems.find((item) => item.textContent?.includes("Reasonix")) ?? null);
172 assert.deepEqual(calls.switches, [], "selecting the current workspace only closes the menu");
173
174 await click(document.querySelector('button[aria-label="Project: Reasonix"]'));
175 await click([...document.querySelectorAll('.composer-workspace-menu--projects [role="menuitem"]')].find((item) => item.textContent?.includes("Other")) ?? null);
176 assert.deepEqual(calls.switches, ["/other"]);
177
178 await click(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
179 const currentBranch = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("feature/current"));
180 assert(currentBranch, "the active branch stays visible even when the branch RPC omits it");
181 assert(currentBranch.classList.contains("composer-workspace-menu__item--active"));
182 await click(currentBranch);
183 assert.deepEqual(calls.checkouts, [], "selecting the current branch does not issue a redundant checkout");
184
185 await click(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
186 const otherBranch = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("feature/other"));
187 await click(otherBranch ?? null);
188 assert.deepEqual(calls.checkouts, ["feature/other"]);
189 assert(document.querySelector('button[aria-label="Current Git branch: feature/other"]'));
190
191 await click(document.querySelector('button[aria-label="Current Git branch: feature/other"]'));
192 const createAction = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Create and check out new branch"));
193 await click(createAction ?? null);
194 const branchInput = document.querySelector('input[aria-label="New branch name"]') as HTMLInputElement | null;
195 assert(branchInput);
196 assert.equal(branchInput.placeholder, "New branch name");
197 assert.equal([...document.querySelectorAll<HTMLButtonElement>('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Create and check out new branch"))?.disabled, true);
198 const graphAction = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Git graph"));
199 await click(graphAction ?? null);
200 assert.equal(calls.history, 1);
201 assert(document.querySelector('[role="dialog"][aria-labelledby="composer-git-graph-title"]'));
202 assert(document.body.textContent?.includes("Ship workspace context"));
203 assert(document.body.textContent?.includes("abcdef1"));
204 await click(document.querySelector('.composer-git-graph__actions button[aria-label="Close"]'));
205 assert.equal(document.querySelector('.composer-git-graph'), null);
206 assert.equal(document.activeElement, document.querySelector('button[aria-label="Current Git branch: feature/other"]'), "closing the Git graph restores focus to the branch trigger");
207
208 await click(document.querySelector('button[aria-label="Work without a project"]'));
209 assert.equal(calls.globals, 1);
210
211 await render({ scope: "global", workspaceRoot: "/stale-cwd", workspaceName: "Stale workspace", remote: false });
212 assert(document.querySelector('button[aria-label="Project: No project"]'), "global sessions never inherit a stale cwd label");
213 assert.equal(document.querySelector('button[aria-label="Work without a project"]'), null, "global sessions do not render a redundant clear action");
214
215 await render(draftContext);
216 assert(document.querySelector('button[aria-label="Project: repo"]'), "drafts show their own project");
217 assert.equal(document.querySelector('.composer-workspace-branch'), null);
218 assert.equal(document.querySelector('button[aria-label^="Current Git branch:"]'), null, "drafts cannot open the previous session's Git menu");
219 await click(document.querySelector('button[aria-label="Project: repo"]'));
220 await click([...document.querySelectorAll('.composer-workspace-menu--projects [role="menuitem"]')].find((item) => item.textContent?.includes("Other")) ?? null);
221 assert.deepEqual(calls.switches, ["/other", "/other"], "drafts can still switch projects");
222 assert.equal(calls.history, 1, "draft project actions never query the old session's Git history");
223
224 await act(async () => { root.unmount(); await flush(); });
225 dom.window.close();
226 console.log("PASS composer workspace context project, branch, create and git graph flows");
227
227 lines Plain Text