返回 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 appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
21 const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
22 const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
23 const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
24
25 function eq(a: unknown, b: unknown, label: string) {
26 if (a === b) {
27 process.stdout.write(` PASS ${label}\n`);
28 passed += 1;
29 } else {
30 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
31 failed += 1;
32 }
33 }
34
35 const CHAT_MIN_WIDTH = 400;
36 const SIDEBAR_WIDTH = 264;
37 const RESIZER_WIDTH = 8;
38 const PREVIEW_MIN_WIDTH = 420;
39 const PREVIEW_DEFAULT_WIDTH = 660;
40 const CHAT_COMFORT_MIN_WIDTH = 560;
41
42 console.log("\nworkspace dock layout");
43
44 const expandedAvailable = availableWorkspacePanelWidth({
45 viewportWidth: 1280,
46 sidebarCollapsed: false,
47 sidebarWidth: SIDEBAR_WIDTH,
48 chatMinWidth: CHAT_MIN_WIDTH,
49 resizerWidth: RESIZER_WIDTH,
50 });
51 eq(expandedAvailable, 608, "1280px viewport leaves room for an expanded-sidebar dock");
52 eq(
53 resolveWorkspacePanelWidth({
54 open: true,
55 maximized: false,
56 preferredWidth: PREVIEW_DEFAULT_WIDTH,
57 minWidth: PREVIEW_MIN_WIDTH,
58 availableWidth: expandedAvailable,
59 }),
60 608,
61 "expanded-sidebar preview clamps to available width instead of overflowing",
62 );
63
64 const collapsedAvailable = availableWorkspacePanelWidth({
65 viewportWidth: 1280,
66 sidebarCollapsed: true,
67 sidebarWidth: SIDEBAR_WIDTH,
68 chatMinWidth: CHAT_MIN_WIDTH,
69 resizerWidth: RESIZER_WIDTH,
70 });
71 eq(collapsedAvailable, 872, "collapsed sidebar restores workspace room");
72 eq(
73 resolveWorkspacePanelWidth({
74 open: true,
75 maximized: false,
76 preferredWidth: PREVIEW_DEFAULT_WIDTH,
77 minWidth: PREVIEW_MIN_WIDTH,
78 availableWidth: collapsedAvailable,
79 }),
80 PREVIEW_DEFAULT_WIDTH,
81 "wide-enough collapsed layout keeps the preferred preview width",
82 );
83
84 const narrowAvailable = availableWorkspacePanelWidth({
85 viewportWidth: 900,
86 sidebarCollapsed: false,
87 sidebarWidth: SIDEBAR_WIDTH,
88 chatMinWidth: CHAT_MIN_WIDTH,
89 resizerWidth: RESIZER_WIDTH,
90 });
91 const narrowRendered = resolveWorkspacePanelWidth({
92 open: true,
93 maximized: false,
94 preferredWidth: PREVIEW_DEFAULT_WIDTH,
95 minWidth: PREVIEW_MIN_WIDTH,
96 availableWidth: narrowAvailable,
97 });
98 eq(narrowAvailable, 228, "very narrow viewports may leave less than the nominal dock minimum");
99 eq(narrowRendered, 228, "very narrow dock still stays inside the viewport");
100 eq(workspacePanelAriaMinWidth(PREVIEW_MIN_WIDTH, narrowRendered), 228, "ARIA minimum follows constrained rendered width");
101
102 eq(
103 resolveWorkspacePanelWidth({
104 open: false,
105 maximized: false,
106 preferredWidth: PREVIEW_DEFAULT_WIDTH,
107 minWidth: PREVIEW_MIN_WIDTH,
108 availableWidth: 0,
109 }),
110 PREVIEW_DEFAULT_WIDTH,
111 "closed panel preserves the saved preferred width",
112 );
113 eq(
114 resolveWorkspacePanelWidth({
115 open: true,
116 maximized: true,
117 preferredWidth: PREVIEW_DEFAULT_WIDTH,
118 minWidth: PREVIEW_MIN_WIDTH,
119 availableWidth: 228,
120 }),
121 PREVIEW_DEFAULT_WIDTH,
122 "maximized panel preserves the saved preferred width",
123 );
124
125 eq(
126 resolveLiveWorkspacePanelWidth({
127 viewportWidth: 1268,
128 sidebarCollapsed: false,
129 sidebarWidth: 400,
130 chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
131 resizerWidth: RESIZER_WIDTH,
132 open: true,
133 maximized: false,
134 preferredWidth: PREVIEW_MIN_WIDTH,
135 minWidth: PREVIEW_MIN_WIDTH,
136 }),
137 300,
138 "live dock drag clamps the hard minimum to the available dock width",
139 );
140
141 eq(
142 resolveLiveWorkspacePanelWidth({
143 viewportWidth: 1280,
144 sidebarCollapsed: false,
145 sidebarWidth: 500,
146 chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
147 resizerWidth: RESIZER_WIDTH,
148 open: true,
149 maximized: false,
150 preferredWidth: PREVIEW_DEFAULT_WIDTH,
151 minWidth: PREVIEW_MIN_WIDTH,
152 }),
153 212,
154 "live sidebar drag recomputes dock width from the dragged sidebar width",
155 );
156 eq(terminalMaxHeight(480), 240, "terminal maximum follows half of the current viewport height");
157 eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessible minimum");
158 eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
159 eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
160 eq(
161 /const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false\);/.test(appSource),
162 true,
163 "closing the dock clears the transient render width, hides the panel, and persists the collapsed preference",
164 );
165 eq(
166 /setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true\);/.test(appSource),
167 true,
168 "opening the dock persists the expanded preference for the next launch",
169 );
170 eq(
171 /terminalPanelOpen[\s\S]*?terminal-drawer/.test(appSource),
172 true,
173 "terminal drawer is an independent panel, not a workspace dock mode",
174 );
175 eq(
176 /const addTerminalOutputToComposer = useCallback\(async \(sessionId: string\) => \{[\s\S]*?app\.TerminalOutputForTab\(activeTabId, sessionId\)[\s\S]*?addWorkspaceTextToComposer\(/.test(appSource),
177 true,
178 "terminal output reaches chat only through the explicit add-output action",
179 );
180 eq(
181 /@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer[\s\S]*?display: flex !important/.test(stylesSource),
182 true,
183 "terminal drawer stays visible on narrow viewports",
184 );
185 eq(
186 /\.layout--terminal-drawer-open \{[\s\S]*?grid-template-rows: var\(--app-chrome-height\) minmax\(0, 1fr\) var\(--terminal-height, 280px\) var\(--statusbar-height\)/.test(stylesSource),
187 true,
188 "terminal-drawer-open layout reserves a grid row for the status bar below the terminal drawer",
189 );
190 eq(
191 /@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer-resizer[\s\S]*?grid-column: 1 !important[\s\S]*?\.layout--workbench-chrome-hidden\.layout--terminal-drawer-open \.terminal-drawer[\s\S]*?grid-row: 2;[\s\S]*?\.layout--workbench-chrome-hidden\.layout--terminal-drawer-open[\s\S]*?minmax\(0, 1fr\) var\(--terminal-height, 280px\) var\(--statusbar-height\)/.test(stylesSource),
192 true,
193 "narrow viewport keeps the resizer and drawer in the content column above the status bar",
194 );
195 eq(
196 /const terminalRenderHeight = clampTerminalHeight\(terminalHeight, viewportHeight\)/.test(appSource)
197 && /"--terminal-height": `\$\{liveTerminalHeight \?\? \(terminalPanelOpen \? terminalRenderHeight : 0\)\}px`/.test(appSource),
198 true,
199 "terminal render height re-clamps whenever the viewport changes",
200 );
201 eq(
202 /aria-hidden=\{!terminalPanelOpen\}/.test(appSource)
203 && /tabIndex=\{terminalPanelOpen \? 0 : -1\}/.test(appSource)
204 && /onKeyDown=\{resizeTerminalWithKeyboard\}/.test(appSource),
205 true,
206 "closed terminal resizer leaves the tab order and open resizer supports keyboard adjustment",
207 );
208 eq(
209 /terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource)
210 && !/\.layout\.layout--terminal-drawer-open \.footer/.test(stylesSource),
211 true,
212 "footer compaction applies only while the terminal is expanded outside Creation mode",
213 );
214 eq(
215 /sidebarImDetailConnection \? "layout--statusbar-hidden" : ""/.test(appSource)
216 && /\.layout\.layout--statusbar-hidden,[\s\S]*?--statusbar-height: 0px;/.test(stylesSource),
217 true,
218 "IM detail collapses the status bar row when the bar is not rendered",
219 );
220 eq(
221 /\.layout--terminal-drawer-expanded \.terminal-drawer \{[\s\S]*?border-top: 1px solid var\(--border-soft\)/.test(stylesSource),
222 true,
223 "terminal drawer has a top border only when expanded, avoiding a collapsed artifact line",
224 );
225 eq(
226 /\.sidebar--workbench \{[\s\S]*?padding: 16px 16px 10px;/.test(stylesSource)
227 && /\.app--darwin \.sidebar--workbench,\s*\.sidebar--workbench \{[\s\S]*?padding: 14px 12px 10px;/.test(stylesSource),
228 true,
229 "workbench sidebar does not reserve the docked status bar twice",
230 );
231 const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock__tabs"[\s\S]*?<div className="workbench-dock__body">/)?.[0] ?? "";
232 eq(
233 workspaceDockTabsSource.length > 0
234 && !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
235 && /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
236 true,
237 "workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
238 );
239 eq(
240 /\.topicbar \{\s*position: relative;\s*z-index: var\(--z-inline-sticky\);/.test(stylesSource)
241 && /\.external-opener__menu \{[\s\S]*?z-index: var\(--z-topicbar-menu\);/.test(stylesSource),
242 true,
243 "topic bar establishes a raised stacking context for external-opener menus",
244 );
245 eq(
246 /\.composer-meta__control--approval \{[\s\S]*?margin-inline-start: 2px;/.test(stylesSource)
247 && /\.composer-modebar__item:hover:not\(:disabled\) \{[\s\S]*?transform: none;/.test(stylesSource)
248 && /\.composer-task-mode-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-task-mode-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource)
249 && /\.composer-profile-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-profile-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource),
250 true,
251 "composer mode controls keep spacing and icon baselines stable on hover",
252 );
253 eq(
254 /\.app--creation \.layout\.layout--creation-chrome-hidden\.layout--terminal-drawer-open \{[\s\S]*?grid-template-rows: minmax\(0, 1fr\) var\(--terminal-height, 280px\)/.test(stylesSource),
255 true,
256 "creation style keeps the terminal drawer below the chat pane",
257 );
258 eq(
259 /sessions\.length > 0 && \([\s\S]*?<TerminalSessionRail/.test(terminalPanelSource),
260 true,
261 "the single terminal session keeps a visible close control",
262 );
263 eq(
264 /const syncWorkspace = useTerminalStore[\s\S]*?const capabilityChanged = previous\.tabId === tabId && previous\.readOnly !== readOnly[\s\S]*?void syncWorkspace\(tabId, capabilityChanged\)/.test(terminalPanelSource),
265 true,
266 "terminal panel refreshes changed capability while reusing an in-flight first-open request",
267 );
268 eq(
269 /readOnly=\{Boolean\(activeTab\?\.readOnly\)\}/.test(appSource)
270 && /const terminalReadOnly = readOnly \|\| Boolean\(workspace\?\.readOnly\)/.test(terminalPanelSource),
271 true,
272 "terminal controls follow the active tab read-only boundary",
273 );
274 eq(
275 /terminal-session-rail__new|onNew/.test(terminalRailSource),
276 false,
277 "terminal tab strip does not duplicate the header's new-session action",
278 );
279 eq(
280 /className="terminal-shell-select"[\s\S]*?shellOptions\.map/.test(terminalPanelSource),
281 true,
282 "terminal header renders the backend-approved shell options",
283 );
284 eq(
285 /createSession\(tabId, "\.", selectedShellId\)/.test(terminalPanelSource),
286 true,
287 "new terminal sessions use the selected shell",
288 );
289 eq(
290 /createSession\(tabId, "\.", "default"\)/.test(terminalPanelSource),
291 false,
292 "new terminal sessions are not hard-coded to the default shell",
293 );
294 eq(
295 /const TerminalPanel = lazy\(\(\) => import\("\.\/components\/TerminalPanel"\)/.test(appSource),
296 true,
297 "terminal and xterm load only when the terminal drawer opens",
298 );
299
300 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
301 if (failed > 0) process.exit(1);
302
302 lines TYPESCRIPT