test(e2e): keep ui lane playwright-only

This commit is contained in:
PerishCode
2026-05-06 16:16:38 +08:00
parent 55c63023c6
commit 7d2ba666da
6 changed files with 82 additions and 318 deletions
+5
View File
@@ -48,6 +48,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared
# sidecar/platform imports. It intentionally skips app outputs because
@@ -81,6 +84,8 @@ jobs:
- name: Test
run: |
pnpm --filter @open-design/e2e test
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.config.ts
pnpm --filter @open-design/contracts test
pnpm --filter @open-design/platform test
pnpm --filter @open-design/sidecar test
+1 -1
View File
@@ -21,7 +21,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie
- App tests live in each app's `tests/` directory, sibling to `src/`; preserve source-relative subpaths inside `tests/` when useful.
- Keep app `src/` directories source-only; do not add new `*.test.ts` or `*.test.tsx` files under `src/`.
- `apps/web/tests/` contains web-owned Vitest tests and uses `*.test.ts` / `*.test.tsx`.
- Playwright UI automation belongs in `e2e/ui/`; do not add Playwright suites or helper scripts under `apps/web`.
- Playwright UI automation belongs in `e2e/ui/`; do not add Playwright suites or UI automation helper scripts under `apps/web`.
## Sidecar awareness
+6 -5
View File
@@ -1,12 +1,12 @@
# e2e/AGENTS.md
Follow the root `AGENTS.md` first. This package owns user-level end-to-end smoke tests only.
Follow the root `AGENTS.md` first. This package owns user-level end-to-end smoke tests and Playwright UI automation only.
## Directory layout
- `specs/`: highest-ROI end-to-end smoke tests suitable for PR or release gating. Keep this layer small and expand it only for regressions that justify always-on signal.
- `tests/`: broader user-level end-to-end coverage, including Vitest checks that intentionally span app/package/resource boundaries. Add feature-depth scenarios here instead of bloating `specs/`.
- `ui/`: Playwright UI automation test files only. Keep helpers and resources out of this directory.
- `ui/`: flat Playwright UI automation test files only. Keep helpers, resources, and non-Playwright harnesses out of this directory.
- `resources/`: declarative resources for e2e suites, such as Playwright UI scenario lists.
- `lib/shared.ts`: tiny cross-suite shared helpers only.
- `lib/vitest/`: Vitest-specific helpers.
@@ -17,9 +17,9 @@ Follow the root `AGENTS.md` first. This package owns user-level end-to-end smoke
- `specs/` files must be `*.spec.ts`.
- `tests/` files must be `*.test.ts`.
- `ui/` files must be flat `*.test.ts` Playwright tests.
- E2E Vitest tests use Node APIs; do not add JSX/TSX, jsdom, or browser-component tests here.
- Web component/runtime tests belong in `apps/web/tests/`.
- `ui/` files must be flat `*.test.ts` Playwright tests. Do not add subdirectories, TSX, Vitest, jsdom, Testing Library, or React harness tests under `ui/`.
- E2E Vitest tests use Node APIs; do not add JSX/TSX, jsdom, or browser-component tests under `specs/` or `tests/`.
- Web component/runtime tests belong in `apps/web/tests/`, not `e2e/ui/`.
- E2E tests may validate cross-app/resource consistency, but must not treat one app's private implementation as a shared helper for another app. Keep test-only helpers local to `e2e/lib/` or promote reusable logic to a pure package such as `packages/contracts`.
- E2E imports may use `@/*` for `lib/*`; keep this alias local to the e2e package.
@@ -34,6 +34,7 @@ pnpm test tests
pnpm typecheck
pnpm exec tsx scripts/playwright.ts clean
pnpm exec playwright test -c playwright.config.ts --list
pnpm exec playwright test -c playwright.config.ts
```
Use a specific file path when validating a single case. Do not add root e2e aliases or extra package scripts for individual cases.
@@ -1,307 +0,0 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import React, { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
let lastChatPaneProps: any | null = null;
const saveMessageSpy = vi.fn(async () => {});
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock('../../apps/web/src/i18n', () => ({
useT: () => ((key: string) => key) as any,
}));
vi.mock('../../apps/web/src/components/ChatPane', async () => {
const mod = await vi.importActual<any>('../../apps/web/src/components/ChatPane');
return {
...mod,
ChatPane: (props: any) => {
lastChatPaneProps = props;
return null;
},
};
});
vi.mock('../../apps/web/src/components/AppChromeHeader', () => ({
AppChromeHeader: () => null,
}));
vi.mock('../../apps/web/src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
vi.mock('../../apps/web/src/components/FileWorkspace', () => ({
FileWorkspace: () => null,
}));
vi.mock('../../apps/web/src/router', () => ({
navigate: () => {},
}));
vi.mock('../../apps/web/src/utils/notifications', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../apps/web/src/utils/notifications')>();
return {
...actual,
playSound: () => {},
showCompletionNotification: async () => {},
};
});
vi.mock('../../apps/web/src/providers/registry', () => ({
fetchPreviewComments: async () => [],
fetchDesignSystem: async () => null,
fetchLiveArtifacts: async () => [],
fetchProjectFiles: async () => [],
fetchSkill: async () => null,
patchPreviewCommentStatus: async () => true,
upsertPreviewComment: async () => null,
deletePreviewComment: async () => true,
writeProjectTextFile: async () => null,
}));
vi.mock('../../apps/web/src/providers/project-events', () => ({
useProjectFileEvents: () => ({ events: [] }),
}));
vi.mock('../../apps/web/src/state/projects', () => ({
listConversations: async () => [{ id: 'conv-1', title: 'Conversation', createdAt: 0 }],
createConversation: async () => ({ id: 'conv-1', title: 'Conversation', createdAt: 0 }),
listMessages: async () => [],
loadTabs: async () => ({ tabs: [], active: null }),
saveTabs: async () => {},
saveMessage: saveMessageSpy,
patchConversation: async () => null,
deleteConversation: async () => true,
patchProject: async () => null,
getTemplate: async () => null,
}));
vi.mock('../../apps/web/src/providers/daemon', () => ({
fetchChatRunStatus: async () => null,
listActiveChatRuns: async () => [],
reattachDaemonRun: async () => {},
streamViaDaemon: async ({ handlers, onRunCreated, onRunStatus }: any) => {
onRunCreated?.('run-1');
onRunStatus?.('running');
handlers.onError(new Error('connection refused'));
},
}));
async function loadProjectView() {
const { ProjectView } = await import('../../apps/web/src/components/ProjectView');
return ProjectView;
}
function baseProject() {
return {
id: 'proj-1',
name: 'Project',
createdAt: 0,
updatedAt: 0,
skillId: null,
designSystemId: null,
metadata: {} as any,
};
}
describe('ProjectView daemon error persistence', () => {
afterEach(() => {
cleanup();
saveMessageSpy.mockClear();
});
it('stores daemon failure details on the assistant message events', async () => {
lastChatPaneProps = null;
const ProjectView = await loadProjectView();
render(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'claude',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={[{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] }] as any}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps).not.toBeNull();
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('hi', []);
});
await waitFor(() => {
const assistant = (lastChatPaneProps!.messages as any[]).find((m: any) => m.role === 'assistant');
expect(assistant).toBeTruthy();
const events = assistant!.events ?? [];
expect(
events.some(
(e: any) => e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
await waitFor(() => {
expect(saveMessageSpy).toHaveBeenCalled();
});
});
it('keeps prior error details after switching agents and sending again', async () => {
lastChatPaneProps = null;
const ProjectView = await loadProjectView();
const view = render(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'claude',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={
[
{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] },
{ id: 'opencode', name: 'OpenCode', detected: true, version: '1.0.0', models: [] },
] as any
}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps).not.toBeNull();
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('first', []);
});
let firstAssistantId = '';
await waitFor(() => {
const assistants = (lastChatPaneProps!.messages as any[]).filter(
(m: any) => m.role === 'assistant',
);
expect(assistants.length).toBe(1);
const first = assistants[0]!;
firstAssistantId = first.id;
const events = first.events ?? [];
expect(
events.some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
// Switch agent (simulates picking a different CLI), then send again.
view.rerender(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'opencode',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={
[
{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] },
{ id: 'opencode', name: 'OpenCode', detected: true, version: '1.0.0', models: [] },
] as any
}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('second', []);
});
await waitFor(() => {
const messages = lastChatPaneProps!.messages as any[];
const firstAssistant = messages.find((m: any) => m.id === firstAssistantId);
expect(firstAssistant).toBeTruthy();
expect(
(firstAssistant!.events ?? []).some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
const assistants = messages.filter((m: any) => m.role === 'assistant');
expect(assistants.length).toBe(2);
const secondAssistant = assistants[1]!;
expect(
(secondAssistant.events ?? []).some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
});
});
+67
View File
@@ -300,6 +300,73 @@ for (const entry of automatedUiScenarios()) {
});
}
test('daemon error details persist between failed sends', async ({ page }) => {
const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic');
if (!entry) throw new Error('prototype-basic scenario missing');
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
agents: [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
let runCount = 0;
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `error-run-${runCount}` }),
});
});
await page.route('**/api/runs/*/events', async (route) => {
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: error',
'data: {"message":"connection refused"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await page.goto('/');
await createProject(page, entry);
await expectWorkspaceReady(page);
await sendPrompt(page, 'first failing prompt');
const errorPills = page.locator('.status-pill', { hasText: 'connection refused' });
await expect(errorPills).toHaveCount(1);
await expect(page.locator('.msg.error')).toContainText('connection refused');
await expect(page.getByText('first failing prompt')).toBeVisible();
await sendPrompt(page, 'second failing prompt');
await expect(errorPills).toHaveCount(2);
await expect(page.getByText('first failing prompt')).toBeVisible();
await expect(page.getByText('second failing prompt')).toBeVisible();
});
async function createProject(
page: Page,
entry: UiScenario,
+3 -5
View File
@@ -270,10 +270,8 @@ async function checkE2eLayout(): Promise<boolean> {
if (repositoryPath.startsWith("e2e/ui/")) {
const relativePath = repositoryPath.slice("e2e/ui/".length);
if (relativePath.includes("/")) {
violations.push(`${repositoryPath} -> e2e UI test files must stay flat under ui/`);
} else if (!/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e UI files must be Playwright *.test.ts files`);
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
}
continue;
}
@@ -309,7 +307,7 @@ async function checkE2eLayout(): Promise<boolean> {
return false;
}
console.log("E2E layout check passed: Vitest, UI, resources, lib, and scripts stay in their lanes.");
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
return true;
}