mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
test(e2e): keep ui lane playwright-only
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user