From 1ab8a0a92f7b9569b94e2229a4b719c54a4aaca3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 04:13:41 +0800 Subject: [PATCH 1/8] test(desktop): retire the parallel E2E display harness #4523 bought E2E time with four Xvfb displays and four Playwright workers. That parallelism is what invalidated the premises the tests were written against, and #4761 removed the need for it: the tier is 35 tests, all of them here because they need a native window, and `workers: 1` has been the config's answer since. The CI step still built four displays for a single worker to use one of. The step is now the same one-line `xvfb-run` every other Electron job in the workflow uses. `MAKA_E2E_X_DISPLAY_BASE` and the worker fixture that read it go with it, and the config comment stops describing a warm prompt-rail scenario that #4825 deleted. Refs #4761 --- .github/workflows/ci.yml | 32 +-------------------------- apps/desktop/e2e/fixtures.ts | 22 +----------------- apps/desktop/e2e/playwright.config.ts | 16 +++++--------- 3 files changed, 7 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a34d0ea26d..bef0189a1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,37 +357,7 @@ jobs: - name: Desktop e2e if: steps.plan.outputs.e2e == 'true' - run: | - set -euo pipefail - display_base=90 - worker_count=4 - xvfb_pids=() - cleanup() { - kill "${xvfb_pids[@]}" 2>/dev/null || true - wait "${xvfb_pids[@]}" 2>/dev/null || true - } - trap cleanup EXIT - for ((index = 0; index < worker_count; index += 1)); do - display=$((display_base + index)) - Xvfb ":$display" -screen 0 1280x1024x24 -nolisten tcp \ - >"$RUNNER_TEMP/xvfb-$display.log" 2>&1 & - xvfb_pids+=("$!") - done - for ((index = 0; index < worker_count; index += 1)); do - display=$((display_base + index)) - for _ in {1..50}; do - [[ -S "/tmp/.X11-unix/X$display" ]] && break - kill -0 "${xvfb_pids[$index]}" 2>/dev/null || break - sleep 0.1 - done - if [[ ! -S "/tmp/.X11-unix/X$display" ]]; then - cat "$RUNNER_TEMP/xvfb-$display.log" - exit 1 - fi - done - MAKA_E2E_X_DISPLAY_BASE="$display_base" \ - npm exec -w @maka/desktop -- playwright test \ - --config e2e/playwright.config.ts --workers="$worker_count" + run: xvfb-run -a npm exec -w @maka/desktop -- playwright test --config e2e/playwright.config.ts # Playwright keeps a trace, a video and a screenshot for every failed # test. Without this they die with the runner, and an e2e flake can only diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index aa5f733143..2d8b562ad8 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -530,27 +530,7 @@ type E2eTestFixtures = { accessibilityNarrativeWindow: Page; }; -type E2eWorkerFixtures = { - isolatedDisplay: void; -}; - -export const test = base.extend({ - isolatedDisplay: [async ({}, use, workerInfo) => { - const base = process.env.MAKA_E2E_X_DISPLAY_BASE; - if (base === undefined) { - await use(); - return; - } - if (!/^\d+$/.test(base)) throw new Error(`Invalid E2E X display base: ${base}`); - const previous = process.env.DISPLAY; - process.env.DISPLAY = `:${Number(base) + workerInfo.parallelIndex}`; - try { - await use(); - } finally { - if (previous === undefined) delete process.env.DISPLAY; - else process.env.DISPLAY = previous; - } - }, { scope: 'worker', auto: true }], +export const test = base.extend({ directoryReferenceWindow: async ({}, use) => { await withE2eWindow( { seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN', showWindow: true }, diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts index 00f23ac63d..568477c48d 100644 --- a/apps/desktop/e2e/playwright.config.ts +++ b/apps/desktop/e2e/playwright.config.ts @@ -22,21 +22,15 @@ import { defineConfig } from '@playwright/test'; /** * Playwright config for the desktop Electron E2E suite. * - * Most tests launch a real Electron window backed by the deterministic fake - * backend (MAKA_E2E=1) against their OWN throwaway userData dir. The read-only - * prompt-rail scenario instead keeps its Electron + Host composition warm for - * a worker and resets its Host range and renderer state per test. Keep the - * local default at one worker: concurrent windows share OS focus, invalidating - * geometry and focus contracts. Developers can still pass `--workers` - * explicitly for a subset that has neither concern. + * Every test launches a real Electron window backed by the deterministic fake + * backend (MAKA_E2E=1) against its OWN throwaway userData dir. One worker, + * everywhere: what is left in this tier is here because it needs a native + * window, and concurrent windows share OS focus, which invalidates exactly the + * focus, pointer and geometry contracts that kept these tests here. * Deliberately no test count here — the previous note carried a stale one that * outlived two rounds of pruning. `playwright test --list` is the only figure * that cannot rot. * - * CI gives every Playwright worker an isolated X display, so they overlap - * without sharing focus or a compositor. Local parallelism is opt-in for the - * same reason. - * * Run from apps/desktop via `npm run e2e`, which builds the app first. */ export default defineConfig({ From 5e3bcef1b1db038afd08a0270b847218a8901f77 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 04:35:28 +0800 Subject: [PATCH 2/8] test(desktop): pin the last workbar and WorkHub geometry in stories Four geometry contracts were still costing an Electron window each, and none of them read anything Electron owns. They are layout under a column width: the browser address field tracking the workbar column, the tool picker's shortcuts staying inside the panel at the 320px floor, Side Chat's composer keeping the conversation's radius token and its send button inside the card, and WorkHub's submitted-work button keeping the target's project name inside its own box. Each is now a story whose `play` measures the same boxes in real Chromium. WorkHub gets its first story group: `WorkHubSurface` already takes its `controller` as a prop, which is the seam production fills, so a story serves one pinned projection instead of driving a live Coordination Session. Every assertion was mutation-checked: deleting the rule it pins turns the story red, with the failure naming that rule's own measurement. Refs #4761 Generated-by: Claude Code --- apps/desktop/e2e/session-workbar.spec.ts | 142 ------------------ apps/desktop/e2e/workhub-layout.spec.ts | 41 +---- .../stories/session-workbar.stories.tsx | 93 ++++++++++++ apps/desktop/stories/workhub.stories.tsx | 138 +++++++++++++++++ 4 files changed, 232 insertions(+), 182 deletions(-) create mode 100644 apps/desktop/stories/workhub.stories.tsx diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 22a6ae701d..0aeca8a138 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -143,148 +143,6 @@ async function waitForCompanionForkId(page: Page, sourceSessionId: string) { return forkId!; } -async function setRightWorkbarWidth(page: Page, width: number) { - const layoutOwner = page.locator('.maka-workbar-layout-vars'); - const workbar = page.locator('.maka-session-workbar[data-placement="right"]'); - await expect(layoutOwner).toHaveCount(1); - await expect(workbar).toBeVisible(); - await layoutOwner.evaluate((element, nextWidth) => { - (element as HTMLElement).style.setProperty( - '--maka-session-workbar-width', - `${nextWidth}px`, - ); - }, width); - await expect - .poll(async () => (await workbar.boundingBox())?.width) - .toBeCloseTo(width, 0); - return workbar; -} - -test('narrow right workbar keeps launcher shortcuts and side-chat send button inside', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('create narrow workbar session'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: create narrow workbar session/)).toBeVisible(); - - await page.getByRole('button', { name: '展开任务工作栏' }).click(); - const launcher = page.getByRole('list', { name: '打开工具' }); - await expect(launcher).toBeVisible(); - const workbar = await setRightWorkbarWidth(page, 320); - - const workbarBox = await workbar.boundingBox(); - const shortcutBoxes = await launcher - .locator('kbd') - .evaluateAll((elements) => - elements.map((element) => { - const box = element.getBoundingClientRect(); - return { left: box.left, right: box.right }; - }), - ); - expect(workbarBox).not.toBeNull(); - expect(shortcutBoxes.length).toBeGreaterThan(0); - for (const shortcutBox of shortcutBoxes) { - expect.soft(shortcutBox.left).toBeGreaterThanOrEqual(workbarBox!.x); - expect.soft(shortcutBox.right).toBeLessThanOrEqual(workbarBox!.x + workbarBox!.width); - } - - await page - .getByRole('button', { - name: /侧边对话.*在不打断主任务的情况下追问和只读探索/, - }) - .click(); - const companion = page.locator('.maka-quote-companion'); - await expect(companion).toBeVisible(); - await setRightWorkbarWidth(page, 320); - const sideChatPanel = page - .locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]') - .filter({ has: companion }); - await expect(sideChatPanel).toBeVisible(); - await expect - .poll(async () => (await sideChatPanel.boundingBox())?.width) - .toBeCloseTo(320, 0); - - const composerCard = companion.locator('.maka-composer-astryx'); - // Keep ChatComposer's inner elevation visible. - await expect(composerCard).toHaveCSS('overflow', 'visible'); - - // #3452: Side Chat is a branch of the main conversation, not a second - // conversation surface, so its dock rounds like the main dock. Both resolve - // Astryx's `--radius-chat`, the same token `ChatMessageBubble` defaults to — - // a side-only `--_chat-composer-radius` split the bubble from the dock the - // moment the bubbles moved back to that default. Compared against the main - // composer rather than a literal so an upstream token change moves both or - // fails here. - const composerRadii = await page.evaluate(() => { - const wrappers = [...document.querySelectorAll('.maka-composer-astryx')]; - const inCompanion = (element: Element) => element.closest('.maka-quote-companion') !== null; - const effectiveRadius = (element: Element | undefined) => { - if (!element) return null; - const styles = getComputedStyle(element); - return ( - styles.getPropertyValue('--_chat-composer-radius').trim() || - styles.getPropertyValue('--radius-chat').trim() - ); - }; - return { - side: effectiveRadius(wrappers.find(inCompanion)), - main: effectiveRadius(wrappers.find((element) => !inCompanion(element))), - }; - }); - expect(composerRadii.side).toBeTruthy(); - expect(composerRadii.side).toBe(composerRadii.main); - // Match the long model-label pressure from the reported side-chat screenshot - // without coupling the fixture's globally useful default model to this test. - await companion.locator('.maka-composer-model-chip-text').evaluate((element) => { - element.textContent = 'Nemotron 3 Ultra Long Context Model'; - }); - const sendButton = companion.getByRole('button', { name: '发送' }); - await expect(sendButton).toBeVisible(); - const [composerBox, sendBox] = await Promise.all([ - composerCard.boundingBox(), - sendButton.boundingBox(), - ]); - expect(composerBox).not.toBeNull(); - expect(sendBox).not.toBeNull(); - expect(sendBox!.x).toBeGreaterThanOrEqual(composerBox!.x); - expect(sendBox!.x + sendBox!.width).toBeLessThanOrEqual( - composerBox!.x + composerBox!.width, - ); -}); - -// #2188: the address field, not the nav buttons, absorbs the column's free -// width. The rule reaches into Astryx Toolbar's slot div, which nothing else -// pins — an upstream slot-wrapper change would silently regress it. -test('browser address field tracks the workbar column width', async ({ window: page }) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('create browser session'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: create browser session/)).toBeVisible(); - await page.getByRole('button', { name: '展开任务工作栏' }).click(); - await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); - await page.getByRole('button', { name: /浏览器.*打开内置浏览器/ }).click(); - await expect(page.getByRole('region', { name: '嵌入式浏览器' })).toBeVisible(); - - const addressInput = page.getByRole('textbox', { name: '浏览器地址' }); - const inputWidth = async () => (await addressInput.boundingBox())!.width; - // The tab's content lives in the overlay panel next to the workbar frame, - // so the width override must land there, not on `.maka-session-workbar`. - const setPanelWidth = async (width: number) => { - const panel = page.locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]'); - await panel.evaluate((element, nextWidth) => { - (element as HTMLElement).style.setProperty('--maka-session-workbar-width', `${nextWidth}px`); - }, width); - await expect.poll(async () => (await panel.boundingBox())?.width).toBeCloseTo(width, 0); - }; - await setPanelWidth(480); - await expect.poll(inputWidth).toBeGreaterThan(250); - await setPanelWidth(320); - // Fails at a flat width without the slot rule: 480 and 320 would measure the same. - await expect.poll(inputWidth).toBeLessThan(220); - await expect.poll(inputWidth).toBeGreaterThan(100); -}); - test('titlebar workbar action restores an existing tool instead of the picker', async ({ gitReviewWindow, }) => { diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 53c5dc39db..059b43d2e6 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -17,46 +17,7 @@ * under the License. */ -import { COMPOSER_INPUT, expect, test, waitForWorkHubReady } from './fixtures'; - -test('WorkHub target metadata remains within the submitted Session control', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('支付回调幂等性'); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - await waitForWorkHubReady(page, 1); - - const routedPrompt = `继续${sessionName},补充重复投递测试点。`; - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill(routedPrompt); - await workHubComposer.press('Enter'); - const submittedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); - await expect(submittedTurn.locator('.workhub-submitted-session small')).toBeVisible(); - - const buttonContainsProject = await submittedTurn.evaluate((turn) => { - const button = turn.querySelector('.workhub-submitted > button')!; - const project = button.querySelector('.workhub-submitted-session small')!; - const buttonBox = button.getBoundingClientRect(); - const projectBox = project.getBoundingClientRect(); - return buttonBox.bottom >= projectBox.bottom; - }); - - expect(buttonContainsProject).toBe(true); -}); +import { expect, test } from './fixtures'; test('WorkHub explains Coordination startup failure and recovers after a default model is set', async ({ window: page, diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 168856188c..ecc20ad128 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -1027,6 +1027,28 @@ export const ToolPicker: Story = { render: () => , }; +// The picker at the column's 320px floor. Every shortcut hint has to stay +// inside the column: the launcher rows lay the label and the `kbd` on one line, +// so the first thing a too-narrow column does is push the hints past the edge. +export const ToolPickerAtColumnFloor: Story = { + decorators: [bridge()], + render: () => , + play: async ({ canvasElement }) => { + const launcher = await within(canvasElement).findByRole('list', { name: '打开工具' }); + const column = launcher.closest('.maka-session-workbar'); + if (!column) throw new Error('workbar column is missing'); + const panelBox = column.getBoundingClientRect(); + const shortcuts = [...launcher.querySelectorAll('kbd')]; + + expect(shortcuts.length).toBeGreaterThan(0); + for (const shortcut of shortcuts) { + const box = shortcut.getBoundingClientRect(); + expect.soft(box.left).toBeGreaterThanOrEqual(panelBox.left); + expect.soft(box.right).toBeLessThanOrEqual(panelBox.right); + } + }, +}; + // Real path: 任务工作栏 → 变更, showing the live branch comparison from the // session cwd. The panel is Git-backed; no message or tool-result fixture is // involved in this story. @@ -1238,6 +1260,35 @@ export const BrowserAt400: Story = { render: () => , }; +// #2188: the address field, not the nav buttons, absorbs the column's free +// width. The rule reaches into Astryx Toolbar's slot div, so an upstream +// slot-wrapper change regresses it silently. +export const BrowserAddressFieldTracksColumnWidth: Story = { + decorators: [bridge({ browserState: LOADED_BROWSER_STATE })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const address = await canvas.findByRole('textbox', { name: '浏览器地址' }); + const frame = canvasElement.querySelector('.maka-detail-with-artifacts'); + // The face's content lives in the overlay panel beside the workbar frame, + // so the panel is what carries the column width, not `.maka-session-workbar`. + const column = address.closest('.maka-session-workbar-panel'); + if (!frame || !column) throw new Error('workbar panel is missing'); + const widthAt = async (columnWidth: number) => { + frame.style.setProperty('--maka-session-workbar-width', `${columnWidth}px`); + await waitFor(() => { + expect(column.getBoundingClientRect().width).toBeCloseTo(columnWidth, 0); + }); + return address.getBoundingClientRect().width; + }; + + expect(await widthAt(480)).toBeGreaterThan(250); + const atFloor = await widthAt(320); + expect(atFloor).toBeLessThan(220); + expect(atFloor).toBeGreaterThan(100); + }, +}; + // Below 990px the grid stacks the same right-placement column under the // conversation: full width, capped at 42dvh. Storybook-UI only: the smoke lane // loads iframes at 1280px, above the stack point, so it renders wide there. @@ -1320,6 +1371,48 @@ export const SideChat: Story = { render: () => , }; +// Real path: 侧边对话 at the column's 320px floor, under a long model label. +export const SideChatAtColumnFloor: Story = { + decorators: [bridge()], + render: () => , + play: async ({ canvasElement }) => { + const companion = canvasElement.querySelector('.maka-quote-companion'); + if (!companion) throw new Error('side chat companion is missing'); + const card = companion.querySelector('.maka-composer-astryx'); + if (!card) throw new Error('side chat composer card is missing'); + + expect(getComputedStyle(card).overflow).toBe('visible'); + + // The plate has to round the same as a bubble does; a side-only + // `--_chat-composer-radius` is what split the dock from the bubble in + // #3452. Measure a probe the token paints instead of reading the token + // back — ink-ladder-contract.test.ts forbids the latter. + const plate = card.firstElementChild; + if (!plate) throw new Error('composer plate is missing'); + const probe = document.createElement('div'); + probe.style.borderRadius = 'var(--radius-chat)'; + card.append(probe); + const bubbleRadius = getComputedStyle(probe).borderTopLeftRadius; + probe.remove(); + expect(bubbleRadius).not.toBe('0px'); + expect(getComputedStyle(plate).borderTopLeftRadius).toBe(bubbleRadius); + + // Written here rather than in the fixture: the fixture's default model is + // shared by every other story. + const label = companion.querySelector('.maka-composer-model-chip-text'); + if (!label) throw new Error('side chat model chip is missing'); + label.textContent = 'Nemotron 3 Ultra Long Context Model'; + + const send = within(companion).getByRole('button', { name: '发送' }); + await waitFor(() => { + const cardBox = card.getBoundingClientRect(); + const sendBox = send.getBoundingClientRect(); + expect(sendBox.left).toBeGreaterThanOrEqual(cardBox.left); + expect(sendBox.right).toBeLessThanOrEqual(cardBox.right); + }); + }, +}; + // Real path: 任务工作栏 → 追踪, on a session that has run turns — the overview // reads a context budget, token/cache figures and the session's facts off a // retried model call and a post-compaction call, while a turn that failed on a diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx new file mode 100644 index 0000000000..be53b4e57e --- /dev/null +++ b/apps/desktop/stories/workhub.stories.tsx @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; +import type { + WorkHubController, + WorkHubCoordinationTurn, + WorkHubProjection, +} from '../src/renderer/workhub-controller'; +import { WorkHubSurface } from '../src/renderer/workhub-surface'; + +// Fidelity convention (#1433): every story names the real app path that +// reaches it. See apps/desktop/stories/FIDELITY.md. +// +// Real host: app-shell.tsx mounts in the conversation column +// when WorkHub is enabled and a Coordination Session exists. The surface takes +// its `controller` as a prop — the same seam production fills with +// `createWorkHubController` — so a story serves one pinned projection and +// conversation instead of driving a live Coordination Session. + +const TARGET_SESSION_ID = 'session-workhub-target'; +const SESSION_NAME = '支付回调幂等性'; +const PROJECT_NAME = 'maka-agent'; +const LOCALE = 'zh-CN'; + +const meta = { + title: 'Product/WorkHub', + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function submittedTurn(): WorkHubCoordinationTurn { + return { + messageId: 'message-1', + turnId: 'turn-1', + text: `继续${SESSION_NAME},补充重复投递测试点。`, + state: 'running', + assignment: { + actionId: 'action-1', + delegationId: 'delegation-1', + targetSessionId: TARGET_SESSION_ID, + targetSessionName: SESSION_NAME, + targetMessageId: 'target-message-1', + targetTurnId: 'target-turn-1', + feedbackState: 'running', + linkState: 'active', + }, + updatedAt: 1, + }; +} + +function projection(): WorkHubProjection { + return { + sessions: [ + { + target: { sessionId: TARGET_SESSION_ID }, + projectName: PROJECT_NAME, + sessionName: SESSION_NAME, + archived: false, + state: 'running', + updatedAt: 1, + }, + ], + turns: [], + }; +} + +function controller(turns: readonly WorkHubCoordinationTurn[]): WorkHubController { + return { + read: async () => projection(), + submit: async () => { + throw new Error('submission is not part of these stories'); + }, + openConversation: async (handler) => { + handler(turns); + return { close: async () => {} }; + }, + recordConversationTurn: async ({ turnId }) => ({ turnId }), + subscribe: () => () => {}, + resetVisitContext: () => {}, + }; +} + +function Surface(props: { turns: readonly WorkHubCoordinationTurn[] }) { + return ( +
+
+ {}} + /> +
+
+ ); +} + +// Real path: WorkHub routed a prompt to an existing Session and reports where +// the work went. The target's project name is a `` inside the Button's +// own label column, so it has to stay inside the control's box: a project name +// that overflows the button reads as loose text under an unrelated row. +export const SubmittedWorkKeepsTargetMetadataInside: Story = { + render: () => , + play: async ({ canvasElement }) => { + await waitFor(() => { + expect(canvasElement.querySelector('.workhub-submitted-session small')).not.toBeNull(); + }); + const button = canvasElement.querySelector('.workhub-submitted > button'); + const project = canvasElement.querySelector('.workhub-submitted-session small'); + if (!button || !project) throw new Error('submitted work control is missing'); + + expect(project.textContent).toBe(PROJECT_NAME); + expect(button.getBoundingClientRect().bottom).toBeGreaterThanOrEqual( + project.getBoundingClientRect().bottom, + ); + }, +}; From 0de0318c5c2ab43d1c118213c525fd6ac5bddd38 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 04:40:39 +0800 Subject: [PATCH 3/8] test(desktop): drop the duplicated E2E thread search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fixture-thread-search.spec.ts` launched a window, seeded a transcript and asserted that a content hit carries its turn id and position. The main-process test for the same handler already asserts exactly that — same query, same `用户消息` summary, same four-field target — plus the title-hit and transcript-failure cases the E2E never covered. Nothing here needed a renderer: the spec's own body only ever called `window.maka.search.thread`. The window fixture it was the sole user of goes with it. Refs #4761 Generated-by: Claude Code --- .../desktop/e2e/fixture-thread-search.spec.ts | 57 ------------------- apps/desktop/e2e/fixtures.ts | 13 ----- 2 files changed, 70 deletions(-) delete mode 100644 apps/desktop/e2e/fixture-thread-search.spec.ts diff --git a/apps/desktop/e2e/fixture-thread-search.spec.ts b/apps/desktop/e2e/fixture-thread-search.spec.ts deleted file mode 100644 index 43a675a2ab..0000000000 --- a/apps/desktop/e2e/fixture-thread-search.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PROMPT_RAIL_SESSION_ID } from '../src/main/e2e-fixture/seed-helpers.js'; -import { - desktopSessionKey, - parseDesktopSessionKey, -} from '../src/shared/runtime-host-identity.js'; -import { expect, test } from './fixtures.js'; - -test('fixture-seeded transcripts return content hits with turn ids', async ({ - threadSearchWindow: page, -}) => { - const outcome = await page.evaluate(async () => - window.maka.search.thread({ - source: 'thread', - query: '第 3 个问题', - limit: 10, - }), - ); - - expect(Array.isArray(outcome), JSON.stringify(outcome)).toBe(true); - if (!Array.isArray(outcome)) return; - - const content = outcome.find( - (hit) => - hit.target?.kind === 'thread' && - hit.target.turnId === 'turn-prompt-rail-3', - ); - expect(content?.summary).toBe('用户消息'); - if (!content || content.target?.kind !== 'thread') { - throw new Error(`expected a thread search hit, got ${JSON.stringify(content)}`); - } - const { hostId } = parseDesktopSessionKey(content.target.sessionId); - expect(content.target).toEqual({ - kind: 'thread', - sessionId: desktopSessionKey({ hostId, sessionId: PROMPT_RAIL_SESSION_ID }), - turnId: 'turn-prompt-rail-3', - sequence: 4, - }); -}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 2d8b562ad8..b3eac80252 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -523,7 +523,6 @@ type E2eTestFixtures = { parentRemovalWindow: Page; railRenderWindow: Page; promptRailWindow: Page; - threadSearchWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -641,18 +640,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - // The same seeded transcript, on a window of its own. Search reads the Host - // through the bridge and renders nothing, so it needs neither the warm - // window's compositor nor its between-test reset — and taking it off the - // reused window is what retires the readiness gate's cross-test bleed (#4707). - threadSearchWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '[data-turn-id]', - e2eFixtureScenario: 'chat-prompt-rail', - locale: 'zh-CN', - }, use); - }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box From bd3c531e8bda7d392ba684405cf3b6890cc77c1b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 10:01:00 +0800 Subject: [PATCH 4/8] test(desktop): move the workbar titlebar contract into the shell story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2E test spent 75 lines of Electron on three claims that are all CSS and renderer state: the tab strip and the collapse toggle report one centre line, the bar gives back the width the platform reports in `--maka-titlebar-overlay-right-width`, and collapsing parks the same toggle in the titlebar at the same x/y before restoring the face that was open rather than the picker. `app-shell.stories.tsx` already mounts the real titlebar and the real shell grid, so the story hosts both bands the toggle moves between — which is the one reason this could not live with the workbar's own stories. The collapse state runs through `reduceWorkbarLayout`, the same reducer the app dispatches into, so "restore brings back the face" is the production rule firing rather than a story-local `useState`. Mutation-checked, each against the built story: - dropping the caption term from the bar's `padding-inline` → the toggle does not move (80px off); - `align-items: flex-start` on the tab strip → centre line off by 2px; - `collapse` clearing `panels` in the reducer → the picker comes back; - `[+]` dispatching `open-launcher` instead of opening the face menu → no `role=menu`. Tier-1 is now 30 tests in 16 files. Generated-by: Claude Code --- apps/desktop/e2e/session-workbar.spec.ts | 71 -------- .../src/renderer/styles/workbar/shell.css | 2 +- apps/desktop/stories/app-shell.stories.tsx | 167 +++++++++++++++++- .../stories/session-workbar.stories.tsx | 5 +- 4 files changed, 166 insertions(+), 79 deletions(-) diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 0aeca8a138..34bad85d39 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -143,77 +143,6 @@ async function waitForCompanionForkId(page: Page, sourceSessionId: string) { return forkId!; } -test('titlebar workbar action restores an existing tool instead of the picker', async ({ - gitReviewWindow, -}) => { - const page = gitReviewWindow.page; - const workspaceActions = page.getByRole('toolbar', { name: '工作区辅助操作' }); - const panel = await openGitChanges(page); - const panelToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first(); - const collapseButton = panelToolbar.getByRole('button', { name: '收起任务工作栏' }); - await expect(workspaceActions).toHaveCount(0); - await expect(collapseButton).toBeVisible(); - await expect(workspaceActions.getByRole('button', { name: '打开工作栏工具' })).toHaveCount(0); - - const activeTab = panelToolbar.getByRole('tab', { selected: true }); - await expect(activeTab).toBeVisible(); - const [toolbarBox, tabBox, toggleBox] = await Promise.all([ - panelToolbar.boundingBox(), - activeTab.boundingBox(), - collapseButton.boundingBox(), - ]); - expect(toolbarBox).not.toBeNull(); - expect(tabBox).not.toBeNull(); - expect(toggleBox).not.toBeNull(); - expect( - Math.abs(tabBox!.y + tabBox!.height / 2 - (toggleBox!.y + toggleBox!.height / 2)), - ).toBeLessThanOrEqual(1); - - const simulatedCaptionWidth = 80; - await page.evaluate((width) => { - document.documentElement.style.setProperty( - '--maka-titlebar-overlay-right-width', - `${width}px`, - ); - }, simulatedCaptionWidth); - await expect - .poll(async () => (await collapseButton.boundingBox())?.x) - .toBe(toggleBox!.x - simulatedCaptionWidth); - const safeAreaToggleBox = await collapseButton.boundingBox(); - expect(safeAreaToggleBox).not.toBeNull(); - - // [+] is a menu over the panel now, not a swap to the launcher: the face you - // are reading stays on screen while you pick another one. - await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click(); - const faceMenu = page.getByRole('menu'); - await expect(faceMenu).toBeVisible(); - await expect(panel).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(faceMenu).toBeHidden(); - const picker = page.getByRole('list', { name: '打开工具' }); - await expect(picker).not.toBeVisible(); - - await collapseButton.click(); - const expandButton = workspaceActions.getByRole('button', { name: '展开任务工作栏' }); - await expect(expandButton).toBeVisible(); - const expandButtonBox = await expandButton.boundingBox(); - expect(expandButtonBox).not.toBeNull(); - expect(Math.abs(expandButtonBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1); - expect(Math.abs(expandButtonBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1); - await expandButton.click(); - - await expect(panel).toBeVisible(); - await expect(picker).not.toBeVisible(); - const restoredCollapseButton = panelToolbar.getByRole('button', { - name: '收起任务工作栏', - }); - await expect(restoredCollapseButton).toBeVisible(); - const restoredToggleBox = await restoredCollapseButton.boundingBox(); - expect(restoredToggleBox).not.toBeNull(); - expect(Math.abs(restoredToggleBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1); - expect(Math.abs(restoredToggleBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1); -}); - test('Git changes re-read the workspace after the app regains focus', async ({ gitReviewWindow, }) => { diff --git a/apps/desktop/src/renderer/styles/workbar/shell.css b/apps/desktop/src/renderer/styles/workbar/shell.css index f62b09e407..c19291701f 100644 --- a/apps/desktop/src/renderer/styles/workbar/shell.css +++ b/apps/desktop/src/renderer/styles/workbar/shell.css @@ -170,7 +170,7 @@ ); /* The right pad is the window titlebar strip's own gutter, not this bar's: the collapse toggle is one control that moves between the two bands, and - `session-workbar.spec.ts` holds it to the same x in both. Narrowing this to + `app-shell.stories.tsx` holds it to the same x in both. Narrowing this to the bar's own gutter would slide it 16px on every collapse. On top of it, the caption buttons where the platform draws them on the right (Windows); macOS puts them on the left, over the sidebar, and reports 0 here. */ diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 419f92dc26..3dd9c636de 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -19,7 +19,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; -import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useReducer, useState, type CSSProperties, type ReactNode } from 'react'; import type { ComponentProps } from 'react'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; @@ -29,11 +29,24 @@ import { Composer, deriveTitlebarProjectName, TitlebarSessionIdentity, + ToastProvider, } from '@maka/ui'; import type { ChatModelChoice, SessionViewMode, TurnViewModel } from '@maka/ui'; import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js'; import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions'; -import { WorkbarTitlebarActions } from '../src/renderer/features/workbar'; +import { + WorkbarServicesProvider, + WorkbarTitlebarActions, +} from '../src/renderer/features/workbar'; +import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; +import { + createFakeWorkbarServices, + createSessionWorkbarPanelsState, + reduceWorkbarLayout, + SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT, + SESSION_WORKBAR_DEFAULT_WIDTH, + type WorkbarLayoutState, +} from '../src/renderer/features/workbar/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { deriveAppShellTurnPresentation } from '../src/renderer/app-shell-turn-view-model'; import { @@ -341,6 +354,8 @@ function ComposedShell(props: { frameHeight?: number | string; /** Drives the footer's update action; `undefined` is the silent phase. */ updateReminder?: SessionListPanelProps['updateReminder']; + workbarCollapsed?: boolean; + onToggleWorkbar?: () => void; }) { const [collapsed, setCollapsed] = useState(props.sidebarCollapsed ?? false); const [viewMode, setViewMode] = useState(props.initialViewMode ?? 'conversation'); @@ -413,8 +428,8 @@ function ComposedShell(props: { )} + dispatch({ type: 'collapse', placement: 'right', collapsed }); + return ( + + + collapseRight(!layout.rightCollapsed)} + detailChildren={ +
+
+
+ } + /> + + + ); +} + +// Real path: 展开任务工作栏 → 文件 → 收起 → 从标题栏再展开. +// +// The collapse toggle is one control that moves between two bands — the +// workbar's own bar and the titlebar's right cluster — and `workbar/shell.css` +// pads the bar with the titlebar strip's gutter precisely so it lands on the +// same x in both. Only a story that mounts both bands can hold it there, which +// is why this lives beside the shell rather than with the workbar's own +// stories. +export const WorkbarCollapseKeepsOneToggleInPlace: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const frame = canvasElement.querySelector( + '[data-maka-contract="session-workbar-right"]', + ); + if (!frame) throw new Error('the right workbar is missing'); + const bar = within(frame.querySelector('.maka-session-workbar-toolbar')!); + const collapse = bar.getByRole('button', { name: '收起任务工作栏' }); + // The launcher stays mounted behind the face, so "not the picker" is a + // claim about reachability: `queryByRole` skips the inactive panel. + const pickerIsShowing = () => + canvas.queryByRole('list', { name: '打开工具' }) !== null; + const face = await bar.findByRole('tab', { selected: true }); + const faceLabel = face.textContent; + + expect(canvas.queryByRole('toolbar', { name: '工作区辅助操作' })).toBeNull(); + expect(pickerIsShowing()).toBe(false); + + const faceBox = face.getBoundingClientRect(); + const openToggleBox = collapse.getBoundingClientRect(); + expect( + Math.abs( + faceBox.y + faceBox.height / 2 - (openToggleBox.y + openToggleBox.height / 2), + ), + ).toBeLessThanOrEqual(1); + + // Windows draws its caption buttons over the right of the strip and reports + // their width here; macOS reports 0. The bar has to give that width back. + const captionWidth = 80; + canvasElement.style.setProperty( + '--maka-titlebar-overlay-right-width', + `${captionWidth}px`, + ); + await waitFor(() => { + expect(collapse.getBoundingClientRect().x).toBeCloseTo( + openToggleBox.x - captionWidth, + 0, + ); + }); + const parked = collapse.getBoundingClientRect(); + + // [+] is a menu over the panel, not a swap to the launcher: the face you + // are reading stays on screen while you pick another one. + await userEvent.click(bar.getByRole('button', { name: '打开或关闭工作栏的面' })); + const menu = await within(document.body).findByRole('menu'); + expect(frame).toBeVisible(); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(menu).not.toBeVisible()); + expect(pickerIsShowing()).toBe(false); + + await userEvent.click(collapse); + const restore = await canvas.findByRole('button', { name: '展开任务工作栏' }); + await waitFor(() => expect(frame).not.toBeVisible()); + const restoreBox = restore.getBoundingClientRect(); + expect(Math.abs(restoreBox.x - parked.x)).toBeLessThanOrEqual(1); + expect(Math.abs(restoreBox.y - parked.y)).toBeLessThanOrEqual(1); + + await userEvent.click(restore); + await waitFor(() => expect(frame).toBeVisible()); + expect(pickerIsShowing()).toBe(false); + const restoredFace = bar.getByRole('tab', { selected: true }); + expect(restoredFace.textContent).toBe(faceLabel); + const restoredToggleBox = bar + .getByRole('button', { name: '收起任务工作栏' }) + .getBoundingClientRect(); + expect(Math.abs(restoredToggleBox.x - parked.x)).toBeLessThanOrEqual(1); + expect(Math.abs(restoredToggleBox.y - parked.y)).toBeLessThanOrEqual(1); + }, +}; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index ecc20ad128..c18f166df7 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -54,9 +54,8 @@ import { // // What this group cannot show: the seam. The workbar's surface tone only reads // as a seam against the conversation plate it stands beside, and the plate is -// two levels up in the shell — as is the titlebar clearance the surface bleeds -// through. Both are pinned by computed-style assertions in -// e2e/session-workbar.spec.ts instead. +// two levels up in the shell — as is the titlebar band the collapse toggle +// moves into. Those belong to app-shell.stories.tsx, which mounts the shell. // // Read these at a canvas of 990px or wider. The app's own breakpoint is on the // viewport, and Storybook's canvas IS the viewport, so a narrower window puts From 23de9f0f40969f104483553356a92f55cff9d08d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 10:27:14 +0800 Subject: [PATCH 5/8] test(desktop): hold the Electron tier to a written budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier just went from 110 tests to 30 by asking one question of each test: does this need a real window? Nothing kept the answer. The PR body said "what stays needs a native window, and was re-checked one by one" — prose, in a PR that will scroll away. `apps/desktop/e2e-budget.json` is that sentence made checkable. It records each spec's test count and the Electron-owned mechanism it needs, and the admission rule sits at the top of the same file, printed back on failure. Counts regenerate with `--write`; the reason cannot, so a new spec has to be justified in the diff or the guard stays red. Not extracted into a shared ratchet with `check-renderer-architecture`: the 3.5k lines there are Babel analysis and `--base` baseline rematerialization, and the part these two share — read a ledger, diff, exit 1 — is under twenty lines each. The subjects also differ (monotonic debt vs an exact inventory), so a common interface would be guessed from two instances rather than read off three. It runs in the planning lane, not the e2e job: that job is conditional, and a guard that only checks the tier when the tier already ran is a guard that grows back. Pure Node, no build — it reads the spec sources, and refuses any top-level `test.*` form whose count it cannot see (a `test.describe` would hide its tests from the scanner rather than be undercounted). Refs #4761 Generated-by: Claude Code --- .github/workflows/ci.yml | 6 + apps/desktop/e2e-budget.json | 75 ++++++++++ apps/desktop/package.json | 2 + apps/desktop/scripts/check-e2e-budget.mjs | 140 ++++++++++++++++++ .../desktop/scripts/check-e2e-budget.test.mjs | 68 +++++++++ package.json | 2 + 6 files changed, 293 insertions(+) create mode 100644 apps/desktop/e2e-budget.json create mode 100644 apps/desktop/scripts/check-e2e-budget.mjs create mode 100644 apps/desktop/scripts/check-e2e-budget.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bef0189a1d..8538b37d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,12 @@ jobs: - name: Check Windows test inventory run: npm run windows:inventory + # Deliberately here and not in the e2e job: that job is conditional, and a + # tier this guard only checks when the tier already ran is a guard that + # grows back (#4761). Pure Node, no build -- it reads the spec sources. + - name: Check Electron e2e budget + run: npm run check:e2e-budget + # Runs on the PR merge result: after a sibling protocol change lands on # main with the same epoch text, the silently merged tree still carries # the current base parent's epoch and this fails instead of shipping two diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json new file mode 100644 index 0000000000..7ad275cef3 --- /dev/null +++ b/apps/desktop/e2e-budget.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "policy": [ + "A test belongs in this tier only when it needs a real Electron window: an OS dialog, a main-process round trip, input released outside the window, persistence across a renderer reload, or a cross-process lifecycle.", + "Layout geometry, renderer state machines and pure CSS go to a Storybook play function; main-process logic goes to node --test. See #4761.", + "Every spec below records the Electron-owned mechanism it needs. If you cannot name one, the test does not belong here.", + "Counts are regenerated with `npm run check:e2e-budget:write` -- the reason has to be written by hand." + ], + "specs": { + "composer-directory-reference.spec.ts": { + "tests": 1, + "electron": "the folder reference has to survive a renderer reload and still agree with the Host's session record" + }, + "context-window-save.spec.ts": { + "tests": 1, + "electron": "the saved window is read back from the Host's connection snapshot, not from renderer state" + }, + "new-task-reload.spec.ts": { + "tests": 1, + "electron": "renderer reload is the whole contract: an explicit new task must not reopen history" + }, + "proxy-password-editing.spec.ts": { + "tests": 1, + "electron": "the password never reaches the renderer; only the Host can report passwordConfigured and authenticate offline" + }, + "quote-window-boundary.spec.ts": { + "tests": 1, + "electron": "a pointer capture released outside the window -- there is no window edge to leave in a browser tab" + }, + "session-draft-focus.spec.ts": { + "tests": 1, + "electron": "needs a second real Session (Host round trip) to switch to; the focus and draft-restore halves alone would not earn a window" + }, + "session-workbar.spec.ts": { + "tests": 5, + "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, and Side Chat's fork lifecycle -- all main-process resources" + }, + "settings.spec.ts": { + "tests": 4, + "electron": "the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write" + }, + "sidebar-project-reload.spec.ts": { + "tests": 1, + "electron": "rail grouping has to be rebuilt from persisted project state after a renderer reload" + }, + "skill-draft-lifecycle.spec.ts": { + "tests": 2, + "electron": "revision retry and cancel are Host-owned draft transitions across a parent and a child Session" + }, + "slash-command-compact.spec.ts": { + "tests": 1, + "electron": "/compact is a Host round trip that ends in a real terminal Turn state" + }, + "streaming-remount.spec.ts": { + "tests": 4, + "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" + }, + "transcript-scroll-cost.spec.ts": { + "tests": 3, + "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" + }, + "workhub-layout.spec.ts": { + "tests": 1, + "electron": "Coordination startup failure comes from the Host, and recovery needs a default model written back to it" + }, + "workhub-reconstruction.spec.ts": { + "tests": 2, + "electron": "delegation linkage is rebuilt by the Host across navigation and across Sessions" + }, + "zh-tw-locale.spec.ts": { + "tests": 1, + "electron": "the locale is a persisted Host setting and only takes effect through a renderer reload" + } + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ac41d0e15e..af787645f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,6 +23,8 @@ "presmoke:storybook": "npm --workspace @maka/core run build", "smoke:storybook": "node ../../scripts/storybook-visual-smoke.mjs", "check:architecture": "node --test scripts/check-renderer-architecture.test.mjs && node scripts/check-renderer-architecture.mjs", + "check:e2e-budget": "node --test scripts/check-e2e-budget.test.mjs && node scripts/check-e2e-budget.mjs --check", + "check:e2e-budget:write": "node scripts/check-e2e-budget.mjs --write", "build": "npm run build:resources && npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer", "build:resources": "node scripts/copy-runtime-filesystem-worker.mjs", "build:test": "npm run build:main && npm run build:preload && npm run build:overlay", diff --git a/apps/desktop/scripts/check-e2e-budget.mjs b/apps/desktop/scripts/check-e2e-budget.mjs new file mode 100644 index 0000000000..0099e93335 --- /dev/null +++ b/apps/desktop/scripts/check-e2e-budget.mjs @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const DESKTOP_ROOT = resolve(dirname(SCRIPT_PATH), '..'); +const LEDGER_PATH = join(DESKTOP_ROOT, 'e2e-budget.json'); +const E2E_ROOT = join(DESKTOP_ROOT, 'e2e'); + +// Playwright forms that create a test, and the ones that only configure a file. +// Anything else at column 0 is an unrecognised form, and the count would be a +// guess -- `test.describe` in particular nests its tests where this scanner +// cannot see them, so it is refused rather than silently undercounted. +const TEST_FORMS = new Set(['test', 'test.only', 'test.skip', 'test.fixme']); +const CONFIG_FORMS = new Set([ + 'test.setTimeout', + 'test.use', + 'test.slow', + 'test.beforeAll', + 'test.beforeEach', + 'test.afterAll', + 'test.afterEach', +]); + +export function countSpecTests(source, file) { + let tests = 0; + for (const [index, line] of source.split(/\r?\n/u).entries()) { + const match = /^(test(?:\.[A-Za-z]+)?)\s*\(/u.exec(line); + if (!match) continue; + const form = match[1]; + if (TEST_FORMS.has(form)) tests += 1; + else if (!CONFIG_FORMS.has(form)) { + throw new Error( + `${file}:${index + 1}: unrecognised top-level \`${form}(\` -- teach check-e2e-budget.mjs how many tests it creates`, + ); + } + } + return tests; +} + +export function collectSpecs(root = E2E_ROOT) { + const specs = {}; + for (const file of readdirSync(root).sort()) { + if (!file.endsWith('.spec.ts')) continue; + specs[file] = countSpecTests(readFileSync(join(root, file), 'utf8'), file); + } + return specs; +} + +export function compare(ledger, actual) { + const violations = []; + const recorded = ledger.specs ?? {}; + for (const file of Object.keys(actual)) { + if (!(file in recorded)) { + violations.push(`${file}: not in the budget -- add it with a reason it needs a real window`); + continue; + } + const entry = recorded[file]; + if (entry.tests !== actual[file]) { + violations.push(`${file}: budget records ${entry.tests} test(s), the file has ${actual[file]}`); + } + if (typeof entry.electron !== 'string' || entry.electron.trim() === '') { + violations.push(`${file}: no reason recorded for needing a real Electron window`); + } + } + for (const file of Object.keys(recorded)) { + if (!(file in actual)) violations.push(`${file}: in the budget but no longer on disk`); + } + return violations; +} + +function render(ledger, actual) { + const specs = {}; + for (const file of Object.keys(actual).sort()) { + specs[file] = { + tests: actual[file], + electron: ledger.specs?.[file]?.electron ?? '', + }; + } + return `${JSON.stringify({ ...ledger, specs }, null, 2)}\n`; +} + +function main(argv) { + const write = argv.includes('--write'); + if (!write && !argv.includes('--check')) { + throw new Error('usage: check-e2e-budget.mjs [--check | --write]'); + } + const ledger = JSON.parse(readFileSync(LEDGER_PATH, 'utf8')); + const actual = collectSpecs(); + if (write) { + writeFileSync(LEDGER_PATH, render(ledger, actual)); + console.log('E2E budget updated; fill in any empty `electron` reason by hand.'); + return; + } + const violations = compare(ledger, actual); + if (violations.length === 0) { + const total = Object.values(actual).reduce((sum, count) => sum + count, 0); + console.log(`E2E budget holds: ${total} tests in ${Object.keys(actual).length} files.`); + return; + } + console.error( + [ + 'The Electron E2E tier drifted from apps/desktop/e2e-budget.json:', + ...violations.map((line) => `- ${line}`), + '', + ...(ledger.policy ?? []), + '', + 'Counts: npm run check:e2e-budget:write', + ].join('\n'), + ); + process.exit(1); +} + +if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/apps/desktop/scripts/check-e2e-budget.test.mjs b/apps/desktop/scripts/check-e2e-budget.test.mjs new file mode 100644 index 0000000000..ea6297d0ac --- /dev/null +++ b/apps/desktop/scripts/check-e2e-budget.test.mjs @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { deepEqual, equal, throws } from 'node:assert/strict'; +import { test } from 'node:test'; +import { compare, countSpecTests } from './check-e2e-budget.mjs'; + +test('counts the test-creating forms and ignores the configuring ones', () => { + const source = [ + "import { test } from './fixtures';", + 'test.setTimeout(120_000);', + "test('one', async () => {});", + "test.skip('two', async () => {});", + ' test("nested call inside a body", 1);', + "test.only('three', async () => {});", + ].join('\n'); + equal(countSpecTests(source, 'sample.spec.ts'), 3); +}); + +test('refuses a form whose test count cannot be read off the top level', () => { + throws( + () => countSpecTests("test.describe('group', () => {});", 'sample.spec.ts'), + /unrecognised top-level `test\.describe\(`/u, + ); +}); + +test('reports a spec that is missing from the budget', () => { + deepEqual( + compare({ specs: {} }, { 'new.spec.ts': 1 }), + ['new.spec.ts: not in the budget -- add it with a reason it needs a real window'], + ); +}); + +test('reports a drifted count, an empty reason, and a deleted spec', () => { + deepEqual( + compare( + { + specs: { + 'drifted.spec.ts': { tests: 1, electron: 'needs a window' }, + 'blank.spec.ts': { tests: 1, electron: ' ' }, + 'gone.spec.ts': { tests: 1, electron: 'needs a window' }, + }, + }, + { 'drifted.spec.ts': 2, 'blank.spec.ts': 1 }, + ), + [ + 'drifted.spec.ts: budget records 1 test(s), the file has 2', + 'blank.spec.ts: no reason recorded for needing a real Electron window', + 'gone.spec.ts: in the budget but no longer on disk', + ], + ); +}); diff --git a/package.json b/package.json index 43c10a5ee5..fbeb5e1f7d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "lint": "biome lint .", "format": "biome format --write .", "format:check": "biome format .", + "check:e2e-budget": "npm --workspace @maka/desktop run check:e2e-budget", + "check:e2e-budget:write": "npm --workspace @maka/desktop run check:e2e-budget:write", "check:renderer-architecture": "npm --workspace @maka/desktop run check:architecture --", "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run build:test && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", From 8f6f8fc0676a802e570a5e8d79d7f49efa61dfae Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 10:34:58 +0800 Subject: [PATCH 6/8] test(desktop): drop what the ablation could not justify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the removal pass over the two commits before this one. Six things came out; nothing else could be removed without losing a proven check. Two story assertions could never fail: - `expect(frame).toBeVisible()` with the face menu open. `frame` is the workbar card, and the launcher lives inside it — the very regression the line was aimed at leaves it visible. `pickerIsShowing()` is the check that actually bites, and it is already there. - comparing the restored tab's label to the one recorded before collapse. The story opens one face, so there is no other label the tab could carry. Four pieces of the budget guard were built for cases the repo does not have: - `--write`, and the two package scripts for it. The ledger is one integer and a hand-written sentence per spec; the failure message already names the number to type. A generator that can only regenerate the half you could read off the error is not worth its own mode. - the `test.only` / `test.skip` / `test.fixme` counting table and the seven-entry `test.setTimeout` / `test.use` / hook allowlist. Zero top-level `test.*` forms exist in the tier. An unrecognised form now throws with the message that tells you to teach the script, which is the same one-line edit adding to a table would have been. - `"version": 1` in the ledger, which nothing read. The assertions that stayed are the ones a mutation turned red, including two checked for this pass: `WorkbarTitlebarActions` rendering while the column is open, and a 4px shift of the titlebar's right cluster. Refs #4761 Generated-by: Claude Code --- apps/desktop/e2e-budget.json | 4 +- apps/desktop/package.json | 3 +- apps/desktop/scripts/check-e2e-budget.mjs | 60 +++++-------------- .../desktop/scripts/check-e2e-budget.test.mjs | 15 +++-- apps/desktop/stories/app-shell.stories.tsx | 4 -- package.json | 1 - 6 files changed, 23 insertions(+), 64 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 7ad275cef3..87c25681d0 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -1,10 +1,8 @@ { - "version": 1, "policy": [ "A test belongs in this tier only when it needs a real Electron window: an OS dialog, a main-process round trip, input released outside the window, persistence across a renderer reload, or a cross-process lifecycle.", "Layout geometry, renderer state machines and pure CSS go to a Storybook play function; main-process logic goes to node --test. See #4761.", - "Every spec below records the Electron-owned mechanism it needs. If you cannot name one, the test does not belong here.", - "Counts are regenerated with `npm run check:e2e-budget:write` -- the reason has to be written by hand." + "Every spec below records the Electron-owned mechanism it needs. If you cannot name one, the test does not belong here." ], "specs": { "composer-directory-reference.spec.ts": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index af787645f6..6d8cb8e6f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,8 +23,7 @@ "presmoke:storybook": "npm --workspace @maka/core run build", "smoke:storybook": "node ../../scripts/storybook-visual-smoke.mjs", "check:architecture": "node --test scripts/check-renderer-architecture.test.mjs && node scripts/check-renderer-architecture.mjs", - "check:e2e-budget": "node --test scripts/check-e2e-budget.test.mjs && node scripts/check-e2e-budget.mjs --check", - "check:e2e-budget:write": "node scripts/check-e2e-budget.mjs --write", + "check:e2e-budget": "node --test scripts/check-e2e-budget.test.mjs && node scripts/check-e2e-budget.mjs", "build": "npm run build:resources && npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer", "build:resources": "node scripts/copy-runtime-filesystem-worker.mjs", "build:test": "npm run build:main && npm run build:preload && npm run build:overlay", diff --git a/apps/desktop/scripts/check-e2e-budget.mjs b/apps/desktop/scripts/check-e2e-budget.mjs index 0099e93335..0cd3e4fc6f 100644 --- a/apps/desktop/scripts/check-e2e-budget.mjs +++ b/apps/desktop/scripts/check-e2e-budget.mjs @@ -17,7 +17,7 @@ * under the License. */ -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -26,31 +26,19 @@ const DESKTOP_ROOT = resolve(dirname(SCRIPT_PATH), '..'); const LEDGER_PATH = join(DESKTOP_ROOT, 'e2e-budget.json'); const E2E_ROOT = join(DESKTOP_ROOT, 'e2e'); -// Playwright forms that create a test, and the ones that only configure a file. -// Anything else at column 0 is an unrecognised form, and the count would be a -// guess -- `test.describe` in particular nests its tests where this scanner -// cannot see them, so it is refused rather than silently undercounted. -const TEST_FORMS = new Set(['test', 'test.only', 'test.skip', 'test.fixme']); -const CONFIG_FORMS = new Set([ - 'test.setTimeout', - 'test.use', - 'test.slow', - 'test.beforeAll', - 'test.beforeEach', - 'test.afterAll', - 'test.afterEach', -]); - +// Every spec writes its tests as a bare `test(` at column 0. Any other +// top-level `test.` form would need a rule of its own -- `test.describe` nests +// its tests where this scanner cannot see them -- so it is refused rather than +// silently undercounted. export function countSpecTests(source, file) { let tests = 0; for (const [index, line] of source.split(/\r?\n/u).entries()) { - const match = /^(test(?:\.[A-Za-z]+)?)\s*\(/u.exec(line); + const match = /^test(\.[A-Za-z]+)?\s*\(/u.exec(line); if (!match) continue; - const form = match[1]; - if (TEST_FORMS.has(form)) tests += 1; - else if (!CONFIG_FORMS.has(form)) { + if (match[1] === undefined) tests += 1; + else { throw new Error( - `${file}:${index + 1}: unrecognised top-level \`${form}(\` -- teach check-e2e-budget.mjs how many tests it creates`, + `${file}:${index + 1}: unrecognised top-level \`test${match[1]}(\` -- teach check-e2e-budget.mjs how many tests it creates`, ); } } @@ -71,7 +59,9 @@ export function compare(ledger, actual) { const recorded = ledger.specs ?? {}; for (const file of Object.keys(actual)) { if (!(file in recorded)) { - violations.push(`${file}: not in the budget -- add it with a reason it needs a real window`); + violations.push( + `${file}: not in the budget (${actual[file]} test(s)) -- add it with a reason it needs a real window`, + ); continue; } const entry = recorded[file]; @@ -88,29 +78,9 @@ export function compare(ledger, actual) { return violations; } -function render(ledger, actual) { - const specs = {}; - for (const file of Object.keys(actual).sort()) { - specs[file] = { - tests: actual[file], - electron: ledger.specs?.[file]?.electron ?? '', - }; - } - return `${JSON.stringify({ ...ledger, specs }, null, 2)}\n`; -} - -function main(argv) { - const write = argv.includes('--write'); - if (!write && !argv.includes('--check')) { - throw new Error('usage: check-e2e-budget.mjs [--check | --write]'); - } +function main() { const ledger = JSON.parse(readFileSync(LEDGER_PATH, 'utf8')); const actual = collectSpecs(); - if (write) { - writeFileSync(LEDGER_PATH, render(ledger, actual)); - console.log('E2E budget updated; fill in any empty `electron` reason by hand.'); - return; - } const violations = compare(ledger, actual); if (violations.length === 0) { const total = Object.values(actual).reduce((sum, count) => sum + count, 0); @@ -123,8 +93,6 @@ function main(argv) { ...violations.map((line) => `- ${line}`), '', ...(ledger.policy ?? []), - '', - 'Counts: npm run check:e2e-budget:write', ].join('\n'), ); process.exit(1); @@ -132,7 +100,7 @@ function main(argv) { if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) { try { - main(process.argv.slice(2)); + main(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/apps/desktop/scripts/check-e2e-budget.test.mjs b/apps/desktop/scripts/check-e2e-budget.test.mjs index ea6297d0ac..27fb010edc 100644 --- a/apps/desktop/scripts/check-e2e-budget.test.mjs +++ b/apps/desktop/scripts/check-e2e-budget.test.mjs @@ -21,19 +21,18 @@ import { deepEqual, equal, throws } from 'node:assert/strict'; import { test } from 'node:test'; import { compare, countSpecTests } from './check-e2e-budget.mjs'; -test('counts the test-creating forms and ignores the configuring ones', () => { +test('counts the top-level tests and nothing nested inside them', () => { const source = [ "import { test } from './fixtures';", - 'test.setTimeout(120_000);', "test('one', async () => {});", - "test.skip('two', async () => {});", - ' test("nested call inside a body", 1);', - "test.only('three', async () => {});", + ' test.setTimeout(120_000);', + ' await expect.poll(() => test(1));', + "test('two', async () => {});", ].join('\n'); - equal(countSpecTests(source, 'sample.spec.ts'), 3); + equal(countSpecTests(source, 'sample.spec.ts'), 2); }); -test('refuses a form whose test count cannot be read off the top level', () => { +test('refuses a top-level form whose test count it cannot read', () => { throws( () => countSpecTests("test.describe('group', () => {});", 'sample.spec.ts'), /unrecognised top-level `test\.describe\(`/u, @@ -43,7 +42,7 @@ test('refuses a form whose test count cannot be read off the top level', () => { test('reports a spec that is missing from the budget', () => { deepEqual( compare({ specs: {} }, { 'new.spec.ts': 1 }), - ['new.spec.ts: not in the budget -- add it with a reason it needs a real window'], + ['new.spec.ts: not in the budget (1 test(s)) -- add it with a reason it needs a real window'], ); }); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 3dd9c636de..cb46e8ea0c 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2914,7 +2914,6 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { const pickerIsShowing = () => canvas.queryByRole('list', { name: '打开工具' }) !== null; const face = await bar.findByRole('tab', { selected: true }); - const faceLabel = face.textContent; expect(canvas.queryByRole('toolbar', { name: '工作区辅助操作' })).toBeNull(); expect(pickerIsShowing()).toBe(false); @@ -2946,7 +2945,6 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { // are reading stays on screen while you pick another one. await userEvent.click(bar.getByRole('button', { name: '打开或关闭工作栏的面' })); const menu = await within(document.body).findByRole('menu'); - expect(frame).toBeVisible(); await userEvent.keyboard('{Escape}'); await waitFor(() => expect(menu).not.toBeVisible()); expect(pickerIsShowing()).toBe(false); @@ -2961,8 +2959,6 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { await userEvent.click(restore); await waitFor(() => expect(frame).toBeVisible()); expect(pickerIsShowing()).toBe(false); - const restoredFace = bar.getByRole('tab', { selected: true }); - expect(restoredFace.textContent).toBe(faceLabel); const restoredToggleBox = bar .getByRole('button', { name: '收起任务工作栏' }) .getBoundingClientRect(); diff --git a/package.json b/package.json index fbeb5e1f7d..862fe01018 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "format": "biome format --write .", "format:check": "biome format .", "check:e2e-budget": "npm --workspace @maka/desktop run check:e2e-budget", - "check:e2e-budget:write": "npm --workspace @maka/desktop run check:e2e-budget:write", "check:renderer-architecture": "npm --workspace @maka/desktop run check:architecture --", "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run build:test && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", From 57b1dae273438220df279fcf6e2075a2130435b4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 11:06:05 +0800 Subject: [PATCH 7/8] test(desktop): drop two duplicate renders and a duplicate guard case Second removal pass, over the tests rather than the code. `BrowserAtColumnFloor` and `BrowserAt400` were pixel stories with no play and no state of their own. `BrowserAddressFieldTracksColumnWidth` uses the same fixture and ends its play at 320px, so the floor was already on screen under a name that says what it proves; 400px sits between two widths with no rule between them, so it showed nothing 320 and 480 do not. Four theme renders in the smoke for a duplicate. The budget guard had two tests over `compare`, one per violation kind. They are one obligation -- the ledger and the tier disagree -- so they are one test with all four disagreements in it. Two claims in comments were false rather than redundant: `workbar/side-chat.css` still sent the reader to `session-workbar.spec.ts` for the `overflow: visible` assertion that now lives in the story, and the budget's reason for `settings.spec.ts` justified two of its four tests. The workbar-chrome one rides along on a window the other tests already need; it is recorded that way instead of being described as something it is not. Refs #4761 Generated-by: Claude Code --- apps/desktop/e2e-budget.json | 2 +- apps/desktop/scripts/check-e2e-budget.test.mjs | 12 +++--------- .../src/renderer/styles/workbar/side-chat.css | 2 +- apps/desktop/stories/session-workbar.stories.tsx | 16 +++------------- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 87c25681d0..3eb9865bb9 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -35,7 +35,7 @@ }, "settings.spec.ts": { "tests": 4, - "electron": "the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write" + "electron": "the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write; the workbar-chrome test rides along on that window and would move to a story the day app-shell.tsx's settings wiring has one" }, "sidebar-project-reload.spec.ts": { "tests": 1, diff --git a/apps/desktop/scripts/check-e2e-budget.test.mjs b/apps/desktop/scripts/check-e2e-budget.test.mjs index 27fb010edc..a6de711f77 100644 --- a/apps/desktop/scripts/check-e2e-budget.test.mjs +++ b/apps/desktop/scripts/check-e2e-budget.test.mjs @@ -39,14 +39,7 @@ test('refuses a top-level form whose test count it cannot read', () => { ); }); -test('reports a spec that is missing from the budget', () => { - deepEqual( - compare({ specs: {} }, { 'new.spec.ts': 1 }), - ['new.spec.ts: not in the budget (1 test(s)) -- add it with a reason it needs a real window'], - ); -}); - -test('reports a drifted count, an empty reason, and a deleted spec', () => { +test('reports every way the tier and the budget can disagree', () => { deepEqual( compare( { @@ -56,11 +49,12 @@ test('reports a drifted count, an empty reason, and a deleted spec', () => { 'gone.spec.ts': { tests: 1, electron: 'needs a window' }, }, }, - { 'drifted.spec.ts': 2, 'blank.spec.ts': 1 }, + { 'drifted.spec.ts': 2, 'blank.spec.ts': 1, 'new.spec.ts': 1 }, ), [ 'drifted.spec.ts: budget records 1 test(s), the file has 2', 'blank.spec.ts: no reason recorded for needing a real Electron window', + 'new.spec.ts: not in the budget (1 test(s)) -- add it with a reason it needs a real window', 'gone.spec.ts: in the budget but no longer on disk', ], ); diff --git a/apps/desktop/src/renderer/styles/workbar/side-chat.css b/apps/desktop/src/renderer/styles/workbar/side-chat.css index b041086329..e8c4f61894 100644 --- a/apps/desktop/src/renderer/styles/workbar/side-chat.css +++ b/apps/desktop/src/renderer/styles/workbar/side-chat.css @@ -64,7 +64,7 @@ bubble; a side-only radius token forked the pair the moment the bubbles moved to the primitive's default. The wrapper is also left unclipped — `overflow: hidden` here would hide the body's hover/focus shadow against the white side - panel, and `session-workbar.spec.ts` asserts `overflow: visible`. */ + panel, and `session-workbar.stories.tsx` asserts `overflow: visible`. */ .maka-session-workbar-panel[data-placement="right"] .maka-quote-companion diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index c18f166df7..55a689ff6f 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -1247,21 +1247,11 @@ export const BrowserInsecure: Story = { render: () => , }; -// The column's 320px floor — the least room the toolbar row ever gets. -export const BrowserAtColumnFloor: Story = { - decorators: [bridge({ browserState: LOADED_BROWSER_STATE })], - render: () => , -}; - -// The width the resize handle lands on most often, between the floor and default. -export const BrowserAt400: Story = { - decorators: [bridge({ browserState: LOADED_BROWSER_STATE })], - render: () => , -}; - // #2188: the address field, not the nav buttons, absorbs the column's free // width. The rule reaches into Astryx Toolbar's slot div, so an upstream -// slot-wrapper change regresses it silently. +// slot-wrapper change regresses it silently. This is also the floor's pixel +// story: the play leaves the column at 320px, the least room the toolbar row +// ever gets. export const BrowserAddressFieldTracksColumnWidth: Story = { decorators: [bridge({ browserState: LOADED_BROWSER_STATE })], render: () => , From e8fa69a1b2fdd10bf958a7d34e3f9b8f103845be Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 11:33:21 +0800 Subject: [PATCH 8/8] test(desktop): close the budget guard's blind spots and the coverage it cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the branch. Three findings were real defects in this change; the rest were narrower than reported. The budget guard did not bound the tier it claimed to bound. `playwright.config.ts` sets `testDir: '.'` with no `testMatch`, so the tier is Playwright's default pattern — recursive, and `.test.ts` as well as `.spec.ts`. The guard read one flat directory for one suffix, and counted only `test(` at column 0. Three ways in, all silent: a subdirectory, the other suffix, and a loop or helper generating tests. Root cause is one seam, not three holes: the guard and the ledger were both written from a single reading of today's 16 files and validated against that same reading, so neither could catch the other. Discovery now mirrors Playwright's own pattern, and a `test(` this scanner cannot attribute to a line is refused the way `test.describe` already was, rather than counted as zero. Deleting `fixture-thread-search.spec.ts` cost two assertions the main-process test did not carry. Its fake catalog returned a bare Runtime Host id where the real one returns a composed Desktop key, and its single-message transcript made `sequence: 0` pass for any projection. Restoring `sequence: messageIndex` as the only value that passes: `packages/core/src/thread-search.ts` forced to `sequence: 0` now turns the unit test red. Without this, a search hit could open the wrong message, or the wrong Session on a second Host. `WorkbarCollapseKeepsOneToggleInPlace` asserted the workbar frame came back but not the face inside it. The face's content is a sibling panel with its own `hidden` (workbar-surface.tsx), so a frame-visible check passes while the face stays hidden — the exact regression the migrated E2E covered. Smaller corrections: `expect.soft` from `storybook/test` is not soft (it builds the assertion before setting the flag, and needs a vitest test context the play function has not got), so it is plain `expect`; the Side Chat story waits for its companion instead of reading it synchronously; the budget's reason for `session-workbar.spec.ts` now covers all five of its tests including the renderer-only one riding along; and three notes this branch made stale — a four-worker CI aside, a story pointing at the retired E2E assertion, and `fullyParallel` under `workers: 1` — are corrected or removed. Left alone: the Side Chat radius story now pins the side composer against `--radius-chat` rather than against the main composer, so a main-composer drift is unguarded. Adding that probe belongs to a main-composer story, not this one; recorded on #2188. Refs #4761 Generated-by: Claude Code --- apps/desktop/e2e-budget.json | 2 +- apps/desktop/e2e/playwright.config.ts | 1 - .../e2e/transcript-scroll-cost.spec.ts | 2 +- apps/desktop/scripts/check-e2e-budget.mjs | 53 ++++++++++++++----- .../desktop/scripts/check-e2e-budget.test.mjs | 38 +++++++++++-- .../runtime-host-search-ipc-main.test.ts | 26 +++++++-- .../stories/agent-graph-panel.stories.tsx | 4 +- apps/desktop/stories/app-shell.stories.tsx | 13 ++++- .../stories/session-workbar.stories.tsx | 16 +++--- 9 files changed, 121 insertions(+), 34 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 3eb9865bb9..383cff4e3c 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -31,7 +31,7 @@ }, "session-workbar.spec.ts": { "tests": 5, - "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, and Side Chat's fork lifecycle -- all main-process resources" + "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, and a first send that has to reach the Host; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host" }, "settings.spec.ts": { "tests": 4, diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts index 568477c48d..df971180a4 100644 --- a/apps/desktop/e2e/playwright.config.ts +++ b/apps/desktop/e2e/playwright.config.ts @@ -35,7 +35,6 @@ import { defineConfig } from '@playwright/test'; */ export default defineConfig({ testDir: '.', - fullyParallel: true, workers: 1, // CI publishes no Playwright report that consumes Git metadata. Disable its // best-effort shallow-history fetch, which otherwise waits on a fixed timeout. diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 9762f7948e..b6954c2486 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -268,7 +268,7 @@ test('paging back through the whole history keeps the mounted range bounded', as // Coming back from the far end is a range reload, not a scroll: the Host // resolves a new window around the tail and the renderer mounts it. The // suite's 10s expect timeout is sized for UI that is already on screen, and - // this step measured past it on a CI runner with four workers competing. + // this step measured past it on a loaded CI runner. await returnToLatest(page); await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) .toHaveCount(1, { timeout: 30_000 }); diff --git a/apps/desktop/scripts/check-e2e-budget.mjs b/apps/desktop/scripts/check-e2e-budget.mjs index 0cd3e4fc6f..f1bda5b891 100644 --- a/apps/desktop/scripts/check-e2e-budget.mjs +++ b/apps/desktop/scripts/check-e2e-budget.mjs @@ -18,7 +18,7 @@ */ import { readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const SCRIPT_PATH = fileURLToPath(import.meta.url); @@ -26,19 +26,32 @@ const DESKTOP_ROOT = resolve(dirname(SCRIPT_PATH), '..'); const LEDGER_PATH = join(DESKTOP_ROOT, 'e2e-budget.json'); const E2E_ROOT = join(DESKTOP_ROOT, 'e2e'); -// Every spec writes its tests as a bare `test(` at column 0. Any other -// top-level `test.` form would need a rule of its own -- `test.describe` nests -// its tests where this scanner cannot see them -- so it is refused rather than -// silently undercounted. +// Playwright's own default `testMatch`, because `playwright.config.ts` sets +// `testDir: '.'` and overrides neither: anything this pattern misses would run +// in the tier while the budget stayed silent about it. +const SPEC_PATTERN = /\.(?:spec|test)\.[cm]?[jt]sx?$/u; + +// Every spec writes its tests as a bare `test(` at column 0. A dotted top-level +// form would need a counting rule of its own (`test.describe` nests its tests +// where a line scanner cannot see them), and an indented `test(` is a test this +// scanner cannot attribute -- a loop or a helper generating them counts as +// zero. Both are refused rather than silently undercounted. export function countSpecTests(source, file) { let tests = 0; for (const [index, line] of source.split(/\r?\n/u).entries()) { - const match = /^test(\.[A-Za-z]+)?\s*\(/u.exec(line); - if (!match) continue; - if (match[1] === undefined) tests += 1; - else { + const top = /^test(\.[A-Za-z]+)?\s*\(/u.exec(line); + if (top) { + if (top[1] === undefined) tests += 1; + else { + throw new Error( + `${file}:${index + 1}: unrecognised top-level \`test${top[1]}(\` -- teach check-e2e-budget.mjs how many tests it creates`, + ); + } + continue; + } + if (/\btest\s*\(/u.test(line)) { throw new Error( - `${file}:${index + 1}: unrecognised top-level \`test${match[1]}(\` -- teach check-e2e-budget.mjs how many tests it creates`, + `${file}:${index + 1}: \`test(\` away from column 0 -- check-e2e-budget.mjs cannot count it`, ); } } @@ -47,10 +60,22 @@ export function countSpecTests(source, file) { export function collectSpecs(root = E2E_ROOT) { const specs = {}; - for (const file of readdirSync(root).sort()) { - if (!file.endsWith('.spec.ts')) continue; - specs[file] = countSpecTests(readFileSync(join(root, file), 'utf8'), file); - } + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + if (entry.name === 'node_modules') continue; + const absolute = join(directory, entry.name); + if (entry.isDirectory()) { + walk(absolute); + continue; + } + if (!SPEC_PATTERN.test(entry.name)) continue; + const file = relative(root, absolute).replaceAll('\\', '/'); + specs[file] = countSpecTests(readFileSync(absolute, 'utf8'), file); + } + }; + walk(root); return specs; } diff --git a/apps/desktop/scripts/check-e2e-budget.test.mjs b/apps/desktop/scripts/check-e2e-budget.test.mjs index a6de711f77..1f7be98a91 100644 --- a/apps/desktop/scripts/check-e2e-budget.test.mjs +++ b/apps/desktop/scripts/check-e2e-budget.test.mjs @@ -19,14 +19,16 @@ import { deepEqual, equal, throws } from 'node:assert/strict'; import { test } from 'node:test'; -import { compare, countSpecTests } from './check-e2e-budget.mjs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { collectSpecs, compare, countSpecTests } from './check-e2e-budget.mjs'; -test('counts the top-level tests and nothing nested inside them', () => { +test('counts the top-level tests and nothing configuring them', () => { const source = [ "import { test } from './fixtures';", "test('one', async () => {});", ' test.setTimeout(120_000);', - ' await expect.poll(() => test(1));', "test('two', async () => {});", ].join('\n'); equal(countSpecTests(source, 'sample.spec.ts'), 2); @@ -39,6 +41,36 @@ test('refuses a top-level form whose test count it cannot read', () => { ); }); +// A loop or a helper creates tests Playwright runs and this scanner cannot +// attribute. Counting them as zero is how the tier grows back in silence. +test('refuses a `test(` it cannot attribute to a line of its own', () => { + throws( + () => countSpecTests('for (const n of [1, 2]) {\n test(`generated ${n}`, fn);\n}', 'a.spec.ts'), + /`test\(` away from column 0/u, + ); +}); + +// playwright.config.ts sets `testDir: '.'` and no `testMatch`, so the tier is +// everything Playwright's default pattern reaches -- subdirectories and the +// `.test.ts` suffix included. +test('finds every file Playwright would run, not just top-level .spec.ts', () => { + const root = mkdtempSync(join(tmpdir(), 'maka-e2e-budget-')); + try { + mkdirSync(join(root, 'nested')); + writeFileSync(join(root, 'plain.spec.ts'), "test('a', fn);\n"); + writeFileSync(join(root, 'suffix.test.ts'), "test('b', fn);\n"); + writeFileSync(join(root, 'nested', 'deep.spec.ts'), "test('c', fn);\ntest('d', fn);\n"); + writeFileSync(join(root, 'fixtures.ts'), "test('not a spec file', fn);\n"); + deepEqual(collectSpecs(root), { + 'nested/deep.spec.ts': 2, + 'plain.spec.ts': 1, + 'suffix.test.ts': 1, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('reports every way the tier and the budget can disagree', () => { deepEqual( compare( diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts index 3f127d52a9..8ddd3391a7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts @@ -24,6 +24,16 @@ import type { SessionCatalogProjection } from '@maka/runtime-host/protocol'; import type { IpcHandler } from '../ipc-reconnect-policy.js'; import type { DesktopRuntimeHostClient } from '../runtime-host-client.js'; import { registerRuntimeHostSearchIpc } from '../runtime-host-search-ipc-main.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; + +// The catalog hands search a composed Desktop key, not a bare Runtime Host id. +// Naming it here is what makes the passthrough in runtime-host-search-ipc-main +// observable: a hit whose target carried the bare id would open nothing on a +// second Host. +const SEARCHABLE_SESSION = desktopSessionKey({ + hostId: 'host-b', + sessionId: 'searchable-session', +}); test('Runtime Host transcripts produce title and content hits with turn ids', async () => { const handlers = new Map(); @@ -38,15 +48,21 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as }, }, client: searchClient({ - listSessions: async () => [catalogSession('searchable-session', '长对话提示词导航示例')], + listSessions: async () => [catalogSession(SEARCHABLE_SESSION, '长对话提示词导航示例')], openSession: async () => ({ + // Three earlier messages so the hit's `sequence` is its real position + // in the transcript. With a single message every projection, correct + // or not, reports 0. loadTranscript: async () => [ + { type: 'user', id: 'host-user-0', turnId: 'turn-host-0', ts: 1, text: '第 0 个问题' }, + { type: 'assistant', id: 'host-reply-0', turnId: 'turn-host-0', ts: 2, text: '回答 0' }, + { type: 'user', id: 'host-user-1', turnId: 'turn-host-1', ts: 3, text: '第 1 个问题' }, { type: 'user', id: 'host-user', turnId: 'turn-host-3', - ts: 1, + ts: 4, text: '第 3 个问题:这一段的调用链路是怎样的?', }, ], @@ -69,7 +85,7 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as assert.equal(titleHits[0]?.summary, '任务标题'); assert.deepEqual(titleHits[0]?.target, { kind: 'thread', - sessionId: 'searchable-session', + sessionId: SEARCHABLE_SESSION, }); const contentHits = expectResults( @@ -83,9 +99,9 @@ test('Runtime Host transcripts produce title and content hits with turn ids', as assert.equal(contentHits[0]?.summary, '用户消息'); assert.deepEqual(contentHits[0]?.target, { kind: 'thread', - sessionId: 'searchable-session', + sessionId: SEARCHABLE_SESSION, turnId: 'turn-host-3', - sequence: 0, + sequence: 3, }); assert.equal(closed, 2); }); diff --git a/apps/desktop/stories/agent-graph-panel.stories.tsx b/apps/desktop/stories/agent-graph-panel.stories.tsx index f9de92b649..dcae4eed15 100644 --- a/apps/desktop/stories/agent-graph-panel.stories.tsx +++ b/apps/desktop/stories/agent-graph-panel.stories.tsx @@ -142,8 +142,8 @@ function graphBridge(snap: AgentGraphClientSnapshot, fail = false) { // `.maka-detail-with-artifacts`). Reuse those wrapper classes so the panel // inherits the composer column's seam rather than an arbitrary fixed box. The // full AppShell grid and Composer chrome around it are not rebuilt here — that -// seam lives in Product/Shell Official AppShell, and its geometry is pinned by -// e2e/session-workbar.spec.ts — so this isolates the panel itself at a +// seam lives in Product/Shell Official AppShell, where its geometry is pinned +// by that group's own play functions — so this isolates the panel itself at a // composer-column width. function panel() { return ( diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index cb46e8ea0c..89b953e9e9 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2891,7 +2891,10 @@ function WorkbarInShell() { ); } -// Real path: 展开任务工作栏 → 文件 → 收起 → 从标题栏再展开. +// Real path: 收起一个开着面的工作栏 → 从标题栏再展开. The face is opened by +// dispatching the app's own `open` action rather than by clicking through the +// launcher, so the story starts where the app does without re-testing the +// launcher's own path. // // The collapse toggle is one control that moves between two bands — the // workbar's own bar and the titlebar's right cluster — and `workbar/shell.css` @@ -2907,6 +2910,12 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { '[data-maka-contract="session-workbar-right"]', ); if (!frame) throw new Error('the right workbar is missing'); + // The face's content is a sibling overlay panel with its own `hidden` + // (workbar-surface.tsx), so a visible frame does not mean a visible face. + const facePanel = canvasElement.querySelector( + '.maka-session-workbar-panel[data-overlay][data-placement="right"]', + ); + if (!facePanel) throw new Error('the open face has no panel'); const bar = within(frame.querySelector('.maka-session-workbar-toolbar')!); const collapse = bar.getByRole('button', { name: '收起任务工作栏' }); // The launcher stays mounted behind the face, so "not the picker" is a @@ -2952,12 +2961,14 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { await userEvent.click(collapse); const restore = await canvas.findByRole('button', { name: '展开任务工作栏' }); await waitFor(() => expect(frame).not.toBeVisible()); + expect(facePanel).not.toBeVisible(); const restoreBox = restore.getBoundingClientRect(); expect(Math.abs(restoreBox.x - parked.x)).toBeLessThanOrEqual(1); expect(Math.abs(restoreBox.y - parked.y)).toBeLessThanOrEqual(1); await userEvent.click(restore); await waitFor(() => expect(frame).toBeVisible()); + expect(facePanel).toBeVisible(); expect(pickerIsShowing()).toBe(false); const restoredToggleBox = bar .getByRole('button', { name: '收起任务工作栏' }) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 55a689ff6f..4c25fc3043 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -1042,8 +1042,8 @@ export const ToolPickerAtColumnFloor: Story = { expect(shortcuts.length).toBeGreaterThan(0); for (const shortcut of shortcuts) { const box = shortcut.getBoundingClientRect(); - expect.soft(box.left).toBeGreaterThanOrEqual(panelBox.left); - expect.soft(box.right).toBeLessThanOrEqual(panelBox.right); + expect(box.left).toBeGreaterThanOrEqual(panelBox.left); + expect(box.right).toBeLessThanOrEqual(panelBox.right); } }, }; @@ -1365,10 +1365,14 @@ export const SideChatAtColumnFloor: Story = { decorators: [bridge()], render: () => , play: async ({ canvasElement }) => { - const companion = canvasElement.querySelector('.maka-quote-companion'); - if (!companion) throw new Error('side chat companion is missing'); - const card = companion.querySelector('.maka-composer-astryx'); - if (!card) throw new Error('side chat composer card is missing'); + const companion = await waitFor(() => { + const found = canvasElement.querySelector('.maka-quote-companion'); + if (!found?.querySelector('.maka-composer-astryx')) { + throw new Error('side chat companion is missing'); + } + return found; + }); + const card = companion.querySelector('.maka-composer-astryx')!; expect(getComputedStyle(card).overflow).toBe('visible');