diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fd1fbf963..f96d05ed60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }} script_mode: ${{ steps.plan.outputs.script_mode }} storage_stress: ${{ steps.plan.outputs.storage_stress }} + storybook: ${{ steps.plan.outputs.storybook }} unit: ${{ steps.plan.outputs.unit }} workspaces: ${{ steps.plan.outputs.workspaces }} steps: @@ -161,15 +162,26 @@ jobs: # e2e step just built; same xvfb pattern. - name: Alignment audit run: xvfb-run -a node scripts/audit-alignment.mjs - # Storybook is where pixel work happens (see stories/FIDELITY.md), but - # nothing verified that it still renders: story typechecking rides along - # in `typecheck`, while the static build and the render/play smoke ran - # only if someone remembered to run them locally. - # - # The E2E suite above drives Electron through `_electron.launch`, which - # uses Electron's own binary — it never downloads a browser. The smoke - # calls `chromium.launch()`, so the Chromium build must be fetched here - # explicitly; without it the step fails on a missing executable. + + # Storybook build + render smoke. Independent of Electron e2e: the smoke + # launches Playwright Chromium, not `_electron.launch`, so it does not + # need xvfb or the desktop cold-start suite. See stories/FIDELITY.md. + storybook: + needs: changes + if: needs.changes.outputs.storybook == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + # Stories import @maka/core / @maka/ui package exports (dist/). The old + # e2e job paid for this via `build:with-deps`; the split job must too. + - name: Build workspace packages + run: npm --workspace @maka/desktop run build:workspace-deps + # The smoke calls `chromium.launch()`; Electron's binary is irrelevant. - name: Install Playwright Chromium run: npx playwright install --with-deps chromium - name: Build Storybook diff --git a/apps/desktop/e2e/bot-onboarding.spec.ts b/apps/desktop/e2e/bot-onboarding.spec.ts index e839574848..f38f7adc45 100644 --- a/apps/desktop/e2e/bot-onboarding.spec.ts +++ b/apps/desktop/e2e/bot-onboarding.spec.ts @@ -18,37 +18,8 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘 const qr = dialog.getByRole('img', { name: '配置钉钉二维码' }); await expect(qr).toHaveAttribute('src', /^data:image\/png;base64,/); await expect(dialog.getByText('请使用钉钉扫描二维码并确认授权')).toBeVisible(); - - // A QR code the user cannot fully see cannot be scanned. What that requires is - // a relationship, not a fixed size: the image fills its frame (so it is never - // shrunk to a corner of it), the frame is square, the code is wholly on screen - // at a scannable size, and the dialog stays inside the window. The frame's - // 284px and the dialog's 522px cap are the design tokens; these are not. - // - // `toBeInViewport()` alone would NOT carry this: its default `ratio: 0` passes - // on any positive intersection, so a QR with one corner on screen clears it. - const dialogBox = await dialog.boundingBox(); - const qrFrameBox = await dialog.locator('.settingsBotOnboardingQrFrame').boundingBox(); - const qrBox = await qr.boundingBox(); - const viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); - expect(dialogBox).not.toBeNull(); - expect(qrFrameBox).not.toBeNull(); - expect(qrBox).not.toBeNull(); - - // The image fills its frame, up to the frame's 1px border on each side. - expect(qrFrameBox!.width - qrBox!.width).toBeLessThanOrEqual(2); - expect(qrFrameBox!.height - qrBox!.height).toBeLessThanOrEqual(2); - // Square, and large enough for a phone camera to resolve the modules. - expect(Math.abs(qrBox!.width - qrBox!.height)).toBeLessThanOrEqual(1); - expect(qrBox!.width).toBeGreaterThanOrEqual(160); - // Centred in the dialog it belongs to. - expect( - Math.abs((dialogBox!.x + dialogBox!.width / 2) - (qrBox!.x + qrBox!.width / 2)), - ).toBeLessThan(2); - // Wholly on screen — every edge, not merely intersecting. - await expect(qr).toBeInViewport({ ratio: 1 }); - expect(dialogBox!.y).toBeGreaterThanOrEqual(0); - expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewport.height); + // QR square / fill-frame geometry is pinned in chat-shell-layout-contract + // (settingsBotOnboardingQrFrame CSS). This journey owns session + secret isolation. await expect(dialog.getByText('已扫码,请在钉钉中完成确认')).toBeVisible({ timeout: 4_000 }); await expect(dialog.getByText('钉钉 已连接')).toBeVisible({ timeout: 5_000 }); diff --git a/apps/desktop/e2e/chat-chrome-style.spec.ts b/apps/desktop/e2e/chat-chrome-style.spec.ts deleted file mode 100644 index c47fcd450c..0000000000 --- a/apps/desktop/e2e/chat-chrome-style.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -type Platform = 'darwin' | 'win32'; - -interface ShellFacts { - viewport: { width: number; height: number }; - shell: DOMRectFacts; - titlebar: DOMRectFacts; - sidebar: DOMRectFacts; - detail: DOMRectFacts; - backgrounds: { - shellImage: string; - sidebarImage: string; - detailImage: string; - }; - effects: { - shellShadow: string; - sidebarShadow: string; - detailShadow: string; - shellFilter: string; - sidebarFilter: string; - detailFilter: string; - }; - opacity: { - shell: number; - sidebar: number; - detail: number; - }; -} - -interface DOMRectFacts { - top: number; - right: number; - bottom: number; - left: number; - width: number; - height: number; -} - -function closeTo(actual: number, expected: number, message: string): void { - expect(Math.abs(actual - expected), `${message}: actual=${actual}, expected=${expected}`).toBeLessThanOrEqual(1); -} - -async function prepareWindow(page: Page, platform: Platform): Promise { - await expect(page.locator('html')).toHaveAttribute('data-os', platform); - const shell = page.locator('.maka-shell-astryx'); - if ((await shell.getAttribute('data-sidebar-state')) === 'collapsed') { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - } - await expect(shell).toHaveAttribute('data-sidebar-state', 'expanded'); -} - -async function readShellFacts(page: Page): Promise { - return page.evaluate(() => { - const shell = document.querySelector('.maka-shell-astryx'); - const titlebar = document.querySelector('.maka-window-titlebar'); - const sidebar = document.querySelector('.maka-session-panel'); - const detail = document.querySelector('.maka-panel-detail'); - if (!shell || !titlebar || !sidebar || !detail) { - throw new Error('official shell surface disappeared mid-read'); - } - - const rect = (element: HTMLElement): DOMRectFacts => { - const value = element.getBoundingClientRect(); - return { - top: value.top, - right: value.right, - bottom: value.bottom, - left: value.left, - width: value.width, - height: value.height, - }; - }; - const shellStyle = getComputedStyle(shell); - const sidebarStyle = getComputedStyle(sidebar); - const detailStyle = getComputedStyle(detail); - - return { - viewport: { width: innerWidth, height: innerHeight }, - shell: rect(shell), - titlebar: rect(titlebar), - sidebar: rect(sidebar), - detail: rect(detail), - backgrounds: { - shellImage: shellStyle.backgroundImage, - sidebarImage: sidebarStyle.backgroundImage, - detailImage: detailStyle.backgroundImage, - }, - effects: { - shellShadow: shellStyle.boxShadow, - sidebarShadow: sidebarStyle.boxShadow, - detailShadow: detailStyle.boxShadow, - shellFilter: shellStyle.filter, - sidebarFilter: sidebarStyle.filter, - detailFilter: detailStyle.filter, - }, - opacity: { - shell: Number(shellStyle.opacity), - sidebar: Number(sidebarStyle.opacity), - detail: Number(detailStyle.opacity), - }, - }; - }); -} - -function assertOfficialShell(facts: ShellFacts, tag: string): void { - closeTo(facts.shell.left, 0, `${tag} shell left`); - closeTo(facts.shell.top, 0, `${tag} shell top`); - closeTo(facts.shell.right, facts.viewport.width, `${tag} shell right`); - closeTo(facts.shell.bottom, facts.viewport.height, `${tag} shell bottom`); - - closeTo(facts.sidebar.top, facts.titlebar.bottom, `${tag} sidebar starts below titlebar`); - closeTo(facts.detail.top, facts.titlebar.bottom, `${tag} detail starts below titlebar`); - closeTo(facts.sidebar.right, facts.detail.left, `${tag} sidebar and detail share one edge`); - closeTo(facts.detail.right, facts.viewport.width, `${tag} detail right`); - for (const [surface, bottom] of Object.entries({ - sidebar: facts.sidebar.bottom, - detail: facts.detail.bottom, - })) { - const platformBottomInset = facts.viewport.height - bottom; - expect(platformBottomInset, `${tag} ${surface} must not overflow the viewport`).toBeGreaterThanOrEqual(0); - expect(platformBottomInset, `${tag} ${surface} must not leave a product-sized bottom gutter`).toBeLessThanOrEqual(12); - } - expect(facts.sidebar.width, `${tag} official SideNav width`).toBeGreaterThanOrEqual(180); - expect(facts.detail.width, `${tag} detail must retain usable width`).toBeGreaterThan(0); - - for (const [surface, image] of Object.entries(facts.backgrounds)) { - expect(image, `${tag} ${surface} must not add a custom gradient/image`).toBe('none'); - } - for (const [surface, shadow] of Object.entries({ - shell: facts.effects.shellShadow, - sidebar: facts.effects.sidebarShadow, - detail: facts.effects.detailShadow, - })) { - expect(shadow, `${tag} ${surface} must stay flat`).toBe('none'); - } - for (const [surface, filter] of Object.entries({ - shell: facts.effects.shellFilter, - sidebar: facts.effects.sidebarFilter, - detail: facts.effects.detailFilter, - })) { - expect(filter, `${tag} ${surface} must not add filter effects`).toBe('none'); - } - for (const [surface, opacity] of Object.entries(facts.opacity)) { - expect(opacity, `${tag} ${surface} must remain fully visible`).toBe(1); - } -} - -test('chat chrome follows the official flat AppShell across platform and theme combos', async ({ - chatChromeDarwinWindow, - chatChromeWin32Window, -}) => { - const windows: Array<{ platform: Platform; page: Page }> = [ - { platform: 'darwin', page: chatChromeDarwinWindow }, - { platform: 'win32', page: chatChromeWin32Window }, - ]; - - for (const { platform, page } of windows) { - await prepareWindow(page, platform); - for (const dark of [false, true]) { - await page.emulateMedia({ colorScheme: dark ? 'dark' : 'light' }); - const html = page.locator('html'); - if (dark) { - await expect(html).toHaveClass(/(?:^| )dark(?:$| )/); - } else { - await expect(html).not.toHaveClass(/(?:^| )dark(?:$| )/); - } - assertOfficialShell(await readShellFacts(page), `${platform}/${dark ? 'dark' : 'light'}`); - } - } -}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index ef3e714c42..ec79faad00 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -218,8 +218,6 @@ export const test = base.extend<{ window: Page; modelPickerLongWindow: Page; longTranscriptWindow: Page; - chatChromeDarwinWindow: Page; - chatChromeWin32Window: Page; sidebarLongSessionsWindow: Page; disclosureOutputWindow: Page; sandboxBoundaryWindow: Page; @@ -227,11 +225,8 @@ export const test = base.extend<{ staleSessionsWindow: Page; sessionWorkbarWindow: Page; botSettingsWindow: Page; + /** #1361: Permissions page with the typed OS-permission snapshot fixture. */ permissionSettingsWindow: Page; - usageSettingsWindow: Page; - searchSettingsWindow: Page; - zhLocaleWindow: Page; - enLocaleWindow: Page; localeSwitchWindow: Page; invocableSkillsWindow: Page; planRemindersWindow: Page; @@ -316,23 +311,6 @@ export const test = base.extend<{ use, ); }, - // Chat-chrome contract (#1312): the long-transcript shell booted with a - // FORCED platform (app:info override), so `data-os` — and with it the - // darwin glass cascade vs the opaque base cascade — is native from the - // first frame on any host. No post-boot attribute flip, which Chromium's - // style recalc resolves relative colors against stale values for. - chatChromeDarwinWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.maka-turn', e2eFixtureScenario: 'long-transcript', locale: 'zh', platform: 'darwin' }, - use, - ); - }, - chatChromeWin32Window: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.maka-turn', e2eFixtureScenario: 'long-transcript', locale: 'zh', platform: 'win32' }, - use, - ); - }, // Sandbox-boundary takeover: boots a deterministic expansion request in the // real desktop shell so the composer-slot placement and non-modal behavior // are covered without a provider or test-only renderer state path. @@ -382,11 +360,9 @@ export const test = base.extend<{ use, ); }, - // #1361: Permission Center with a typed OS-permission snapshot (see - // `main/permission-snapshot-e2e-fixture.ts`). The narrow-layout contract is - // about rows that carry grant buttons, which the host's real TCC state cannot - // guarantee — a granted dev machine renders none, and Linux CI reports most - // permissions as `unsupported`. + // One live window-floor smoke: permission rows with the three-button guided + // screen-recording shape. CSS contracts pin declarations; this measures + // scrollWidth containment at SAFE_MIN_WIDTH (480). permissionSettingsWindow: async ({}, use) => { await withE2eWindow( { @@ -398,52 +374,6 @@ export const test = base.extend<{ use, ); }, - // #1364: Usage with seeded request traffic + details-on settings, so the - // request-log Table actually renders (the default window fixture keeps - // `showDetails` false and has no logs — the table CSS could regress without - // failing anything). - usageSettingsWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - // The tabs bar, not the table: the renderer's first stats fetch can - // race the fixture seeding, so the spec refreshes until the seeded - // request log lands. - readinessSelector: '.settingsUsageTabsBar', - e2eFixtureScenario: 'settings-usage', - locale: 'zh', - }, - use, - ); - }, - // #1364: Web Search with a configured Tavily key; queries are answered by - // the typed fixture in `main/web-search-e2e-fixture.ts` (e2e runs offline), - // so the hostile-width result list is reachable deterministically. - searchSettingsWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.settingsWebSearchQueryInputRow', - e2eFixtureScenario: 'settings-search', - locale: 'zh', - }, - use, - ); - }, - // Representative e2e-fixture renderer launches in both supported locales. - // These use the same production LocaleProvider override path as screenshot capture. - zhLocaleWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.appFrame', e2eFixtureScenario: 'all', locale: 'zh' }, - use, - ); - }, - enLocaleWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.appFrame', e2eFixtureScenario: 'all', locale: 'en' }, - use, - ); - }, // Keep this fixture unpinned so the Follow system assertion observes the // actual host language while the legacy fixtures remain deterministic. localeSwitchWindow: async ({}, use) => { diff --git a/apps/desktop/e2e/floating-layers.spec.ts b/apps/desktop/e2e/floating-layers.spec.ts index fa8e59c694..7883993a16 100644 --- a/apps/desktop/e2e/floating-layers.spec.ts +++ b/apps/desktop/e2e/floating-layers.spec.ts @@ -89,48 +89,11 @@ test('model picker only exposes a rendered active descendant', async ({ await expect(page.locator(`#${activeDescendant}`)).toHaveRole('option'); }); -test('model picker keeps keyboard highlight inside the scroll viewport', async ({ - modelPickerLongWindow: page, -}) => { - await page.getByRole('button', { name: /选择新对话模型/ }).click(); - - const search = page.getByPlaceholder('搜索模型'); - const listbox = page.getByRole('listbox'); - const popup = listbox.locator('xpath=ancestor::*[@popover][1]'); - const options = listbox.getByRole('option'); - const optionCount = await options.count(); - expect(optionCount).toBeGreaterThan(8); - await expect - .poll(() => popup.evaluate((element) => element.getBoundingClientRect().height)) - .toBeLessThanOrEqual(420); - await expect - .poll(() => - listbox.evaluate((element) => element.scrollHeight > element.clientHeight), - ) - .toBe(true); - - for (let index = 0; index < optionCount; index += 1) { - await search.press('ArrowDown'); - } +// Model-picker mark geometry / label ellipsis: CSS contract +// (chat-shell-layout-contract). Listbox scroll-into-view is Astryx-owned. +// Keep focus restore + real session model/thinking persistence below. - const activeDescendant = await search.getAttribute('aria-activedescendant'); - expect(activeDescendant).not.toBeNull(); - const activeOption = page.locator(`[id="${activeDescendant}"]`); - await expect.poll(() => listbox.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); - await expect - .poll(() => - activeOption.evaluate((element) => { - const list = element.closest('[role="listbox"]'); - if (!list) return false; - const optionRect = element.getBoundingClientRect(); - const listRect = list.getBoundingClientRect(); - return optionRect.top >= listRect.top - 0.5 && optionRect.bottom <= listRect.bottom + 0.5; - }), - ) - .toBe(true); -}); - -test('model Selector owns hit testing, focus restoration, and aligned long rows', async ({ +test('model Selector restores focus to its opener on Escape', async ({ window: page, }) => { const trigger = page.getByRole('button', { name: /选择新对话模型/ }); @@ -140,34 +103,7 @@ test('model Selector owns hit testing, focus restoration, and aligned long rows' const listbox = page.getByRole('listbox'); const popup = listbox.locator('xpath=ancestor::*[@popover][1]'); await expect(search).toBeFocused(); - await expect - .poll(() => trigger.evaluate((element) => getComputedStyle(element).pointerEvents)) - .toBe('auto'); - await expect - .poll(() => popup.evaluate((element) => getComputedStyle(element).pointerEvents)) - .toBe('auto'); - - const brandedRow = listbox.getByRole('option').filter({ has: page.locator('.modelPickerProviderMark') }).first(); - await expect(brandedRow).toBeVisible(); - await expect - .poll(() => - brandedRow.evaluate((element) => { - const mark = element.querySelector('.modelPickerProviderMark'); - const label = element.querySelector('.modelPickerOptionLabel'); - if (!mark || !label) return false; - const markRect = mark.getBoundingClientRect(); - const labelRect = label.getBoundingClientRect(); - return Math.abs(markRect.top + markRect.height / 2 - (labelRect.top + labelRect.height / 2)) <= 1; - }), - ) - .toBe(true); - - await search.fill('sonnet'); - const longLabel = listbox.locator('.modelPickerOptionLabel').first(); - await expect(longLabel).toBeVisible(); - await expect - .poll(() => longLabel.evaluate((element) => getComputedStyle(element).textOverflow)) - .toBe('ellipsis'); + await expect(listbox.getByRole('option').first()).toBeVisible(); await page.keyboard.press('Escape'); await expect(popup).toBeHidden(); diff --git a/apps/desktop/e2e/locale-renderer.spec.ts b/apps/desktop/e2e/locale-renderer.spec.ts index 78a68fadcc..ee08a2f81a 100644 --- a/apps/desktop/e2e/locale-renderer.spec.ts +++ b/apps/desktop/e2e/locale-renderer.spec.ts @@ -1,34 +1,47 @@ +import type { Page } from '@playwright/test'; import { test, expect } from './fixtures'; -// The boot-time locale override, which the runtime switch below cannot cover: -// these windows resolve their locale before the first frame. A rendered control -// carrying the translated name is the observable evidence — the previous -// screenshot byte-size floor would have passed on a blank frame just as well. -test('Chinese e2e-fixture renderer uses the resolved locale', async ({ zhLocaleWindow: page }) => { - await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); - await expect(page.getByRole('button', { name: '展开侧边栏' })).toBeVisible(); -}); +/** Sidebar expand is absent when already expanded (state can survive reload). */ +async function ensureSidebarExpanded(page: Page): Promise { + const expand = page.getByRole('button', { name: /展开侧边栏|Expand sidebar/ }); + if (await expand.isVisible().catch(() => false)) { + await expand.click(); + } +} -test('English e2e-fixture renderer uses the resolved locale', async ({ enLocaleWindow: page }) => { - await expect(page.locator('html')).toHaveAttribute('lang', 'en'); - await expect(page.getByRole('button', { name: 'Expand sidebar' })).toBeVisible(); -}); - -test('locale switching, persistence, and Follow system need no reload', async ({ localeSwitchWindow: page }) => { - await page.getByRole('button', { name: /展开侧边栏|Expand sidebar/ }).click(); +async function openSettingsGeneral(page: Page): Promise<{ + settingsNavigation: ReturnType; + settings: ReturnType; +}> { + await ensureSidebarExpanded(page); await page.getByRole('button', { name: /设置|Settings/ }).click(); const settingsNavigation = page.getByRole('navigation', { name: /设置分组|Settings sections/, }); const settings = page.getByRole('main', { name: /设置内容|Settings content/ }); await settingsNavigation.getByRole('button', { name: /通用|General/, exact: true }).click(); + return { settingsNavigation, settings }; +} + +// Runtime locale switching must not require a reload; boot-time hydration must +// still read the persisted uiLocale after a full remount. +test('locale switching, persistence, and Follow system need no reload', async ({ localeSwitchWindow: page }) => { + let { settings } = await openSettingsGeneral(page); - await page.evaluate(() => { (window as unknown as { __localeE2eMarker: string }).__localeE2eMarker = 'alive'; }); let language = settings.getByRole('radiogroup', { name: /界面语言|Interface language/ }); await language.getByRole('radio', { name: 'English', exact: true }).click(); await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect.poll(() => page.evaluate(() => window.maka.settings.get().then((value) => value.personalization.uiLocale))).toBe('en'); + // Boot path: first paint after remount must honor the persisted store. + await page.reload(); + await page.waitForSelector('.maka-composer-textarea', { timeout: 20_000 }); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect.poll(() => page.evaluate(() => window.maka.settings.get().then((value) => value.personalization.uiLocale))).toBe('en'); + + ({ settings } = await openSettingsGeneral(page)); + + await page.evaluate(() => { (window as unknown as { __localeE2eMarker: string }).__localeE2eMarker = 'alive'; }); language = settings.getByRole('radiogroup', { name: 'Interface language' }); await language.getByRole('radio', { name: '中文', exact: true }).click(); await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); diff --git a/apps/desktop/e2e/project-management.spec.ts b/apps/desktop/e2e/project-management.spec.ts index 2fbf1888a1..c34ea67e50 100644 --- a/apps/desktop/e2e/project-management.spec.ts +++ b/apps/desktop/e2e/project-management.spec.ts @@ -1,21 +1,17 @@ import { test, expect } from './fixtures'; -test('project picker keeps fixed actions outside the scroll region and can create an unassigned task', async ({ +// Project picker product journey. "Fixed actions outside the scroll region" +// is owned by `.maka-composer-project-scroll` CSS +// (chat-shell-layout-contract.test.ts). +test('project picker can create an unassigned task and surfaces it under project grouping', async ({ window: page, }) => { const picker = page.locator('.maka-composer-workspace-picker'); await picker.click(); - const projectScroll = page.locator('.maka-composer-project-scroll'); - const addProject = page.getByRole('menuitem', { name: '添加项目' }); const noProject = page.getByRole('menuitem', { name: '无项目' }); - await expect(projectScroll).toBeAttached(); - await expect(addProject).toBeVisible(); + await expect(page.getByRole('menuitem', { name: '添加项目' })).toBeVisible(); await expect(noProject).toBeVisible(); - await expect(projectScroll).toHaveCSS('overflow-y', 'auto'); - expect(await projectScroll.locator('text=添加项目').count()).toBe(0); - expect(await projectScroll.locator('text=无项目').count()).toBe(0); - await noProject.click(); await expect(picker).toContainText('无项目'); diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 20aefaea56..360b7cf89d 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -31,48 +31,6 @@ const probeScroller = `(() => { // rewrote the placeholder geometry rather than finishing over nothing. const WARMED_HEIGHT_FLOOR = 24 * 800; -type ColumnGeometry = { - hostLeft: number; - viewportLeft: number; - hostViewportLeftDelta: number; - turnCenter: number; - composerCenter: number; - turnComposerCenterDelta: number; - hostDisplay: string; - hostFlexDirection: string; - hostGap: string; -}; - -async function probeColumnGeometry(page: import('@playwright/test').Page): Promise { - return await page.evaluate(() => { - const host = document.querySelector('.maka-chat-layout'); - const viewport = document.querySelector('[data-chat-scroll-container="true"]'); - const turn = document.querySelector('.maka-turn'); - const composer = document.querySelector('.composer .maka-composer-astryx'); - if (!host || !viewport || !turn || !composer) { - throw new Error('Expected the active chat host, viewport, turn, and composer to be mounted'); - } - - const hostRect = host.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); - const turnRect = turn.getBoundingClientRect(); - const composerRect = composer.getBoundingClientRect(); - const hostStyle = getComputedStyle(host); - return { - hostLeft: hostRect.left, - viewportLeft: viewportRect.left, - hostViewportLeftDelta: viewportRect.left - hostRect.left, - turnCenter: turnRect.left + turnRect.width / 2, - composerCenter: composerRect.left + composerRect.width / 2, - turnComposerCenterDelta: - (turnRect.left + turnRect.width / 2) - (composerRect.left + composerRect.width / 2), - hostDisplay: hostStyle.display, - hostFlexDirection: hostStyle.flexDirection, - hostGap: hostStyle.gap, - }; - }); -} - /** * Wait for the warm-up's own terminal state, which the scroller publishes as * `data-turn-warmup="settled"`. @@ -104,47 +62,10 @@ async function settleGeometry(page: import('@playwright/test').Page, options: { } } -test('chat viewport and message column share the composer centerline', async ({ longTranscriptWindow: page }) => { - await expect(page.locator('.maka-turn')).toHaveCount(24); - - for (const width of [900, 1180, 1440]) { - await page.setViewportSize({ width, height: 760 }); - const geometry = await probeColumnGeometry(page); - const diagnostics = JSON.stringify({ width, ...geometry }); - expect(Math.abs(geometry.hostViewportLeftDelta), diagnostics).toBeLessThanOrEqual(1); - expect(Math.abs(geometry.turnComposerCenterDelta), diagnostics).toBeLessThanOrEqual(1); - } -}); - -test('empty chat has no phantom vertical range and stays flush with the viewport', async ({ window: page }) => { - const content = page.locator('.mainColumn[data-home-surface="true"] .maka-chatContent'); - await expect(content).toBeVisible(); - - for (const width of [900, 1180, 1440]) { - await page.setViewportSize({ width, height: 760 }); - const geometry = await page.evaluate(() => { - const host = document.querySelector('.maka-chat-layout'); - const viewport = document.querySelector('[data-chat-scroll-container="true"]'); - const content = document.querySelector('.maka-chatContent'); - if (!host || !viewport || !content) throw new Error('Expected the empty chat scroll surface'); - const hostRect = host.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); - const contentStyle = getComputedStyle(content); - return { - hostViewportLeftDelta: viewportRect.left - hostRect.left, - contentDisplay: contentStyle.display, - contentGap: contentStyle.gap, - scrollRange: viewport.scrollHeight - viewport.clientHeight, - scrollTop: viewport.scrollTop, - }; - }); - const diagnostics = JSON.stringify({ width, ...geometry }); - expect(Math.abs(geometry.hostViewportLeftDelta), diagnostics).toBeLessThanOrEqual(1); - expect(geometry.contentDisplay, diagnostics).toBe('flex'); - expect(geometry.scrollRange, diagnostics).toBeLessThanOrEqual(1); - expect(geometry.scrollTop, diagnostics).toBeLessThanOrEqual(1); - } -}); +// Column centerline / empty-chat flush used to live here as live box metrics. +// Those outcomes are owned by `.maka-chat-layout` flex contracts +// (chat-shell-layout-contract.test.ts). This file only keeps content-visibility +// pin/warm-up behaviour that a static CSS read cannot prove. test('long session opens pinned to bottom and stays pinned while geometry settles', async ({ longTranscriptWindow: page }) => { await expect(page.locator('.maka-turn')).toHaveCount(24); diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 7b7b5d9f39..cf0a6cd79a 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -17,11 +17,6 @@ test('send a message and see the fake backend stream a reply', async ({ window: await composer.press('Enter'); await expect(page.getByText(/Fake backend received: hello e2e/)).toBeVisible(); - await page.getByRole('button', { name: '切换当前会话模型' }).click(); - await expect( - page.getByRole('listbox').locator('.modelPickerProviderMark[data-provider="anthropic"] svg'), - ).toBeVisible(); - await page.keyboard.press('Escape'); }); test('exposes the Astryx Markdown code-copy action', async ({ window: page }) => { diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index de17c187c8..7afa78990d 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from './fixtures'; +// Workbar product journey. Narrow max-height and "toggle unmounted without a +// session" are pinned in chat-shell-layout-contract (CSS + chrome actions source). test('session tools share one user-controlled workbar', async ({ sessionWorkbarWindow: page }) => { const workbar = page.getByRole('complementary', { name: '会话工作栏' }); const tabs = workbar.getByRole('navigation', { name: '会话工作栏栏目' }); @@ -13,7 +15,6 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW // Keyboard-driven disclosure, observed through what the user can read: a // collapsed section hides its rows, and Enter on the trigger reveals them. - // The trigger's own box metrics are Astryx Collapsible's to keep. const recent = workbar.getByRole('button', { name: /最近结束/ }); const recentRow = workbar.getByText('验证 Goal 一次提醒门禁'); await expect(recent).toHaveAttribute('aria-expanded', 'false'); @@ -32,13 +33,6 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW await tabs.getByRole('button', { name: /文件/ }).click(); await expect(workbar.getByText('暂无生成文件')).toBeVisible(); - await page.setViewportSize({ width: 480, height: 320 }); - const narrowLayout = await workbar.evaluate((element) => ({ - height: element.getBoundingClientRect().height, - viewportHeight: window.innerHeight, - })); - expect(narrowLayout.height).toBeLessThanOrEqual(narrowLayout.viewportHeight * 0.42 + 1); - await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); await page .getByRole('navigation', { name: '对话列表' }) @@ -47,13 +41,3 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW await expect(workbar).toBeHidden(); await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); }); - -test('workbar toggle stays unmounted without an active session', async ({ window: page }) => { - // The overflow trigger also carries aria-expanded (its menu opens/closes), - // so pin the workbar toggle by its label instead of the shared attribute. - const toggle = page - .getByRole('toolbar', { name: '工作区辅助操作' }) - .getByRole('button', { name: /会话工作栏/ }); - - await expect(toggle).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index 748b6d0d09..10a288a6db 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -5,44 +5,42 @@ function settingsNavigation(page: Page) { return page.getByRole('navigation', { name: /^(设置分组|Settings sections)$/ }); } -test('general default-model options keep provider marks inside the Selector slot', async ({ - window: page, -}) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); +/** + * #1361 — one live window-floor smoke. CSS contracts pin the load-bearing + * declarations; this still measures the user-visible synthesis (row body + * floor + page horizontal containment) at SAFE_MIN_WIDTH. + */ +test('permission rows keep their text at the window floor', async ({ permissionSettingsWindow: page }) => { + await page.setViewportSize({ width: 480, height: 900 }); const settings = page.getByRole('main', { name: '设置内容' }); - await settingsNavigation(page).getByRole('button', { name: '通用', exact: true }).click(); + await expect(settings.getByRole('heading', { name: '系统权限' })).toBeVisible(); + + // Prove the fixture renders the three-button guided row before measuring — + // that shape is what used to squeeze the body to 0px. + const rows = settings.locator('.settingsOsPermissionRow'); + await expect(rows).toHaveCount(5); + const guidedRows = rows.filter({ + has: page.getByRole('button', { name: '引导授权', exact: true }), + }); + await expect(guidedRows).toHaveCount(1); + await expect(guidedRows.getByRole('button')).toHaveCount(3); + + await expect.poll( + () => + rows.evaluateAll((elements) => + elements.every((element) => { + const body = element.querySelector('.settingsOsPermissionBody'); + if (!body) return false; + return ( + body.getBoundingClientRect().width >= 101 && body.scrollWidth <= body.clientWidth + ); + }), + ), + ).toBe(true); - await settings.getByRole('button', { name: '默认模型' }).click(); - const mark = page.getByRole('listbox').locator('.modelPickerProviderMark').first(); - await expect(mark).toBeVisible(); - await expect - .poll(() => - mark.evaluate((element) => { - const markRect = element.getBoundingClientRect(); - const asset = element.firstElementChild; - const option = element.closest('[role="option"]'); - const label = option?.querySelector('.modelPickerOptionLabel'); - if (!asset || !label) return null; - const assetRect = asset.getBoundingClientRect(); - const labelRect = label.getBoundingClientRect(); - return { - usesSettingsPlate: element.querySelector('.providerLogo') !== null, - square: markRect.width === markRect.height && assetRect.width === assetRect.height, - contained: - assetRect.width <= markRect.width && - assetRect.height <= markRect.height && - markRect.width <= 16 && - markRect.height <= 16, - aligned: - Math.abs( - markRect.top + markRect.height / 2 - - (labelRect.top + labelRect.height / 2), - ) <= 1, - }; - }), - ) - .toEqual({ usesSettingsPlate: false, square: true, contained: true, aligned: true }); + await expect.poll( + () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), + ).toBe(true); }); /** @@ -52,8 +50,6 @@ test('general default-model options keep provider marks inside the Selector slot * apply path without depending on pixel colors. */ test('changing the theme in settings applies to the UI', async ({ window: page }) => { - // The sidebar starts collapsed on a fresh workspace; expand it to reach - // the settings entry in the sidebar footer. await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); await expect(page.getByLabel('设置内容')).toBeVisible(); @@ -71,25 +67,20 @@ test('changing the theme in settings applies to the UI', async ({ window: page } ).toBe(true); }); -test('settings textareas use Astryx native resizing and persist edits across section re-entry', async ({ window: page }) => { +test('settings textareas persist edits across section re-entry', async ({ window: page }) => { await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); await settingsNavigation(page).getByRole('button', { name: '通用', exact: true }).click(); const textarea = page.getByRole('textbox', { name: '助手语气偏好' }); await expect(textarea).toBeVisible(); - await expect(textarea).toHaveCSS('resize', 'vertical'); const edited = Array.from({ length: 7 }, (_, index) => `偏好 ${index + 1}`).join('\n'); await textarea.fill(edited); await expect(textarea).toHaveValue(edited); await settingsNavigation(page).getByRole('button', { name: '记忆', exact: true }).click(); await expect(page.locator('label').filter({ hasText: '记忆标题' })).toBeVisible(); - await expect(page.locator('label').filter({ hasText: '记忆标签' })).toBeVisible(); - await expect(page.locator('label').filter({ hasText: '记忆内容' })).toBeVisible(); - await expect(page.locator('label').filter({ hasText: 'MEMORY.md 内容' })).toBeVisible(); - await expect(page.getByRole('textbox', { name: '记忆内容' })).toHaveCSS('resize', 'vertical'); - await expect(page.getByRole('textbox', { name: 'MEMORY.md 内容' })).toHaveCSS('resize', 'vertical'); + await expect(page.getByRole('textbox', { name: '记忆内容' })).toBeVisible(); await settingsNavigation(page).getByRole('button', { name: '数据', exact: true }).click(); await expect(page.locator('label').filter({ hasText: '导入时同名连接的处理方式' })).toBeVisible(); @@ -117,209 +108,6 @@ test('voice settings expose Astryx-owned fields and persist a draft on blur', as await expect(settings.getByRole('textbox', { name: '语言(可选)' })).toHaveValue('en'); }); -/** - * #1361 — the Permissions and Health summary strips were fixed 4- and 5-track - * grids. At the sanitized window floor (`SAFE_MIN_WIDTH` = 480) that divided the - * narrow content column into ~30-40px slivers: the Health tiles ended up with a - * 4px content box, so even a single digit overflowed. Same family as the #1304 - * settings tile overflow — #1335 taught StatTile to wrap, but a tile squeezed - * below one character wide has nothing left to wrap into. - * - * Locks the outcome rather than a fixed column count: every rendered track - * remains readable and nothing overflows horizontally. The responsive - * SideNav may leave enough room for more tracks even at the window floor. - */ -async function openSettings(page: import('@playwright/test').Page) { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); - return page.getByRole('main', { name: '设置内容' }); -} - -/** Real track widths, with `auto-fit`'s collapsed 0px tracks filtered out. */ -function summaryGeometry(summary: ReturnType) { - return summary.evaluate((element) => { - const tracks = getComputedStyle(element) - .gridTemplateColumns.trim() - .split(/\s+/) - .map((track) => Number.parseFloat(track)) - .filter((track) => track > 0); - return { - trackCount: tracks.length, - narrowestTrack: Math.round(Math.min(...tracks)), - tileCount: element.querySelectorAll('[data-slot="stat-tile"]').length, - valuesContained: Array.from( - element.querySelectorAll('[data-slot="stat-tile-value"]'), - ).every((value) => value.scrollWidth <= value.clientWidth), - }; - }); -} - -test('permission rows keep their text at the window floor', async ({ permissionSettingsWindow: page }) => { - await page.setViewportSize({ width: 480, height: 900 }); - const settings = page.getByRole('main', { name: '设置内容' }); - await expect(settings.getByRole('heading', { name: '系统权限' })).toBeVisible(); - - // Prove the fixture renders the shape this contract is about before measuring - // it: the requestable + openable screen-recording row also draws the guided - // drag action, and that three-button row is the one whose body the `auto` - // actions track could otherwise squeeze to 0px. - // Reading the host's real TCC state would silently skip this — see - // `main/permission-snapshot-e2e-fixture.ts`. - const rows = settings.locator('.settingsOsPermissionRow'); - await expect(rows).toHaveCount(5); - const guidedRows = rows.filter({ - has: page.getByRole('button', { name: '引导授权', exact: true }), - }); - await expect(guidedRows).toHaveCount(1); - await expect(guidedRows.getByRole('button')).toHaveCount(3); - - await expect.poll( - () => - rows.evaluateAll((elements) => - elements.every((element) => { - const body = element.querySelector('.settingsOsPermissionBody'); - if (!body) return false; - // Wide enough for the widest status Badge (~101px intrinsic and - // `whitespace-nowrap` by primitive contract), and not clipping. - return ( - body.getBoundingClientRect().width >= 101 && body.scrollWidth <= body.clientWidth - ); - }), - ), - ).toBe(true); - - const permissionSummary = settings.locator('.settingsPermissionSummary'); - await expect.poll(async () => { - const { trackCount, narrowestTrack, valuesContained } = await summaryGeometry(permissionSummary); - return { - hasSummaryTracks: trackCount > 0, - tracksStayLegible: narrowestTrack >= 96, - valuesContained, - }; - }).toEqual({ hasSummaryTracks: true, tracksStayLegible: true, valuesContained: true }); - - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - -test('summary grids keep one track per metric when wide', async ({ window: page }) => { - await page.setViewportSize({ width: 1280, height: 900 }); - const settings = await openSettings(page); - - // `auto-fit` must not cost the full-width layout: one track per metric, on - // every summary grid that uses it. - const expectOneTrackPerTile = async (selector: string, metrics: number) => { - await expect(settings.locator(selector)).toBeVisible(); - await expect.poll(async () => { - const { trackCount, tileCount } = await summaryGeometry(settings.locator(selector)); - return { trackCount, tileCount }; - }).toEqual({ trackCount: metrics, tileCount: metrics }); - }; - - await settingsNavigation(page).getByRole('button', { name: '权限与能力', exact: true }).click(); - await expectOneTrackPerTile('.settingsPermissionSummary', 4); - - await settingsNavigation(page).getByRole('button', { name: '健康', exact: true }).click(); - await expectOneTrackPerTile('.settingsHealthSummary', 5); - - await settingsNavigation(page).getByRole('button', { name: '使用统计', exact: true }).click(); - await expectOneTrackPerTile('.settingsUsageSummary', 4); -}); - -test('capability diagnostics stay contained when expanded at the window floor', async ({ permissionSettingsWindow: page }) => { - await page.setViewportSize({ width: 480, height: 900 }); - const settings = page.getByRole('main', { name: '设置内容' }); - - await settings.getByRole('button', { name: '展开详情' }).click(); - const capabilityList = settings.locator('.settingsCapabilityList'); - await expect(capabilityList).toHaveAttribute('data-diagnostics-open', 'true'); - - // The layers grid used to hold a hard `minmax(150px, …)` floor, wider than the - // whole content column and pushed overflow up through the row. - await expect(settings.locator('.settingsCapabilityLayers').first()).toBeVisible(); - await expect.poll( - () => - capabilityList.evaluate((element) => ({ - listContained: element.scrollWidth <= element.clientWidth, - rowsContained: Array.from(element.querySelectorAll('.settingsCapabilityRow')).every( - (row) => row.scrollWidth <= row.clientWidth, - ), - layersContained: Array.from(element.querySelectorAll('.settingsCapabilityLayers')).every( - (layers) => layers.scrollWidth <= layers.clientWidth, - ), - })), - ).toEqual({ listContained: true, rowsContained: true, layersContained: true }); - - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - -/** - * #1364 — list-page geometry at the window floor. - * - * Usage: the requests Astryx Table's explicit column widths give it an intrinsic - * width wider than the settings column even at full window width; it must - * scroll inside its own container (#1360 fix) instead of dragging the page - * into horizontal scroll, and the five-tab bar scrolls within itself the same - * way. Web search: unbreakable tokens (env-var hint, result hostnames/URLs) - * must wrap instead of widening the page. - */ -/** - * #1364 review follow-up: the containment test above never reaches the two - * long-content branches — the default fixture has no request logs (so the - * requests Table never renders) and no Tavily key (so the page stops at - * the no-key message). These two lock the actual fixes against the states - * that broke: the request table scrolls inside its own container while the - * page stays put, and the hostile-width results (bare-URL title, long - * snippet) wrap inside their cards. - */ -test('usage request log scrolls inside its own container at the window floor', async ({ - usageSettingsWindow: page, -}) => { - await page.setViewportSize({ width: 480, height: 900 }); - const settings = page.getByRole('main', { name: '设置内容' }); - const scroller = settings.locator('.settingsUsageTable .astryx-table-scroll-wrapper'); - // The renderer's first stats fetch can race the fixture seeding on boot; - // refresh until the seeded request log lands. - await expect(async () => { - await settings.getByRole('button', { name: '刷新使用统计' }).click(); - await expect(scroller).toBeVisible({ timeout: 1_000 }); - }).toPass(); - await expect(scroller).toHaveCSS('overflow-x', 'auto'); - // The seeded log's nowrap columns are intrinsically wider than the floor - // column, so the scroller must actually be scrolling its table… - await expect.poll( - () => scroller.evaluate((element) => element.scrollWidth > element.clientWidth + 1), - ).toBe(true); - // …while the page around it stays contained. - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - -test('web search results wrap inside their cards at the window floor', async ({ - searchSettingsWindow: page, -}) => { - await page.setViewportSize({ width: 480, height: 900 }); - const settings = page.getByRole('main', { name: '设置内容' }); - await settings.getByLabel('联网搜索真实查询').fill('electron vibrancy 排查'); - await settings.getByRole('button', { name: '搜索', exact: true }).click(); - - const results = settings.locator('.settingsWebSearchResult'); - await expect(results).toHaveCount(3); - await expect.poll( - () => - results.evaluateAll((elements) => - elements.every((element) => element.scrollWidth <= element.clientWidth), - ), - ).toBe(true); - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - test('remote access opens a channel detail from the overview and returns', async ({ window: page }) => { await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); @@ -330,19 +118,7 @@ test('remote access opens a channel detail from the overview and returns', async await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible(); await expect(settings.getByRole('heading', { name: '接入更多渠道' })).toBeVisible(); - const telegramRow = settings.getByRole('button', { name: /接入 Telegram/ }); - await expect.poll( - () => telegramRow.evaluate((element) => getComputedStyle(element).boxShadow), - ).toBe('none'); - - await telegramRow.focus(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Shift+Tab'); - await expect.poll( - () => telegramRow.evaluate((element) => getComputedStyle(element).boxShadow), - ).not.toBe('none'); - - await telegramRow.click(); + await settings.getByRole('button', { name: /接入 Telegram/ }).click(); await expect(settings.getByRole('heading', { name: /Telegram/ })).toBeVisible(); const backButton = settings.getByRole('button', { name: '返回远程接入' }); await expect(backButton).toBeVisible(); @@ -368,17 +144,12 @@ test('remote access opens a channel detail from the overview and returns', async await page.keyboard.press('Tab'); await expect(tokenInput).toBeFocused(); - const identityValue = settings.getByLabel('Telegram运行状态').locator('dd').first(); - await expect(identityValue).toHaveCSS('white-space', 'normal'); - await expect(identityValue).toHaveCSS('overflow-wrap', 'anywhere'); - await backButton.click(); await expect(settings.getByRole('heading', { name: '接入更多渠道' })).toBeVisible(); }); test('remote access prioritizes a configured channel that needs attention', async ({ window: page }) => { const runtimeError = 'runtime-diagnostic-'.repeat(10); - await page.setViewportSize({ width: 480, height: 820 }); await page.evaluate(async (lastError) => { await window.maka.settings.update({ botChat: { @@ -410,30 +181,7 @@ test('remote access prioritizes a configured channel that needs attention', asyn await expect(settings.getByText(runtimeError, { exact: true })).toBeVisible(); await expect(activeChannels.nth(1)).toHaveAccessibleName(/管理 Telegram/); - const overview = settings.locator('.settingsRemoteAccessOverview'); - const attentionRow = settings.locator('.settingsRemoteAccessChannelRow').first(); - const catalogRows = settings.locator('.settingsRemoteAccessCatalogRow'); - await expect(attentionRow.locator('.settingsRemoteAccessItemTitle')).toHaveCSS('flex-wrap', 'wrap'); - await expect(attentionRow.locator('.settingsRemoteAccessItemDescription')).toHaveCSS('overflow-wrap', 'anywhere'); - await expect(attentionRow.locator('.settingsRemoteAccessItemActions')).toHaveCSS('display', 'none'); - await expect(catalogRows.first()).toBeVisible(); - await expect(catalogRows.first().locator('.settingsRemoteAccessItemActions')).toHaveCSS('display', 'none'); - await expect(settings.locator('.settingsRemoteAccessSectionHeader').first()).toHaveCSS('flex-direction', 'column'); - await expect.poll( - () => - overview.evaluate((element) => ({ - overviewContained: element.scrollWidth <= element.clientWidth, - rowsContained: Array.from(element.querySelectorAll('.settingsRemoteAccessChannelRow')) - .every((row) => row.scrollWidth <= row.clientWidth), - catalogRowsContained: Array.from(element.querySelectorAll('.settingsRemoteAccessCatalogRow')) - .every((row) => row.scrollWidth <= row.clientWidth), - })), - ).toEqual({ overviewContained: true, rowsContained: true, catalogRowsContained: true }); - await activeChannels.nth(0).click(); - const detailHeader = settings.locator('.settingsBotDetailHeader'); - const detailHeaderBody = settings.locator('.settingsBotDetailHeaderBody'); - const runtimeStatus = settings.locator('.settingsBotStatusGrid'); const enabledSwitch = settings.getByRole('switch', { name: '启用Discord渠道' }); const configDocs = settings.getByRole('link', { name: '查看配置文档' }); const connectButton = settings.getByRole('button', { name: '测试并连接' }); @@ -445,265 +193,7 @@ test('remote access prioritizes a configured channel that needs attention', asyn await expect(configDocs).toBeFocused(); await page.keyboard.press('Tab'); await expect(connectButton).toBeFocused(); - await expect(detailHeaderBody).toHaveCSS('grid-column-start', '1'); - await expect(detailHeaderBody).toHaveCSS('grid-column-end', '-1'); - await expect.poll( - () => - detailHeader.evaluate((element) => ({ - contained: element.scrollWidth <= element.clientWidth, - bodyUsesFullRow: (() => { - const body = element.querySelector('.settingsBotDetailHeaderBody'); - if (!body) return false; - const headerStyle = getComputedStyle(element); - const expectedWidth = element.clientWidth - - Number.parseFloat(headerStyle.paddingLeft) - - Number.parseFloat(headerStyle.paddingRight); - return body.getBoundingClientRect().width >= expectedWidth - 1; - })(), - switchPrecedesDocs: (() => { - const toggle = element.querySelector('.settingsBotDetailSwitch'); - const docs = element.querySelector('.settingsBotConfigDocLink'); - return toggle && docs - ? Boolean(toggle.compareDocumentPosition(docs) & Node.DOCUMENT_POSITION_FOLLOWING) - : false; - })(), - headingPrecedesSwitch: (() => { - const heading = element.querySelector('h3'); - const toggle = element.querySelector('.settingsBotDetailSwitch'); - return heading && toggle - ? Boolean(heading.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING) - : false; - })(), - })), - ).toEqual({ - contained: true, - bodyUsesFullRow: true, - switchPrecedesDocs: true, - headingPrecedesSwitch: true, - }); - await expect.poll( - () => - runtimeStatus.evaluate((element) => { - const columns = getComputedStyle(element).gridTemplateColumns.trim(); - return { - defined: columns !== 'none', - count: columns.split(/\s+/).length, - }; - }), - ).toEqual({ defined: true, count: 1 }); const recentFailure = settings.getByRole('alert').filter({ hasText: '最近一次失败' }); await expect(recentFailure).toContainText(runtimeError); - await expect(recentFailure.getByText(runtimeError)).toHaveCSS('overflow-wrap', 'anywhere'); - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - -/** - * Direct Astryx FormLayout and Item consumers remain contained at both the - * wide layout and the 480px window floor. This intentionally avoids locking - * the deleted generic row classes or reasserting their custom geometry. - */ -test('general forms and Astryx Item controls stay contained across widths', async ({ window: page }) => { - await page.setViewportSize({ width: 1280, height: 900 }); - const settings = await openSettings(page); - await settingsNavigation(page).getByRole('button', { name: '通用', exact: true }).click(); - - const formLayout = settings.locator('.settingsFormLayout').first(); - const incognitoRow = settings.locator('.astryx-item').filter({ hasText: '隐身模式' }); - const modelRow = settings.locator('.astryx-item').filter({ hasText: '默认模型' }); - - await expect(formLayout).toHaveCSS('display', 'flex'); - await expect(incognitoRow).toHaveCSS('display', 'flex'); - await expect(modelRow).toHaveCSS('display', 'flex'); - - await page.setViewportSize({ width: 480, height: 900 }); - for (const control of [page.getByLabel('显示名称'), page.getByLabel('助手语气偏好')]) { - await expect.poll(() => control.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); - } - await expect.poll(() => incognitoRow.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); - await expect.poll(() => modelRow.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); - - // The proxy sub-form only renders behind the switches. Astryx owns both - // horizontal layouts; Maka only provides their inset within the Settings - // card, so the product must remain scroll-free at the window floor. - await settings.getByRole('switch', { name: '启用代理服务器' }).click(); - await settings.getByRole('switch', { name: '启用代理认证' }).click(); - const formLayouts = settings.locator('.settingsFormLayout'); - await expect(formLayouts).toHaveCount(3); - await expect(formLayouts.nth(1)).toHaveCSS('display', 'grid'); - await expect(formLayouts.nth(2)).toHaveCSS('display', 'grid'); - - await expect.poll( - () => settings.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); -}); - -/** - * #1362 review follow-up: the user-facing result of the palette-label wrap — - * at the window floor the full name stays readable (the old nowrap+ellipsis - * cut "Catppuccin Mocha" to "Catppucc…" with no way to recover it). - */ -/** - * Three smaller pages at the window floor. The Data page adds two page-owned - * surfaces: the Astryx conflict-strategy field and the workspace path's - * wrapping mono value. - */ -/** - * #1363 review: the full English field label is wider than the 480px floor's - * content column, so the field and label must remain contained without a - * product-owned wrapper. - */ -test('data config strategy stays contained at the window floor in English', async ({ - enLocaleWindow: page, -}) => { - await page.setViewportSize({ width: 480, height: 900 }); - await page.getByRole('button', { name: 'Expand sidebar' }).click(); - await page.getByRole('button', { name: 'Settings' }).click(); - const settings = page.getByRole('main', { name: 'Settings content' }); - await settingsNavigation(page).getByRole('button', { name: 'Data', exact: true }).click(); - - const strategy = settings.getByRole('combobox', { - name: 'How to handle connections with the same name during import', - }); - await expect(strategy).toBeVisible(); - const strategyBox = await strategy.boundingBox(); - const settingsBox = await settings.boundingBox(); - expect(strategyBox).not.toBeNull(); - expect(settingsBox).not.toBeNull(); - expect(strategyBox!.x).toBeGreaterThanOrEqual(settingsBox!.x); - expect(strategyBox!.x + strategyBox!.width).toBeLessThanOrEqual( - settingsBox!.x + settingsBox!.width, - ); -}); - -/** - * Window-floor sweep over the settings pages that share the default `window` - * fixture (#1304 / #1361 / #1364). - * - * These were five separate tests paying five Electron cold starts to do the - * same thing: shrink to the 480px `SAFE_MIN_WIDTH` floor and walk pages. Every - * page-specific assertion below is carried over unchanged — the sweep only - * stops re-launching the app between them. Pages that need their own seeded - * fixture (permissions, usage logs, web-search results) stay separate above, - * because their state is what makes their contract reachable at all. - */ -test('settings pages stay contained at the window floor', async ({ window: page }) => { - await page.setViewportSize({ width: 480, height: 900 }); - const settings = await openSettings(page); - const pageContained = () => - expect - .poll(() => settings.evaluate((element) => element.scrollWidth <= element.clientWidth)) - .toBe(true); - - await test.step('health summary tiles stay readable', async () => { - await settingsNavigation(page).getByRole('button', { name: '健康', exact: true }).click(); - const healthSummary = settings.locator('.settingsHealthSummary'); - await expect(healthSummary).toBeVisible(); - await expect.poll(async () => { - const { trackCount, narrowestTrack, valuesContained } = await summaryGeometry(healthSummary); - return { - hasSummaryTracks: trackCount > 0, - tracksStayLegible: narrowestTrack >= 80, - valuesContained, - }; - }).toEqual({ hasSummaryTracks: true, tracksStayLegible: true, valuesContained: true }); - await pageContained(); - }); - - await test.step('usage tabs scroll within themselves', async () => { - await settingsNavigation(page).getByRole('button', { name: '使用统计', exact: true }).click(); - const tabsBar = settings.locator('.settingsUsageTabsBar'); - await expect(tabsBar).toBeVisible(); - await expect(tabsBar).toHaveCSS('overflow-x', 'auto'); - await expect.poll(async () => { - const { trackCount, valuesContained } = await summaryGeometry( - settings.locator('.settingsUsageSummary'), - ); - return { hasSummaryTracks: trackCount > 0, valuesContained }; - }).toEqual({ hasSummaryTracks: true, valuesContained: true }); - await pageContained(); - }); - - await test.step('the web-search hint wraps its unbreakable tokens', async () => { - // Not `exact`: the nav entry's accessible name carries its Beta badge. - await settingsNavigation(page).getByRole('button', { name: '联网搜索' }).click(); - const disabledReason = settings.locator('.settingsWebSearchDisabledReason'); - await expect(disabledReason).toBeVisible(); - await expect(disabledReason).toHaveCSS('overflow-wrap', 'anywhere'); - await pageContained(); - }); - - await test.step('memory keeps its preview header and status Item contained', async () => { - await settingsNavigation(page).getByRole('button', { name: '记忆', exact: true }).click(); - const previewHeader = settings.locator('.settingsMemoryPromptPreviewHeader'); - await expect(previewHeader).toBeVisible(); - await expect(previewHeader).toHaveCSS('flex-wrap', 'wrap'); - await expect.poll( - () => previewHeader.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); - - // Memory's label + status Badge + switch travel as one Astryx Item. At the - // floor, the cluster and the row must remain contained without - // reintroducing the retired form-row geometry. - const statusRow = settings.locator('.astryx-item').filter({ hasText: '本地 MEMORY.md' }); - await expect(statusRow).toBeVisible(); - await expect.poll( - () => - statusRow.evaluate((element) => { - const cluster = element.querySelector('.settingsFormRowControlCluster'); - return { - clusterContained: !!cluster && cluster.scrollWidth <= cluster.clientWidth, - rowContained: element.scrollWidth <= element.clientWidth, - }; - }), - ).toEqual({ clusterContained: true, rowContained: true }); - await pageContained(); - }); - - await test.step('palette names wrap instead of clipping', async () => { - await settingsNavigation(page).getByRole('button', { name: '外观', exact: true }).click(); - const label = settings.getByText('Catppuccin Mocha', { exact: true }); - await expect(label).toBeVisible(); - // Wrapping, not clipping: nothing hides past the box in either axis. - await expect.poll( - () => - label.evaluate((element) => ({ - horizontallyContained: element.scrollWidth <= element.clientWidth, - verticallyContained: element.scrollHeight <= element.clientHeight, - })), - ).toEqual({ horizontallyContained: true, verticallyContained: true }); - await pageContained(); - }); - - await test.step('data keeps its strategy field and workspace path inside the column', async () => { - await settingsNavigation(page).getByRole('button', { name: '数据', exact: true }).click(); - const strategy = settings.getByRole('combobox', { name: '导入时同名连接的处理方式' }); - await expect(strategy).toBeVisible(); - const strategyBox = await strategy.boundingBox(); - const settingsBox = await settings.boundingBox(); - expect(strategyBox).not.toBeNull(); - expect(settingsBox).not.toBeNull(); - expect(strategyBox!.x).toBeGreaterThanOrEqual(settingsBox!.x); - expect(strategyBox!.x + strategyBox!.width).toBeLessThanOrEqual( - settingsBox!.x + settingsBox!.width, - ); - const workspaceValue = settings.locator('span[data-mono="true"]').first(); - await expect(workspaceValue).toBeVisible(); - await expect.poll( - () => workspaceValue.evaluate((element) => element.scrollWidth <= element.clientWidth), - ).toBe(true); - await pageContained(); - }); - - await test.step('about and daily review stay contained', async () => { - await settingsNavigation(page).getByRole('button', { name: '关于', exact: true }).click(); - await expect(settings.locator('.settingsAboutPage')).toBeVisible(); - await pageContained(); - - await settingsNavigation(page).getByRole('button', { name: '每日回顾', exact: true }).click(); - await expect(settings.locator('.settingsFeatureStatusPage')).toBeVisible(); - await pageContained(); - }); }); diff --git a/apps/desktop/e2e/sidebar-navigation.spec.ts b/apps/desktop/e2e/sidebar-navigation.spec.ts index a61fb9df1a..23391db037 100644 --- a/apps/desktop/e2e/sidebar-navigation.spec.ts +++ b/apps/desktop/e2e/sidebar-navigation.spec.ts @@ -147,15 +147,6 @@ test('session delete intent opens only after its menu closes and restores the tr await expect(trigger).toBeFocused(); }); -test('session heading stays singular and the default list has no redundant heading', async ({ - sidebarLongSessionsWindow: page, -}) => { - const sidebar = await expandedSidebar(page); - - await expect(sidebar.getByText('会话', { exact: true })).toHaveCount(1); - await expect(sidebar.locator('.maka-list-group-label')).toHaveCount(0); -}); - test('scheduled-task hub restores the last selected child module', async ({ window: page }) => { const sidebar = await expandedSidebar(page); const scheduledTasks = sidebar.getByRole('button', { name: '定时任务', exact: true }); diff --git a/apps/desktop/e2e/topbar-overflow.spec.ts b/apps/desktop/e2e/topbar-overflow.spec.ts deleted file mode 100644 index 05b83fdcbb..0000000000 --- a/apps/desktop/e2e/topbar-overflow.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { test, expect } from './fixtures'; - -// Locks the structure promised by #1433 (desired outcome 3): the workspace -// topbar collapses its secondary actions behind a single overflow menu -// instead of rendering them as persistent icon buttons. The workbar toggle -// stays direct (it is a layout control, symmetric with the sidebar toggle). -// -// Uses the sessionWorkbarWindow fixture so the workbar toggle is mounted — -// the resident-button count is then exactly two (overflow trigger + workbar). -test('workspace topbar folds secondary actions into an overflow menu', async ({ sessionWorkbarWindow: page }) => { - const toolbar = page.getByRole('toolbar', { name: '工作区辅助操作' }); - await expect(toolbar).toBeVisible(); - - // Structural boundary: exactly two resident buttons (overflow + workbar). - // A future direct button added here fails before the inset geometry - // contract ever notices — that contract only checks count↔inset math. - await expect(toolbar.getByRole('button')).toHaveCount(2); - - // The four secondary actions are not direct toolbar buttons. - for (const name of ['问题反馈', '打开命令面板', '打开帮助', '打开健康中心']) { - await expect(toolbar.getByRole('button', { name })).toHaveCount(0); - } - - const overflow = toolbar.getByRole('button', { name: '更多操作' }); - await expect(overflow).toBeVisible(); - await expect - .poll(async () => { - const radii = await toolbar.getByRole('button').evaluateAll((buttons) => - buttons.map((button) => getComputedStyle(button).borderRadius), - ); - return new Set(radii).size; - }) - .toBe(1); - - // Each secondary action surfaces only behind the overflow menu, and clicking - // a menuitem fires its callback (the destination opens) — not just visible. - const openSecondary = async (label: string): Promise => { - await overflow.press('Enter'); - await expect(page.getByRole('menu', { name: '更多操作' })).toBeVisible(); - await page.getByRole('menuitem', { name: label }).click(); - }; - - // feedback → opens Settings (About section). - await openSecondary('问题反馈'); - const settings = page.getByRole('main', { name: '设置内容' }); - await expect(settings).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(settings).toBeHidden(); - - // help → opens the keyboard-shortcut dialog. - await openSecondary('打开帮助'); - const help = page.locator('.maka-help-modal'); - await expect(help).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(help).toBeHidden(); - - // health → opens Settings (Health section). - await openSecondary('打开健康中心'); - await expect(settings).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(settings).toBeHidden(); - - // The command-palette entry is end-to-end verified by command-palette.spec - // (overflow → menuitem → dialog); here we only assert it is present. - await overflow.press('Enter'); - await expect(page.getByRole('menuitem', { name: '打开命令面板' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/window-titlebar.spec.ts b/apps/desktop/e2e/window-titlebar.spec.ts index 3b5c0ceaf3..b59b8c5773 100644 --- a/apps/desktop/e2e/window-titlebar.spec.ts +++ b/apps/desktop/e2e/window-titlebar.spec.ts @@ -6,8 +6,8 @@ import type { Page } from '@playwright/test'; * * `.maka-window-titlebar` is the only element allowed to declare * `-webkit-app-region: drag`, and it occupies the shell's first grid row — both - * static gates live in `app-region-hygiene-contract.test.ts` and - * `window-titlebar-contract.test.ts`. What no static gate can see is the + * static gates live in `app-region-hygiene-contract.test.ts`. What no static + * gate can see is the * RENDERED rectangle against whatever the window currently contains, and that is * where every known defect in this area lived: * diff --git a/apps/desktop/src/main/__tests__/app-region-hygiene-contract.test.ts b/apps/desktop/src/main/__tests__/app-region-hygiene-contract.test.ts new file mode 100644 index 0000000000..c8f5a0f74d --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-region-hygiene-contract.test.ts @@ -0,0 +1,93 @@ +/** + * App-region hygiene for the frameless Electron shell. + * + * `.maka-window-titlebar` is the only `-webkit-app-region: drag` surface; action + * clusters carve themselves out with `no-drag`. Live rendered-geometry checks + * stay in e2e/window-titlebar.spec.ts (Playwright cannot exercise the OS hit + * test). These contracts pin the declarations that used to be only assumed by + * comments in main-window.ts — scoped to each rule body, not a cross-`}` scan. + */ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { + REPO_ROOT, + readAllRendererCss, + stripCssComments, + assertCssRuleDecls, +} from './css-test-helpers.js'; + +const SHELL_LAYOUT = resolve(REPO_ROOT, 'apps/desktop/src/renderer/styles/shell-layout.css'); +const WINDOW_STATE = resolve(REPO_ROOT, 'apps/desktop/src/main/window-state.ts'); +const MAIN_WINDOW = resolve(REPO_ROOT, 'apps/desktop/src/main/main-window.ts'); + +describe('app-region hygiene', () => { + it('keeps drag exclusive to the window titlebar and carves action clusters with no-drag', async () => { + const allCss = stripCssComments(await readAllRendererCss()); + const dragMatches = [...allCss.matchAll(/-webkit-app-region:\s*drag/g)]; + assert.equal( + dragMatches.length, + 1, + `exactly one -webkit-app-region: drag declaration expected; found ${dragMatches.length}`, + ); + + const shell = stripCssComments(await readFile(SHELL_LAYOUT, 'utf8')); + assertCssRuleDecls( + shell, + '.maka-window-titlebar', + [ + /-webkit-app-region:\s*drag/, + /height:\s*calc\(\s*var\(--h-titlebar\)\s*-\s*var\(--maka-window-resize-edge\)\s*\)/, + /margin:\s*var\(--maka-window-resize-edge\)\s+var\(--maka-window-resize-edge\)\s+0/, + ], + 'titlebar must be the sole drag surface with resize-edge insets', + ); + assertCssRuleDecls( + shell, + '.maka-shell-topbar-rail', + [/-webkit-app-region:\s*no-drag/], + 'left titlebar rail must carve no-drag', + ); + assertCssRuleDecls( + shell, + '.maka-workspace-top-actions', + [/-webkit-app-region:\s*no-drag/], + 'workspace action cluster must carve no-drag', + ); + }); + + it('keeps sanitizeBounds floors and BrowserWindow minHeight aligned', async () => { + // Product truth today: SAFE_MIN_WIDTH is a restore/fixture floor in + // window-state.ts; BrowserWindow only sets minHeight (not minWidth). + // Do not claim a runtime width floor that is not wired. + const windowState = await readFile(WINDOW_STATE, 'utf8'); + const mainWindow = await readFile(MAIN_WINDOW, 'utf8'); + assert.match(windowState, /export const SAFE_MIN_WIDTH = 480;/); + assert.match(windowState, /export const SAFE_MIN_HEIGHT = 320;/); + assert.match( + mainWindow, + /minHeight:\s*SAFE_MIN_HEIGHT/, + 'BrowserWindow minHeight must share SAFE_MIN_HEIGHT with sanitizeBounds', + ); + assert.doesNotMatch( + mainWindow, + /minWidth:\s*SAFE_MIN_WIDTH/, + 'runtime minWidth is intentionally unset; restore floor is SAFE_MIN_WIDTH only', + ); + assert.match(mainWindow, /resizable:\s*true/, 'window must stay explicitly resizable'); + }); + + it('keeps native titleBarOverlay height aligned with --h-titlebar', async () => { + const tokens = stripCssComments( + await readFile(resolve(REPO_ROOT, 'apps/desktop/src/renderer/maka-tokens.css'), 'utf8'), + ); + const mainWindow = await readFile(MAIN_WINDOW, 'utf8'); + assert.match(tokens, /--h-titlebar:\s*36px;/); + assert.match( + mainWindow, + /const TITLEBAR_OVERLAY_HEIGHT = 36;/, + 'titleBarOverlay height must match --h-titlebar: 36px', + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/chat-shell-layout-contract.test.ts b/apps/desktop/src/main/__tests__/chat-shell-layout-contract.test.ts new file mode 100644 index 0000000000..0d3bac9a4b --- /dev/null +++ b/apps/desktop/src/main/__tests__/chat-shell-layout-contract.test.ts @@ -0,0 +1,151 @@ +/** + * Chat / workspace chrome layout contracts demoted from Electron e2e. + * + * These outcomes used to be measured with getBoundingClientRect after a cold + * start. The load-bearing fixes are pure CSS or source structure: pin them + * here with rule-scoped helpers (not cross-`}` regex). Keep content-visibility + * pin/warm-up and focus restoration in e2e. + */ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { + REPO_ROOT, + stripCssComments, + assertCssRuleDecls, + cssMediaBody, +} from './css-test-helpers.js'; + +const STYLES = resolve(REPO_ROOT, 'apps/desktop/src/renderer/styles'); +const CHROME_ACTIONS = resolve( + REPO_ROOT, + 'apps/desktop/src/renderer/app-shell-chrome-actions.tsx', +); + +async function readCss(name: string): Promise { + return stripCssComments(await readFile(resolve(STYLES, name), 'utf8')); +} + +describe('chat shell layout contracts', () => { + it('keeps ChatLayout flex contracts that kill the phantom dock range', async () => { + const css = await readCss('chat-header.css'); + assertCssRuleDecls( + css, + '.maka-chat-layout', + [/display:\s*flex/, /flex-direction:\s*column/, /min-height:\s*0/], + 'chat layout must be a column flex host with min-height 0', + ); + assertCssRuleDecls( + css, + '.maka-chat-layout > :first-child', + [/min-height:\s*0/, /flex:\s*1\s+0\s+auto/], + 'message area must flex without a 100% min-height phantom', + ); + assertCssRuleDecls( + css, + '.maka-chat-layout > :last-child', + [/flex-shrink:\s*0/], + 'composer/dock row must not shrink under the message area', + ); + }); + + it('keeps the project catalog as the only scroll region in the workspace menu', async () => { + const css = await readCss('composer.css'); + assertCssRuleDecls( + css, + '.maka-composer-project-scroll', + [/max-height:\s*224px/, /overflow-y:\s*auto/], + 'project list must scroll inside a capped region', + ); + }); + + it('caps the narrow session workbar so it cannot own the viewport', async () => { + const css = await readCss('chat-detail.css'); + const media = cssMediaBody(css, '(max-width: 990px)'); + assert.ok(media, 'narrow workbar media query must exist'); + assertCssRuleDecls( + media!, + '.maka-session-workbar', + [/max-height:\s*min\(\s*42dvh\s*,\s*360px\s*\)/], + 'narrow workbar must stay under 42dvh', + ); + }); + + it('keeps model picker marks square and labels ellipsized', async () => { + const css = await readCss('model-switcher.css'); + assertCssRuleDecls( + css, + '.modelPickerProviderMark', + [/width:\s*1rem/, /height:\s*1rem/, /flex:\s*0\s+0\s+1rem/], + 'provider marks must be a fixed 1rem square', + ); + assertCssRuleDecls( + css, + '.modelPickerOptionLabel', + [/overflow:\s*hidden/, /text-overflow:\s*ellipsis/, /white-space:\s*nowrap/], + 'long model labels must ellipsize', + ); + }); + + it('keeps bot onboarding QR frames square and image-filling', async () => { + const css = await readCss('settings/bot.css'); + assertCssRuleDecls( + css, + '.settingsBotOnboardingQrFrame', + [/width:\s*284px/, /height:\s*284px/, /place-items:\s*center/], + 'QR frame must be a fixed square', + ); + assertCssRuleDecls( + css, + '.settingsBotOnboardingQrFrame img', + [/width:\s*100%/, /height:\s*100%/, /object-fit:\s*contain/], + 'QR image must fill its frame', + ); + }); + + it('folds workspace secondary actions into one overflow menu in source', async () => { + const source = await readFile(CHROME_ACTIONS, 'utf8'); + assert.match( + source, + /export function AppShellWorkspaceTopActions/, + 'workspace top actions component must exist', + ); + assert.match(source, /copy\.moreActions/, 'overflow trigger must be the more-actions control'); + // Wiring, not just copy strings: empty onClick would still pass a name scan. + assert.match( + source, + /onClick=\{\(\)\s*=>\s*scheduleAfterMenuClose\(props\.onOpenFeedback\)\}/, + 'feedback menu item must schedule onOpenFeedback', + ); + assert.match( + source, + /onClick=\{\(\)\s*=>\s*scheduleAfterMenuClose\(props\.onOpenPalette\)\}/, + 'command palette menu item must schedule onOpenPalette', + ); + assert.match( + source, + /onClick=\{\(\)\s*=>\s*scheduleAfterMenuClose\(props\.onOpenHelp\)\}/, + 'help menu item must schedule onOpenHelp', + ); + assert.match( + source, + /onClick=\{\(\)\s*=>\s*scheduleAfterMenuClose\(props\.onOpenHealth\)\}/, + 'health menu item must schedule onOpenHealth', + ); + // Secondary actions only enter the menu, never as sibling IconButtons. + assert.doesNotMatch( + source, + /AppShellWorkspaceTopActions[\s\S]*?onOpenFeedback[\s\S]*? { describe('expandCssImports (fail closed on bad @import)', () => { @@ -74,6 +81,112 @@ describe('css-test-helpers', () => { }); }); + describe('cssRuleBody (stops at the target rule’s closing brace)', () => { + const sheet = ` +.maka-chat-layout { + display: flex; + flex-direction: column; + min-height: 0; +} +.maka-chat-layout > :first-child { + min-height: 0; + flex: 1 0 auto; +} +.maka-shell-topbar-rail { + display: flex; +} +.maka-workspace-top-actions { + -webkit-app-region: no-drag; +} +`; + + it('returns only the matched rule’s own declarations', () => { + const body = cssRuleBody(sheet, '.maka-chat-layout'); + assert.ok(body); + assert.match(body!, /display:\s*flex/); + assert.match(body!, /min-height:\s*0/); + assert.doesNotMatch(body!, /flex:\s*1\s+0\s+auto/); + }); + + it('fails closed when the property only lives on a later sibling rule', () => { + // Mutation: drop min-height from .maka-chat-layout; child still has it. + const mutated = sheet.replace( + /\.maka-chat-layout\s*\{[^}]*?min-height:\s*0;\s*/s, + '.maka-chat-layout {\n display: flex;\n flex-direction: column;\n', + ); + const body = cssRuleBody(mutated, '.maka-chat-layout'); + assert.ok(body); + assert.doesNotMatch(body!, /min-height:\s*0/); + // The naive cross-rule regex still "passes" — document the bug class. + const naive = /\.maka-chat-layout\s*\{[\s\S]*?min-height:\s*0;/; + assert.equal(naive.test(mutated), true, 'naive regex is the false-green pattern'); + assert.throws( + () => assertCssRuleDecls(mutated, '.maka-chat-layout', [/min-height:\s*0/]), + /must declare/, + ); + }); + + it('fails closed when no-drag only lives on a later action cluster', () => { + const body = cssRuleBody(sheet, '.maka-shell-topbar-rail'); + assert.ok(body); + assert.doesNotMatch(body!, /-webkit-app-region:\s*no-drag/); + assert.throws( + () => assertCssRuleDecls(sheet, '.maka-shell-topbar-rail', [/-webkit-app-region:\s*no-drag/]), + /must declare/, + ); + assert.doesNotThrow(() => + assertCssRuleDecls(sheet, '.maka-workspace-top-actions', [/-webkit-app-region:\s*no-drag/]), + ); + }); + + it('returns null for a missing selector', () => { + assert.equal(cssRuleBody(sheet, '.does-not-exist'), null); + }); + + it('does not match a right-hand combinator target as the rule selector', () => { + const withSibling = ` +.settingsOsPermissionRow + .settingsOsPermissionRow { + border-top: 1px solid red; +} +.settingsOsPermissionRow { + display: flex; + flex-wrap: wrap; +} +`; + const body = cssRuleBody(withSibling, '.settingsOsPermissionRow'); + assert.ok(body); + assert.match(body!, /display:\s*flex/); + assert.doesNotMatch(body!, /border-top/); + }); + }); + + describe('cssMediaBody', () => { + const sheet = ` +@media (max-width: 620px) { + .settingsRemoteAccessItemActions { + display: none; + } + .settingsBotStatusGrid { + grid-template-columns: 1fr; + } +} +@media (max-width: 990px) { + .maka-session-workbar { + max-height: min(42dvh, 360px); + } +} +`; + + it('extracts one media block without bleeding into the next', () => { + const body = cssMediaBody(sheet, '(max-width: 620px)'); + assert.ok(body); + assert.match(body!, /\.settingsRemoteAccessItemActions/); + assert.doesNotMatch(body!, /\.maka-session-workbar/); + const rule = cssRuleBody(body!, '.settingsRemoteAccessItemActions'); + assert.match(rule!, /display:\s*none/); + }); + }); + describe('assertCustomPropPinnedOnce', () => { it('accepts a single declaration with the exact value', () => { assert.doesNotThrow(() => assertCustomPropPinnedOnce('--font-weight-normal: 400;', '--font-weight-normal', '400')); diff --git a/apps/desktop/src/main/__tests__/css-test-helpers.ts b/apps/desktop/src/main/__tests__/css-test-helpers.ts index 27ceece3dd..104fb131d9 100644 --- a/apps/desktop/src/main/__tests__/css-test-helpers.ts +++ b/apps/desktop/src/main/__tests__/css-test-helpers.ts @@ -57,6 +57,80 @@ export function stripCssComments(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, ''); } +/** Escape a CSS selector for a RegExp, allowing flexible whitespace. */ +function escapeCssSelector(selector: string): string { + return selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); +} + +/** + * Slice the body of a `{ ... }` block starting at `openBraceIndex`. + * Brace-depth aware so nested blocks (e.g. inside `@media`) stay intact. + */ +function extractBraceBlock(source: string, openBraceIndex: number): string | null { + if (source[openBraceIndex] !== '{') return null; + let depth = 0; + for (let i = openBraceIndex; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) return source.slice(openBraceIndex + 1, i); + } + } + return null; +} + +/** + * Return the declaration body of the first rule whose selector matches, + * stopping at that rule's own closing `}`. + * + * Unlike `/selector\s*\{[\s\S]*?prop:/`, this does not crawl into later + * sibling rules. Removing a property from the target rule fails even when a + * child or neighbor rule still declares it — the cross-`}` false-green bug + * that demoted layout contracts used to have. + * + * The selector must start a rule (after `^`, `{`, or `}`), so a right-hand + * combinator target like `.row + .row` does not satisfy a search for `.row`. + */ +export function cssRuleBody(css: string, selector: string): string | null { + const stripped = stripCssComments(css); + const re = new RegExp(`(?:^|[\\{\\}])\\s*${escapeCssSelector(selector)}\\s*\\{`); + const match = re.exec(stripped); + if (!match) return null; + return extractBraceBlock(stripped, match.index + match[0].length - 1); +} + +/** + * Return the body of the first `@media { ... }` block. + * `mediaCondition` is the part after `@media`, e.g. `(max-width: 620px)`. + */ +export function cssMediaBody(css: string, mediaCondition: string): string | null { + const stripped = stripCssComments(css); + const cond = escapeCssSelector(mediaCondition); + const re = new RegExp(`@media\\s*${cond}\\s*\\{`); + const match = re.exec(stripped); + if (!match) return null; + return extractBraceBlock(stripped, match.index + match[0].length - 1); +} + +/** Assert a selector's own rule body matches each declaration pattern. */ +export function assertCssRuleDecls( + css: string, + selector: string, + decls: RegExp[], + message?: string, +): void { + const body = cssRuleBody(css, selector); + assert.ok(body != null, message ?? `rule ${selector} must exist`); + for (const decl of decls) { + assert.match( + body!, + decl, + message ?? `${selector} must declare ${decl} in its own rule body`, + ); + } +} + /** Ban non-literal `font:` shorthand in renderer CSS. * * `font:` shorthand can hide bare font-weight (`font: 600 12px sans-serif`), diff --git a/apps/desktop/src/main/__tests__/flat-shell-chrome-contract.test.ts b/apps/desktop/src/main/__tests__/flat-shell-chrome-contract.test.ts new file mode 100644 index 0000000000..e0a13eeed9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/flat-shell-chrome-contract.test.ts @@ -0,0 +1,63 @@ +/** + * Official flat AppShell chrome contract (#1312). + * + * The former e2e walked platform×theme combos measuring live box-shadow / + * background-image / opacity on the shell surfaces. Those outcomes are owned + * by source CSS: the shell must fill the window through min-width/min-height 0 + * + overflow hidden, paint a flat canvas/detail surface, and not attach a + * product gradient or box-shadow on the shell host. Pin the declarations on + * each rule body; keep interactive shell journeys in e2e. + */ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT, stripCssComments, cssRuleBody } from './css-test-helpers.js'; + +const SHELL_LAYOUT = resolve(REPO_ROOT, 'apps/desktop/src/renderer/styles/shell-layout.css'); +const REFERENCE = resolve(REPO_ROOT, 'apps/desktop/src/renderer/reference-shell.css'); + +async function readCss(path: string): Promise { + return stripCssComments(await readFile(path, 'utf8')); +} + +describe('flat shell chrome CSS contract', () => { + it('keeps the Astryx shell a full-window constrained flat host', async () => { + const shell = await readCss(SHELL_LAYOUT); + const appFrame = cssRuleBody(shell, '.appFrame'); + const host = cssRuleBody(shell, '.maka-shell-astryx'); + const detail = cssRuleBody(shell, '.maka-shell-astryx .maka-panel-detail'); + + assert.ok(appFrame, '.appFrame rule must exist'); + assert.match(appFrame!, /width:\s*100%/); + assert.match(appFrame!, /background:\s*var\(--surface-canvas\)/); + assert.doesNotMatch(appFrame!, /background-image\s*:/); + assert.doesNotMatch(appFrame!, /box-shadow\s*:/); + + assert.ok(host, '.maka-shell-astryx rule must exist'); + assert.match(host!, /width:\s*100%/); + assert.match(host!, /min-width:\s*0/); + assert.match(host!, /min-height:\s*0/); + assert.match(host!, /overflow:\s*hidden/); + assert.doesNotMatch(host!, /background-image\s*:/); + assert.doesNotMatch(host!, /box-shadow\s*:/); + assert.doesNotMatch(host!, /filter\s*:/); + assert.doesNotMatch(host!, /opacity\s*:/); + + assert.ok(detail, '.maka-shell-astryx .maka-panel-detail rule must exist'); + assert.match(detail!, /min-width:\s*0/); + assert.match(detail!, /min-height:\s*0/); + assert.match(detail!, /background:\s*var\(--background\)/); + assert.doesNotMatch(detail!, /background-image\s*:/); + assert.doesNotMatch(detail!, /box-shadow\s*:/); + }); + + it('keeps the reference shell flatness override present', async () => { + const reference = await readCss(REFERENCE); + assert.match( + reference, + /box-shadow:\s*none/, + 'reference shell flatness override must remain present', + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/settings-window-floor-contract.test.ts b/apps/desktop/src/main/__tests__/settings-window-floor-contract.test.ts new file mode 100644 index 0000000000..39594d4681 --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-window-floor-contract.test.ts @@ -0,0 +1,199 @@ +/** + * Settings window-floor layout contract (#1304 / #1361 / #1364). + * + * Pin load-bearing CSS on the rule that owns each declaration (not a + * cross-`}` scan). User-visible containment at SAFE_MIN_WIDTH remains a live + * e2e smoke in settings.spec.ts; product journeys stay in e2e. + */ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { + REPO_ROOT, + stripCssComments, + assertCssRuleDecls, + cssMediaBody, +} from './css-test-helpers.js'; + +const SETTINGS_CSS = resolve(REPO_ROOT, 'apps/desktop/src/renderer/styles/settings'); + +async function readSettingsCss(name: string): Promise { + return stripCssComments(await readFile(resolve(SETTINGS_CSS, name), 'utf8')); +} + +describe('settings window-floor CSS contract', () => { + it('keeps permission OS rows structurally wrapable with a readable body floor', async () => { + const css = await readSettingsCss('permission.css'); + + assertCssRuleDecls( + css, + '.settingsOsPermissionRow', + [/display:\s*flex/, /flex-wrap:\s*wrap/], + 'OS permission rows must wrap actions under the body instead of squeezing text to 0', + ); + assertCssRuleDecls( + css, + '.settingsOsPermissionRow > .settingsOsPermissionBody', + [/flex:\s*1\s+1\s+101px/, /min-width:\s*101px/], + 'permission body must keep the status-badge floor (~101px)', + ); + assertCssRuleDecls( + css, + '.settingsOsPermissionHeading strong', + [/min-width:\s*0/, /overflow-wrap:\s*anywhere/], + 'long permission titles must be allowed to shrink and break', + ); + assertCssRuleDecls( + css, + '.settingsPermissionSummary', + [/grid-template-columns:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*96px\s*,\s*1fr\s*\)\s*\)/], + 'permission summary must auto-fit from a legible track floor, not four hard tracks', + ); + assertCssRuleDecls( + css, + '.settingsCapabilityLayers', + [ + /grid-template-columns:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*min\(\s*150px\s*,\s*100%\s*\)\s*,\s*1fr\s*\)\s*\)/, + ], + 'capability layers must not hard-floor a 150px track past the content column', + ); + }); + + it('keeps health and usage metric strips auto-fit with legible floors', async () => { + const health = await readSettingsCss('health.css'); + const usage = await readSettingsCss('bot.css'); + + assertCssRuleDecls( + health, + '.settingsHealthSummary', + [/grid-template-columns:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*80px\s*,\s*1fr\s*\)\s*\)/], + 'health summary must auto-fit from an 80px floor', + ); + assertCssRuleDecls( + usage, + '.settingsUsageSummary', + [ + /grid-template-columns:\s*repeat\(\s*auto-fit\s*,\s*minmax\(\s*min\(\s*120px\s*,\s*100%\s*\)\s*,\s*1fr\s*\)\s*\)/, + ], + 'usage summary must auto-fit instead of four hard tracks', + ); + assertCssRuleDecls( + usage, + '.settingsUsagePage', + [/grid-template-columns:\s*minmax\(\s*0\s*,\s*1fr\s*\)/], + 'usage page must pin a 0-floor column so wide children scroll inside, not expand the page', + ); + assertCssRuleDecls( + usage, + '.settingsUsageTabsBar', + [/overflow-x:\s*auto/], + 'usage tabs must scroll within themselves at the window floor', + ); + }); + + it('wraps unbreakable web-search tokens and keeps inputs shrinkable', async () => { + const css = await readSettingsCss('web-search.css'); + + assertCssRuleDecls( + css, + '.settingsWebSearchDisabledReason', + [/overflow-wrap:\s*anywhere/], + 'env-var hint must wrap unbreakable tokens at the floor', + ); + assertCssRuleDecls( + css, + '.settingsWebSearchResult a', + [/overflow-wrap:\s*anywhere/], + 'result titles that are bare URLs must wrap inside the card', + ); + assertCssRuleDecls( + css, + '.settingsWebSearchResult small', + [/overflow-wrap:\s*anywhere/], + 'result hostnames must wrap inside the card', + ); + assertCssRuleDecls( + css, + '.settingsWebSearchKeyField, .settingsWebSearchQueryField', + [/min-width:\s*0/], + 'search inputs must be allowed to shrink below UA size at the floor', + ); + }); + + it('lets memory preview chrome wrap and keeps form surfaces min-width free', async () => { + const memory = await readSettingsCss('memory.css'); + const form = await readSettingsCss('form.css'); + const rows = await readSettingsCss('rows.css'); + + assertCssRuleDecls( + memory, + '.settingsMemoryPromptPreviewHeader', + [/flex-wrap:\s*wrap/], + 'memory preview header must wrap the status cluster under the title', + ); + assertCssRuleDecls( + form, + '.settingsSurface', + [/min-height:\s*0/, /overflow:\s*hidden/], + 'settings surface must be a constrained flex child', + ); + assertCssRuleDecls( + rows, + '.settingsRows', + [/overflow:\s*hidden/], + 'settings row cards must clip horizontal overflow of children', + ); + }); + + it('keeps remote-access rows and detail headers readable at the window floor', async () => { + const css = await readSettingsCss('bot.css'); + + assertCssRuleDecls( + css, + '.settingsRemoteAccessItemTitle', + [/flex-wrap:\s*wrap/, /overflow-wrap:\s*anywhere/], + 'channel titles must wrap status chips instead of overflowing', + ); + assertCssRuleDecls( + css, + '.settingsRemoteAccessItemDescription', + [/overflow-wrap:\s*anywhere/], + 'channel diagnostics must wrap unbreakable tokens', + ); + assertCssRuleDecls( + css, + '.settingsBotStatusGrid dd', + [/overflow-wrap:\s*anywhere/], + 'runtime status values must wrap inside the grid', + ); + + const media = cssMediaBody(css, '(max-width: 620px)'); + assert.ok(media, 'narrow remote-access media query must exist'); + assertCssRuleDecls( + media!, + '.settingsRemoteAccessItemActions', + [/display:\s*none/], + 'narrow overview must drop the redundant chevron column', + ); + assertCssRuleDecls( + media!, + '.settingsBotDetailHeaderBody', + [/grid-column:\s*1\s*\/\s*-1/], + 'narrow detail body must span the full header row', + ); + assertCssRuleDecls( + media!, + '.settingsBotStatusGrid', + [/grid-template-columns:\s*1fr/], + 'narrow runtime status must collapse to one column', + ); + // Grouped selector owns the stack — pin the group rule body. + assertCssRuleDecls( + media!, + '.settingsRemoteAccessSectionHeader, .settingsBotConfigurationHeader', + [/flex-direction:\s*column/], + 'narrow section headers must stack', + ); + }); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 44c42d3aa4..c117b7433d 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -125,7 +125,6 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'settings-daily-review', 'settings-permissions', 'settings-voice', - 'settings-search', 'settings-usage', 'settings-health', 'module-skills', @@ -547,8 +546,6 @@ function buildE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState | nul return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'permissions' }; case 'settings-voice': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'voice' }; - case 'settings-search': - return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'search' }; case 'settings-usage': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'usage' }; case 'settings-health': diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 53acf8456f..b4ef43b525 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts @@ -37,24 +37,6 @@ export async function writeSettings( settings.usage.range = 'all'; settings.usage.showDetails = true; } - // Settings → 联网搜索: a configured Tavily key so the live-query controls - // are enabled. The query itself is answered by the typed fixture in - // `main/web-search-e2e-fixture.ts` — no network round-trip in e2e. - if (scenario === 'settings-search') { - settings.webSearch = { - ...settings.webSearch, - enabled: true, - providers: { - ...settings.webSearch.providers, - tavily: { - ...settings.webSearch.providers.tavily, - apiKey: 'e2e-tavily-fixture-key', - credentialSource: 'saved', - credentialStatus: 'valid', - }, - }, - }; - } await writeJson(join(workspaceRoot, 'settings.json'), settings); } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 38d9239ad1..3d670e808d 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -83,8 +83,8 @@ const SHOW_FALLBACK_TIMEOUT_MS = 4000; // PR-WINDOW-TITLEBAR-0: the titleBarOverlay height matches the renderer // `--h-titlebar: 36px` token so the native control strip and the in-app top -// chrome share a baseline; `window-titlebar-contract.test.ts` fails if the two -// numbers drift. The overlay color/symbolColor are reused both at window +// chrome share a baseline; `app-region-hygiene-contract.test.ts` fails if the +// two numbers drift. The overlay color/symbolColor are reused both at window // creation (to avoid a first-frame flash against the window `backgroundColor`) // and on runtime mode/palette changes via `setTitleBarOverlayTheme` — Windows // only, which is why macOS passes the height alone. diff --git a/apps/desktop/src/main/web-search-e2e-fixture.ts b/apps/desktop/src/main/web-search-e2e-fixture.ts deleted file mode 100644 index 0832427af9..0000000000 --- a/apps/desktop/src/main/web-search-e2e-fixture.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { WebSearchResultRow } from '@maka/core'; - -/** - * #1364 review follow-up: typed web-search results for the `settings-search` - * e2e fixture. - * - * The Web Search page's containment contract is about the rendered result - * list — a bare-URL title, a long proportional title, and a long snippet are - * the widths that used to drag the whole page into horizontal overflow at the - * 480px window floor. A real Tavily round-trip cannot exercise that: e2e runs - * offline (CI has no key and no network), so without this fixture the test - * would stop at the no-key message and the result CSS could regress silently. - * - * Mirrors `webSearchLiveResults` in `stories/settings/settings-pages.stories.tsx` - * so the story baseline and the E2E contract describe the same page. The - * `settings-search` scenario also seeds a configured Tavily key (see - * `e2e-fixture/scenarios-settings.ts`) so the query controls are enabled. - * - * Production is untouched: this returns null unless the fixture scenario is - * active, and the IPC handler falls through to the real `queryTavily`. - */ -const FIXTURE_SCENARIO = 'settings-search'; - -export function webSearchResultsE2eFixture(): WebSearchResultRow[] | null { - if (process.env.MAKA_E2E_FIXTURE !== FIXTURE_SCENARIO) return null; - return [ - { - provider: 'tavily', - title: - 'Electron 窗口在 macOS Sequoia 上 vibrancy 失效的完整排查记录:从 NSVisualEffectView 到 CSS backdrop-filter 的九层封装', - url: 'https://blog.example-engineering-weekly.com/posts/2026/07/electron-vibrancy-regression-macos-sequoia-troubleshooting-notes-part-three', - snippet: - '本文覆盖 vibrancy 在 Sequoia 15.4 上的三类失效场景:窗口层级变化后 material 不再刷新、data-vibrancy 属性与 CSS 级联的竞态、以及 transparent 窗口在外接显示器上的合成器回退。附带最小复现仓库与九个已验证的 workaround,其中第七个(延迟一帧重设 backgroundColor)对 Electron 33 仍然有效。', - source: 'blog.example-engineering-weekly.com', - }, - { - provider: 'tavily', - title: 'Tavily API rate limits', - url: 'https://docs.tavily.com/rate-limits', - snippet: 'Standard plans allow 100 requests per minute.', - source: 'docs.tavily.com', - }, - { - provider: 'tavily', - title: - 'https://raw.githubusercontent.com/example/monorepo/refs/heads/main/packages/runtime/ARCHITECTURE.md', - url: 'https://raw.githubusercontent.com/example/monorepo/refs/heads/main/packages/runtime/ARCHITECTURE.md', - snippet: 'Runtime architecture notes.', - source: 'raw.githubusercontent.com', - }, - ]; -} diff --git a/apps/desktop/src/main/web-search-ipc-main.ts b/apps/desktop/src/main/web-search-ipc-main.ts index 3614f57b91..c56c56fa48 100644 --- a/apps/desktop/src/main/web-search-ipc-main.ts +++ b/apps/desktop/src/main/web-search-ipc-main.ts @@ -8,7 +8,6 @@ import type { WorkspacePrivacyContext } from '@maka/core/incognito'; import type { createSettingsStore } from '@maka/storage'; import { resolveTavilyApiKey } from './web-search/credentials.js'; import { queryTavily, TAVILY_TEST_LIMIT, TAVILY_TEST_QUERY } from './web-search/tavily.js'; -import { webSearchResultsE2eFixture } from './web-search-e2e-fixture.js'; type SettingsStore = ReturnType; @@ -50,11 +49,6 @@ export function registerWebSearchIpc(deps: WebSearchIpcDeps): void { message: '请先在 设置 · 联网搜索 中启用 Tavily。', }; } - // #1364: e2e runs offline — the `settings-search` fixture answers with - // deterministic hostile-width results AFTER the real validation and - // enablement gates above, so only the Tavily round-trip is replaced. - const fixtureResults = webSearchResultsE2eFixture(); - if (fixtureResults) return { ok: true as const, results: fixtureResults }; const effectiveKey = resolveTavilyApiKey({ settings, draftKey: request?.apiKey }); const limit = normalizeWebSearchLimit(request?.limit); return queryTavily({ apiKey: effectiveKey, query, limit }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f2ba17072b..5838ca1a8b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3626,9 +3626,8 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => driver.renames.length === 1); await waitFor(() => terminal.titles.includes('Raw title (Maka)')); - assert.equal( + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Session renamed to "Raw title"'), - true, ); exitMaka(terminal); diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 312a870cb5..f9d561b22b 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -54,7 +54,6 @@ export type E2eFixtureScenario = | 'settings-daily-review' | 'settings-permissions' | 'settings-voice' - | 'settings-search' | 'settings-usage' | 'settings-health' | 'module-skills' diff --git a/packages/runtime/src/invocation-context.ts b/packages/runtime/src/invocation-context.ts index bd2689e316..096bc29aeb 100644 --- a/packages/runtime/src/invocation-context.ts +++ b/packages/runtime/src/invocation-context.ts @@ -25,11 +25,11 @@ import type { EphemeralVoiceAudio } from '@maka/core/voice'; // ============================================================================ /** - * Where the invocation entered the runtime. Desktop, bot, and gateway should - * eventually share the same runner; `test` covers in-process fake-service - * invocations like the ones in this node's test suite. + * Where the invocation entered the runtime. Desktop, bot, and gateway share + * the same runner; `test` covers in-process fake-service invocations like the + * ones in this package's test suite. */ -export const INVOCATION_SOURCES = ['desktop', 'bot', 'test'] as const; +export const INVOCATION_SOURCES = ['desktop', 'bot', 'gateway', 'test'] as const; export type InvocationSource = (typeof INVOCATION_SOURCES)[number]; export function isInvocationSource(value: unknown): value is InvocationSource { diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index f4b072a4d8..e0caa43232 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -24,13 +24,31 @@ const TYPECHECK_ONLY_FILES = new Set([ 'tsconfig.lib.json', ]); -// Scripts the e2e job runs. Editing one of these changes what that job -// verifies, so it has to re-run — a unit test on the runner is not evidence -// that the run it drives still works. -const E2E_DRIVING_SCRIPTS = new Set([ - 'scripts/audit-alignment.mjs', - 'scripts/storybook-visual-smoke.mjs', -]); +// Scripts the Electron e2e job runs. Editing one of these changes what that +// job verifies, so it has to re-run — a unit test on the runner is not +// evidence that the run it drives still works. +const E2E_DRIVING_SCRIPTS = new Set(['scripts/audit-alignment.mjs']); + +// Scripts / paths that can break Storybook without touching product ship +// gates. Storybook is a catalog harness, not the product: typecheck already +// typechecks stories and checks annotations, unit/e2e cover product behavior. +// Running build+smoke on every desktop/ui PR taxes CI for almost no unique +// signal — only the catalog and its wiring need this job. +const STORYBOOK_DRIVING_SCRIPTS = new Set(['scripts/storybook-visual-smoke.mjs']); + +// .storybook/preview.tsx imports THEME_PALETTES from this module. Narrower +// than "any packages/core change". +const STORYBOOK_CORE_SETTINGS = 'packages/core/src/settings.ts'; + +function isStorybookPath(path) { + if (STORYBOOK_DRIVING_SCRIPTS.has(path) || path === STORYBOOK_CORE_SETTINGS) return true; + if (path === 'apps/desktop/.storybook' || path.startsWith('apps/desktop/.storybook/')) + return true; + if (path === 'apps/desktop/stories' || path.startsWith('apps/desktop/stories/')) return true; + // packages/ui also ships its own story tree (see .storybook/main.ts). + if (path === 'packages/ui/stories' || path.startsWith('packages/ui/stories/')) return true; + return false; +} const EXTENDED_SCRIPT_FILES = new Set([ 'scripts/check-cua-driver-bundle.mjs', @@ -110,6 +128,7 @@ export function planTests(changedFiles, options = {}) { runtimeSandbox: graph.dirs.includes('packages/cli'), scriptMode: 'full', storageStress: graph.dirs.includes('packages/storage'), + storybook: true, workspaces: [...graph.dirs], }; } @@ -162,22 +181,15 @@ export function planTests(changedFiles, options = {}) { return { code, - // Gates every renderer verification: the Electron E2E suite, the alignment - // audit, and the Storybook build/smoke. Deliberately keyed on DIRECT - // workspace changes, not the reverse-dependency closure — a storage or - // runtime change must not drag the renderer suites along. @maka/core is - // the one exception: .storybook/preview.tsx reads THEME_PALETTES straight - // out of packages/core/src/settings.ts, so a core change can break the - // Storybook build without touching apps/desktop or packages/ui. + // Electron E2E + alignment audit. Direct desktop/ui only — a storage or + // runtime change must not drag cold Electron boots. // - // The scripts that DRIVE those suites belong here too. `scripts/**` only - // sets scriptMode, so without this a change to the smoke runner itself was - // verified by its unit tests and never by the run it orchestrates — the one - // change most able to make the guard silently stop guarding. + // Scripts that DRIVE the suite belong here too. `scripts/**` only sets + // scriptMode, so without this a change to the auditor itself was verified + // by its unit tests and never by the run it orchestrates. e2e: directWorkspaces.has('apps/desktop') || directWorkspaces.has('packages/ui') || - directWorkspaces.has('packages/core') || files.some((path) => E2E_DRIVING_SCRIPTS.has(path)), full: false, headless: workspaces.includes('packages/headless'), @@ -188,6 +200,10 @@ export function planTests(changedFiles, options = {}) { runtimeSandbox: workspaces.includes('packages/cli'), scriptMode, storageStress, + // Storybook build + smoke: catalog/harness only. Not every desktop/ui/core + // PR — product ship gates are typecheck, unit, and Electron e2e. See + // isStorybookPath. + storybook: files.some((path) => isStorybookPath(path)), workspaces, }; } @@ -201,6 +217,7 @@ export function formatGitHubOutputs(plan) { `runtime_sandbox=${plan.runtimeSandbox}`, `script_mode=${plan.scriptMode}`, `storage_stress=${plan.storageStress}`, + `storybook=${plan.storybook}`, `unit=${plan.workspaces.length > 0}`, `workspaces=${plan.workspaces.join(',')}`, ].join('\n'); diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 6c37a7ff85..189fbed4fc 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -11,23 +11,46 @@ test('impact planning distinguishes docs, UI, and backend changes', () => { const ui = planTests(['packages/ui/src/button.tsx'], { graph }); assert.equal(ui.e2e, true); + // Product UI work is not a Storybook catalog change — typecheck/unit/e2e own it. + assert.equal(ui.storybook, false); assert.equal(ui.scriptMode, 'none'); assert.deepEqual(ui.workspaces, ['packages/ui', 'apps/desktop']); - // .storybook/preview.tsx reads THEME_PALETTES from @maka/core, and the e2e - // job now builds and smokes Storybook, so a core change must reach it. - assert.equal(planTests(['packages/core/src/settings.ts'], { graph }).e2e, true); + // Ordinary desktop product files must not drag Storybook Chromium. + assert.equal(planTests(['apps/desktop/src/main/main.ts'], { graph }).storybook, false); + assert.equal(planTests(['apps/desktop/e2e/settings.spec.ts'], { graph }).storybook, false); - // A script the e2e job RUNS must re-run it; a script it does not stays off. - // Otherwise the smoke runner is the one file that can stop guarding without - // the guard ever running against the change. + // Catalog + harness only. + assert.equal( + planTests(['apps/desktop/stories/app-shell.stories.tsx'], { graph }).storybook, + true, + ); + assert.equal(planTests(['apps/desktop/.storybook/preview.tsx'], { graph }).storybook, true); + assert.equal(planTests(['packages/ui/stories/composer.stories.tsx'], { graph }).storybook, true); + + // .storybook/preview.tsx reads THEME_PALETTES from this one core module. + // Other core paths must not force Storybook. + const coreSettings = planTests(['packages/core/src/settings.ts'], { graph }); + assert.equal(coreSettings.storybook, true); + assert.equal(coreSettings.e2e, false); + assert.equal(planTests(['packages/core/src/index.ts'], { graph }).storybook, false); + + // A script the Storybook job RUNS must re-run Storybook, not Electron e2e. const smoke = planTests(['scripts/storybook-visual-smoke.mjs'], { graph }); - assert.equal(smoke.e2e, true); + assert.equal(smoke.storybook, true); + assert.equal(smoke.e2e, false); assert.equal(smoke.scriptMode, 'fast'); assert.equal(planTests(['scripts/check-story-annotations.mjs'], { graph }).e2e, false); + assert.equal(planTests(['scripts/check-story-annotations.mjs'], { graph }).storybook, false); + + // Alignment auditor drives the e2e job (not Storybook). + const alignment = planTests(['scripts/audit-alignment.mjs'], { graph }); + assert.equal(alignment.e2e, true); + assert.equal(alignment.storybook, false); const backend = planTests(['packages/storage/src/session-store.ts'], { graph }); assert.equal(backend.e2e, false); + assert.equal(backend.storybook, false); for (const workspace of ['packages/storage', 'packages/runtime', 'apps/desktop']) { assert.ok(backend.workspaces.includes(workspace)); } @@ -71,6 +94,7 @@ test('global and unknown production changes fail safe to the complete suite', () const plan = planTests([path], { graph }); assert.equal(plan.full, true); assert.equal(plan.e2e, true); + assert.equal(plan.storybook, true); assert.deepEqual(plan.workspaces, graph.dirs); } });