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