diff --git a/apps/desktop/e2e/agent-graph-layout.spec.ts b/apps/desktop/e2e/agent-graph-layout.spec.ts deleted file mode 100644 index 8739520196..0000000000 --- a/apps/desktop/e2e/agent-graph-layout.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, test } from './fixtures'; - -test('production AgentGraphPanel keeps its heading visible without covering scrolled content', async ({ agentGraphWindow: page }) => { - const panel = page.getByRole('region', { name: 'Agent Graph', exact: true }); - await expect(panel).toBeVisible(); - await expect(panel.locator('.maka-agent-graph-operators > li')).toHaveCount(24); - const content = panel.locator('.maka-agent-graph-content'); - const before = await panel.evaluate((element) => { - const heading = element.querySelector('.maka-agent-graph-heading'); - const content = element.querySelector('.maka-agent-graph-content'); - if (!(heading instanceof HTMLElement) || !(content instanceof HTMLElement)) { - throw new Error('Agent Graph production panel structure is incomplete'); - } - const panelRect = element.getBoundingClientRect(); - const headingRect = heading.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - return { - panelHeight: panelRect.height, - headingTop: headingRect.top, - headingBottom: headingRect.bottom, - contentTop: contentRect.top, - contentScrollHeight: content.scrollHeight, - contentClientHeight: content.clientHeight, - contentScrollTop: content.scrollTop, - }; - }); - expect(before.contentScrollHeight).toBeGreaterThan(before.contentClientHeight); - await content.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - const after = await panel.evaluate((element) => { - const heading = element.querySelector('.maka-agent-graph-heading'); - const content = element.querySelector('.maka-agent-graph-content'); - if (!(heading instanceof HTMLElement) || !(content instanceof HTMLElement)) { - throw new Error('Agent Graph production panel structure is incomplete'); - } - const headingRect = heading.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - return { - headingTop: headingRect.top, - headingBottom: headingRect.bottom, - contentTop: contentRect.top, - contentScrollTop: content.scrollTop, - }; - }); - expect(after.contentScrollTop).toBeGreaterThan(0); - expect(Math.abs(after.headingTop - before.headingTop)).toBeLessThanOrEqual(1); - expect(Math.abs(after.contentTop - before.contentTop)).toBeLessThanOrEqual(1); - expect(after.contentTop).toBeGreaterThanOrEqual(after.headingBottom); - const collapse = page.getByRole('button', { name: '收起 Agent Graph', exact: true }); - await collapse.click(); - await expect(panel).toHaveAttribute('data-collapsed', 'true'); - // The data attribute is committed before a newly loaded stylesheet has - // necessarily completed its first style recalculation. Wait for the - // collapsed contract rather than sampling that transient pre-CSS value. - await expect - .poll(async () => panel.locator('.maka-agent-graph-heading').evaluate((element) => { - const style = getComputedStyle(element); - return { paddingBottom: style.paddingBottom, borderBottomWidth: style.borderBottomWidth }; - })) - .toEqual({ paddingBottom: '0px', borderBottomWidth: '0px' }); - const collapsedHeading = await panel.locator('.maka-agent-graph-heading').evaluate((element) => { - const style = getComputedStyle(element); - return { paddingBottom: style.paddingBottom, borderBottomWidth: style.borderBottomWidth }; - }); - expect(collapsedHeading.paddingBottom).toBe('0px'); - expect(collapsedHeading.borderBottomWidth).toBe('0px'); -}); diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts deleted file mode 100644 index e90ff398ad..0000000000 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ /dev/null @@ -1,406 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, test, COMPOSER_INPUT, waitForInvocableSkills } from './fixtures'; - -type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; - -declare global { - interface Window { - /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ - makaE2eLatch?: { - arm(key: LatchKey, options?: { oneShot?: boolean }): void; - release(key: LatchKey): void; - reject(key: LatchKey, message: string): void; - }; - } -} - -/** - * Hold the next call (or every call) to one bridge method until released, so - * an IPC in-flight window can be observed deterministically instead of raced - * against the fake backend's near-instant replies. Installed by the preload - * under the isolated-E2E gate; its absence in an E2E window is a wiring bug, - * not a reason to skip. - */ -async function armBridgeLatch( - page: import('@playwright/test').Page, - key: LatchKey, - options?: { oneShot?: boolean }, -): Promise { - const armed = await page.evaluate(({ key: latchKey, options: latchOptions }) => { - if (!window.makaE2eLatch) return false; - window.makaE2eLatch.arm(latchKey, latchOptions); - return true; - }, { key, options }); - expect(armed, 'the preload E2E latch is installed').toBe(true); -} - -async function releaseBridgeLatch( - page: import('@playwright/test').Page, - key: LatchKey, -): Promise { - await page.evaluate((latchKey) => window.makaE2eLatch?.release(latchKey), key); -} - -async function rejectBridgeLatch( - page: import('@playwright/test').Page, - key: LatchKey, -): Promise { - await page.evaluate( - (latchKey) => window.makaE2eLatch?.reject(latchKey, 'forced E2E bridge failure'), - key, - ); -} - -// The fake echo can paint before Runtime Host publishes terminal turn ownership. -// Use the catalog's known-empty live set as the barrier before latching its next read. -async function waitForSessionTurnToSettle( - page: import('@playwright/test').Page, -): Promise { - await expect - .poll(async () => - page.evaluate(async () => { - const sessions = await window.maka.sessions.list(); - return sessions[0]?.runningTurnIds ?? null; - }), - ) - .toEqual([]); -} - -/** - * Toggling Plan from the + menu must not move the menu. - * - * The toggle changes the new chat's collaboration mode, which re-fetches the - * invocable-Skill projection. That refresh clears the list fail-closed for the - * `/` popup, and the regression this spec pins is the Skills row reading the - * transient `[]` as "no skills available": it grayed out and grew a - * description line for the length of the round trip, so the open menu's - * geometry blinked on every Plan click (MatrixA/fix-plan-click-flicker). - * - * The watcher is armed in-page BEFORE the click: the blink lives inside one - * IPC round trip and is gone by the time a polling assertion could look. - */ - -interface PlusMenuWatch { - noSkillsTextAppeared: boolean; - skillsRowDisabled: boolean; - planRowDisabled: boolean; - heights: number[]; -} - -declare global { - interface Window { - __plusMenuWatch?: PlusMenuWatch; - __plusMenuWatchStop?: () => void; - } -} - -test('toggling Plan keeps the + menu open, enabled and the same size', async ({ - invocableSkillsWindow: page, -}) => { - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - await expect(menu).toBeVisible(); - - const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); - const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); - // The seeded catalog has settled before the fixture yields the page, so the - // baseline is an enabled row with no caveat — what must survive the toggle. - await expect(skillsRow).not.toHaveAttribute('aria-disabled', 'true'); - await expect(menu).not.toContainText('当前没有可用技能'); - - // The layer scales in on open (translate + scale 0.95 → 1), and a bounding - // box read mid-entrance is smaller than the resting one. Let the entrance - // finish so the recorded baseline is the height the menu must keep. - await menu.evaluate(async (menuElement) => { - const layer = menuElement.closest('[popover]') ?? menuElement; - await Promise.all( - layer.getAnimations().map((animation) => animation.finished.catch(() => {})), - ); - }); - - await menu.evaluate((menuElement) => { - const watch: PlusMenuWatch = { - noSkillsTextAppeared: false, - skillsRowDisabled: false, - planRowDisabled: false, - heights: [menuElement.getBoundingClientRect().height], - }; - const inspect = () => { - if (menuElement.textContent?.includes('当前没有可用技能')) { - watch.noSkillsTextAppeared = true; - } - for (const row of menuElement.querySelectorAll('[aria-disabled="true"]')) { - if (row.textContent?.includes('选择技能')) watch.skillsRowDisabled = true; - if (row.getAttribute('role') === 'menuitemcheckbox') watch.planRowDisabled = true; - } - const height = menuElement.getBoundingClientRect().height; - const last = watch.heights[watch.heights.length - 1] ?? height; - if (Math.abs(height - last) > 0.5) watch.heights.push(height); - }; - const observer = new MutationObserver(inspect); - observer.observe(menuElement, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - }); - window.__plusMenuWatch = watch; - window.__plusMenuWatchStop = () => { - inspect(); - observer.disconnect(); - }; - }); - - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); - // The mode mark lands on the footer while the menu stays where it was. - await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toBeVisible(); - await expect(menu).toBeVisible(); - - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); - await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0); - await expect(menu).toBeVisible(); - - // Both refreshes the two toggles kicked off have reached the backend once - // this resolves; the margin covers the renderer commit that follows. - await waitForInvocableSkills(page, ['project-only', 'workspace-only']); - await page.waitForTimeout(250); - - const watch = await page.evaluate(() => { - window.__plusMenuWatchStop?.(); - return window.__plusMenuWatch; - }); - expect(watch, 'the in-page watcher survived the journey').toBeTruthy(); - expect(watch?.noSkillsTextAppeared, 'no transient "no skills" line').toBe(false); - expect(watch?.skillsRowDisabled, 'the Skills row never grayed out').toBe(false); - expect(watch?.planRowDisabled, 'the Plan row never grayed out').toBe(false); - expect(watch?.heights, 'the menu kept one height throughout').toHaveLength(1); -}); - -test('a Skills click during the catalog refresh does nothing, then works settled', async ({ - invocableSkillsWindow: page, -}) => { - // The row's enabled look mid-refresh is a held presentation of the previous - // catalog; acting on it would type a stray `/` against the fail-closed - // list. Hold the refresh open on a latch and click straight into it. - await armBridgeLatch(page, 'newTasks.listInvocableSkills'); - - const composer = page.locator(COMPOSER_INPUT); - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); - const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); - - // The Plan toggle starts the (now latched) refresh; the row announces the - // held state — busy to assistive technology, a class for styling and tests - // — and a click inside the window has no effect at all. - await planRow.click(); - await expect(skillsRow).toHaveClass(/maka-composer-skills-loading/); - await expect(skillsRow).toHaveAttribute('aria-busy', 'true'); - await skillsRow.click(); - await expect(menu).toBeVisible(); - await expect(composer).toHaveText(''); - await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0); - - // Keyboard activation is the same no-op: Enter on the focused row neither - // closes the menu nor writes the slash. - await skillsRow.focus(); - await page.keyboard.press('Enter'); - await expect(menu).toBeVisible(); - await expect(composer).toHaveText(''); - await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0); - - // Released, the same activation opens the `/` popup as usual. Re-focus the - // row first: the settle re-renders the composer input's trigger config, - // which takes focus back to the editor (long-standing behavior on every - // catalog refresh, independent of this journey). - await releaseBridgeLatch(page, 'newTasks.listInvocableSkills'); - await expect(skillsRow).not.toHaveClass(/maka-composer-skills-loading/); - await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true'); - await skillsRow.focus(); - await page.keyboard.press('Enter'); - await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); -}); - -test('a context switch re-enters loading instead of holding the old catalog', async ({ - invocableSkillsWindow: page, -}) => { - // Populate and settle a session-scoped catalog first. - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('alpha-marker'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); - - // Switching to a new chat is a context change: the new-task catalog is - // latched, so the Skills row must present as loading — deferring activation - // — rather than as the previous session's settled, actionable catalog. - await armBridgeLatch(page, 'newTasks.listInvocableSkills'); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(composer).toHaveText(''); - - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); - await expect(skillsRow).toHaveAttribute('aria-busy', 'true'); - await skillsRow.click(); - await expect(menu).toBeVisible(); - await expect(composer).toHaveText(''); - - await releaseBridgeLatch(page, 'newTasks.listInvocableSkills'); - await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true'); - await skillsRow.click(); - await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); -}); - -test('two rapid Plan toggles land on the last requested state', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('alpha-marker'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); - await waitForSessionTurnToSettle(page); - - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); - // The echo can land while the turn is still settling, and mode changes are - // refused mid-turn; wait for the row to become actionable. - await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); - - // The session-list refresh is the tail of a Plan commit: latching its next - // call keeps the commit pending — deterministically — after the mode has - // already landed and the row repainted checked. Armed only now, so the - // send's own refreshes above cannot consume the latch. - await armBridgeLatch(page, 'sessions.list', { oneShot: true }); - - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); - - // Second click while the first commit is still pending: the latest ask is - // OFF, and it must not be dropped just because the registry is busy. - await planRow.click(); - - // The queued intent drains after the in-flight commit settles: OFF wins. - await releaseBridgeLatch(page, 'sessions.list'); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); -}); - -test('a failed catalog refresh keeps the committed Plan state visible', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('alpha-marker'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); - await waitForSessionTurnToSettle(page); - - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); - await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); - - await armBridgeLatch(page, 'sessions.list', { oneShot: true }); - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); - - await rejectBridgeLatch(page, 'sessions.list'); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); -}); - -test('latest Plan intent still reaches the Host after a catalog refresh fails', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('alpha-marker'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); - await waitForSessionTurnToSettle(page); - - await page.getByRole('button', { name: '添加上下文' }).click(); - const planRow = page.getByRole('menu', { name: '添加上下文' }) - .getByRole('menuitemcheckbox', { name: 'Plan' }); - await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); - - await armBridgeLatch(page, 'sessions.list', { oneShot: true }); - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); - await planRow.click(); - await rejectBridgeLatch(page, 'sessions.list'); - - await expect.poll(async () => page.evaluate(async () => { - const sessions = await window.maka.sessions.list(); - return sessions[0]?.collaborationMode; - })).toBe('agent'); - await expect(planRow).toHaveAttribute('aria-checked', 'false'); -}); - -test('removing the session while a toggle is pending settles clean', async ({ - invocableSkillsWindow: page, -}) => { - // pending → cleanup → settle: the Session's renderer lifecycle ends while a - // mode commit (and a queued follow-up intent) is still in flight. Cleanup - // must drop the queued ask with the rest of the Session state, so the - // commit's tail has nothing to replay against the removed Session. - // - // Archiving is what ends that lifecycle from the rail now that deleting has - // moved to Settings. It is the same ending as far as this case is concerned: - // `archiveSession` clears the active id, the active messages and the whole - // family's renderer state, exactly as removal did. - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('alpha-marker'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); - await waitForSessionTurnToSettle(page); - - await page.getByRole('button', { name: '添加上下文' }).click(); - const menu = page.getByRole('menu', { name: '添加上下文' }); - const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); - // Mode changes are refused mid-turn; wait for the row to become actionable. - await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); - await armBridgeLatch(page, 'sessions.list', { oneShot: true }); - await planRow.click(); - await expect(planRow).toHaveAttribute('aria-checked', 'true'); - await planRow.click(); - await page.keyboard.press('Escape'); - await expect(menu).not.toBeVisible(); - - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const row = sidebar.locator('[data-maka-contract="session-row"]').first(); - await row.hover(); - await row.getByRole('button', { name: '任务操作' }).click(); - await page.getByRole('menuitem', { name: '归档', exact: true }).click(); - - await releaseBridgeLatch(page, 'sessions.list'); - await expect(sidebar.locator('[data-maka-contract="session-row"]')).toHaveCount(0); - await expect(page.getByRole('alertdialog')).toHaveCount(0); - // The composer is back on a clean new chat: no error surfaced and no stale - // Plan state re-applied by the drained commit. - await expect(composer).toBeVisible(); - await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index c62b9d4ae0..aa5f733143 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -416,7 +416,6 @@ async function withE2eWindow( locale, platform, showWindow, - scrollMotion, invocableSkills, gitReviewExtraFiles, parentRemovalSessions, @@ -427,8 +426,6 @@ async function withE2eWindow( readinessSelector: string; e2eFixtureScenario?: string; locale?: 'zh-CN' | 'zh-TW' | 'en'; - /** Opt this window back into animated scrolling; see `scroll-motion-policy`. */ - scrollMotion?: 'auto' | 'smooth'; /** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */ platform?: 'darwin' | 'win32' | 'linux'; /** Show fixtures whose contract depends on compositor-paced frames. */ @@ -473,7 +470,6 @@ async function withE2eWindow( scenario: e2eFixtureScenario, locale, platform, - scrollMotion, showWindow: visibleWindow, }), }); @@ -519,62 +515,16 @@ async function withE2eWindow( } } -interface PromptRailWorker { - app: ElectronApplication; - page: Page; - viewport: { width: number; height: number }; -} - -async function setPromptRailWindowVisible( - worker: PromptRailWorker, - visible: boolean, -): Promise { - await worker.app.evaluate(({ BrowserWindow }, shouldShow) => { - const window = BrowserWindow.getAllWindows()[0]; - if (!window) throw new Error('the prompt-rail BrowserWindow is missing'); - // showInactive, not show: this worker window is re-revealed between every - // test in the file, and show() activates the app each time — a suite run - // would yank the developer's foreground away a dozen times over. The - // window still needs to be on screen for the compositor. - if (shouldShow) window.showInactive(); - else window.hide(); - }, visible); -} - -async function resetPromptRailWindow(worker: PromptRailWorker): Promise { - await worker.page.evaluate(async () => { - const controls = ( - window as typeof window & { - makaE2eLatch?: { - releaseRendererObservations(): Promise; - }; - } - ).makaE2eLatch; - if (!controls) throw new Error('the isolated E2E controls are unavailable'); - await controls.releaseRendererObservations(); - localStorage.clear(); - sessionStorage.clear(); - }); - await worker.page.setViewportSize(worker.viewport); - await worker.page.reload(); - await worker.page.waitForSelector('[data-turn-id]', { timeout: 20_000 }); -} - type E2eTestFixtures = { window: Page; - agentGraphWindow: Page; - onboardingWindow: Page; gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; - linkColorWindow: Page; projectSidebarWindow: Page; parentRemovalWindow: Page; railRenderWindow: Page; promptRailWindow: Page; threadSearchWindow: Page; - partialHistoryWindow: Page; requestHeaderRowWindow: Page; - permissionCenterWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; accessibilityNarrativeWindow: Page; @@ -582,7 +532,6 @@ type E2eTestFixtures = { type E2eWorkerFixtures = { isolatedDisplay: void; - promptRailWorker: PromptRailWorker; }; export const test = base.extend({ @@ -623,26 +572,6 @@ export const test = base.extend({ window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN' }, use); }, - agentGraphWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-agent-graph-panel', - e2eFixtureScenario: 'agent-graph-layout', - locale: 'zh-CN', - showWindow: true, - }, - use, - ); - }, - onboardingWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '[data-maka-contract="onboarding-card"]', - locale: 'zh-CN', - showWindow: true, - }, use); - }, gitReviewWindow: async ({}, use) => { await withE2eWindow( { @@ -668,13 +597,6 @@ export const test = base.extend({ invocableSkills: true, }, use); }, - linkColorWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '.settingsBotConfigDocLink', - e2eFixtureScenario: 'settings-bots-onboarding', - }, use); - }, // Seeded connection so the composer is ready, plus one registered Project so // the workspace picker under it has a second target to move to. newTaskTargetWindow: async ({}, use) => { @@ -720,10 +642,10 @@ export const test = base.extend({ use, ); }, - // Keep this scenario's real Electron + Host composition warm for the worker, - // while the test-scoped wrapper below restores Host and renderer state - // between tests. Tests on it may run a Turn, so the reset is not read-only. - promptRailWorker: [async ({}, use) => { + // A multi-prompt transcript. Each cost assertion gets an isolated Host and + // renderer so observation state cannot bleed between tests. The window is + // shown because these cases drive the real compositor through CDP. + promptRailWindow: async ({}, use) => { await withE2eWindow({ seed: false, // A rendered turn, deliberately not the rail: Playwright treats a @@ -737,22 +659,7 @@ export const test = base.extend({ // passes on a Chinese desktop and cannot find it on an English CI runner. locale: 'zh-CN', showWindow: true, - }, async (page, { app }) => { - const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight })); - await use({ app, page, viewport }); - }); - }, { scope: 'worker' }], - // A multi-prompt transcript. Shown, because the perf suite that measures it - // reads real frame pacing, and a throttled compositor paces nothing a user - // would see. - promptRailWindow: async ({ promptRailWorker }, use) => { - await setPromptRailWindowVisible(promptRailWorker, true); - try { - await resetPromptRailWindow(promptRailWorker); - await use(promptRailWorker.page); - } finally { - await setPromptRailWindowVisible(promptRailWorker, false); - } + }, use); }, // The same seeded transcript, on a window of its own. Search reads the Host // through the bridge and renders nothing, so it needs neither the warm @@ -766,17 +673,6 @@ export const test = base.extend({ locale: 'zh-CN', }, use); }, - // A transcript larger than the bounded Desktop range. Clicking an unloaded - // prompt exercises the real load-around path and its partial-history UI. - partialHistoryWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '[data-turn-id]', - e2eFixtureScenario: 'chat-partial-history', - locale: 'zh-CN', - showWindow: true, - }, use); - }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box @@ -790,15 +686,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - permissionCenterWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '.settingsCapabilityGroup', - e2eFixtureScenario: 'settings-permissions', - locale: 'zh-CN', - showWindow: true, - }, use); - }, // A data-backed conversation with settled tool evidence and the workbar open // beside it. Shown because the accessibility journey follows real native // focus order through the transcript into the composer controls. diff --git a/apps/desktop/e2e/link-color-contract.spec.ts b/apps/desktop/e2e/link-color-contract.spec.ts deleted file mode 100644 index 16cd371faa..0000000000 --- a/apps/desktop/e2e/link-color-contract.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures'; - -async function renderedLinkColors(page: Page, dark: boolean) { - return page.evaluate(async (isDark) => { - const root = document.documentElement; - const renderedLink = document.querySelector('.settingsBotConfigDocLink')!; - renderedLink.style.setProperty('transition', 'none', 'important'); - root.setAttribute('data-maka-theme', 'tokyo-night'); - // Both halves, exactly as theme.ts setDarkClass does it: the class carries - // the mode to Astryx, and color-scheme is what resolves the palette's - // light-dark() pairs (DESIGN.md §8). Toggling the class alone leaves every - // colour on its light value. - root.classList.toggle('dark', isDark); - root.style.colorScheme = isDark ? 'dark' : 'light'; - - // Astryx's re-declares color-scheme on its own wrapper from React - // state that follows the class through a MutationObserver, so the app's - // subtree lands a commit after the root does. Wait for the mode to reach - // the content rather than for a frame: a rAF is not always enough, and a - // fixed delay would be a race with a number on it. - const settled = () => getComputedStyle(renderedLink).colorScheme === (isDark ? 'dark' : 'light'); - for (let attempt = 0; attempt < 120 && !settled(); attempt += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - - const resolve = (value: string) => { - const probe = document.createElement('span'); - probe.style.setProperty('color', value, 'important'); - // Inside the themed subtree, next to the link it is compared against — - // document.body sits OUTSIDE Astryx's wrapper and so resolves its - // light-dark() pairs against the root's color-scheme instead. - renderedLink.parentElement!.appendChild(probe); - const color = getComputedStyle(probe).color; - probe.remove(); - return color; - }; - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext('2d', { willReadFrequently: true })!; - const rgba = (value: string) => { - context.clearRect(0, 0, 1, 1); - context.fillStyle = value; - context.fillRect(0, 0, 1, 1); - return [...context.getImageData(0, 0, 1, 1).data]; - }; - const colors = { - link: rgba(resolve('var(--link)')), - solid: rgba(resolve('var(--accent-solid)')), - accent: rgba(resolve('var(--accent)')), - rendered: rgba(getComputedStyle(renderedLink).color), - }; - return colors; - }, dark); -} - -test('link text follows the solid accent tier in light and dark palettes', async ({ - linkColorWindow: page, -}) => { - const light = await renderedLinkColors(page, false); - const dark = await renderedLinkColors(page, true); - for (const colors of [light, dark]) { - expect(colors.link).toEqual(colors.solid); - expect(colors.link).not.toEqual(colors.accent); - expect(colors.rendered).toEqual(colors.link); - } - expect(dark.link).not.toEqual(light.link); -}); diff --git a/apps/desktop/e2e/onboarding-viewport.spec.ts b/apps/desktop/e2e/onboarding-viewport.spec.ts deleted file mode 100644 index 0605dd3b49..0000000000 --- a/apps/desktop/e2e/onboarding-viewport.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, test } from './fixtures'; - -test('first-run onboarding stays within the chat viewport', async ({ onboardingWindow }) => { - const geometry = await onboardingWindow.evaluate(() => { - const pageRoot = document.scrollingElement; - const scrollContainer = document.querySelector('[data-chat-scroll-container="true"]'); - const surface = document.querySelector('[data-maka-contract="onboarding-surface"]'); - const card = document.querySelector('[data-maka-contract="onboarding-card"]'); - if (!pageRoot || !scrollContainer || !surface || !card) { - throw new Error('Onboarding geometry is unavailable'); - } - - const scrollRect = scrollContainer.getBoundingClientRect(); - const surfaceRect = surface.getBoundingClientRect(); - const cardRect = card.getBoundingClientRect(); - return { - pageClientHeight: pageRoot.clientHeight, - pageScrollHeight: pageRoot.scrollHeight, - clientHeight: scrollContainer.clientHeight, - scrollHeight: scrollContainer.scrollHeight, - surfaceTop: surfaceRect.top, - surfaceBottom: surfaceRect.bottom, - viewportTop: scrollRect.top, - viewportBottom: scrollRect.bottom, - onboardingLayout: scrollContainer.dataset.makaOnboarding, - cardTop: cardRect.top, - cardBottom: cardRect.bottom, - }; - }); - - expect(geometry.pageScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.pageClientHeight, - ); - expect(geometry.scrollHeight, JSON.stringify(geometry, null, 2)).toBe(geometry.clientHeight); - expect(geometry.onboardingLayout).toBe('true'); - expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); - expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.cardBottom).toBeLessThanOrEqual(geometry.viewportBottom); -}); - -test('first-run onboarding remains reachable at the minimum window size', async ({ - onboardingWindow, -}) => { - await onboardingWindow.setViewportSize({ width: 480, height: 320 }); - - const surface = onboardingWindow.locator('[data-maka-contract="onboarding-surface"]'); - const initialScrollTop = await surface.evaluate((element) => element.scrollTop); - await surface.hover(); - await onboardingWindow.mouse.wheel(0, 10_000); - await expect.poll(() => surface.evaluate((element) => element.scrollTop)).toBeGreaterThan( - initialScrollTop, - ); - - const geometry = await onboardingWindow.evaluate(() => { - const pageRoot = document.scrollingElement; - const scrollContainer = document.querySelector('[data-chat-scroll-container="true"]'); - const surface = document.querySelector('[data-maka-contract="onboarding-surface"]'); - const content = surface?.querySelector('.maka-onboarding-center'); - if (!pageRoot || !scrollContainer || !surface || !content) { - throw new Error('Onboarding geometry is unavailable'); - } - - const scrollRect = scrollContainer.getBoundingClientRect(); - const surfaceRect = surface.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - - return { - windowInnerWidth: window.innerWidth, - windowInnerHeight: window.innerHeight, - pageClientHeight: pageRoot.clientHeight, - pageScrollHeight: pageRoot.scrollHeight, - viewportClientHeight: scrollContainer.clientHeight, - viewportScrollHeight: scrollContainer.scrollHeight, - viewportTop: scrollRect.top, - viewportBottom: scrollRect.bottom, - surfaceClientHeight: surface.clientHeight, - surfaceScrollHeight: surface.scrollHeight, - surfaceTop: surfaceRect.top, - surfaceBottom: surfaceRect.bottom, - finalScrollTop: surface.scrollTop, - contentBottom: contentRect.bottom, - }; - }); - - expect(geometry.windowInnerWidth).toBe(480); - expect(geometry.windowInnerHeight).toBe(320); - expect(geometry.pageScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.pageClientHeight, - ); - expect(geometry.viewportScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.viewportClientHeight, - ); - expect(geometry.surfaceScrollHeight).toBeGreaterThan(geometry.surfaceClientHeight); - expect(initialScrollTop).toBe(0); - expect(geometry.finalScrollTop).toBeGreaterThan(initialScrollTop); - expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); - expect(geometry.contentBottom).toBeLessThanOrEqual(geometry.surfaceBottom); -}); diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts deleted file mode 100644 index 9b70c12b6a..0000000000 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -const NOTICE = '.maka-transcript-history-controls'; - -async function waitForPaint(page: Page): Promise { - await page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - })); -} - -async function noticePresentation(page: Page) { - return page.locator(NOTICE).evaluate((notice) => { - const style = getComputedStyle(notice); - const box = notice.getBoundingClientRect(); - const composer = document.querySelector('.maka-composer-astryx'); - if (!composer) throw new Error('the composer is missing'); - const composerBox = composer.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderWidths: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], - display: style.display, - flexWrap: style.flexWrap, - justifyContent: style.justifyContent, - widthDelta: Math.abs(box.width - composerBox.width), - centerDelta: Math.abs( - (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, - ), - fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth, - hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, - }; - }); -} - -test('partial history is a quiet reading-column control with neutral rail ticks', async ({ - partialHistoryWindow: page, -}) => { - await page.setViewportSize({ width: 1_400, height: 800 }); - await expect(page.locator(NOTICE)).toHaveCount(0); - - const firstPrompt = page.locator( - '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', - ); - await expect(firstPrompt).toBeVisible(); - await firstPrompt.click(); - - const notice = page.locator(NOTICE); - await expect(notice).toBeVisible(); - await expect(notice).toContainText('正在查看较早的消息'); - await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible(); - await expect(notice).not.toContainText(/保存|加载/); - - const regular = await noticePresentation(page); - expect(regular).toEqual({ - backgroundColor: 'rgba(0, 0, 0, 0)', - borderWidths: ['0px', '0px', '0px', '0px'], - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'center', - widthDelta: expect.any(Number), - centerDelta: expect.any(Number), - fitsViewport: true, - hasHorizontalOverflow: false, - }); - expect(regular.widthDelta).toBeLessThanOrEqual(1); - expect(regular.centerDelta).toBeLessThanOrEqual(1); - - await page.mouse.move(0, 0); - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - const railPresentation = await page.evaluate(() => { - const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; - const presentation = (tick: HTMLElement) => { - const bar = tick.querySelector('.maka-prompt-rail-tick-bar'); - if (!bar) throw new Error('a prompt rail tick is missing its bar'); - const style = getComputedStyle(bar); - return { - backgroundColor: style.backgroundColor, - borderStyle: style.borderStyle, - borderWidth: style.borderWidth, - boxShadow: style.boxShadow, - }; - }; - const neutralPaint = ticks - .filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover')) - .map(presentation); - const residentStyleRules = [...document.styleSheets].flatMap((sheet) => - [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident')) - ); - return { - residentAttributeCount: document.querySelectorAll('[data-resident]').length, - residentStyleRuleCount: residentStyleRules.length, - neutralTickCount: neutralPaint.length, - neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size, - }; - }); - expect(railPresentation.residentAttributeCount).toBe(0); - expect(railPresentation.residentStyleRuleCount).toBe(0); - expect(railPresentation.neutralTickCount).toBeGreaterThan(1); - expect(railPresentation.neutralPaintCount).toBe(1); - - await page.setViewportSize({ width: 520, height: 720 }); - await waitForPaint(page); - const narrow = await noticePresentation(page); - expect(narrow.centerDelta).toBeLessThanOrEqual(1); - expect(narrow.fitsViewport).toBe(true); - expect(narrow.hasHorizontalOverflow).toBe(false); - - await notice.getByRole('button', { name: '返回最新消息' }).click(); - await expect(notice).toHaveCount(0); - await expect( - page.locator('[data-turn-id="turn-partial-history-8"]'), - ).toBeVisible(); -}); diff --git a/apps/desktop/e2e/permission-center-metadata-layout.spec.ts b/apps/desktop/e2e/permission-center-metadata-layout.spec.ts deleted file mode 100644 index 9309073c1d..0000000000 --- a/apps/desktop/e2e/permission-center-metadata-layout.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Locator, Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -async function expandComputerUse(page: Page): Promise { - const row = page.locator('[data-readiness]').first(); - const trigger = row.locator('button[aria-expanded]').first(); - await trigger.click(); - await expect(trigger).toHaveAttribute('aria-expanded', 'true'); - return row; -} - -async function metadataTextRows(row: Locator) { - return row.evaluate((element) => { - const firstTextMetrics = (root: HTMLElement) => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let node = walker.nextNode(); - while (node && !node.textContent?.trim()) node = walker.nextNode(); - if (!node?.parentElement) throw new Error('Metadata cell has no text'); - const range = document.createRange(); - range.selectNodeContents(node); - return { - bottom: range.getBoundingClientRect().bottom, - fontSize: getComputedStyle(node.parentElement).fontSize, - }; - }; - - return Array.from( - element.querySelectorAll('.settingsCapabilityMetadata > dl'), - ).flatMap((grid) => { - const terms = Array.from(grid.querySelectorAll(':scope > dt')); - const values = Array.from(grid.querySelectorAll(':scope > dd')); - if (terms.length !== values.length) throw new Error('Metadata pairs are incomplete'); - return terms.map((term, index) => { - const value = values[index]!; - const labelMetrics = firstTextMetrics(term); - const valueMetrics = firstTextMetrics(value); - return { - label: term.textContent?.trim() ?? '', - value: value.textContent?.trim() ?? '', - labelTextBottom: labelMetrics.bottom, - valueTextBottom: valueMetrics.bottom, - labelFontSize: labelMetrics.fontSize, - valueFontSize: valueMetrics.fontSize, - }; - }); - }); - }); -} - -test('Permission Center gives each metadata label and value consistent body typography', async ({ - permissionCenterWindow: page, -}) => { - await page.setViewportSize({ width: 1490, height: 900 }); - const rows = await metadataTextRows(await expandComputerUse(page)); - - expect(rows.length).toBeGreaterThan(0); - for (const row of rows) { - expect( - Math.abs(row.labelTextBottom - row.valueTextBottom), - `${row.label} and ${row.value} should share a text baseline`, - ).toBeLessThanOrEqual(1); - expect( - row.valueFontSize, - `${row.label} and ${row.value} should use the same body text size`, - ).toBe(row.labelFontSize); - } -}); diff --git a/apps/desktop/e2e/quote-selection.spec.ts b/apps/desktop/e2e/quote-window-boundary.spec.ts similarity index 59% rename from apps/desktop/e2e/quote-selection.spec.ts rename to apps/desktop/e2e/quote-window-boundary.spec.ts index a89b6b158f..8fd5a516a8 100644 --- a/apps/desktop/e2e/quote-selection.spec.ts +++ b/apps/desktop/e2e/quote-window-boundary.spec.ts @@ -19,6 +19,8 @@ import { expect, test, COMPOSER_INPUT } from './fixtures'; +// This stays in Electron: the physical pointer leaves the renderer viewport, +// and Chromium pointer capture must route its release back to the owning Turn. test('a transcript drag releases outside the window through its owning Turn', async ({ window: page, }) => { @@ -80,15 +82,11 @@ test('a transcript drag releases outside the window through its owning Turn', as const startX = bounds.x + 2; const selectedX = bounds.x + bounds.width - 2; - // Ignore any collapsed selectionchange left by the Composer focus change; - // the marker below must come from this drag while capture is active. await turn.evaluate((element) => { const owner = element as HTMLElement; delete owner.dataset.e2eSelectionChanged; }); - // Pointer capture must route the physical release back to the owning Turn - // after the mouse leaves the renderer viewport. await page.mouse.move(startX, y); await page.mouse.down(); await page.mouse.move(selectedX, y, { steps: 5 }); @@ -97,62 +95,9 @@ test('a transcript drag releases outside the window through its owning Turn', as .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) .toBe(true); await expect(turn).toHaveAttribute('data-e2e-selection-changed', 'true'); - // Leave through the transcript side of the viewport. Exiting through the - // left would drag across the navigation rail and correctly turn this into a - // cross-scope Selection, which the quote resolver must reject. await page.mouse.move(1220, y, { steps: 5 }); await page.mouse.up(); await expect(turn).toHaveAttribute('data-e2e-captured-pointer-up', 'true'); await expect(quoteLayer).toBeVisible(); }); - -// Known broken, kept visible rather than described in a PR nobody re-reads. -// Selecting inside a still-streaming answer loses the Selection when the -// stream closes: the markdown renderer rebuilds the paragraph's inline -// fragments, the browser discards the Selection those nodes held, and it does -// so without a `selectionchange` — so the quote hook, which re-reads the -// Selection 350ms after pointer release, finds nothing to offer. -// -// Not fixable from this repo as it stands: the rebuild is inside -// @astryxdesign/core, and pinning the `isStreaming` / `settledText` props that -// drive it still reproduces. Closing this needs an upstream fix, or a decision -// to snapshot the quote at pointerup — which would show a quote bar over text -// whose highlight the browser has already erased. -test.fixme('a drag begun while the answer streams still offers a quote', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('pointer capture source'); - await composer.press('Enter'); - - const reply = page.getByText(/Fake backend received: pointer capture source/); - await expect(reply).toBeVisible(); - const bounds = await reply.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const rect = range.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; - }); - const y = bounds.y + bounds.height / 2; - await page.mouse.move(bounds.x + 2, y); - await page.mouse.down(); - await page.mouse.move(bounds.x + bounds.width - 2, y, { steps: 5 }); - await page.mouse.up(); - - // Assert the contract that is actually broken, not the quote bar: the bar is - // also reachable while the gap is open, because the hook's 350ms read can win - // the race against the stream close and leave a bar standing over a Selection - // the browser has already erased. Wait for the close, then require the - // Selection to still be there — that is the state the quote hook needs and - // the one the fragment rebuild destroys. - await expect - .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) - .toBe(true); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await expect - .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) - .toBe(true); -}); diff --git a/apps/desktop/e2e/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts deleted file mode 100644 index 772d5f5ae9..0000000000 --- a/apps/desktop/e2e/request-header-row-contract.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { expect, test } from './fixtures'; -import { getProviderSettingsCopy } from '../src/renderer/features/connection-settings'; - -/** - * The custom request header row's remove button centres on the FIELD, not on - * whatever box its own cell happens to declare. - * - * `.requestHeaderRow` is `align-items: start` on purpose: the value input can - * grow an inline error underneath itself and the trash icon must not ride down - * with it. That makes `.requestHeaderRemove` responsible for stating the height - * it centres its button inside, and a wrong height there is invisible in a - * diff — the rule looks deliberate either way. It regressed exactly once, as a - * bare `min-height: 2.5rem` against 32px inputs, which parked the icon 4px low. - * - * `scripts/audit-alignment.mjs` cannot cover this. It clusters controls by - * `parentElement`, and this row wraps every cell in its own div, so the inputs - * and the button are never in the same cluster to compare. It also only sees - * surfaces as they first render, and this editor is three clicks deep. - */ - -const copy = getProviderSettingsCopy('zh-CN').detail; - -test('the request header remove button centres on its field', async ({ - requestHeaderRowWindow: page, -}) => { - // `no-models` is the seeded openai-compatible relay — the custom relay whose - // detail page carries the advanced request editor. - await page.locator('[data-connection-slug="no-models"] button').first().click(); - await page.locator(`button[aria-label="${copy.edit}: ${copy.requestHeaders}"]`).click(); - await page.getByRole('button', { name: copy.addHeader }).click(); - await expect(page.locator('.requestHeaderRow')).toHaveCount(1); - - const geometry = await page.evaluate(() => { - const row = document.querySelector('.requestHeaderRow')!; - // Select the actual field wrapper directly so this contract test measures - // the visual field box, not a DOM relationship that may change. - const field = row.querySelector('.requestHeaderName')!; - const cell = row.querySelector('.requestHeaderRemove')!; - const button = cell.querySelector('button')!; - const centre = (element: Element) => { - const box = element.getBoundingClientRect(); - return box.top + box.height / 2; - }; - return { - drift: centre(button) - centre(field), - fieldHeight: field.getBoundingClientRect().height, - cellHeight: cell.getBoundingClientRect().height, - }; - }); - - // Allow a small amount of cross-platform/sub-pixel variance here: a - // fractional device pixel ratio can shift centres slightly, but the 4px - // regression this pins is still far outside this tolerance. - expect(Math.abs(geometry.drift)).toBeLessThanOrEqual(1); - // The mechanism, named separately so a failure says which half broke: the - // cell must not declare a meaningfully taller box than the field it centres - // against. Allow a small epsilon for DOMRect/font-rendering variance. - expect(geometry.cellHeight).toBeLessThanOrEqual(geometry.fieldHeight + 1); -}); diff --git a/apps/desktop/e2e/settings-row-focus-ring.spec.ts b/apps/desktop/e2e/settings-row-focus-ring.spec.ts deleted file mode 100644 index 76200d2849..0000000000 --- a/apps/desktop/e2e/settings-row-focus-ring.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ensureSidebarExpanded, expect, test } from './fixtures'; -import type { Locator, Page } from '@playwright/test'; - -/** - * A settings row rings its OWN tab stop and nothing else. - * - * Astryx's Item draws `outline: 2px solid accent` at `:has(:focus-visible)` - * unconditionally. That is right only for a row whose click target is the - * invisible ` +
+ + + +
+ + ); +} + const meta = { title: 'Product/Composer Slash Menu', component: SlashMenuHarness, @@ -378,3 +416,42 @@ export const SurvivesASameContentProjectionRefresh: Story = { await expect(skillsGroup.isConnected).toBe(true); }, }; + +// Real path: leaving an existing Session for a new-task composer. The previous +// Session's populated Skill projection must stop being actionable in the same +// render; the new surface stays busy until its own projection resolves. +export const ContextSwitchStartsWithALoadingCatalog: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = overlay(); + await waitFor(() => expect(projectionLoads).toBeGreaterThan(0)); + await userEvent.click(within(canvasElement).getByRole('button', { + name: 'Switch to new task', + })); + await userEvent.click(page.getByRole('button', { name: '添加上下文' })); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const skillsRow = within(menu).getByRole('menuitem', { name: /选择技能/ }); + + await waitFor(() => expect(skillsRow).toHaveAttribute('aria-busy', 'true')); + await expect(skillsRow).not.toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(skillsRow); + await expect(menu).toBeVisible(); + await expect(editor(canvasElement)).toHaveTextContent(''); + await expect(page.queryByRole('listbox', { name: /技能/ })).not.toBeInTheDocument(); + + releaseHeldProjection?.(); + await waitFor(() => { + const settledRow = within( + page.getByRole('menu', { name: '添加上下文' }), + ).getByRole('menuitem', { name: /选择技能/ }); + expect(settledRow).not.toHaveAttribute('aria-busy'); + }); + const settledRow = within( + page.getByRole('menu', { name: '添加上下文' }), + ).getByRole('menuitem', { name: /选择技能/ }); + await userEvent.click(settledRow); + await expect(await page.findByRole('listbox', { name: /技能/ }, { + timeout: 5_000, + })).toBeVisible(); + }, +}; diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx index 3fd821ab3c..0905029f1d 100644 --- a/apps/desktop/stories/onboarding.stories.tsx +++ b/apps/desktop/stories/onboarding.stories.tsx @@ -23,6 +23,7 @@ import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; import type { OnboardingState } from '@maka/core/onboarding'; import type { SettingsSection } from '@maka/core/settings'; import { ChatSurfaceLayout, ChatView } from '@maka/ui'; +import { expect, waitFor } from 'storybook/test'; import { OnboardingHero } from '../src/renderer/onboarding-hero'; const meta = { @@ -36,7 +37,7 @@ type Story = StoryObj; const MAKA_WINDOW_FLOOR_VIEWPORT = { makaWindowFloor: { name: 'Maka window floor', - styles: { width: '480px', height: '900px' }, + styles: { width: '480px', height: '320px' }, type: 'desktop' as const, }, }; @@ -68,7 +69,12 @@ const connections: LlmConnection[] = [ * three wrappers outside it mirror the app shell because app-shell.tsx itself * cannot be mounted as a story without its main-process orchestration. */ -function DetailPane(props: { children?: ReactNode }) { +function DetailPane(props: { + children?: ReactNode; + height?: number | string; + minHeight?: number; + width?: number | string; +}) { const emptyOverride = props.children === undefined ? undefined :
{props.children}
; @@ -76,7 +82,12 @@ function DetailPane(props: { children?: ReactNode }) {
( + '[data-chat-scroll-container="true"]', + ); + const surface = canvasElement.querySelector('.maka-onboarding-surface'); + const card = canvasElement.querySelector('[data-maka-contract="onboarding-card"]'); + if (!pageRoot || !scrollContainer || !surface || !card) { + throw new Error('Onboarding geometry is unavailable'); + } + const scrollRect = scrollContainer.getBoundingClientRect(); + const surfaceRect = surface.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + return { + pageClientHeight: pageRoot.clientHeight, + pageScrollHeight: pageRoot.scrollHeight, + viewportClientHeight: scrollContainer.clientHeight, + viewportScrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollRect.top, + viewportBottom: scrollRect.bottom, + surfaceClientHeight: surface.clientHeight, + surfaceScrollHeight: surface.scrollHeight, + surfaceTop: surfaceRect.top, + surfaceBottom: surfaceRect.bottom, + cardTop: cardRect.top, + cardBottom: cardRect.bottom, + onboardingLayout: scrollContainer.dataset.makaOnboarding, + }; +} + function heroProps( state: OnboardingState, overrides: Partial> = {}, @@ -122,6 +163,16 @@ export const NeedsConnection: Story = { ), + play: async ({ canvasElement }) => { + const geometry = onboardingGeometry(canvasElement); + expect(geometry.pageScrollHeight).toBe(geometry.pageClientHeight); + expect(geometry.viewportScrollHeight).toBe(geometry.viewportClientHeight); + expect(geometry.onboardingLayout).toBe('true'); + expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); + expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.cardBottom).toBeLessThanOrEqual(geometry.viewportBottom); + }, }; // Real path: a configured connection exists but its credential is unavailable. @@ -181,8 +232,29 @@ export const NarrowWindow: Story = { parameters: { viewport: { options: MAKA_WINDOW_FLOOR_VIEWPORT } }, globals: { viewport: { value: 'makaWindowFloor', isRotated: false } }, render: () => ( - + ), + play: async ({ canvasElement }) => { + const surface = canvasElement.querySelector('.maka-onboarding-surface'); + if (!surface) throw new Error('Onboarding surface is unavailable'); + const initialScrollTop = surface.scrollTop; + surface.scrollTop = surface.scrollHeight; + await waitFor(() => expect(surface.scrollTop).toBeGreaterThan(initialScrollTop)); + + const geometry = onboardingGeometry(canvasElement); + const content = surface.querySelector('.maka-onboarding-center'); + if (!content) throw new Error('Onboarding content is unavailable'); + const app = canvasElement.querySelector('.app'); + if (!app) throw new Error('Onboarding app shell is unavailable'); + expect(app.getBoundingClientRect().width).toBe(480); + expect(app.getBoundingClientRect().height).toBe(320); + expect(geometry.pageScrollHeight).toBe(geometry.pageClientHeight); + expect(geometry.viewportScrollHeight).toBe(geometry.viewportClientHeight); + expect(geometry.surfaceScrollHeight).toBeGreaterThan(geometry.surfaceClientHeight); + expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); + expect(content.getBoundingClientRect().bottom).toBeLessThanOrEqual(geometry.surfaceBottom); + }, }; diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index e663b33b0d..ec6a0f37b3 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -38,8 +38,10 @@ import type { ApiKeyOnboardingBridge, ConnectionOAuthBridge, } from '../../src/renderer/features/connection-settings'; +import { getProviderSettingsCopy } from '../../src/renderer/features/connection-settings'; const NOW = Date.parse('2026-07-01T08:00:00Z'); +const detailCopy = getProviderSettingsCopy('zh-CN').detail; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -841,6 +843,30 @@ export const RelayConnectionDetail: Story = { await expect(none).toHaveAttribute('aria-checked', 'false'); await expect(partial).toHaveAttribute('aria-description', '1/4 个模型'); await expect(none).toHaveAttribute('aria-description', '全部未声明'); + + await userEvent.keyboard('{Escape}'); + await userEvent.click( + body.getByRole('button', { + name: `${detailCopy.edit}: ${detailCopy.requestHeaders}`, + }), + ); + await userEvent.click(body.getByRole('button', { name: detailCopy.addHeader })); + await waitFor(() => { + expect(canvasElement.querySelectorAll('.requestHeaderRow')).toHaveLength(1); + }); + const row = canvasElement.querySelector('.requestHeaderRow'); + const field = row?.querySelector('.requestHeaderName'); + const cell = row?.querySelector('.requestHeaderRemove'); + const button = cell?.querySelector('button'); + if (!field || !cell || !button) throw new Error('Request header row is incomplete'); + const centre = (element: Element) => { + const box = element.getBoundingClientRect(); + return box.top + box.height / 2; + }; + expect(Math.abs(centre(button) - centre(field))).toBeLessThanOrEqual(1); + expect(cell.getBoundingClientRect().height).toBeLessThanOrEqual( + field.getBoundingClientRect().height + 1, + ); }, }; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 2e0c647255..58a997284c 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1614,6 +1614,36 @@ function makeBotAttentionBridge(settings: AppSettings) { const withBotAttentionBridge = withScopedMakaBridge(makeBotAttentionBridge(botAttentionSettings)); +function renderedLinkColors(renderedLink: HTMLElement) { + const root = document.documentElement; + renderedLink.style.setProperty('transition', 'none', 'important'); + root.setAttribute('data-maka-theme', 'tokyo-night'); + + const resolve = (value: string) => { + const probe = document.createElement('span'); + probe.style.setProperty('color', value, 'important'); + renderedLink.parentElement?.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Link color canvas is unavailable'); + const rgba = (value: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data]; + }; + return { + link: rgba(resolve('var(--link)')), + solid: rgba(resolve('var(--accent-solid)')), + accent: rgba(resolve('var(--accent)')), + rendered: rgba(getComputedStyle(renderedLink).color), + }; +} + type SettingsStoryProps = { section: SettingsSection; connections?: LlmConnection[]; @@ -1623,8 +1653,34 @@ type SettingsStoryProps = { /** Seeds 已归档任务. Empty for every story that is not about that page. */ archivedTaskSessions?: readonly SessionSummary[]; seedSnapshotCache?(cache: SettingsSnapshotCache): void; + frameHeight?: number | string; + frameMinHeight?: number; + frameWidth?: number | string; }; +async function tabTo(target: HTMLElement, limit = 120) { + for (let index = 0; index < limit; index += 1) { + await userEvent.tab(); + if (document.activeElement === target) return; + } + throw new Error('Tab order never reached the target control'); +} + +function focusedRowOutline() { + const active = document.activeElement as HTMLElement | null; + const row = active?.closest('.astryx-item'); + if (!row) return null; + const style = getComputedStyle(row); + return { outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth }; +} + +function fieldChrome(element: HTMLElement) { + const field = element.parentElement; + if (!field) throw new Error('Settings field chrome is missing'); + const style = getComputedStyle(field); + return `${style.borderColor} | ${style.boxShadow}`; +} + /** * The provider has to sit above the body: 已归档任务's story bridge confirms * through the same toast surface the shell's row action uses, and a hook cannot @@ -1672,8 +1728,9 @@ function SettingsStoryFrame(props: SettingsStoryProps) { data-maka-e2e-fixture="true" style={{ background: 'var(--surface-canvas)', - height: '100dvh', - minHeight: 640, + height: props.frameHeight ?? '100dvh', + minHeight: props.frameMinHeight ?? 640, + width: props.frameWidth ?? '100%', }} > @@ -1777,6 +1834,91 @@ export const General: Story = { decorators: [withSettingsBridge], render: () => , }; +// Real path: 设置 → 通用 → 默认模型. The popover remains a DOM descendant +// of its Item after entering the top layer, so focused search must not ring +// the whole settings row. +export const GeneralPickerOpenFocusRing: Story = { + decorators: [withSettingsBridge], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole('button', { name: '默认模型' }); + await userEvent.click(trigger); + await waitFor(() => { + const active = document.activeElement as HTMLElement | null; + expect(document.querySelector('[popover]:popover-open')).not.toBeNull(); + expect(active?.closest('[popover]:popover-open')).not.toBeNull(); + }); + const active = document.activeElement as HTMLElement; + const row = active.closest('.astryx-item'); + expect(row).not.toBeNull(); + expect(row ? getComputedStyle(row).outlineStyle : null).toBe('none'); + }, +}; + +// Real path: keyboard navigation through 设置 → 通用. The field carries the +// visible focus treatment; its containing Item does not add a second ring. +export const GeneralKeyboardFocusRing: Story = { + decorators: [withSettingsBridge], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tone = await canvas.findByRole('textbox', { name: '助手语气偏好' }); + const trigger = canvas.getByRole('button', { name: '默认模型' }); + const resting = fieldChrome(trigger); + tone.focus(); + await tabTo(trigger); + expect(focusedRowOutline()?.outlineStyle).toBe('none'); + await waitFor(() => expect(fieldChrome(trigger)).not.toBe(resting)); + }, +}; + +// Real path: Windows High Contrast keyboard navigation through 设置 → 通用. +// The field loses its own paint there, so the Item retains the focus ring. +export const GeneralForcedColorsFocusRing: Story = { + decorators: [withSettingsBridge], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tone = await canvas.findByRole('textbox', { name: '助手语气偏好' }); + const trigger = canvas.getByRole('button', { name: '默认模型' }); + const resting = fieldChrome(trigger); + tone.focus(); + await tabTo(trigger); + expect(fieldChrome(trigger)).toBe(resting); + expect(focusedRowOutline()?.outlineStyle).toBe('solid'); + }, +}; +// Real path: 设置 → 通用 in a wide, short Desktop window. The main pane owns +// overflow even when the pointer is over its blank right gutter. +export const GeneralWideShort: Story = { + decorators: [withSettingsBridge], + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('textbox', { name: '助手语气偏好' }); + const pane = canvasElement.querySelector('.settingsMainPane'); + const content = pane?.querySelector('.settingsPageStack'); + const layoutContent = pane?.querySelector('.astryx-layout-content'); + if (!pane || !content || !layoutContent) throw new Error('Settings layout is incomplete'); + const paneRect = pane.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(paneRect.right - contentRect.right).toBeGreaterThan(40); + expect(pane.scrollHeight).toBeGreaterThan(pane.clientHeight); + expect(getComputedStyle(layoutContent).overflowY).not.toBe('auto'); + expect(getComputedStyle(pane).overflowY).toBe('auto'); + pane.scrollTop = 600; + await waitFor(() => expect(pane.scrollTop).toBeGreaterThan(0)); + expect(content.getBoundingClientRect().width).toBeGreaterThan(0); + }, +}; // Cold path: Desktop-owned preferences are ready while the selected Runtime // Host settings read is still pending. The complete page topology stays // visible as neutral row placeholders, without treating hydration as a warning. @@ -2039,7 +2181,26 @@ export const GeneralGitBash: Story = { // Real path: 设置 → 外观. export const Appearance: Story = { decorators: [withSettingsBridge], + globals: { locale: 'en' }, render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('heading', { name: 'App icon' }); + for (const name of ['Azure', 'Classic']) { + const input = await canvas.findByRole('checkbox', { name }); + const card = input.parentElement; + const content = card ? [...card.children].find((child) => child.tagName !== 'INPUT') : null; + if (!(card instanceof HTMLElement) || !(content instanceof HTMLElement)) { + throw new Error(`Appearance card ${name} is incomplete`); + } + const cardRect = card.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(cardRect.height).toBeGreaterThan(contentRect.height); + expect( + Math.abs((contentRect.top - cardRect.top) - (cardRect.bottom - contentRect.bottom)), + ).toBeLessThanOrEqual(1); + } + }, }; /** #1362: proxy + auth enabled so the full form-grid stack renders. */ // Real path: 设置 → 使用统计 → 供应商统计, before any usage has been recorded. @@ -2207,6 +2368,44 @@ export const WebSearch: Story = { export const BotChatNeedsAttention: Story = { decorators: [withBotAttentionBridge], render: () => , + play: async ({ canvasElement }) => { + const dingtalk = await waitForStoryButton( + canvasElement, + (button) => button.closest('.settingsRemoteAccessCatalogRow')?.textContent?.includes('钉钉') === true, + ); + await userEvent.click(dingtalk); + await waitForStoryCondition( + () => canvasElement.querySelector('.settingsBotConfigDocLink') !== null, + 'Bot configuration documentation link did not render', + ); + const link = canvasElement.querySelector('.settingsBotConfigDocLink'); + if (!link) throw new Error('Bot configuration documentation link did not render'); + const colors = renderedLinkColors(link); + expect(colors.link).toEqual(colors.solid); + expect(colors.link).not.toEqual(colors.accent); + expect(colors.rendered).toEqual(colors.link); + }, +}; +// Real path: keyboard navigation through 设置 → 远程接入. A catalog Item owns +// its invisible tab stop, so the row ring is the focus indicator and remains. +export const BotChatCatalogRowFocusRing: Story = { + decorators: [withBotAttentionBridge], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const nav = canvas.getByRole('button', { name: '远程接入' }); + await waitForStoryCondition( + () => canvasElement.querySelector('.settingsRemoteAccessCatalogRow > button') !== null, + 'Remote Access catalog row did not render', + ); + nav.focus(); + for (let index = 0; index < 120; index += 1) { + await userEvent.tab(); + if (document.activeElement?.matches('.settingsRemoteAccessCatalogRow > button')) break; + } + expect(document.activeElement?.matches('.settingsRemoteAccessCatalogRow > button')).toBe(true); + expect(focusedRowOutline()).toEqual({ outlineStyle: 'solid', outlineWidth: '2px' }); + }, }; // Real path: 设置 → 每日回顾. export const DailyReview: Story = { @@ -2589,6 +2788,35 @@ export const PermissionCenterDiagnosticsExpanded: Story = { canvasElement.querySelector('[data-readiness] button[aria-expanded="true"]') !== null, 'Permission Center story did not expand a capability row', ); + const row = canvasElement.querySelector('[data-readiness]'); + if (!row) throw new Error('Permission Center capability row did not render'); + const firstTextMetrics = (root: HTMLElement) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node && !node.textContent?.trim()) node = walker.nextNode(); + if (!node?.parentElement) throw new Error('Metadata cell has no text'); + const range = document.createRange(); + range.selectNodeContents(node); + return { + bottom: range.getBoundingClientRect().bottom, + fontSize: getComputedStyle(node.parentElement).fontSize, + }; + }; + const grids = row.querySelectorAll('.settingsCapabilityMetadata > dl'); + expect(grids.length).toBeGreaterThan(0); + for (const grid of grids) { + const terms = [...grid.querySelectorAll(':scope > dt')]; + const values = [...grid.querySelectorAll(':scope > dd')]; + expect(values).toHaveLength(terms.length); + for (const [index, term] of terms.entries()) { + const value = values[index]; + if (!value) throw new Error('Permission metadata value is missing'); + const labelMetrics = firstTextMetrics(term); + const valueMetrics = firstTextMetrics(value); + expect(Math.abs(labelMetrics.bottom - valueMetrics.bottom)).toBeLessThanOrEqual(1); + expect(valueMetrics.fontSize).toBe(labelMetrics.fontSize); + } + } }, }; // Real path: 设置 → 健康 (also reachable from the topbar health action), with probes diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 574e60548c..2961cca642 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -27,7 +27,6 @@ export type E2eFixtureScenario = | 'turn-narrative' | 'turn-narrative-browser' | 'chat-prompt-rail' - | 'chat-partial-history' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' @@ -37,7 +36,6 @@ export type E2eFixtureScenario = | 'module-mcp' | 'module-daily-review' | 'scheduled-tasks' - | 'agent-graph-layout' | 'sidebar-search-modal-open'; export interface E2eFixtureState { @@ -46,13 +44,6 @@ export interface E2eFixtureState { activeSessionId?: string; openSettingsSection?: SettingsSection; reducedMotion?: boolean; - /** - * Opt a fixture back into animated scrolling. Captures collapse scroll - * motion so a screenshot never depends on when it settles, which also means - * no fixture can exercise a scroll that is still in flight — and that is - * precisely what the prompt rail's jump has to survive. - */ - scrollMotion?: 'auto' | 'smooth'; theme?: 'light' | 'dark' | 'auto'; locale?: UiLocale; timezone?: string; diff --git a/packages/ui/src/session-setting-intent.test.ts b/packages/ui/src/session-setting-intent.test.ts index 038f67f091..8d48fdc5c7 100644 --- a/packages/ui/src/session-setting-intent.test.ts +++ b/packages/ui/src/session-setting-intent.test.ts @@ -232,6 +232,163 @@ test('rapid revision-aware requests retire only after the last committed revisio assert.equal(controller!.overlayByChannel.model['session-1'], undefined); }); +test('rapid requests drain to the latest requested value', async () => { + const { controller, writes } = await mountIntentHarness(); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan']); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); + + await act(async () => { + writes[0]!.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan', 'agent']); + + await act(async () => { + writes[1]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); +}); + +test('a rejected catalog refresh does not roll back a committed value', async () => { + const { controller, writes } = await mountIntentHarness({ + refreshCatalog: async () => { + throw new Error('catalog unavailable'); + }, + }); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + writes[0]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'plan'); +}); + +test('a newer request still writes after the in-flight write fails', async () => { + const errors: Array<{ attempted: string; error: unknown }> = []; + const { controller, writes } = await mountIntentHarness({ errors }); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + writes[0]!.result.reject(new Error('first write failed')); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan', 'agent']); + assert.deepEqual(errors, []); + + await act(async () => { + writes[1]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); +}); + +test('clearing a session drops its pending and queued requests', async () => { + const { controller, writes } = await mountIntentHarness(); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + controller().clear('session-1'); + assert.equal(await completion, false); + }); + assert.equal(controller().overlayByChannel.model['session-1'], undefined); + + await act(async () => { + writes[0]!.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan']); + assert.equal(controller().overlayByChannel.model['session-1'], undefined); +}); + +async function mountIntentHarness(options?: { + refreshCatalog?: () => Promise; + errors?: Array<{ attempted: string; error: unknown }>; +}) { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + const writes: Array<{ + value: string; + result: ReturnType> & { reject(error: unknown): void }; + }> = []; + let captured: Controller | undefined; + await act(async () => { + root.render(createElement(IntentHarness, { + capture: (next) => { + captured = next; + }, + refreshCatalog: options?.refreshCatalog ?? (async () => {}), + modelWrite: async (_sessionId, value) => { + let reject!: (error: unknown) => void; + const pending = deferred(); + const promise = new Promise((resolve, rejectPromise) => { + reject = rejectPromise; + pending.promise.then(resolve, rejectPromise); + }); + const result = { ...pending, promise, reject }; + writes.push({ value, result }); + return promise; + }, + onModelWriteError: (_sessionId, error, attempted) => { + options?.errors?.push({ attempted, error }); + }, + })); + }); + return { + controller: () => { + assert.ok(captured); + return captured; + }, + writes, + }; +} + +function IntentHarness({ + capture, + refreshCatalog, + modelWrite, + onModelWriteError, +}: { + capture(controller: Controller): void; + refreshCatalog(): Promise; + modelWrite(sessionId: string, value: string): Promise; + onModelWriteError(sessionId: string, error: unknown, attempted: string): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision: 0, + refreshCatalog, + channels: { + model: { write: modelWrite, onWriteError: onModelWriteError }, + permission: { write: async () => true, onWriteError: () => {} }, + }, + }); + capture(controller); + return null; +} + function RapidRevisionHarness({ capture, catalogRevision, diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 5892728f1d..1b8f542bea 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, waitFor, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionBlockedReason, SessionStatus, SessionSummary } from '@maka/core/session'; import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.js'; @@ -54,6 +54,9 @@ function makeSession(input: { hasUnread?: boolean; backend?: SessionSummary['backend']; llmConnectionSlug?: string; + projectId?: string | null; + cwd?: string; + lastMessagePreview?: string; }): SessionSummary { const status = input.status ?? 'active'; return { @@ -71,6 +74,11 @@ function makeSession(input: { connectionLocked: false, model: 'glm-4.7', permissionMode: 'ask', + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.lastMessagePreview !== undefined + ? { lastMessagePreview: input.lastMessagePreview } + : {}), }; } @@ -546,12 +554,21 @@ export const ProjectGroups: Story = { name: 'worktree 上的修复', status: 'running', lastMessageAt: NOW - 1 * 60 * 1000, + projectId: maka.id, + cwd: '/workspace/maka-agent/.worktree/sidebar', + lastMessagePreview: '正在把侧栏交互契约迁移到浏览器 story。', }), makeSession({ id: 'proj-docs', name: '文档站改版', lastMessageAt: NOW - 30 * 60 * 1000, }), + makeSession({ + id: 'proj-loose', + name: '未归属的临时任务', + projectId: null, + lastMessageAt: NOW - 45 * 60 * 1000, + }), ]; return ( @@ -581,6 +598,11 @@ export const ProjectGroups: Story = { project: missing, sessions: [], }, + { + id: '__ungrouped__', + label: '未归属项目', + sessions: [sessions[3]!], + }, ], projectActions: { onNew: noop, @@ -594,6 +616,125 @@ export const ProjectGroups: Story = { ); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + const projectRow = canvasElement.querySelector( + '[data-project-id="project:project-maka"]', + ); + if (!projectRow) throw new Error('project row is missing'); + const navigation = projectRow.querySelector( + 'button[aria-controls]:not([aria-haspopup="menu"])', + ); + if (!navigation) throw new Error('project navigation is missing'); + const action = within(projectRow).getByRole('button', { + name: 'maka-agent 项目操作', + }); + const groupId = navigation.getAttribute('aria-controls'); + if (!groupId) throw new Error('project navigation does not own a group'); + const group = canvasElement.ownerDocument.getElementById(groupId); + if (!group) throw new Error('project task group is missing'); + const taskControl = group.querySelector('[data-session-id] button'); + if (!taskControl) throw new Error('nested task control is missing'); + const projectTitle = within(navigation).getByText('maka-agent', { exact: true }); + const taskTitle = within(taskControl).getByText('worktree 上的修复', { exact: true }); + + expect(projectRow.querySelectorAll('button button')).toHaveLength(0); + const projectBox = navigation.getBoundingClientRect(); + const taskBox = taskControl.getBoundingClientRect(); + const sessionInset = taskBox.x - projectBox.x; + expect(sessionInset).toBeGreaterThanOrEqual(6); + expect(sessionInset).toBeLessThan(16); + expect(Math.abs( + projectTitle.getBoundingClientRect().x - taskTitle.getBoundingClientRect().x, + )).toBeLessThanOrEqual(2); + + navigation.focus(); + await userEvent.tab(); + await expect(action).toHaveFocus(); + await userEvent.tab(); + await expect(taskControl).toHaveFocus(); + + navigation.focus(); + await userEvent.keyboard('{Enter}'); + await expect(navigation).toHaveAttribute('aria-expanded', 'false'); + await expect(group).toHaveAttribute('aria-hidden', 'true'); + expect(group.getAttribute('inert')).not.toBeNull(); + await userEvent.keyboard('{Enter}'); + await expect(navigation).toHaveAttribute('aria-expanded', 'true'); + await expect(group).toHaveAttribute('aria-hidden', 'false'); + expect(group.getAttribute('inert')).toBeNull(); + + action.focus(); + await userEvent.keyboard('{Enter}'); + await expect(page.getByRole('menuitem', { name: '新建任务' })).toBeVisible(); + await userEvent.keyboard('{Escape}'); + await expect(action).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + await userEvent.click(page.getByRole('menuitem', { name: '重命名' })); + await expect(page.getByRole('dialog', { name: '重命名项目' })).toBeVisible(); + await userEvent.click(page.getByRole('button', { name: '关闭' })); + await expect(action).toHaveFocus(); + + await userEvent.hover(taskControl); + const taskCard = await page.findByText('正在把侧栏交互契约迁移到浏览器 story。'); + const taskHoverCard = taskCard.closest( + '.maka-sidebar-hover-card[data-kind="session"]', + ); + if (!taskHoverCard) throw new Error('task hover card is missing'); + await expect(within(taskHoverCard).getByText('worktree 上的修复')).toBeVisible(); + await expect(within(taskHoverCard).getByText(/glm-4\.7/)).toBeVisible(); + + await userEvent.hover(navigation); + await waitFor(() => expect( + canvasElement.ownerDocument.querySelector( + '.maka-sidebar-hover-card[data-kind="project"]', + ), + ).toBeVisible()); + const projectHoverCard = canvasElement.ownerDocument.querySelector( + '.maka-sidebar-hover-card[data-kind="project"]', + ); + if (!projectHoverCard) throw new Error('project hover card is missing'); + await expect(within(projectHoverCard).getByText('1 个任务')).toBeVisible(); + await expect(within(projectHoverCard).getByText(/目录可用/)).toBeVisible(); + await userEvent.unhover(navigation); + await waitFor(() => expect(projectHoverCard).not.toBeVisible()); + + const taskRow = taskControl.closest('[data-session-id]'); + if (!taskRow) throw new Error('task row is missing'); + const timestamp = taskRow.querySelector('.maka-session-row-time'); + if (!timestamp) throw new Error('task timestamp is missing'); + const taskActionButton = within(taskRow).getByRole('button', { name: /任务操作$/ }); + taskActionButton.focus(); + await userEvent.keyboard('{Enter}'); + const renameTask = page.getByRole('menuitem', { name: '重命名' }); + await expect(renameTask).toBeVisible(); + const taskAction = taskRow.querySelector('.maka-session-row-action'); + if (!taskAction) throw new Error('task action is missing'); + await expect(taskAction).toHaveAttribute( + 'data-menu-open', + 'true', + ); + await userEvent.hover(renameTask); + await expect(timestamp).toHaveStyle({ visibility: 'hidden' }); + await userEvent.click(renameTask); + await expect(await page.findByRole('dialog', { name: '重命名任务' }, { + timeout: 5_000, + })).toBeVisible(); + await userEvent.click(page.getByRole('button', { name: '关闭' })); + + const ungroupedRow = canvasElement.querySelector( + '[data-project-id="__ungrouped__"]', + ); + const ungroupedNavigation = ungroupedRow?.querySelector( + ':scope > div > .astryx-side-nav-item', + ); + if (!ungroupedNavigation) throw new Error('ungrouped project row is missing'); + ungroupedNavigation.focus(); + await expect(await page.findByRole('dialog', { + name: '未归属项目 分组详情', + })).toBeVisible(); + }, }; // Group-by-project where a project's only task is pinned, so the project row diff --git a/scripts/fixture-env.mjs b/scripts/fixture-env.mjs index 3d9dc4ee67..fff66e506e 100644 --- a/scripts/fixture-env.mjs +++ b/scripts/fixture-env.mjs @@ -59,7 +59,6 @@ function isDeniedEnvKey(key) { * locale?: 'zh-CN' | 'zh-TW' | 'en', * platform?: 'darwin' | 'win32' | 'linux', * theme?: 'light' | 'dark', - * scrollMotion?: 'auto' | 'smooth', * timezone?: string, * showWindow?: boolean, * }} [options] @@ -104,12 +103,6 @@ export function buildFixtureEnv(userDataDir, homeDir, options = {}) { // explicitly. The decision stays with the caller: this builder is a pure // function of its arguments, so a test asserting "hidden run stays hidden" // means the same thing on a laptop and on a CI runner. - // Captures collapse scroll motion so their state never depends on when a - // scroll settles. A fixture whose subject IS the scrolling asks for the - // production behavior back — see `scroll-motion-policy`. Per launch rather - // than per scenario: it costs several seconds of settling per window, and - // only the case that needs it should pay. - if (options.scrollMotion) env.MAKA_E2E_FIXTURE_SCROLL_MOTION = options.scrollMotion; if (options.showWindow) env.MAKA_E2E_SHOW_WINDOW = '1'; return env; } diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs index 6b4b697685..67149b2b65 100644 --- a/scripts/storybook-visual-smoke.mjs +++ b/scripts/storybook-visual-smoke.mjs @@ -69,8 +69,12 @@ const DARK_THEME_SENTINEL_STORY_IDS = new Set([ 'product-accessibility-dialogs--rename-conversation', 'product-markdown--rich-assistant-answer', 'product-settings-pages--appearance', + 'product-settings-pages--bot-chat-needs-attention', 'product-shell-official-appshell--default-layout', ]); +const FORCED_COLORS_STORY_IDS = new Set([ + 'product-settings-pages--general-forced-colors-focus-ring', +]); // This is a catalog render and accessibility-tree health check. // Story `play` functions do run: many stories reach their named final state @@ -161,6 +165,7 @@ export function catalogJobs( colorSchemes.map((colorScheme) => ({ storyId: entry.id, colorScheme, + forcedColors: FORCED_COLORS_STORY_IDS.has(entry.id) ? 'active' : 'none', palette, })), ); @@ -182,7 +187,8 @@ export function storyViewport(storyId) { } export function jobLabel(job) { - return `${job.storyId} (${job.colorScheme}/${job.palette})`; + const forcedColors = job.forcedColors === 'active' ? '/forced-colors' : ''; + return `${job.storyId} (${job.colorScheme}/${job.palette}${forcedColors})`; } async function smokeStory(page, baseUrl, job, options = {}) { @@ -200,7 +206,7 @@ async function smokeStory(page, baseUrl, job, options = {}) { try { await page.addInitScript(installStorybookRenderProbe, { storyId: job.storyId }); await page.setViewportSize(storyViewport(job.storyId)); - await page.emulateMedia({ colorScheme: job.colorScheme }); + await page.emulateMedia({ colorScheme: job.colorScheme, forcedColors: job.forcedColors }); await page.goto(storyUrl(baseUrl, job), { waitUntil: 'load' }); try { @@ -361,6 +367,7 @@ async function runCli() { const requiredStoryIds = new Set([ ...REQUIRED_COMPUTER_USE_STORY_IDS, ...DARK_THEME_SENTINEL_STORY_IDS, + ...FORCED_COLORS_STORY_IDS, ]); const missingRequiredStories = [...requiredStoryIds].filter((storyId) => !storyIds.has(storyId)); if (missingRequiredStories.length > 0) { diff --git a/scripts/storybook-visual-smoke.test.mjs b/scripts/storybook-visual-smoke.test.mjs index a51a8a3f4d..eca595657a 100644 --- a/scripts/storybook-visual-smoke.test.mjs +++ b/scripts/storybook-visual-smoke.test.mjs @@ -42,6 +42,7 @@ test('ordinary catalog stories render the default palette in light mode', () => { storyId: 'product-settings--memory', colorScheme: 'light', + forcedColors: 'none', palette: 'default', }, ], @@ -52,8 +53,16 @@ test('dark theme sentinel stories render the default palette in both colour sche const storyId = 'product-settings-pages--appearance'; assert.deepEqual(catalogJobs(storyIndex(storyId), { themePalettes: THEME_PALETTES }), [ - { storyId, colorScheme: 'light', palette: 'default' }, - { storyId, colorScheme: 'dark', palette: 'default' }, + { storyId, colorScheme: 'light', forcedColors: 'none', palette: 'default' }, + { storyId, colorScheme: 'dark', forcedColors: 'none', palette: 'default' }, + ]); +}); + +test('forced-colors stories render under the forced palette', () => { + const storyId = 'product-settings-pages--general-forced-colors-focus-ring'; + + assert.deepEqual(catalogJobs(storyIndex(storyId), { themePalettes: THEME_PALETTES }), [ + { storyId, colorScheme: 'light', forcedColors: 'active', palette: 'default' }, ]); }); @@ -65,16 +74,18 @@ test('the reference story renders every palette in both colour schemes', () => { assert.equal(jobs.length, 22); assert.equal(new Set(jobs.map((job) => `${job.colorScheme}/${job.palette}`)).size, 22); assert.deepEqual(jobs.slice(0, 4), [ - { storyId: REFERENCE_STORY_ID, colorScheme: 'light', palette: 'default' }, - { storyId: REFERENCE_STORY_ID, colorScheme: 'dark', palette: 'default' }, + { storyId: REFERENCE_STORY_ID, colorScheme: 'light', forcedColors: 'none', palette: 'default' }, + { storyId: REFERENCE_STORY_ID, colorScheme: 'dark', forcedColors: 'none', palette: 'default' }, { storyId: REFERENCE_STORY_ID, colorScheme: 'light', + forcedColors: 'none', palette: 'test-palette-1', }, { storyId: REFERENCE_STORY_ID, colorScheme: 'dark', + forcedColors: 'none', palette: 'test-palette-1', }, ]);