返回 DeepSeek-Reasonix
app-chrome-tabs.test.ts
根目录 / desktop / frontend / src / __tests__ / app-chrome-tabs.test.ts
1 // Run: tsx src/__tests__/app-chrome-tabs.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, tabMetaFallbackDelay } from "../lib/tabMetaRefresh";
7 import type { TabMeta } from "../lib/types";
8
9 const testDir = dirname(fileURLToPath(import.meta.url));
10 const appSource = readFileSync(resolve(testDir, "../AppRuntime.tsx"), "utf8"), workspaceFocusSource = readFileSync(resolve(testDir, "../lib/workspaceRefreshStore.ts"), "utf8");
11 const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8");
12 const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8");
13 const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8");
14 const topicShortcutOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useTopicNavigationShortcuts.ts"), "utf8");
15 const runtimeHandlersSource = readFileSync(resolve(testDir, "../app-runtime/useRuntimeEventHandlers.ts"), "utf8");
16 const sessionNavigationSource = readFileSync(resolve(testDir, "../app-runtime/useSessionNavigationCommands.ts"), "utf8");
17 const chromeCommandsSource = readFileSync(resolve(testDir, "../app-runtime/useAppChromeCommands.ts"), "utf8");
18 const dockToggleSource = readFileSync(resolve(testDir, "../app-shell/DockToggleButton.tsx"), "utf8");
19 const chatPaneSource = readFileSync(resolve(testDir, "../app-shell/ChatPaneRegion.tsx"), "utf8");
20 const transcriptSurfaceSource = readFileSync(resolve(testDir, "../app-runtime/useTranscriptSurfaceProjection.ts"), "utf8");
21 const desktopNavigationOwnerSource = readFileSync(resolve(testDir, "../app-runtime/desktopNavigationOwner.ts"), "utf8");
22 const appViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8");
23 const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8");
24 const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
25 const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8"), forkWorktreeSource = readFileSync(resolve(testDir, "../lib/forkWorktree.ts"), "utf8");
26 const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
27 const workspacePanelSource = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8");
28 const rewindCommitSource = readFileSync(resolve(testDir, "../lib/rewindCommit.ts"), "utf8");
29 const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
30 const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
31
32 let passed = 0;
33 let failed = 0;
34
35 function ok(value: unknown, label: string) {
36 if (value) {
37 process.stdout.write(` PASS ${label}\n`);
38 passed += 1;
39 } else {
40 process.stdout.write(` FAIL ${label}\n`);
41 failed += 1;
42 }
43 }
44
45 function deferred<T>() {
46 let resolve!: (value: T) => void;
47 let reject!: (reason?: unknown) => void;
48 const promise = new Promise<T>((res, rej) => {
49 resolve = res;
50 reject = rej;
51 });
52 return { promise, resolve, reject };
53 }
54
55 function matchingBlocks(selector: string): string[] {
56 const blocks: string[] = [];
57 const rule = /([^{}]+)\{([^{}]*)\}/g;
58 let match: RegExpExecArray | null;
59 while ((match = rule.exec(stylesSource)) !== null) {
60 const selectors = match[1].split(",").map((part) => part.trim());
61 if (selectors.includes(selector)) blocks.push(match[2]);
62 }
63 return blocks;
64 }
65
66 function finalDeclaration(selector: string, property: string): string | undefined {
67 let value: string | undefined;
68 for (const block of matchingBlocks(selector)) {
69 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
70 let match: RegExpExecArray | null;
71 while ((match = declaration.exec(block)) !== null) {
72 value = match[1].trim();
73 }
74 }
75 return value;
76 }
77
78 console.log("\napp chrome tabs");
79
80 const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({
81 id: "tab-1",
82 scope: "project",
83 workspaceRoot: "/repo",
84 workspaceName: "repo",
85 topicId: "topic-1",
86 topicTitle: "Topic",
87 label: "model",
88 ready: true,
89 running: false,
90 cancellable: false,
91 mode: "normal",
92 active: true,
93 cwd: "/repo",
94 ...overrides,
95 });
96
97 ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes");
98 ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state");
99 ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency");
100 ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further");
101 ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately");
102 ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately");
103 ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests");
104
105 {
106 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
107 const first = deferred<TabMeta[]>();
108 const second = deferred<TabMeta[]>();
109 let loads = 0;
110 const firstRefresh = coordinator.run(() => {
111 loads += 1;
112 return first.promise;
113 });
114 const secondRefresh = coordinator.run(() => {
115 loads += 1;
116 return second.promise;
117 });
118 const saturatedRefresh = coordinator.run(() => {
119 loads += 1;
120 return Promise.resolve([]);
121 });
122 await Promise.resolve();
123 ok(loads === 2, "tab metadata refresh caps outstanding backend calls");
124
125 const latestTabs = [tabMeta({ id: "tab-latest" })];
126 second.resolve(latestTabs);
127 const saturatedResult = await saturatedRefresh;
128 ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request");
129 ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel");
130 ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state");
131
132 first.resolve([tabMeta({ id: "tab-stale" })]);
133 const firstResult = await firstRefresh;
134 await secondRefresh;
135 ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot");
136 }
137
138 {
139 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
140 const preMutationA = deferred<TabMeta[]>();
141 const preMutationB = deferred<TabMeta[]>();
142 const postMutation = deferred<TabMeta[]>();
143 let loads = 0;
144 const loadValues: TabMeta[][] = [
145 [tabMeta({ id: "pre-a" })],
146 [tabMeta({ id: "pre-b" })],
147 [tabMeta({ id: "post-mutation" })],
148 ];
149 const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise];
150
151 const firstRefresh = coordinator.run(() => {
152 const index = loads;
153 loads += 1;
154 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
155 });
156 const secondRefresh = coordinator.run(() => {
157 const index = loads;
158 loads += 1;
159 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
160 });
161 await Promise.resolve();
162 ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots");
163
164 const mutationRefresh = coordinator.run(
165 () => {
166 const index = loads;
167 loads += 1;
168 return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
169 },
170 { invalidate: true },
171 );
172 await Promise.resolve();
173 ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees");
174
175 preMutationA.resolve(loadValues[0]);
176 const firstResult = await firstRefresh;
177 await Promise.resolve();
178 await Promise.resolve();
179 ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees");
180 ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate");
181
182 const postTabs = loadValues[2];
183 postMutation.resolve(postTabs);
184 const mutationResult = await mutationRefresh;
185 ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request");
186 ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot");
187 ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state");
188
189 preMutationB.resolve(loadValues[1]);
190 const secondResult = await secondRefresh;
191 ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state");
192 }
193
194 {
195 const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1);
196 const preMutation = deferred<TabMeta[]>();
197 const latestPostMutation = deferred<TabMeta[]>();
198 const started: string[] = [];
199
200 const preMutationRefresh = coordinator.run(() => {
201 started.push("pre");
202 return preMutation.promise;
203 });
204 await Promise.resolve();
205
206 const firstMutationRefresh = coordinator.run(
207 () => {
208 started.push("first-mutation");
209 return Promise.resolve([tabMeta({ id: "first-mutation" })]);
210 },
211 { invalidate: true },
212 );
213 const latestMutationRefresh = coordinator.run(
214 () => {
215 started.push("latest-mutation");
216 return latestPostMutation.promise;
217 },
218 { invalidate: true },
219 );
220
221 preMutation.resolve([tabMeta({ id: "pre" })]);
222 const preMutationResult = await preMutationRefresh;
223 await Promise.resolve();
224 await Promise.resolve();
225 ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load");
226 ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load");
227
228 const latestTabs = [tabMeta({ id: "latest-mutation" })];
229 latestPostMutation.resolve(latestTabs);
230 const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]);
231 ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot");
232 ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot");
233 ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative");
234 }
235
236 ok(
237 !appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") && runtimeHandlersSource.includes('import("../lib/workspaceRefreshStore")') &&
238 workspaceFocusSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
239 runtimeHandlersSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") &&
240 /void refreshTabMetas\(\);\s+schedule\(\);/.test(workspaceFocusSource),
241 "tab metadata refresh is event-driven with a visibility-aware fallback",
242 );
243
244
245 ok(
246 /const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
247 /workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) &&
248 /export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) &&
249 /reasonix\.workspacePanel\.open/.test(layoutStoreSource),
250 "right dock open state is restored from per-project localStorage with expanded first-launch default",
251 );
252
253 ok(
254 /const workbenchChromeHidden = true/.test(appViewSource),
255 "workbench chrome is hidden for every desktop platform",
256 );
257
258 ok(
259 /topicbar__chrome-btn/.test(dockToggleSource),
260 "workbench keeps chrome controls in the topic bar",
261 );
262
263 // The app tab strip that consumed the tab reveal signal is gone; the transcript
264 // keeps its own cell and the shared reveal still has to bump both independently.
265 ok(!appSource.includes("transcriptRevealSignal"), "retired transcript reveal state is removed");
266
267
268 ok(transcriptSource.includes('t("chat.toLatest")'), "jump-to-bottom affordance uses localized transcript text");
269
270 ok(
271 /setActive\(items\.length > 0 \? 0 : -1\)/.test(commandPaletteSource),
272 "command palette highlights the first item when opened with an empty query",
273 );
274
275 ok(
276 /topicShortcutIndexFromEvent\(event, input\.platform\)/.test(topicShortcutOwnerSource) &&
277 /useTopicShortcuts\(input\.enabled, input\.platform\)/.test(topicShortcutOwnerSource),
278 "topic shortcuts use the resolved desktop platform",
279 );
280
281 ok(
282 /topicShortcutLabel\(shortcutIndex, shortcutPlatform\)/.test(projectTreeSource),
283 "topic shortcut badges render the platform-specific modifier",
284 );
285
286 ok(
287 /if \(!enabled\) hideBadges\(\);/.test(topicShortcutsSource) &&
288 /if \(heldRef\.current\) hideBadges\(\);/.test(topicShortcutsSource) &&
289 /window\.removeEventListener\("blur", onBlur\);\s*hideBadges\(\);/.test(topicShortcutsSource),
290 "topic shortcut badge state is cleared when disabled, interrupted, or cleaned up",
291 );
292
293 // session-submission-lifecycle.test.tsx verifies source-only undo invalidation
294 // before send, and zero invalidation for stale/read-only/disposed submissions.
295
296 // session-undo-lifecycle.test.tsx drives the production useSessionUndo owner:
297 // code-only rewind retains the committed transaction id, full rewinds fill the
298 // composer only after success, failures leave the banner untouched, and the
299 // edit prompt honors the undo banner gate.
300
301
302
303 // pending-plan-revision-lifecycle.test.tsx drives running/idle, tab changes,
304 // replacement sessions, identical queued text, old finally and disposal.
305
306
307
308 ok(
309 /app\.NewSessionForTab\(tabId\)/.test(controllerSource) &&
310 /app\.ClearSessionForTab\(tabId\)/.test(controllerSource) &&
311 /app\.CompactForTab\(tabId\)/.test(controllerSource) &&
312 /import\("\.\/rewindCommit"\)/.test(controllerSource) &&
313 /app\.PreviewRewindForTab\(sourceTabId, turn, scope\)/.test(rewindCommitSource) &&
314 /app\.CommitRewindForTab\(sourceTabId, remoteLegacy \? "" : \(plan\.planId \|\| ""\), turn, scope\)/.test(rewindCommitSource) &&
315 /app\.UndoRewindForTab\(sourceTabId, transactionId\)/.test(rewindCommitSource) &&
316 /bindings\.ForkForTab\(sourceTabId, turn\)[\s\S]*bindings\.ForkWorktreeForTab\(sourceTabId, turn\)/.test(forkWorktreeSource) &&
317 /app\.SummarizeFromForTab\(sourceTabId, turn\)/.test(controllerSource) &&
318 /NewSessionForTab\(tabID: string\)/.test(bridgeSource) &&
319 /CompactForTab\(tabID: string\)/.test(bridgeSource) &&
320 /PreviewRewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource) &&
321 /CommitRewindForTab\(tabID: string, planID: string, turn: number, scope: string\)/.test(bridgeSource) &&
322 /UndoRewindForTab\(tabID: string, transactionID: string\)/.test(bridgeSource),
323 "session-changing controller actions use explicit tab-scoped bridge bindings",
324 );
325
326 ok(
327 /plan\.coverage === "partial"/.test(rewindCommitSource) &&
328 /window\.confirm\(t\("rewind\.confirmPartialCoverage"/.test(rewindCommitSource) &&
329 /plan\?\.conflicts\?\.length \? "overwrite_checkpoint" : ""/.test(workspacePanelSource),
330 "rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
331 );
332
333 ok(/const transcriptHydrating = input\.hydrating && !input\.hydrateHistoryLoaded;/.test(transcriptSurfaceSource) &&
334 /hydrating=\{transcript\.transcriptHydrating \|\| \(transitioning && !transcript\.navigationDataReady\)\}/.test(chatPaneSource) &&
335 /surfaceCommitToken=\{transcript\.surfaceCommitToken\}/.test(chatPaneSource) && /onSurfacePaintReady=\{commands\.onSurfacePaintReady\}/.test(chatPaneSource),
336 "Welcome stays suppressed through target data commit and navigation settles only after paint readiness",
337 );
338
339
340 ok(
341 /if \(heroMode\) \{[\s\S]*?const maxHeight = composerHeroInputMaxHeight\(\);[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
342 !/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
343 "Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
344 );
345
346
347
348 ok(
349 /return navigation\.enqueueNavigationWithIntent\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}, navigationIntentSeq\);/.test(sessionNavigationSource) &&
350 /return navigation\.enqueueNavigationWithIntent\(\{ kind: "sidebar-im", connection \}, navigationIntentSeq\);/.test(sessionNavigationSource) &&
351 /navigation\.enqueueNavigationWithIntent\(\{ kind: "resume-session", session \}, navigationIntentSeq\)/.test(sessionNavigationSource),
352 "topic, IM, and history navigation use the shared intent-fenced formal-session path",
353 );
354
355 ok(
356 /const targetRoot = scope === "project" \? workspaceRoot : ""/.test(sessionNavigationSource) &&
357 /return input\.draft\.open\(scope, targetRoot\);/.test(sessionNavigationSource) &&
358 !/enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: targetRoot \}\)/.test(sessionNavigationSource) &&
359 /if \(activeTab\?\.remote\)[\s\S]*navigation\.openRemoteProject\(activeTab\.remote, \{ newSession: true \}\)/.test(sessionNavigationSource),
360 "local blank sessions open durable drafts while remote new-session keeps its runtime path",
361 );
362
363 ok(
364 /projectTree:\s*\{[\s\S]*?activeTab:\s*draftActive \? undefined : activeTab/.test(appViewSource),
365 "a draft surface clears the previous formal-session highlight in the project tree",
366 );
367
368
369 // The owner resumes history through topic activation alone; a second
370 // resumeSession call would re-pin a session the activation already pinned.
371 const historyResumeBlock = desktopNavigationOwnerSource.match(/const \{ session \} = request;[\s\S]*?ports\.closeHistory\(\);/)?.[0] ?? "";
372 ok(
373 historyResumeBlock.includes("ports.closeHistory()") && !historyResumeBlock.includes("resumeSession"),
374 "history navigation does not re-resume a session that topic activation already pinned",
375 );
376
377
378 ok(
379 finalDeclaration(".sidebar", "--reasonix-draggable") === "drag" &&
380 finalDeclaration(".app--windows .sidebar", "--reasonix-draggable") === "no-drag" &&
381 finalDeclaration(".sidebar-resizer", "--reasonix-draggable") === "no-drag",
382 "Windows sidebar avoids native window drag without changing other platforms",
383 );
384
385 ok(
386 finalDeclaration(".topicbar", "--reasonix-draggable") === "drag" &&
387 finalDeclaration(".topicbar button", "--reasonix-draggable") === "no-drag" &&
388 finalDeclaration(".topicbar__actions", "--reasonix-draggable") === "no-drag",
389 "the shell bar is the window drag surface and opts its controls out",
390 );
391
392 ok(
393 finalDeclaration(".msg", "--reasonix-draggable") === undefined &&
394 finalDeclaration(".msg", "-webkit-app-region") === undefined &&
395 finalDeclaration(".reasoning__head", "-webkit-app-region") === undefined &&
396 finalDeclaration(".tool__head", "-webkit-app-region") === undefined &&
397 finalDeclaration(".process-card__head", "-webkit-app-region") === undefined &&
398 finalDeclaration(".compaction", "-webkit-app-region") === undefined,
399 "transcript content does not participate in native app-region subtraction",
400 );
401
402 ok(
403 finalDeclaration(".chat-pane", "overflow") === "hidden" &&
404 finalDeclaration(".chat-pane", "min-height") === "0",
405 "the chat pane clips overflow so zoomed transcript boxes cannot paint into the shell bar",
406 );
407
408 ok(
409 finalDeclaration(".app--windows.app--creation .topicbar", "position") === "relative" &&
410 finalDeclaration(".app--windows.app--creation .topicbar", "z-index") === "var(--z-app-chrome)" &&
411 finalDeclaration(".app--windows.app--creation .topicbar", "min-height") === "40px" &&
412 finalDeclaration(":root[data-theme-style] .app--windows.app--creation .topicbar", "min-height") === "40px" &&
413 finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "none !important" &&
414 finalDeclaration(".app--windows.app--creation .topicbar__title-row", "transform") === "none" &&
415 finalDeclaration(".app--windows-frameless.app--creation", "--windows-window-controls-height") === "40px" &&
416 finalDeclaration(".app--creation .topicbar", "min-height") === "56px" &&
417 finalDeclaration(":root[data-theme-style] .app--creation .topicbar", "padding-top") === "14px" &&
418 finalDeclaration(".app--creation .topicbar__title-row", "transform") === "translateY(-3px)",
419 "Windows Creation stays 40px while macOS and Linux keep the shared Creation geometry",
420 );
421
422 // Every style now renders the bar as the layout's own first row, so there is no
423 // chrome row left to remove and no layout class describing its absence.
424 ok(
425 /\.topicbar \{\s*position: relative;\s*z-index: var\(--z-inline-sticky\);\s*grid-row: 1;\s*grid-column: 1 \/ -1;/.test(stylesSource) &&
426 /grid-template-rows: auto minmax\(0, 1fr\) var\(--statusbar-height\)/.test(stylesSource),
427 "the shell bar spans every column as the layout's first row",
428 );
429
430 // The bar covers the sidebar's column too, so the macOS inset moves from a
431 // sidebar-collapsed special case onto the bar itself. The sidebar is the row
432 // below the bar now, so the traffic lights can no longer reach it.
433 ok(
434 finalDeclaration(".app--darwin .topicbar", "padding-left") === "var(--chrome-left-safe-offset)" &&
435 finalDeclaration(".app--darwin .sidebar--workbench", "padding") === "14px 12px 10px",
436 "macOS leaves safe space for inset window controls on the shell bar, not the sidebar",
437 );
438
439 // The shell bar owns the window's drag region now. While the dock's control
440 // strip was also draggable, any control that did not opt out individually —
441 // the leading overview chevron did not — never received a click.
442 ok(
443 finalDeclaration(".workbench-dock__tools", "--reasonix-draggable") === "no-drag" &&
444 finalDeclaration(".workbench-dock__tabs", "--reasonix-draggable") === "no-drag" &&
445 finalDeclaration(".workbench-dock__tab", "--reasonix-draggable") === "no-drag" &&
446 finalDeclaration(".workbench-dock__tab-overview", "--reasonix-draggable") !== "drag",
447 "the dock's control strip is not a window drag region, so every control stays clickable",
448 );
449
450 // The dock wraps TabContainer in .workbench-dock__panel. It must stretch that
451 // container: unstyled, the wrapper is a content-sized block, so the container
452 // collapses to its content and its own overflow clip then cuts off the tab
453 // overview popover, which is positioned inside it.
454 ok(
455 finalDeclaration(".workbench-dock__panel", "flex") === "1 1 auto" &&
456 finalDeclaration(".workbench-dock__panel", "display") === "flex" &&
457 finalDeclaration(".tab-container", "overflow") === "hidden",
458 "the dock panel stretches its tab container so in-dock popovers are not clipped",
459 );
460
461 ok(
462 finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none",
463 "active dock tab underline is removed in favor of the rounded-rect selected state",
464 );
465
466 ok(
467 finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "width") === "1px" &&
468 finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "height") === "16px" &&
469 finalDeclaration(".app--workbench .workbench-dock__tab + .workbench-dock__tab::before", "background")?.includes("--border-soft"),
470 ".app--workbench .workbench-dock__tab + .workbench-dock__tab::before renders a restrained divider between right-dock tabs",
471 );
472
473 ok(
474 finalDeclaration(".app--creation .workbench-dock__tab + .workbench-dock__tab::before", "content") === undefined,
475 "Creation right-dock tabs keep their equal-column treatment without dividers",
476 );
477
478 // The bar, not the dock's tools row, is the window's native title surface now:
479 // it carries the caption inset at every dock state, and the tools row is a plain
480 // tab strip that reserves nothing for the window controls.
481 ok(
482 finalDeclaration(".app--windows-frameless .topicbar", "padding-right") === "var(--windows-window-controls-safe)" &&
483 finalDeclaration(".app--windows-frameless.app--workbench .workbench-dock__tools", "height") === undefined,
484 "the Windows caption inset sits on the shell bar, not on the dock's tools row",
485 );
486
487 ok(
488 /@container \(max-width: 420px\) \{[\s\S]*?\.app--workbench \.workbench-dock__tab,[\s\S]*?:root\[data-theme-style\] \.app--workbench \.workbench-dock__tab \{[\s\S]*?padding-left:\s*10px;[\s\S]*?padding-right:\s*10px;[\s\S]*?gap:\s*4px;/.test(stylesSource),
489 "workbench keeps compact four-tab spacing at narrow dock widths",
490 );
491
492 for (const selector of [
493 ":root[data-theme-style] .app--workbench .topicbar",
494 ":root[data-theme-style] .app--workbench .topicbar__chrome-btn",
495 ":root[data-theme-style] .app--workbench .topicbar__icon-btn",
496 ":root[data-theme-style] .app--workbench .topicbar__action-btn",
497 ]) {
498 ok(
499 finalDeclaration(selector, "box-shadow") === "none",
500 `${selector} stays flat after removing the workbench chrome row`,
501 );
502 }
503
504 ok(
505 finalDeclaration(":root[data-theme-style] .app--workbench .topicbar", "background") === "var(--bg-elev)",
506 "workbench topic bar uses elevated background for light-mode white",
507 );
508
509 for (const selector of [
510 ":root[data-theme-style] .app--workbench .topicbar__identity",
511 ":root[data-theme-style] .app--workbench .topicbar__title-row",
512 ":root[data-theme-style] .app--workbench .topicbar__title-row h1",
513 ":root[data-theme-style] .app--workbench .tooltip-trigger:has(.topicbar__icon-btn)",
514 ]) {
515 ok(
516 finalDeclaration(selector, "background") === "transparent" &&
517 finalDeclaration(selector, "box-shadow") === "none" &&
518 finalDeclaration(selector, "filter") === "none",
519 `${selector} cannot paint residual title-row shadows in workbench mode`,
520 );
521 }
522
523 for (const selector of [
524 ":root[data-theme-style] .app--workbench .topicbar__icon-btn",
525 ":root[data-theme-style] .app--workbench .topicbar__chrome-btn",
526 ":root[data-theme-style] .app--workbench .topicbar__icon-btn:hover",
527 ":root[data-theme-style] .app--workbench .topicbar__icon-btn:focus-visible",
528 ":root[data-theme-style] .app--workbench .topicbar__chrome-btn:hover:not(.topicbar__chrome-btn--blocked)",
529 ":root[data-theme-style] .app--workbench .topicbar__chrome-btn:focus-visible:not(.topicbar__chrome-btn--blocked)",
530 ]) {
531 ok(
532 finalDeclaration(selector, "background") === "transparent",
533 `${selector} does not paint a hover block in workbench mode`,
534 );
535 }
536
537 ok(
538 finalDeclaration(".skip-to-composer", "box-shadow") === "none" &&
539 finalDeclaration(".skip-to-composer:focus-visible", "box-shadow")?.includes("0 12px 28px"),
540 "offscreen skip link does not leak its focus shadow into the workbench title area",
541 );
542
543 // The OS drag runtime drops any mousedown with detail !== 1, so a double
544 // click on a drag region never reaches the OS: both title-bar-hiding platforms
545 // have to zoom from here or not at all.
546 ok(
547 /chromeDoubleClickZooms\s*=\s*input\.windowsFrameless\s*\|\|\s*input\.platform === "darwin"/.test(chromeCommandsSource),
548 "title-bar double click zooms on macOS as well as frameless Windows",
549 );
550 ok(
551 /handleChromeTitlebarDoubleClick[\s\S]{0,700}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(chromeCommandsSource),
552 "title-bar double click still ignores interactive controls",
553 );
554
555 console.log(`\n${passed} passed, ${failed} failed`);
556 if (failed > 0) process.exit(1);
557
557 lines TYPESCRIPT