返回 DeepSeek-Reasonix
workspace-layout.test.ts
根目录 / desktop / frontend / src / __tests__ / workspace-layout.test.ts
1 // Run: tsx src/__tests__/workspace-layout.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import {
7 availableWorkspacePanelWidth,
8 resolveLiveWorkspacePanelWidth,
9 resolveWorkspacePanelWidth,
10 workspacePanelAriaMinWidth,
11 } from "../lib/workspaceLayout";
12 import {
13 clampTerminalHeight,
14 terminalMaxHeight,
15 } from "../store/layout";
16
17 let passed = 0;
18 let failed = 0;
19 const testDir = dirname(fileURLToPath(import.meta.url));
20 const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
21 const sessionActionsSource = readFileSync(resolve(testDir, "../components/TopicbarSessionActions.tsx"), "utf8");
22 const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
23 const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
24 const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
25 const terminalLifecycleSource = readFileSync(resolve(testDir, "../lib/useWarmTerminalPanel.ts"), "utf8");
26 const appRuntimeViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8");
27
28 function eq(a: unknown, b: unknown, label: string) {
29 if (a === b) {
30 process.stdout.write(` PASS ${label}\n`);
31 passed += 1;
32 } else {
33 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
34 failed += 1;
35 }
36 }
37
38 const CHAT_MIN_WIDTH = 400;
39 const SIDEBAR_WIDTH = 264;
40 const RESIZER_WIDTH = 8;
41 const PREVIEW_MIN_WIDTH = 420;
42 const PREVIEW_DEFAULT_WIDTH = 660;
43 const CHAT_COMFORT_MIN_WIDTH = 560;
44
45 console.log("\nworkspace dock layout");
46 eq(
47 /readOnly:\s*Boolean\(activeTab\?\.readOnly\s*&&\s*!activeTab\.takenOver\)/.test(appRuntimeViewSource),
48 true,
49 "takeover spectators keep the terminal interactive while ordinary read-only tabs stay locked",
50 );
51 eq(/\.app__dock-toggle/.test(stylesSource), false, "the workspace toggle is a bar action button, not a fixed overlay the bar must dodge");
52 // Tabs size to their label and the strip to its tabs, so the add button sits
53 // beside the last tab. A stretch rule (equal columns) or the removed
54 // max-width: 520px container query would fill the row instead.
55 eq(
56 /\.workbench-dock__tabs \{[\s\S]*?flex: 0 1 auto;[\s\S]*?width: max-content;/.test(stylesSource) &&
57 /\.workbench-dock__tab \{[\s\S]*?flex: 0 1 auto;[\s\S]*?min-width: 30px;[\s\S]*?max-width: 118px;/.test(stylesSource) &&
58 !/@container \(max-width: 520px\) \{\s*\.workbench-dock__tabs/.test(stylesSource),
59 true,
60 "right-dock tabs keep their content width instead of stretching to fill the strip",
61 );
62
63 const expandedAvailable = availableWorkspacePanelWidth({
64 viewportWidth: 1280,
65 sidebarCollapsed: false,
66 sidebarWidth: SIDEBAR_WIDTH,
67 chatMinWidth: CHAT_MIN_WIDTH,
68 resizerWidth: RESIZER_WIDTH,
69 });
70 eq(expandedAvailable, 608, "1280px viewport leaves room for an expanded-sidebar dock");
71 eq(
72 resolveWorkspacePanelWidth({
73 open: true,
74 maximized: false,
75 preferredWidth: PREVIEW_DEFAULT_WIDTH,
76 minWidth: PREVIEW_MIN_WIDTH,
77 availableWidth: expandedAvailable,
78 }),
79 608,
80 "expanded-sidebar preview clamps to available width instead of overflowing",
81 );
82
83 const collapsedAvailable = availableWorkspacePanelWidth({
84 viewportWidth: 1280,
85 sidebarCollapsed: true,
86 sidebarWidth: SIDEBAR_WIDTH,
87 chatMinWidth: CHAT_MIN_WIDTH,
88 resizerWidth: RESIZER_WIDTH,
89 });
90 eq(collapsedAvailable, 872, "collapsed sidebar restores workspace room");
91 eq(
92 resolveWorkspacePanelWidth({
93 open: true,
94 maximized: false,
95 preferredWidth: PREVIEW_DEFAULT_WIDTH,
96 minWidth: PREVIEW_MIN_WIDTH,
97 availableWidth: collapsedAvailable,
98 }),
99 PREVIEW_DEFAULT_WIDTH,
100 "wide-enough collapsed layout keeps the preferred preview width",
101 );
102
103 const narrowAvailable = availableWorkspacePanelWidth({
104 viewportWidth: 900,
105 sidebarCollapsed: false,
106 sidebarWidth: SIDEBAR_WIDTH,
107 chatMinWidth: CHAT_MIN_WIDTH,
108 resizerWidth: RESIZER_WIDTH,
109 });
110 const narrowRendered = resolveWorkspacePanelWidth({
111 open: true,
112 maximized: false,
113 preferredWidth: PREVIEW_DEFAULT_WIDTH,
114 minWidth: PREVIEW_MIN_WIDTH,
115 availableWidth: narrowAvailable,
116 });
117 eq(narrowAvailable, 228, "very narrow viewports may leave less than the nominal dock minimum");
118 eq(narrowRendered, 228, "very narrow dock still stays inside the viewport");
119 eq(workspacePanelAriaMinWidth(PREVIEW_MIN_WIDTH, narrowRendered), 228, "ARIA minimum follows constrained rendered width");
120
121 eq(
122 resolveWorkspacePanelWidth({
123 open: false,
124 maximized: false,
125 preferredWidth: PREVIEW_DEFAULT_WIDTH,
126 minWidth: PREVIEW_MIN_WIDTH,
127 availableWidth: 0,
128 }),
129 PREVIEW_DEFAULT_WIDTH,
130 "closed panel preserves the saved preferred width",
131 );
132 eq(
133 resolveWorkspacePanelWidth({
134 open: true,
135 maximized: true,
136 preferredWidth: PREVIEW_DEFAULT_WIDTH,
137 minWidth: PREVIEW_MIN_WIDTH,
138 availableWidth: 228,
139 }),
140 PREVIEW_DEFAULT_WIDTH,
141 "maximized panel preserves the saved preferred width",
142 );
143
144 eq(
145 resolveLiveWorkspacePanelWidth({
146 viewportWidth: 1268,
147 sidebarCollapsed: false,
148 sidebarWidth: 400,
149 chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
150 resizerWidth: RESIZER_WIDTH,
151 open: true,
152 maximized: false,
153 preferredWidth: PREVIEW_MIN_WIDTH,
154 minWidth: PREVIEW_MIN_WIDTH,
155 }),
156 300,
157 "live dock drag clamps the hard minimum to the available dock width",
158 );
159
160 eq(
161 resolveLiveWorkspacePanelWidth({
162 viewportWidth: 1280,
163 sidebarCollapsed: false,
164 sidebarWidth: 500,
165 chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
166 resizerWidth: RESIZER_WIDTH,
167 open: true,
168 maximized: false,
169 preferredWidth: PREVIEW_DEFAULT_WIDTH,
170 minWidth: PREVIEW_MIN_WIDTH,
171 }),
172 212,
173 "live sidebar drag recomputes dock width from the dragged sidebar width",
174 );
175 eq(terminalMaxHeight(480), 240, "terminal maximum follows half of the current viewport height");
176 eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessible minimum");
177 eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
178 eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
179 eq(
180 /\.workspace-panel-resizer \{[\s\S]*?grid-column: 3;[\s\S]*?justify-self: start;[\s\S]*?width: 1px;/.test(stylesSource)
181 && /\.workspace-panel-resizer::before \{[\s\S]*?left: 0;[\s\S]*?right: -7px;/.test(stylesSource),
182 true,
183 "workspace resize hit area starts at the dock boundary and never overlaps the chat scrollbar gutter",
184 );
185 eq(
186 /@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer[\s\S]*?display: flex !important/.test(stylesSource),
187 true,
188 "terminal drawer stays visible on narrow viewports",
189 );
190 eq(
191 /\.layout--terminal-drawer-open \{[\s\S]*?grid-template-rows: auto minmax\(0, 1fr\) var\(--terminal-height, 280px\) var\(--statusbar-height\)/.test(stylesSource),
192 true,
193 "terminal-drawer-open layout keeps the shell bar's row and reserves one for the status bar below the terminal drawer",
194 );
195 // grid-template-columns interpolates only between track lists of equal length,
196 // so the closed state must keep the dock's track at zero width rather than
197 // dropping it — otherwise the toggle snaps while the sidebar's column animates.
198 eq(
199 /\.layout--sidebar-collapsed \{\s*--sidebar-width: 0px;\s*grid-template-columns: 0px minmax\(0, 1fr\) minmax\(0px, 0px\);/.test(stylesSource) &&
200 /grid-template-columns: var\(--sidebar-expanded-width\) minmax\(0, 1fr\) minmax\(0px, 0px\);/.test(stylesSource) &&
201 /\.layout--workspace-open \{[\s\S]*?grid-template-columns: var\(--sidebar-width\) minmax\(0, 1fr\) minmax\(0, var\(--workspace-width\)\);/.test(stylesSource),
202 true,
203 "the dock keeps a zero-width third track while closed so opening and closing it interpolates",
204 );
205 eq(
206 /@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer,[\s\S]*?display: flex !important;[\s\S]*?grid-column: 1 !important;[\s\S]*?grid-row: 3;[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer-resizer,[\s\S]*?grid-column: 1 !important/.test(stylesSource),
207 true,
208 "narrow viewport keeps the resizer and drawer in the content column above the status bar",
209 );
210 eq(
211 /\.layout--terminal-drawer-expanded \.terminal-drawer \{[\s\S]*?border-top: 1px solid var\(--border-soft\)/.test(stylesSource),
212 true,
213 "terminal drawer has a top border only when expanded, avoiding a collapsed artifact line",
214 );
215 eq(
216 /\.sidebar--workbench \{[\s\S]*?padding: 16px 16px 10px;/.test(stylesSource)
217 && /\.app--darwin \.sidebar--workbench,\s*\.sidebar--workbench \{[\s\S]*?padding: 14px 12px 10px;/.test(stylesSource),
218 true,
219 "workbench sidebar does not reserve the docked status bar twice",
220 );
221 eq(
222 /\.topicbar \{\s*position: relative;\s*z-index: var\(--z-inline-sticky\);/.test(stylesSource)
223 && /\.external-opener__menu \{[\s\S]*?z-index: var\(--z-topicbar-menu\);/.test(stylesSource),
224 true,
225 "topic bar establishes a raised stacking context for external-opener menus",
226 );
227 eq(
228 /\.composer-meta__control--approval \{[\s\S]*?margin-inline-start: 2px;/.test(stylesSource)
229 && /\.composer-modebar__item:hover:not\(:disabled\) \{[\s\S]*?transform: none;/.test(stylesSource)
230 && /\.composer-task-mode-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-task-mode-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource),
231 true,
232 "composer mode controls keep spacing and icon baselines stable on hover",
233 );
234 eq(
235 /\.app--creation \.layout \{[\s\S]*?--statusbar-height: 0px;/.test(stylesSource)
236 && /\.app--creation \.statusbar \{\s*display: none;/.test(stylesSource),
237 true,
238 "creation style collapses the status bar row so the terminal drawer sits directly below the chat pane",
239 );
240 eq(
241 /sessions\.length > 0 && \([\s\S]*?<TerminalSessionRail/.test(terminalPanelSource),
242 true,
243 "the single terminal session keeps a visible close control",
244 );
245 eq(
246 /const syncWorkspace = useTerminalStore[\s\S]*?const capabilityChanged = previous\.tabId === tabId && previous\.readOnly !== readOnly[\s\S]*?void syncWorkspace\(tabId, capabilityChanged\)/.test(terminalPanelSource),
247 true,
248 "terminal panel refreshes changed capability while reusing an in-flight first-open request",
249 );
250 eq(
251 /state\.tabId === tabId \? state\.workspace : null/.test(terminalPanelSource)
252 && /state\.tabId === tabId \? state\.activeSessionId : null/.test(terminalPanelSource)
253 && /setSelectionAction\(null\);\s*\}, \[active\?\.id, tabId\]\)/.test(terminalPanelSource),
254 true,
255 "rapid tab switches cannot paint the previous tab's terminal or selection action",
256 );
257 eq(
258 /terminal-session-rail__new|onNew/.test(terminalRailSource),
259 false,
260 "terminal tab strip does not duplicate the header's new-session action",
261 );
262 eq(
263 /className="terminal-shell-select"[\s\S]*?shellOptions\.map/.test(terminalPanelSource),
264 true,
265 "terminal header renders the backend-approved shell options",
266 );
267 eq(
268 /createSession\(tabId, "\.", selectedShellId\)/.test(terminalPanelSource),
269 true,
270 "new terminal sessions use the selected shell",
271 );
272 eq(
273 /createSession\(tabId, "\.", "default"\)/.test(terminalPanelSource),
274 false,
275 "new terminal sessions are not hard-coded to the default shell",
276 );
277 eq(
278 /onPointerEnter=\{terminalEnabled \? prefetchTerminal : undefined\}/.test(sessionActionsSource)
279 && /onFocus=\{terminalEnabled \? prefetchTerminal : undefined\}/.test(sessionActionsSource)
280 && /void import\("\.\.\/components\/TerminalPanel"\)/.test(terminalLifecycleSource),
281 true,
282 "pointer and keyboard intent prefetch the terminal chunk before opening from the topic bar",
283 );
284 eq(
285 /registerTerminalSink\(session\.id, \(bytes\) => terminal\.write\(bytes\), openRef\.current\)/.test(terminalViewSource)
286 && /terminalSinkRef\.current\?\.setActive\(open\)/.test(terminalViewSource),
287 true,
288 "the warm terminal pauses PTY output while collapsed and resumes from its output cursor",
289 );
290 eq(
291 /useGlobalShortcut\(\s*"selection\.addToChat"/.test(terminalPanelSource)
292 && /<kbd>\{addShortcut\}<\/kbd>/.test(terminalPanelSource),
293 true,
294 "terminal selection-to-chat exposes the shared configurable shortcut",
295 );
296
297 // C1: the chat pane keeps its 400px floor no matter how wide the dock is
298 // dragged — the dock's available width is viewport minus sidebar minus the
299 // 400px chat minimum minus the resizer, so chat can never be squeezed below it.
300 const chatFloorDock = availableWorkspacePanelWidth({
301 viewportWidth: 1000,
302 sidebarCollapsed: false,
303 sidebarWidth: SIDEBAR_WIDTH,
304 chatMinWidth: CHAT_MIN_WIDTH,
305 resizerWidth: RESIZER_WIDTH,
306 });
307 eq(
308 chatFloorDock + SIDEBAR_WIDTH + CHAT_MIN_WIDTH + RESIZER_WIDTH <= 1000,
309 true,
310 "C1: dock width never consumes the chat 400px floor (chat stays readable)",
311 );
312 // Sanity: with a wide viewport the dock gets more room, but the chat floor is
313 // still reserved — chat is never the thing that shrinks.
314 const wideDock = availableWorkspacePanelWidth({
315 viewportWidth: 1600,
316 sidebarCollapsed: false,
317 sidebarWidth: SIDEBAR_WIDTH,
318 chatMinWidth: CHAT_MIN_WIDTH,
319 resizerWidth: RESIZER_WIDTH,
320 });
321 eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, chat floor untouched");
322
323 // C3: switching dock tabs (context/files/changed) must never resize the dock —
324 // the preferred width is a single source (rightDockTreeWidth), not a
325 // detail-dependent ternary that would jump the sidebar per tab.
326
327 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
328 if (failed > 0) process.exit(1);
329
329 lines TYPESCRIPT