diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4952b14c49..714b545eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,6 +368,18 @@ jobs: npm exec -w @maka/desktop -- playwright test \ --config e2e/playwright.config.ts --workers="$worker_count" + # Playwright keeps a trace, a video and a screenshot for every failed + # test. Without this they die with the runner, and an e2e flake can only + # be diagnosed by reproducing it. + - name: Upload Desktop e2e results + if: failure() && steps.plan.outputs.e2e == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-e2e-results + path: apps/desktop/e2e/test-results/ + if-no-files-found: ignore + retention-days: 7 + - name: Browser WebContentsView semantic smoke if: steps.plan.outputs.e2e == 'true' # Hosted Linux runners cannot configure Electron's SUID helper. This diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 3d7d3b6ab3..41ed8e69ac 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -19,7 +19,7 @@ import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; import type { CDPSession, Locator, Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { awaitSendReady, expect, test, COMPOSER_INPUT } from './fixtures'; import { auditAxTree } from '../../../scripts/ax-tree-audit.mjs'; import { groupedNav } from '../src/renderer/settings/settings-nav'; @@ -52,13 +52,27 @@ async function tabTo(page: Page, target: Locator, label: string, limit = 30): Pr ).toBe(true); } +/** + * Walk to the skip link from the document start, taking the start back if a + * cold start moves it. + * + * Parking focus on `body` is not a one-shot the renderer respects: the composer + * restores its draft caret with `getSelection().addRange(...)`, and a range set + * inside a `contenteditable` focuses it — so once per cold start, tens of + * milliseconds after the park and with no `focus()` call to fence on, focus + * lands in the composer. A walk that starts there has to run out the tab ring + * and wrap around, which is over budget. The restore fires once, so re-park and + * walk again rather than widening the budget — the budget is the assertion. + */ async function enterMainFromSkipLink(page: Page): Promise { - await page.evaluate(() => { - document.body.tabIndex = -1; - document.body.focus(); - }); const skipLink = page.getByRole('link', { name: '跳到主要内容' }); - await tabTo(page, skipLink, 'skip link', 10); + await expect(async () => { + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + }); + await tabTo(page, skipLink, 'skip link', 10); + }).toPass({ timeout: 30_000 }); await page.keyboard.press('Enter'); await expect(page.getByRole('main')).toBeFocused(); await page.evaluate(() => document.body.removeAttribute('tabindex')); @@ -183,6 +197,7 @@ test('data-backed conversation exposes ordered todos and keyboard access to tool await page.keyboard.insertText('/graph on'); const send = page.getByRole('button', { name: '发送' }); await tabTo(page, send, 'Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); await expect(page.getByText('Graph Mode 已开启', { exact: true })).toBeVisible(); await assertAxHealth(cdp, 'overlay/graph-mode-toast'); @@ -203,6 +218,7 @@ test('toast and error states expose healthy live regions', async ({ window: page const cdp = await page.context().newCDPSession(page); const composer = page.locator(COMPOSER_INPUT); await composer.fill('/graph history'); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.getByText('Graph 历史', { exact: true })).toBeVisible(); await assertAxHealth(cdp, 'overlay/graph-history-toast'); @@ -224,10 +240,17 @@ test('a streaming answer exposes a healthy live conversation state', async ({ wi await tabTo(page, composer, 'streaming composer', 60); await page.keyboard.insertText(FAKE_HOLD_OPEN_PROMPT); const send = page.getByRole('button', { name: '发送' }); + // After the Tab walk, not before it: a tooltip-carrying Astryx Button is + // disabled via `aria-disabled`, so it stays focusable and `tabTo` would + // reach it either way. await tabTo(page, send, 'streaming Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); - await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); + await expect(page.locator('.maka-bubble-streaming')).toContainText( + 'Fake backend waiting', + { timeout: 20_000 }, + ); await expect(page.getByRole('button', { name: '停止' })).toBeEnabled(); await assertAxHealth(cdp, 'conversation/streaming'); @@ -254,8 +277,8 @@ test('composer and workbar entry points expose named actionable controls', async await tabTo(page, composer, 'new-task composer', 60); await page.keyboard.insertText(prompt); const send = page.getByRole('button', { name: '发送' }); - await expect(send).toBeEnabled(); await tabTo(page, send, 'new-task Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible({ timeout: 30_000, diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 9eaacdcacf..57f2e203ba 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -71,6 +71,31 @@ export async function ensureSidebarExpanded(page: Page): Promise { ).toBeVisible(); } +/** + * Wait until the composer is in a state where Enter is a real submission: the + * connections projection has produced at least one connection, the draft is + * non-empty, no known blocker is showing and no earlier send is still in + * flight. 发送 is disabled for all of that, so it is the one signal covering + * it; intermediate signals (a cleared draft, an updated model label) resolve + * earlier and mean nothing here. + * + * It does NOT cover the submission-readiness probe: an unresolved snapshot is + * not a hard block, so the button is enabled while the probe is in flight, and + * `send()` awaits the probe again on its own — a first send inside a barrier + * that gives up at 30s. The post-send assertions wait 20s, which covers every + * admission measured here but not that whole barrier. Widening them past it + * buys nothing: the 60s test budget is the real cap, a send admitted at 25s + * leaves the multi-send specs unable to finish anyway, and the only change + * would be trading a named assertion failure for a bare test timeout. A probe + * that comes back blocked still drops the send with no feedback, which is a + * product gap, not something a test-side fence can close. + */ +export async function awaitSendReady(page: Page): Promise { + await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ + timeout: 20_000, + }); +} + /** * Wait for the default Host's Coordination Session and the WorkHub projection * to agree that the surface is ready. A mounted WorkHub main is not sufficient: @@ -670,9 +695,9 @@ export const test = base.extend({ use, ); }, - // This scenario is read-only at the Host boundary. Keep its real Electron + - // Host composition warm for the worker, while the test-scoped wrapper below - // restores Host and renderer state between tests. + // 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) => { await withE2eWindow({ seed: false, @@ -682,6 +707,10 @@ export const test = base.extend({ // assertion that names it. readinessSelector: '[data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', + // Every other fixture window names its locale; without one the renderer + // takes the host's, so any test that reaches a control by its label + // passes on a Chinese desktop and cannot find it on an English CI runner. + locale: 'zh', showWindow: true, }, async (page, { app }) => { const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight })); @@ -717,8 +746,17 @@ export const test = base.extend({ promptRailMotionWindow: async ({}, use) => { await withE2eWindow({ seed: false, - readinessSelector: '[data-turn-id]', + // The transcript and the fixture attributes arrive on two unordered + // async paths: `runDeferredStartupRefreshes` fires `refreshSessions()` + // and `applyE2eFixture()` side by side, and only the second one — after + // its `e2eFixture.getState()` IPC resolves — writes + // `data-maka-scroll-motion`. A turn can therefore paint while the + // document still says nothing about scroll motion. Requiring both in one + // selector is what makes "this window scrolls smoothly" true by the time + // a test body reads it. + readinessSelector: 'html[data-maka-scroll-motion="smooth"] [data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', + locale: 'zh', showWindow: true, scrollMotion: 'smooth', }, use); diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index 998cd3c6ba..d0f238d988 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -23,7 +23,13 @@ import { FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import type { Locator } from '@playwright/test'; -import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; +import { + awaitSendReady, + COMPOSER_INPUT, + ensureSidebarExpanded, + expect, + test, +} from './fixtures'; interface SessionObservationLatchWindow extends Window { /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ @@ -54,10 +60,12 @@ test('a failed first observation seed reconnects to the live Turn', async ({ win const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-bubble-streaming')).toContainText( 'Fake backend waiting', + { timeout: 20_000 }, ); await page.getByRole('button', { name: '停止' }).click(); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { @@ -73,11 +81,12 @@ test('remounting a live surface leaves accumulated output settled', async ({ const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_REWRITE_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); const accumulatedOutput = 'prefix sk-123456789012345'; const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput); + await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); await ensureSidebarExpanded(page); @@ -137,8 +146,12 @@ test('keeps a completed reply after an interrupted turn and conversation remount expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); const composer = page.locator(COMPOSER_INPUT); await composer.fill('temporary conversation'); + await awaitSendReady(page); await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText('Fake backend received: temporary conversation'); + await expect(page.getByRole('log')).toContainText( + 'Fake backend received: temporary conversation', + { timeout: 20_000 }, + ); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000, }); @@ -157,9 +170,11 @@ test('keeps a completed reply after an interrupted turn and conversation remount await expect(composer).toHaveText(''); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-bubble-streaming')).toContainText( 'Fake backend waiting', + { timeout: 20_000 }, ); const originalSessionId = await sidebar .locator('[data-session-id]:has([aria-current="page"])') @@ -178,9 +193,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount { timeout: 20_000 }, ).toBe(0); await composer.fill(FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT); - await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ - timeout: 20_000, - }); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-user-message', { hasText: FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, @@ -220,11 +233,12 @@ test('returning to a live conversation settles output accumulated while away', a expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.'; const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput); + await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); await page.getByRole('button', { name: '展开侧边栏' }).click(); @@ -239,9 +253,11 @@ test('returning to a live conversation settles output accumulated while away', a await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); await expect(composer).toHaveText(''); await composer.fill('temporary second conversation'); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.getByRole('log')).toContainText( 'Fake backend received: temporary second conversation', + { timeout: 20_000 }, ); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000, diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 8e599c683d..2c81b5a68d 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -17,7 +17,13 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT, ensureSidebarExpanded } from './fixtures'; +import { + awaitSendReady, + expect, + test, + COMPOSER_INPUT, + ensureSidebarExpanded, +} from './fixtures'; import type { Page } from '@playwright/test'; /** @@ -196,6 +202,8 @@ function measureTailLag(page: Page, frames: number): Promise<{ async function sendPrompt(page: Page, text: string): Promise { const composer = page.locator(COMPOSER_INPUT); await composer.fill(text); + // Switching Session or model restarts asynchronous send admission. + await awaitSendReady(page); await composer.press('Enter'); } @@ -313,7 +321,9 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac .__makaBackgroundTailProbe = state; }, tailSessionId); await sendPrompt(page, LONG_PROMPT); - await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible(); + await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible({ + timeout: 20_000, + }); // The transcript collapses before each async replacement. This round trip // therefore exercises the production ordering that made a saved scrollTop diff --git a/packages/ui/src/__tests__/rail-alignment-claim.test.ts b/packages/ui/src/__tests__/rail-alignment-claim.test.ts new file mode 100644 index 0000000000..98de36ac69 --- /dev/null +++ b/packages/ui/src/__tests__/rail-alignment-claim.test.ts @@ -0,0 +1,56 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveRailAlignedTarget } from '../chat-view.js'; + +test('a rail claim aims its own navigation and nothing after it', () => { + // The click, before the shell has published anything. + let claim = resolveRailAlignedTarget({ turnId: 'a' }, undefined).claim; + assert.deepEqual(claim, { turnId: 'a' }); + + // The load the click asked for. The reveal has to agree with the rail. + let resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // Still the same command, re-rendered while the loaded range settles. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // A later search for the same Turn is a different command, and wants the + // search contract back. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 2 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search for another Turn spends an unconsumed rail claim', () => { + const resolved = resolveRailAlignedTarget({ turnId: 'a' }, { turnId: 'b', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search with no rail claim behind it is centred', () => { + const resolved = resolveRailAlignedTarget(undefined, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.deepEqual(resolved.target, { turnId: 'a', nonce: 1, align: 'center' }); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index b090851041..ea812fe1be 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -91,6 +91,35 @@ export interface ChatViewGoalIndicatorProps { goalIndicator?: SessionContextGoal; } +/** A rail click's outstanding request that the reveal for its Turn agree with it. */ +export type RailAlignmentClaim = { turnId: string; nonce?: number }; + +/** + * Which edge the transcript's reveal should use for the target the shell is + * publishing, and what is left of the rail's claim afterwards. + * + * A claim belongs to the one navigation its click asked for, not to the Turn: + * it binds to the first target that arrives for that Turn and is spent on + * anything else. A later search for the same Turn is a different command with + * its own nonce, and gets the search contract back. + */ +export function resolveRailAlignedTarget( + claim: RailAlignmentClaim | undefined, + target: T | undefined, +): { + claim: RailAlignmentClaim | undefined; + target: (T & { align: 'start' | 'center' }) | undefined; +} { + if (!target) return { claim, target: undefined }; + const aimedByRail = claim !== undefined + && claim.turnId === target.turnId + && (claim.nonce === undefined || claim.nonce === target.nonce); + return { + claim: aimedByRail ? { turnId: target.turnId, nonce: target.nonce } : undefined, + target: { ...target, align: aimedByRail ? 'start' : 'center' }, + }; +} + /** Persistent navigation position with a direct path back to the transcript tail. */ export function TranscriptHistoryNotice({ title, @@ -510,11 +539,24 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const scrollAuthority = useTranscriptScrollAuthority(); + // A rail click aims itself: it puts the prompt at the top of the scrollport + // and holds it there while the loaded range settles. Asking the shell to load + // an unloaded prompt also publishes a scroll target, and that reveal centres + // the turn with the app's scroll motion — a second answer to "where should + // this turn sit", and an animated one, which walks the prompt back off the + // top for a second after the rail has landed it. The reveal keeps its other + // job of recording the reading position; it just has to agree with the rail + // about the edge. + const railClaimRef = useRef(undefined); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { + railClaimRef.current = { turnId: turn.turnId }; loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } }, []); + const railAlignment = resolveRailAlignedTarget(railClaimRef.current, props.scrollTargetTurn); + railClaimRef.current = railAlignment.claim; + const scrollTargetTurn = railAlignment.target; const inlineTransientMessages = tailTurnId ? transientMessages.filter((message) => { const turn = turns.find((candidate) => candidate.turnId === tailTurnId); @@ -540,7 +582,7 @@ export function ChatView(props: { scrollRef, sessionId: props.activeSession?.id, messages: props.messages, - target: props.scrollTargetTurn, + target: scrollTargetTurn, restoreTarget: props.restoreTargetTurn, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index d3e12ffb9b..5ab67edfb7 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -40,7 +40,13 @@ export function useChatScroll(input: { scrollRef: RefObject; sessionId?: string; messages: readonly StoredMessage[]; - target?: { turnId: string; nonce: number }; + /** + * A turn to reveal, and where its requester wants it. `center` with the + * app's scroll motion is the reveal a search result wants; `start` is for a + * requester that is already aiming this turn itself and only needs the + * reveal to agree with it, instantly and at the same edge. + */ + target?: { turnId: string; nonce: number; align?: 'start' | 'center' }; restoreTarget?: { turnId: string; unavailable?: boolean }; onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; @@ -182,7 +188,12 @@ export function useChatScroll(input: { useEffect(() => { const explicitTarget = input.target?.turnId - ? { kind: 'search' as const, turnId: input.target.turnId, nonce: input.target.nonce } + ? { + kind: 'search' as const, + turnId: input.target.turnId, + nonce: input.target.nonce, + align: input.target.align ?? ('center' as const), + } : undefined; const restoreTurnId = activation.current?.restoreTurnId; const target = explicitTarget ?? (restoreTurnId @@ -217,9 +228,13 @@ export function useChatScroll(input: { } handledTarget.current = chosen; const targetElement = element as HTMLElement; + const alignToStart = target.kind !== 'search' || target.align === 'start'; targetElement.scrollIntoView({ - behavior: target.kind === 'search' ? input.behavior : 'auto', - block: target.kind === 'search' ? 'center' : 'start', + // A reveal that agrees with a requester already aiming this turn has to + // be instant too: an animated one is a second writer moving the + // scroller for a second after the requester has landed it. + behavior: alignToStart ? 'auto' : input.behavior, + block: alignToStart ? 'start' : 'center', }); // A command can land at the browser's existing offset and therefore // produce no scroll event. Reuse the authority-backed reporter so that