Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"tests": 1,
"electron": "needs a second real Session (Host round trip) to switch to; the focus and draft-restore halves alone would not earn a window"
},
"session-local-recovery.spec.ts": {
"tests": 3,
"electron": "SQLite admission crosses preload and main, survives both renderer reload and complete Electron restart, and cached history must render when the live IPC endpoint is unavailable"
},
"session-workbar.spec.ts": {
"tests": 6,
"electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, a first send that has to reach the Host, and per-Session collapse persisted across a renderer reload; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host"
Expand Down
41 changes: 22 additions & 19 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,9 @@ export async function ensureSidebarExpanded(page: Page): Promise<void> {
}

/**
* 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.
* Wait until Enter can submit this non-empty draft to its selected target.
* The control reflects local admission readiness, not Host connectivity.
* Merely mounting the editor does not mean target selection has finished.
*/
export async function awaitSendReady(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({
Expand Down Expand Up @@ -436,7 +422,7 @@ async function withE2eWindow(
railRenderSessions?: boolean;
newTaskProject?: boolean;
},
use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise<void>,
use: (page: Page, context: { userDataDir: string; app: ElectronApplication; restart(): Promise<Page> }) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
// Lives inside the throwaway userData dir so the existing teardown removes
Expand Down Expand Up @@ -505,7 +491,17 @@ async function withE2eWindow(
const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
}
await use(page, { userDataDir, app });
await use(page, { userDataDir, app, restart: async () => {
await closeElectronApplication(app!, 5_000);
app = await electron.launch({
args: ['.', ...(visibleWindow ? inactiveWindowPlatformArgs() : [])],
cwd: DESKTOP_ROOT,
env: buildFixtureEnv(userDataDir, homeDir, { scenario: e2eFixtureScenario, locale, platform, showWindow: visibleWindow }),
});
const restored = await app.firstWindow();
await restored.waitForSelector(readinessSelector, { timeout: 20_000 });
return restored;
} });
} finally {
try {
if (app) await closeElectronApplication(app, 5_000);
Expand All @@ -516,6 +512,7 @@ async function withE2eWindow(
}

type E2eTestFixtures = {
sessionLocalWindow: { page: Page; app: ElectronApplication; restart(): Promise<Page> };
window: Page;
gitReviewWindow: { page: Page; projectRoot: string };
invocableSkillsWindow: Page;
Expand All @@ -532,6 +529,12 @@ type E2eTestFixtures = {
};

export const test = base.extend<E2eTestFixtures>({
sessionLocalWindow: async ({}, use) => {
await withE2eWindow(
{ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN', showWindow: true },
async (page, { app, restart }) => use({ page, app, restart }),
);
},
directoryReferenceWindow: async ({}, use) => {
await withE2eWindow(
{ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN', showWindow: true },
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/e2e/quote-window-boundary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { expect, test, COMPOSER_INPUT } from './fixtures';
import { awaitSendReady, 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.
Expand All @@ -27,6 +27,7 @@ test('a transcript drag releases outside the window through its owning Turn', as
await page.setViewportSize({ width: 1200, height: 800 });
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('pointer capture source');
await awaitSendReady(page);
await composer.press('Enter');

// Select from a settled answer. Selecting from a streaming one is broken for
Expand Down
171 changes: 171 additions & 0 deletions apps/desktop/e2e/session-local-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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 { resolve } from 'node:path';
import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures';

test('a locally saved message survives renderer and application restart, then executes once', async ({
sessionLocalWindow,
}, testInfo) => {
let { page } = sessionLocalWindow;
const { app, restart } = sessionLocalWindow;
const first = 'durable history before restart';
await page.locator(COMPOSER_INPUT).fill(first);
await awaitSendReady(page);
await page.locator(COMPOSER_INPUT).press('Enter');
await expect(page.getByText(`Fake backend received: ${first}`)).toBeVisible();
await ensureSidebarExpanded(page);
const sessionId = await page
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(sessionId).toBeTruthy();
await expect
.poll(() =>
page.evaluate(
async (id) => !!(await window.maka.sessionLocal.readTranscript(id)),
sessionId!,
),
)
.toBe(true);

// Pause only the delivery scheduler in this isolated main process. Admission,
// SQLite, renderer/preload, and the later Host execution all remain real.
await app.evaluate((_electron, modulePath) => {
const require = process.getBuiltinModule('module').createRequire(`${process.cwd()}/`);
const { DesktopSessionLocalService } = require(modulePath);
DesktopSessionLocalService.prototype.wake = () => {};
}, resolve('dist/main/session-local-service.js'));
const pending = 'saved locally across a complete application restart';
await page.locator(COMPOSER_INPUT).fill(pending);
await awaitSendReady(page);
await page.locator(COMPOSER_INPUT).press('Enter');
await expect(page.locator(COMPOSER_INPUT)).toHaveText('');
await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible();
const before = await page.evaluate((id) => window.maka.sessionLocal.listMessages(id), sessionId!);
const message = before.find((item) => item.text === pending)!;
expect(message.state).toBe('saved');
await page.screenshot({ path: testInfo.outputPath('locally-saved.png') });

await page.reload();
await ensureSidebarExpanded(page);
await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible();
expect(
(await page.evaluate((id) => window.maka.sessionLocal.listMessages(id), sessionId!)).find(
(item) => item.text === pending,
)?.messageId,
).toBe(message.messageId);

page = await restart();
await ensureSidebarExpanded(page);
await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${pending}`),
).toBeVisible({ timeout: 20_000 });
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${pending}`),
).toHaveCount(1);
await expect
.poll(() =>
page.evaluate(
async (id) => (await window.maka.sessionLocal.listMessages(id)).length,
sessionId!,
),
)
.toBe(0);
await page.screenshot({ path: testInfo.outputPath('recovered.png') });
});

test('cached history remains readable when the live transcript endpoint is unavailable', async ({
sessionLocalWindow,
}, testInfo) => {
const { page, app } = sessionLocalWindow;
const prompt = 'history available without a live transcript';
await page.locator(COMPOSER_INPUT).fill(prompt);
await awaitSendReady(page);
await page.locator(COMPOSER_INPUT).press('Enter');
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${prompt}`),
).toBeVisible();
await ensureSidebarExpanded(page);
const sessionId = await page
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
await expect
.poll(() =>
page.evaluate(
async (id) => !!(await window.maka.sessionLocal.readTranscript(id)),
sessionId!,
),
)
.toBe(true);
// Fault only the live endpoint, not the cache bridge or renderer projection.
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('sessions:transcript:open');
ipcMain.handle('sessions:transcript:open', () => {
throw new Error('E2E live transcript unavailable');
});
});
await page.reload();
await ensureSidebarExpanded(page);
await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${prompt}`),
).toBeVisible();
await expect(page.locator('.maka-chat-recovery-notice')).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('cached-history.png') });
});

test('a new task is readable locally before the Host session exists', async ({
sessionLocalWindow,
}) => {
let { page } = sessionLocalWindow;
await sessionLocalWindow.app.evaluate((_electron, modulePath) => {
const require = process.getBuiltinModule('module').createRequire(`${process.cwd()}/`);
const { DesktopSessionLocalService } = require(modulePath);
DesktopSessionLocalService.prototype.wake = () => {};
}, resolve('dist/main/session-local-service.js'));
const prompt = 'first message saved before Host creation';
await page.locator(COMPOSER_INPUT).fill(prompt);
await awaitSendReady(page);
await page.locator(COMPOSER_INPUT).press('Enter');
await expect(page.locator(COMPOSER_INPUT)).toHaveText('');
await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible();
await ensureSidebarExpanded(page);
const sessionId = await page
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(sessionId).toBeTruthy();
await expect(page.getByText('读取任务失败', { exact: true })).toHaveCount(0);
await expect(page.getByText('Graph 状态刷新失败。', { exact: true })).toHaveCount(0);
await page.reload();
await ensureSidebarExpanded(page);
await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(page.getByText('已本地保存 · 等待发送')).toBeVisible();
await expect(page.getByText('读取任务失败', { exact: true })).toHaveCount(0);
page = await sessionLocalWindow.restart();
await ensureSidebarExpanded(page);
await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${prompt}`),
).toBeVisible({ timeout: 20_000 });
await expect(
page.getByLabel('Maka 的回答').getByText(`Fake backend received: ${prompt}`),
).toHaveCount(1);
});
5 changes: 4 additions & 1 deletion apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
* under the License.
*/

import { COMPOSER_INPUT, test, expect } from './fixtures';
import { awaitSendReady, COMPOSER_INPUT, test, expect } from './fixtures';
import type { Page } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';

async function openGitChanges(page: Page) {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create review session');
await awaitSendReady(page);
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: create review session/)).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
Expand All @@ -36,6 +37,7 @@ async function openGitChanges(page: Page) {
async function createSession(page: Page, prompt: string) {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(prompt);
await awaitSendReady(page);
await composer.press('Enter');
await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
Expand Down Expand Up @@ -219,6 +221,7 @@ test('Terminal ownership follows the active Session and stops the old resource',
.not.toBe('running');

await composer.fill('create replacement session');
await awaitSendReady(page);
await composer.press('Enter');
await expect(page.getByText('Fake backend received: create replacement session')).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';
import { awaitSendReady, COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';

interface SettingsChunkLatchWindow extends Window {
makaE2eLatch?: {
Expand Down Expand Up @@ -78,6 +78,7 @@ test('Settings loading surface owns unmodified Escape', async ({ window: page })
test('opening settings commits an active titlebar rename', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create a session for settings rename');
await awaitSendReady(page);
await composer.press('Enter');

const identity = page.locator('[data-maka-contract="titlebar-identity"]');
Expand All @@ -100,6 +101,7 @@ test('settings hides expanded workbar chrome and restores it on close', async ({
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create a session with an expanded workbar');
await awaitSendReady(page);
await composer.press('Enter');

await page.getByRole('button', { name: '展开任务工作栏' }).click();
Expand Down
Loading
Loading