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
2 changes: 2 additions & 0 deletions docs-web/content/docs/developer-testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Playwright starts `node dist/index.js` on `http://127.0.0.1:4444` with an isolat

Use `tests/e2e/helpers/e2e-fixtures.ts` for deterministic browser tests that need temporary git repositories, selected Code UX projects, public-API sprint/task seeding, local-git HOST execution, QA-disabled project settings, API polling, or onboarding/tour suppression. The fake provider supports prompt markers such as `[mock-provider:sleep=250]`, `[mock-provider:fail]`, `[mock-provider:exit=2]`, `[mock-provider:no-op]`, and `[mock-provider:write=relative/path.txt]`.

`tests/e2e/dashboard-workflows.spec.ts` covers the pre-orchestration product path: isolated local-git project selection, UI draft sprint creation, UI task creation with dependencies, core route landmarks, collection API visibility, and unhandled browser error capture without starting planning or provider execution.

## Coverage thresholds

Enforced in CI:
Expand Down
2 changes: 2 additions & 0 deletions docs-web/developer/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Playwright starts `node dist/index.js` on `http://127.0.0.1:4444` with an isolat

Use `tests/e2e/helpers/e2e-fixtures.ts` for deterministic browser tests that need temporary git repositories, selected Code UX projects, public-API sprint/task seeding, local-git HOST execution, QA-disabled project settings, API polling, or onboarding/tour suppression. The fake provider supports prompt markers such as `[mock-provider:sleep=250]`, `[mock-provider:fail]`, `[mock-provider:exit=2]`, `[mock-provider:no-op]`, and `[mock-provider:write=relative/path.txt]`.

`tests/e2e/dashboard-workflows.spec.ts` covers the pre-orchestration product path: isolated local-git project selection, UI draft sprint creation, UI task creation with dependencies, core route landmarks, collection API visibility, and unhandled browser error capture without starting planning or provider execution.

## Coverage thresholds

