Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 31 additions & 8 deletions apps/desktop/e2e/accessibility-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> {
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 });
Comment thread
Astro-Han marked this conversation as resolved.
await page.keyboard.press('Enter');
await expect(page.getByRole('main')).toBeFocused();
await page.evaluate(() => document.body.removeAttribute('tabindex'));
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');

Expand All @@ -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,
Expand Down
46 changes: 42 additions & 4 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ export async function ensureSidebarExpanded(page: Page): Promise<void> {
).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
Comment thread
Astro-Han marked this conversation as resolved.
* 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<void> {
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:
Expand Down Expand Up @@ -670,9 +695,9 @@ export const test = base.extend<E2eTestFixtures, E2eWorkerFixtures>({
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,
Expand All @@ -682,6 +707,10 @@ export const test = base.extend<E2eTestFixtures, E2eWorkerFixtures>({
// 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 }));
Expand Down Expand Up @@ -717,8 +746,17 @@ export const test = base.extend<E2eTestFixtures, E2eWorkerFixtures>({
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]',
Comment thread
Astro-Han marked this conversation as resolved.
e2eFixtureScenario: 'chat-prompt-rail',
locale: 'zh',
showWindow: true,
scrollMotion: 'smooth',
}, use);
Expand Down
30 changes: 23 additions & 7 deletions apps/desktop/e2e/streaming-remount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
});
Expand All @@ -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"])')
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/e2e/transcript-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -196,6 +202,8 @@ function measureTailLag(page: Page, frames: number): Promise<{
async function sendPrompt(page: Page, text: string): Promise<void> {
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');
}

Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions packages/ui/src/__tests__/rail-alignment-claim.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
Loading