返回 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 { key: "project_remote_box", kind: "project", label: "SSH Box", root: "remote-project:box:/srv/app", remote: { hostId: "box", workspace: "/srv/app" } },
28 ],
29 catalog: { state: "ready", revision: 1, indexed: 2, total: 2, repairPending: 0 },
30 indexed: 2,
31 total: 2,
32 indexingDone: true,
33 };
34 },
35 GitBranchesForTab: async () => ["main", "feature/other"],
36 GitCheckoutForTab: async (_tab: string, _root: string, name: string) => { calls.checkouts.push(name); },
37 GitCreateBranchForTab: async (_tab: string, _root: string, name: string) => { calls.creates.push(name); },
38 WorkspaceGitHistory: async () => {
39 calls.history += 1;
40 return [{ hash: "abcdef123456", author: "Ada", date: "2026-09-16T02:00:00Z", message: "Ship workspace context" }];
41 },
42 });
43
44 const baseContext: ComposerWorkspaceContext = {
45 scope: "project",
46 workspaceRoot: "/repo",
47 workspaceName: "Reasonix",
48 gitBranch: "feature/current",
49 tabId: "tab-a",
50 scopeKey: "project:/repo",
51 onSwitchWorkspace: async (path) => { if (path) calls.switches.push(path); },
52 onWorkWithoutProject: async () => { calls.globals += 1; },
53 onRefreshProjects: async () => {},
54 };
55
56 const noop = () => {};
57 const surfaceInput = {
58 base: { running: false },
59 view: {
60 hidden: false,
61 inert: false,
62 hero: true,
63 headline: "Welcome",
64 remote: false,
65 rewindCommitting: false,
66 messageActionPending: false,
67 decisionActive: false,
68 runtimeTransitioning: false,
69 controllerReady: true,
70 showContextWindowRing: false,
71 },
72 profile: { collaborationMode: "normal", toolApprovalMode: "ask", goal: "" },
73 router: { handleSend: noop, handleSteer: noop },
74 modes: { applyMode: noop, applyToolApprovalMode: noop },
75 goals: { setCollaborationModeFromUi: noop, clearGoalFromUi: noop, editGoalFromUi: noop },
76 remoteGoal: { pauseGoal: noop, resumeGoal: noop, setEffort: noop },
77 modelSwitch: { switchModelFromUi: noop },
78 inserts: { composerInsertRequest: undefined, selectedTextRequest: undefined },
79 control: { handleCancelActive: noop },
80 remoteComposer: { send: noop, cancel: noop, ready: true, profileReady: true, liveStore: undefined },
81 localLiveStore: undefined,
82 onInvocationMetadataChange: noop,
83 onCycleMode: noop,
84 transientDismissSignal: 0,
85 sessionKey: "session-a",
86 workspaceScopeKey: "project:/repo",
87 workspaceContext: baseContext,
88 fileRefRefreshKey: "",
89 guidance: null,
90 guidanceQueuePreviewItems: undefined,
91 } as unknown as ComposerSurfaceInput;
92 assert.equal(buildComposerSurface(surfaceInput).props.workspaceContext, baseContext, "empty sessions expose workspace selection");
93 const missingTab = { authentication: { status: "missing_credential", providerName: "relay" } };
94 assert.equal(buildComposerSurface({ ...surfaceInput, tab: missingTab }).props.submitDisabled, true, "missing credentials block unchanged settings");
95 assert.equal(buildComposerSurface({ ...surfaceInput, tab: { ...missingTab, modelSettingsPending: true } }).props.submitDisabled, false, "saved settings can reach backend apply-before-admission");
96 assert.equal(buildComposerSurface({ ...surfaceInput, tab: { ...missingTab, modelSettingsPending: true }, view: { ...surfaceInput.view, controllerReady: false } }).props.submitDisabled, true, "pending settings never bypass controller readiness");
97 assert.equal(buildComposerSurface({ ...surfaceInput, view: { ...surfaceInput.view, hero: false } }).props.workspaceContext, undefined, "established sessions use the compact follow-up composer");
98 assert.equal(buildComposerSurface(surfaceInput).props.persistentDraft, undefined, "formal composer never mounts a legacy draft owner");
99 const canonicalTab = { session: { hostId: "local", sessionId: "canonical-input" }, sessionPath: "", sessionGeneration: 3 };
100 assert.equal(buildComposerSurface({ ...surfaceInput, tab: canonicalTab }).props.inboxSessionPath, "session-id:canonical-input", "canonical follow-ups use the session identity when no legacy path exists");
101 const startingInput = { ...surfaceInput, tab: canonicalTab, view: { ...surfaceInput.view,
102 inert: true, targetInputReady: true, controllerReady: false } };
103 const startingSurface = buildComposerSurface(startingInput);
104 assert.equal(startingSurface.inert, false, "bound new session input is usable before runtime startup finishes");
105 assert.equal(startingSurface.props.disabled, false, "new-session typing does not wait for a controller");
106 assert.equal(startingSurface.props.submitDisabled, true, "typing readiness never admits a send to a starting controller");
107 assert.equal(buildComposerSurface({ ...startingInput, view: { ...startingInput.view, targetInputReady: false } }).inert, true, "old session input stays fenced before the new target binds");
108 assert.equal(buildComposerSurface({ ...startingInput, view: { ...startingInput.view, remote: true } }).inert, true, "local startup editing does not bypass remote navigation ownership");
109
110 const rootElement = document.getElementById("root");
111 assert(rootElement);
112 const root: Root = createRoot(rootElement);
113 let context = baseContext;
114 const flush = async () => {
115 await new Promise<void>((resolve) => setTimeout(resolve, 0));
116 await Promise.resolve();
117 };
118 const render = async (next?: Partial<ComposerWorkspaceContext>) => {
119 context = { ...context, ...next };
120 await act(async () => {
121 root.render(<LocaleProvider><ToastProvider><ComposerWorkspaceContextBar context={context} /></ToastProvider></LocaleProvider>);
122 await flush();
123 });
124 };
125 const click = async (element: Element | null) => {
126 assert(element);
127 await act(async () => {
128 element.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
129 await flush();
130 });
131 };
132 await render();
133 assert(document.querySelector('[role="toolbar"][aria-label="Current work context"]'));
134 assert(document.querySelector('button[aria-label="Project: Reasonix"]'));
135 assert(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
136
137 await click(document.querySelector('button[aria-label="Project: Reasonix"]'));
138 assert.equal(calls.snapshots, 1);
139 assert(document.querySelector('input[aria-label="Search workspaces"]'));
140 const projectItems = [...document.querySelectorAll('.composer-workspace-menu--projects [role="menuitem"]')];
141 assert(projectItems.some((item) => item.textContent?.includes("Reasonix")));
142 assert(projectItems.some((item) => item.textContent?.includes("Other")));
143 assert(!projectItems.some((item) => item.textContent?.includes("SSH Box")), "remote projects never appear among local folders to switch to");
144 await click(projectItems.find((item) => item.textContent?.includes("Reasonix")) ?? null);
145 assert.deepEqual(calls.switches, [], "selecting the current workspace only closes the menu");
146
147 await click(document.querySelector('button[aria-label="Project: Reasonix"]'));
148 await click([...document.querySelectorAll('.composer-workspace-menu--projects [role="menuitem"]')].find((item) => item.textContent?.includes("Other")) ?? null);
149 assert.deepEqual(calls.switches, ["/other"]);
150
151 await click(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
152 const currentBranch = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("feature/current"));
153 assert(currentBranch, "the active branch stays visible even when the branch RPC omits it");
154 assert(currentBranch.classList.contains("composer-workspace-menu__item--active"));
155 await click(currentBranch);
156 assert.deepEqual(calls.checkouts, [], "selecting the current branch does not issue a redundant checkout");
157
158 await click(document.querySelector('button[aria-label="Current Git branch: feature/current"]'));
159 const otherBranch = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("feature/other"));
160 await click(otherBranch ?? null);
161 assert.deepEqual(calls.checkouts, ["feature/other"]);
162 assert(document.querySelector('button[aria-label="Current Git branch: feature/other"]'));
163
164 await click(document.querySelector('button[aria-label="Current Git branch: feature/other"]'));
165 const createAction = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Create and check out new branch"));
166 await click(createAction ?? null);
167 const branchInput = document.querySelector('input[aria-label="New branch name"]') as HTMLInputElement | null;
168 assert(branchInput);
169 assert.equal(branchInput.placeholder, "New branch name");
170 assert.equal([...document.querySelectorAll<HTMLButtonElement>('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Create and check out new branch"))?.disabled, true);
171 const graphAction = [...document.querySelectorAll('.composer-workspace-menu--branches [role="menuitem"]')].find((item) => item.textContent?.includes("Git graph"));
172 await click(graphAction ?? null);
173 assert.equal(calls.history, 1);
174 const graphDialog = document.querySelector('[role="dialog"][aria-labelledby="composer-git-graph-title"]');
175 assert(graphDialog);
176 assert.ok(graphDialog.closest(".modal-backdrop")?.parentElement === document.body, "the Git graph escapes the composer frame's isolated stacking context");
177 assert.ok(!rootElement.contains(graphDialog), "the Git graph is not painted inside the composer tree");
178 assert(document.body.textContent?.includes("Ship workspace context"));
179 assert(document.body.textContent?.includes("abcdef1"));
180 await click(document.querySelector('.composer-git-graph__actions button[aria-label="Close"]'));
181 assert.equal(document.querySelector('.composer-git-graph'), null);
182 assert.equal(document.activeElement, document.querySelector('button[aria-label="Current Git branch: feature/other"]'), "closing the Git graph restores focus to the branch trigger");
183
184 await click(document.querySelector('button[aria-label="Use default workspace"]'));
185 assert.equal(calls.globals, 1);
186
187 await render({ scope: "global", workspaceRoot: "/stale-cwd", workspaceName: "Stale workspace", remote: false });
188 assert(document.querySelector('button[aria-label="Project: Default workspace"]'), "global sessions never inherit a stale cwd label");
189 assert.equal(document.querySelector('button[aria-label="Use default workspace"]'), null, "global sessions do not render a redundant clear action");
190
191 await act(async () => { root.unmount(); await flush(); });
192 dom.window.close();
193 console.log("PASS composer workspace context project, branch, create and git graph flows");
194
194 lines Plain Text