Enforced in CI:
Expand Down
2 changes: 2 additions & 0 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ The Playwright config starts `node dist/index.js`, waits on the local `/health`
Focused examples:
```bash
pnpm run test:e2e -- tests/e2e/product-smoke.spec.ts
pnpm run test:e2e -- tests/e2e/dashboard-workflows.spec.ts
pnpm run test:e2e -- tests/e2e/sprint-task-lifecycle.spec.ts
pnpm run test:e2e -- tests/e2e/app-release-smoke.spec.ts
pnpm run test:e2e -- tests/e2e/project-setup-release.spec.ts
Expand All @@ -111,6 +112,7 @@ The release-style E2E suite lives under `tests/e2e` and exercises the production

- `tests/e2e/app-release-smoke.spec.ts`, which verifies the normal app shell, core dashboard routes, responsive task-board behavior, route landmarks, and unexpected browser errors.
- `tests/e2e/project-setup-release.spec.ts`, which verifies first-run onboarding completion, visible Add Project modal behavior for a credential-free local directory under the OS temp path, dashboard project selection, `/projects` landmarks, `/tasks` navigation, loading/error checks, and desktop/mobile overflow checks without provider secrets or orchestration endpoints.
- `tests/e2e/dashboard-workflows.spec.ts`, which verifies isolated local-git project selection, draft sprint creation, dependent task creation, collection API visibility, core route landmarks, and unhandled browser error capture without starting planning or provider execution.
- `tests/e2e/sprint-task-lifecycle.spec.ts`, which verifies draft sprint and implementation task create/edit/delete behavior through the visible dashboard flows and collection API assertions.
- `tests/e2e/helpers/prepare-app.ts`, which prepares deterministic app state through dashboard HTTP APIs for onboarding, local project selection, draft sprint setup, task setup, updates, deletes, and cleanup.
- `tests/e2e/helpers/e2e-fixtures.ts`, which adds reusable helpers for temporary git repositories, selected Code UX project seeding, project settings overrides for local-git HOST execution, QA-disabled deterministic sprint/task fixtures, API polling, and dashboard onboarding/tour suppression.
Expand Down
19 changes: 13 additions & 6 deletions tests/e2e/accessibility-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { test, expect } from '@playwright/test';
import { completeOnboarding, ensureSelectedProject } from './helpers/prepare-app';
import { prepareSelectedLocalGitProject, type SeededCodeUxProject } from './helpers/e2e-fixtures';

let fixture: SeededCodeUxProject | null = null;

test.beforeEach(async ({ page, request }, testInfo) => {
await completeOnboarding(request);
await ensureSelectedProject(request, { testInfo, fixtureKey: 'accessibility' });
fixture = await prepareSelectedLocalGitProject(page, request, testInfo, 'accessibility');
});

await page.addInitScript(() => {
localStorage.setItem('codeux:dashboard-tour-hidden:v1', 'true');
});
test.afterEach(async () => {
await fixture?.cleanup();
fixture = null;
});

test('Dashboard accessibility smoke test', async ({ page }) => {
await page.goto('/');

await expect(page.getByRole('dialog', { name: /make the runtime ready/i })).toHaveCount(0);
await expect(page.getByRole('dialog', { name: /projects|sprints|tasks|settings/i })).toHaveCount(0);

// 1. Skip link
const skipLink = page.locator('a[href="#main-content"]');
await expect(skipLink).toHaveAttribute('class', /sr-only/);
Expand All @@ -34,6 +39,7 @@ test('Dashboard accessibility smoke test', async ({ page }) => {
// 5. Project Selector
const projectSelector = page.getByRole('button', { name: /Project/i });
await expect(projectSelector).toBeVisible();
await expect(projectSelector).toContainText(fixture?.project.name ?? '');

// 6. Stats Chart (if visible)
const statsChart = page.getByRole('region', { name: /Statistics|Chart/i }).first();
Expand All @@ -58,4 +64,5 @@ test('Dashboard accessibility smoke test', async ({ page }) => {

const sprintLedger = page.getByRole('region', { name: 'Sprint Ledger' });
await expect(sprintLedger).toBeVisible();
await expect(projectSelector).toContainText(fixture?.project.name ?? '');
});
238 changes: 238 additions & 0 deletions tests/e2e/dashboard-workflows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { expect, type APIRequestContext, type Locator, type Page, test, type TestInfo } from '@playwright/test';
import type { ProjectSummary, SprintRecord, TaskRecord } from '../../src/contracts/project-management-types.js';
import {
createE2eFixturePrefix,
fetchSprintsViaApi,
fetchTasksViaApi,
prepareSelectedLocalGitProject,
type SeededCodeUxProject,
} from './helpers/e2e-fixtures';

type RouteCase = {
path: string;
landmark: (page: Page) => Locator;
};

const routeCases: RouteCase[] = [
{ path: '/', landmark: (page) => page.getByRole('heading', { name: 'Overview' }) },
{ path: '/projects', landmark: (page) => page.getByRole('heading', { name: 'Manage Projects' }) },
{ path: '/sprints', landmark: (page) => page.getByRole('region', { name: 'Sprint Ledger' }) },
{ path: '/tasks', landmark: (page) => page.getByRole('heading', { name: 'Task Board', exact: true }) },
{ path: '/agents', landmark: (page) => page.getByRole('region', { name: 'Agents' }) },
{ path: '/stats', landmark: (page) => page.getByRole('region', { name: 'Statistics' }) },
{ path: '/scheduler', landmark: (page) => page.getByTestId('scheduler-page-root') },
{ path: '/config', landmark: (page) => page.getByRole('heading', { name: 'Settings & Integration' }) },
{ path: '/memory', landmark: (page) => page.getByRole('heading', { name: 'Memory Map' }) },
{ path: '/browser', landmark: (page) => page.getByTestId('browser-page-root') },
{ path: '/files', landmark: (page) => page.getByTestId('file-browser-page-root') },
];

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function installPageErrorCapture(page: Page): string[] {
const errors: string[] = [];
page.on('pageerror', (error) => {
errors.push(error.message);
});
return errors;
}

async function expectNoPageErrors(errors: string[]): Promise<void> {
expect(errors, errors.join('\n')).toEqual([]);
}

async function expectNoPersistentLoading(page: Page): Promise<void> {
await expect(page.getByText(/loading dashboard|loading workspace|loading projects/i)).toHaveCount(0);
}

async function prepareWorkflowProject(
page: Page,
request: APIRequestContext,
testInfo: TestInfo,
fixtureKey: string,
): Promise<SeededCodeUxProject> {
return prepareSelectedLocalGitProject(page, request, testInfo, fixtureKey);
}

async function selectProjectFromDashboard(page: Page, project: ProjectSummary): Promise<void> {
const projectButton = page.locator('[data-tour-id="project-selector"]');
await expect(projectButton).toBeVisible();
await projectButton.click();

const projectList = page.getByRole('listbox', { name: 'Project list' });
await expect(projectList).toBeVisible();
await page.getByLabel('Filter projects').fill(project.name);

const option = page.getByRole('option', { name: new RegExp(escapeRegExp(project.name)) });
await expect(option).toBeVisible();
await Promise.all([
page.waitForResponse((response) => (
response.request().method() === 'PUT'
&& response.url().includes(`/api/projects/${encodeURIComponent(project.id)}/select`)
&& response.status() === 200
)),
option.click(),
]);

await expect(projectButton).toContainText(project.name);
}

async function createDraftSprintFromUi(
page: Page,
project: ProjectSummary,
name: string,
goal: string,
): Promise<SprintRecord> {
await page.goto('/sprints');
await selectProjectFromDashboard(page, project);
await expect(page.getByRole('region', { name: 'Sprint Ledger' })).toBeVisible();
await page.getByRole('button', { name: 'New Sprint' }).first().click();

const composer = page.getByRole('form', { name: 'Sprint composer' });
await expect(composer).toBeVisible();
await composer.getByPlaceholder('Runtime hardening').fill(name);
await composer
.getByPlaceholder('Describe the outcome, affected systems, and what done looks like when this sprint lands.')
.fill(goal);
const draftModeButton = composer
.locator('button[type="button"]')
.filter({ hasText: /^Save Draft/ })
.first();
await draftModeButton.click();
const submitButton = composer.locator('button[type="submit"]').filter({ hasText: 'Save Draft' });
await expect(submitButton).toBeVisible();

const [response] = await Promise.all([
page.waitForResponse((candidate) => (
candidate.request().method() === 'POST'
&& candidate.url().includes(`/api/projects/${encodeURIComponent(project.id)}/sprints`)
&& candidate.status() === 201
)),
submitButton.click(),
]);

const sprint = await response.json() as SprintRecord;
await expect(page.getByText(name).first()).toBeVisible();
return sprint;
}

async function createTaskFromUi(
page: Page,
project: ProjectSummary,
sprintId: string,
input: {
title: string;
description: string;
promptMarkdown: string;
dependsOnTitle?: string;
},
): Promise<TaskRecord> {
await page.goto(`/tasks?sprintId=${encodeURIComponent(sprintId)}`);
await selectProjectFromDashboard(page, project);
await expect(page.getByRole('heading', { name: 'Task Board', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New Task' }).click();

const composer = page.locator('form').filter({ hasText: /Task Composer|Create task/i });
await expect(composer).toBeVisible();
await composer.getByPlaceholder('Fix navigation layout shift').fill(input.title);
await composer.getByPlaceholder('Summarize the intent and outcome.').fill(input.description);
await composer.getByPlaceholder(/Detailed markdown instructions for the .*agent\./).fill(input.promptMarkdown);

if (input.dependsOnTitle) {
const dependencyButton = composer.getByRole('button', { name: new RegExp(escapeRegExp(input.dependsOnTitle)) });
await expect(dependencyButton).toBeVisible();
await dependencyButton.click();
await expect(dependencyButton).toHaveAttribute('aria-pressed', 'true');
}

const [response] = await Promise.all([
page.waitForResponse((candidate) => (
candidate.request().method() === 'POST'
&& candidate.url().includes(`/api/projects/${encodeURIComponent(project.id)}/tasks`)
&& candidate.status() === 201
)),
composer.getByRole('button', { name: 'Create Task' }).click(),
]);

return await response.json() as TaskRecord;
}

test.describe('dashboard workflows before orchestration', () => {
let fixture: SeededCodeUxProject | null = null;

test.afterEach(async () => {
await fixture?.cleanup();
fixture = null;
});

test('selects an isolated project, creates a draft sprint, and creates dependent tasks', async ({ page, request }, testInfo) => {
const errors = installPageErrorCapture(page);
fixture = await prepareWorkflowProject(page, request, testInfo, 'dashboard-workflow');
const { project } = fixture;
const prefix = createE2eFixturePrefix({ testInfo, fixtureKey: 'dashboard-workflow' });
const sprintName = `${prefix} dashboard draft sprint`;
const sprintGoal = 'Validate pre-orchestration dashboard workflow coverage without planning or provider execution.';
const firstTaskTitle = `${prefix} foundation task`;
const secondTaskTitle = `${prefix} dependent task`;

await page.goto('/');
await selectProjectFromDashboard(page, project);

const sprint = await createDraftSprintFromUi(page, project, sprintName, sprintGoal);
expect(sprint.status).toBe('idle');
expect(sprint.goal).toContain('pre-orchestration');

const firstTask = await createTaskFromUi(page, project, sprint.id, {
title: firstTaskTitle,
description: 'Create deterministic setup coverage for the workflow.',
promptMarkdown: 'Keep this task in draft/pending state for dashboard workflow E2E coverage.',
});
const secondTask = await createTaskFromUi(page, project, sprint.id, {
title: secondTaskTitle,
description: 'Depend on the foundation task while staying pending.',
promptMarkdown: 'Verify visible dependency controls without launching provider execution.',
dependsOnTitle: firstTaskTitle,
});

await expect(page.getByRole('region', { name: /Queued 2 tasks/i })).toBeVisible();
await expect(page.getByLabel(new RegExp(`Task ${escapeRegExp(firstTask.taskKey)}: ${escapeRegExp(firstTaskTitle)}\\. Status queued`, 'i'))).toBeVisible();
await expect(page.getByLabel(new RegExp(`Task ${escapeRegExp(secondTask.taskKey)}: ${escapeRegExp(secondTaskTitle)}\\. Status queued`, 'i'))).toBeVisible();
await expect(page.getByText(/1 dependency blocker/i).first()).toBeVisible();

const { sprints } = await fetchSprintsViaApi(request, project.id);
expect(sprints.find((candidate) => candidate.id === sprint.id)).toMatchObject({
name: sprintName,
status: 'idle',
});

const tasks = await fetchTasksViaApi(request, project.id, sprint.id);
expect(tasks.find((candidate) => candidate.id === firstTask.id)).toMatchObject({
title: firstTaskTitle,
status: 'pending',
dependsOnTaskIds: [],
});
expect(tasks.find((candidate) => candidate.id === secondTask.id)).toMatchObject({
title: secondTaskTitle,
status: 'pending',
dependsOnTaskIds: [firstTask.id],
});
await expectNoPageErrors(errors);
});

test('core routes render stable landmarks without unhandled page errors', async ({ page, request }, testInfo) => {
const errors = installPageErrorCapture(page);
fixture = await prepareWorkflowProject(page, request, testInfo, 'dashboard-routes');

for (const route of routeCases) {
await page.goto(route.path);
await expect(page).toHaveURL(new RegExp(`${route.path === '/' ? '/$' : `${escapeRegExp(route.path)}$`}`));
await selectProjectFromDashboard(page, fixture.project);
await expect(route.landmark(page)).toBeVisible();
await expect(page.locator('[data-tour-id="project-selector"]')).toContainText(fixture.project.name);
await expectNoPersistentLoading(page);
await expectNoPageErrors(errors);
}
});
});
28 changes: 19 additions & 9 deletions tests/e2e/sprint-ledger-responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { test, expect, type Page } from '@playwright/test';
import { completeOnboarding, createDraftSprint, ensureSelectedProject } from './helpers/prepare-app';
import {
createSprintWithTasks,
prepareSelectedLocalGitProject,
type SeededCodeUxProject,
} from './helpers/e2e-fixtures';

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
Expand All @@ -21,21 +25,27 @@ async function ensureProjectSelected(page: Page, projectName: string): Promise<v
}

test.describe('Sprint Ledger Responsive Layout E2E Tests', () => {
let fixture: SeededCodeUxProject | null = null;
let projectName: string;
let sprintName: string;

test.beforeEach(async ({ request }, testInfo) => {
await completeOnboarding(request);
const project = await ensureSelectedProject(request, { testInfo, fixtureKey: 'responsive' });
const sprint = await createDraftSprint(request, project.id, {
testInfo,
fixtureKey: 'responsive',
goal: 'Verify that sprint ledger remains readable on narrow viewports.',
test.beforeEach(async ({ page, request }, testInfo) => {
fixture = await prepareSelectedLocalGitProject(page, request, testInfo, 'responsive');
const { sprint } = await createSprintWithTasks(request, fixture.project.id, {
sprint: {
name: `${fixture.project.name} responsive ledger sprint`,
goal: 'Verify that sprint ledger remains readable on narrow viewports.',
},
});
projectName = project.name;
projectName = fixture.project.name;
sprintName = sprint.name;
});

test.afterEach(async () => {
await fixture?.cleanup();
fixture = null;
});

test('adapts layout and displays correct labels on mobile vs desktop', async ({ page, request }) => {
const health = await request.get('/health');
await expect(health).toBeOK();
Expand Down