diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a34d0ea26d..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 @@ -357,37 +363,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-budget.json b/apps/desktop/e2e-budget.json new file mode 100644 index 0000000000..383cff4e3c --- /dev/null +++ b/apps/desktop/e2e-budget.json @@ -0,0 +1,73 @@ +{ + "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." + ], + "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, 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, + "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, + "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/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 aa5f733143..b3eac80252 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -523,34 +523,13 @@ type E2eTestFixtures = { parentRemovalWindow: Page; railRenderWindow: Page; promptRailWindow: Page; - threadSearchWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; 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 }, @@ -661,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 diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts index 00f23ac63d..df971180a4 100644 --- a/apps/desktop/e2e/playwright.config.ts +++ b/apps/desktop/e2e/playwright.config.ts @@ -22,26 +22,19 @@ 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({ 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/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 22a6ae701d..34bad85d39 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -143,219 +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, -}) => { - 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/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/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/package.json b/apps/desktop/package.json index ac41d0e15e..6d8cb8e6f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,6 +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", "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..f1bda5b891 --- /dev/null +++ b/apps/desktop/scripts/check-e2e-budget.mjs @@ -0,0 +1,133 @@ +/* + * 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 } from 'node:fs'; +import { dirname, join, relative, 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'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 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}: \`test(\` away from column 0 -- check-e2e-budget.mjs cannot count it`, + ); + } + } + return tests; +} + +export function collectSpecs(root = E2E_ROOT) { + const specs = {}; + 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; +} + +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 (${actual[file]} test(s)) -- 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 main() { + const ledger = JSON.parse(readFileSync(LEDGER_PATH, 'utf8')); + const actual = collectSpecs(); + 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 ?? []), + ].join('\n'), + ); + process.exit(1); +} + +if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) { + try { + 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 new file mode 100644 index 0000000000..1f7be98a91 --- /dev/null +++ b/apps/desktop/scripts/check-e2e-budget.test.mjs @@ -0,0 +1,93 @@ +/* + * 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 { 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 configuring them', () => { + const source = [ + "import { test } from './fixtures';", + "test('one', async () => {});", + ' test.setTimeout(120_000);', + "test('two', async () => {});", + ].join('\n'); + equal(countSpecTests(source, 'sample.spec.ts'), 2); +}); + +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, + ); +}); + +// 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( + { + 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, '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/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/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/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/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 419f92dc26..89b953e9e9 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 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` +// 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'); + // 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 + // claim about reachability: `queryByRole` skips the inactive panel. + const pickerIsShowing = () => + canvas.queryByRole('list', { name: '打开工具' }) !== null; + const face = await bar.findByRole('tab', { selected: true }); + + 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'); + 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()); + 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: '收起任务工作栏' }) + .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 168856188c..4c25fc3043 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 @@ -1027,6 +1026,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(box.left).toBeGreaterThanOrEqual(panelBox.left); + expect(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. @@ -1226,16 +1247,35 @@ export const BrowserInsecure: Story = { render: () => , }; -// The column's 320px floor — the least room the toolbar row ever gets. -export const BrowserAtColumnFloor: Story = { +// #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. 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: () => , -}; + 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; + }; -// 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: () => , + 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 @@ -1320,6 +1360,52 @@ 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 = 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'); + + // 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, + ); + }, +}; diff --git a/package.json b/package.json index 43c10a5ee5..862fe01018 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "lint": "biome lint .", "format": "biome format --write .", "format:check": "biome format .", + "check:e2e-budget": "npm --workspace @maka/desktop run check:e2e-budget", "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",