| 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 |