open-design/e2e/ui/project-management-flows.test.ts
Patrick A 32fd5286b5
chore(e2e): improve test framework quality (#2305)
* chore(e2e): improve test framework quality

- Add lib/timeouts.ts with CI-scaled short/medium/long/xlong constants
- Add lib/playwright/mock-factory.ts to centralise standard localStorage,
  /api/agents, and /api/app-config mock setup; migrate critical-smoke and
  workspace-keyboard-flows to use applyStandardMocks()
- Delete empty lib/shared.ts placeholder
- Replace waitFor({ state: 'detached' }).catch(() => {}) with
  waitFor({ state: 'hidden' }) in all UI tests; 'hidden' resolves
  immediately when the element was never in the DOM, eliminating the
  silent error-swallowing catch
- Remove redundant .catch(() => false) from all isVisible() call sites
  since isVisible() never throws in Playwright
- Convert .waitFor().then(() => true).catch(() => false) guards in
  openDesignFile() to explicit try/catch blocks for clarity
- Simplify sendPrompt() in app.test.ts: replace the 3-attempt manual
  retry loop with a single fill + pressSequentially fallback; the core
  workaround for contenteditable unreliability is preserved but the
  loop structure is gone

* fix(e2e): guard routeMockAgents to GET only

routeMockAgents was intercepting all HTTP methods and returning the mock
fixture, silently swallowing any agent mutation requests. Mirror the
GET-only guard from routeAppConfig so writes fall through to the daemon.

* fix(e2e): address code review findings

- sendPrompt() in app.test.ts, workspace-keyboard-flows.test.ts,
  app-restoration.test.ts: drop fill() (unreliable on contenteditable,
  inputValue() always returns '' for them) and go straight to
  pressSequentially(), which types key-by-key and is authoritative
- Import T from timeouts.ts in app.test.ts and use T.short for the
  input/button waits, making the timeouts module non-dead

* fix(e2e): resolve adversarial review findings

- Revert sendPrompt to fill(): chat-composer-input is a textarea, not
  contenteditable; fill() is atomic and ~60x faster than pressSequentially
- Use T.medium in all waitForLoadingToClear calls: CI workers scale this
  to 20s automatically via the CI env var, eliminating cold-runner flakes
- Add T import to 6 files that needed it for T.medium
- Fix openDesignFile try/catch scope in app-manual-edit: previously the
  catch block only caught waitFor but click/expect errors were also swallowed;
  now only waitFor is inside try, real interaction failures propagate
- Fix regex escaping: .replace('.', '\\.') -> .replace(/\./g, '\\.') in
  app-manual-edit and app-design-files to handle multi-dot filenames
- Migrate entry-chrome-flows.test.ts to applyStandardMocks: it had the
  identical 3-call setup pattern as the factory but was not migrated
- Add GET method guard to project-management-flows app-config route handler,
  matching the pattern used by every other route handler in the suite
- Remove no-op 'as const' from timeouts.ts: Math.ceil returns number,
  not a literal, so the assertion had no effect
- Update e2e/AGENTS.md: remove deleted lib/shared.ts entry, document
  lib/timeouts.ts and lib/playwright/mock-factory.ts

* fix(e2e): scope openDesignFile try/catch to waitFor only

Move click and expect(preview).toBeVisible() outside the catch block so
that a regression in either open path (tab-click or file-list fallback)
fails loudly instead of being silently absorbed. The try now wraps only
the fileTabButton.waitFor existence probe; the subsequent click and final
assertion are unconditional.

---------

Co-authored-by: Patrick A <186436799+eefynet@users.noreply.github.com>
Co-authored-by: Patrick A <259201958+eefynet@users.noreply.github.com>
2026-05-23 00:24:32 +08:00

758 lines
27 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
agentCliEnv: {},
},
},
});
});
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' }],
},
],
},
});
});
});
test('project title rename persists after reload and ignores blank titles', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Original rename title');
await expectWorkspaceReady(page);
const title = page.getByTestId('project-title');
await renameProjectTitle(page, title, 'Renamed persistent title');
await expect(title).toContainText('Renamed persistent title');
await page.reload();
await expectWorkspaceReady(page);
await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title');
await renameProjectTitle(page, page.getByTestId('project-title'), ' ');
await page.reload();
await expectWorkspaceReady(page);
await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title');
const project = await fetchCurrentProject(page);
expect(project.name).toBe('Renamed persistent title');
});
test('canceling design file deletion keeps the file and open tab', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Design file delete cancel flow');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyPng(page, 'delete-cancel.png');
const fileTab = tabBySuffix(page, uploadedName);
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('delete-cancel.png');
await dialog.dismiss();
});
await page.getByTestId('design-files-tab').click();
await rowByFileName(page, uploadedName).hover();
await menuByFileName(page, uploadedName).click();
await page.getByTestId(`design-file-delete-${uploadedName}`).click();
await expect(rowByFileName(page, uploadedName)).toBeVisible();
await expect(fileTab).toBeVisible();
const { projectId } = getProjectContextFromUrl(page);
const files = await listProjectFiles(page, projectId);
expect(files.map((file) => file.name)).toContain(uploadedName);
});
test('home design card deletion supports cancel and confirm flows', async ({ page }) => {
const projectName = `Home delete design flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
const designCard = homeDesignCard(page, projectName);
await expect(designCard).toBeVisible();
// Cancel flow: open the overflow menu, choose Delete, then dismiss the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(projectName);
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(confirmDialog).toHaveCount(0);
await expect(designCard).toBeVisible();
// Confirm flow: same trigger, this time accept the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await expect(confirmDialog2).toContainText(projectName);
await confirmDialog2.getByRole('button', { name: /^delete$/i }).click();
await expect(homeDesignCard(page, projectName)).toHaveCount(0);
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.status()).toBe(404);
});
test('home designs view toggle switches between grid and kanban and persists', async ({ page }) => {
const projectName = `Home view toggle flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await expect(homeDesignCard(page, projectName)).toBeVisible();
await expect(page.locator('.design-grid')).toBeVisible();
await expect(page.locator('.design-kanban-board')).toHaveCount(0);
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.locator('.design-grid')).toHaveCount(0);
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.design-kanban-card', { hasText: projectName })).toBeVisible();
await page.reload();
await expectDesignsView(page);
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
const projectsAfterReload = await listProjectsFromApi(page);
expect(projectsAfterReload.some((project) => project.id === projectId && project.name === projectName)).toBe(true);
await page.getByTestId('designs-view-grid').click();
await expect(page.locator('.design-grid')).toBeVisible();
await expect(homeDesignCard(page, projectName)).toBeVisible();
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
});
test('home designs search filters projects and recovers from no results', async ({ page }) => {
const stamp = Date.now();
const alphaName = `Home search alpha ${stamp}`;
const betaName = `Home search beta ${stamp}`;
await page.goto('/');
await createProject(page, alphaName);
await expectWorkspaceReady(page);
const alphaProjectId = getProjectContextFromUrl(page).projectId;
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await createProject(page, betaName);
await expectWorkspaceReady(page);
const betaProjectId = getProjectContextFromUrl(page).projectId;
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toBeVisible();
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('alpha');
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
await search.fill(`missing-${stamp}`);
await expect(homeDesignCard(page, alphaName)).toHaveCount(0);
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
await expect(page.locator('.tab-empty')).toBeVisible();
await search.fill('');
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toBeVisible();
const projects = await listProjectsFromApi(page);
expect(projects).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: alphaProjectId, name: alphaName }),
expect.objectContaining({ id: betaProjectId, name: betaName }),
]),
);
});
test('projects grid card rename updates the card title and persists after reload', async ({ page }) => {
const originalName = `Projects rename flow ${Date.now()}`;
const renamedName = `${originalName} renamed`;
await page.goto('/');
await createProject(page, originalName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
const card = homeDesignCard(page, originalName);
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^rename$/i }).click();
const renameModal = page.locator('.modal-rename');
await expect(renameModal).toBeVisible();
const renameInput = renameModal.getByRole('textbox');
await expect(renameInput).toHaveValue(originalName);
await renameInput.fill(renamedName);
await renameModal.locator('button.primary').click();
await expect(homeDesignCard(page, renamedName)).toBeVisible();
await expect(homeDesignCard(page, originalName)).toHaveCount(0);
await page.reload();
await expectDesignsView(page);
await expect(homeDesignCard(page, renamedName)).toBeVisible();
const project = await fetchProjectById(page, projectId);
expect(project.name).toBe(renamedName);
});
test('projects select mode supports multi-select delete with cancel and confirm', async ({ page }) => {
const firstName = `Batch delete A ${Date.now()}`;
const secondName = `Batch delete B ${Date.now()}`;
await page.goto('/');
await createProject(page, firstName);
await expectWorkspaceReady(page);
const firstProjectId = getProjectContextFromUrl(page).projectId;
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await createProject(page, secondName);
await expectWorkspaceReady(page);
const secondProjectId = getProjectContextFromUrl(page).projectId;
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await page.locator('.designs-select-toggle').click();
await homeDesignCard(page, firstName).click();
await homeDesignCard(page, secondName).click();
await expect(page.locator('.designs-select-bar')).toBeVisible();
await expect(page.locator('.design-card.is-selected')).toHaveCount(2);
await page.getByRole('button', { name: /Delete selected/i }).click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(confirmDialog).toHaveCount(0);
await expect(homeDesignCard(page, firstName)).toBeVisible();
await expect(homeDesignCard(page, secondName)).toBeVisible();
await page.getByRole('button', { name: /Delete selected/i }).click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await confirmDialog2.getByRole('button', { name: /^delete/i }).click();
await expect(homeDesignCard(page, firstName)).toHaveCount(0);
await expect(homeDesignCard(page, secondName)).toHaveCount(0);
await expect(page.locator('.designs-select-bar')).toHaveCount(0);
const firstResponse = await page.request.get(`/api/projects/${firstProjectId}`);
const secondResponse = await page.request.get(`/api/projects/${secondProjectId}`);
expect(firstResponse.status()).toBe(404);
expect(secondResponse.status()).toBe(404);
});
test('projects kanban cards open projects and support delete cancel and confirm', async ({ page }) => {
const projectName = `Kanban flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
const kanbanCard = page.locator('.design-kanban-card', { hasText: projectName });
await expect(kanbanCard).toBeVisible();
await kanbanCard.click();
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}$`));
await expect(page.getByTestId('project-title')).toContainText(projectName);
const openedProject = await fetchCurrentProject(page);
expect(openedProject.name).toBe(projectName);
await page.getByRole('button', { name: /back to projects/i }).click();
await expectDesignsView(page);
await expect(page.locator('.design-kanban-board')).toBeVisible();
const kanbanCardAgain = page.locator('.design-kanban-card', { hasText: projectName });
await kanbanCardAgain.locator('.design-card-close').click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(kanbanCardAgain).toBeVisible();
await kanbanCardAgain.locator('.design-card-close').click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await confirmDialog2.getByRole('button', { name: /^delete/i }).click();
await expect(page.locator('.design-kanban-card', { hasText: projectName })).toHaveCount(0);
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.status()).toBe(404);
});
test('projects page shows the empty state when there are no projects', async ({ page }) => {
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.continue();
});
await page.goto('/projects');
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator('.tab-empty')).toBeVisible();
await expect(page.locator('.tab-empty')).toContainText('No projects yet');
await expect(page.locator('.design-grid')).toHaveCount(0);
await expect(page.locator('.design-kanban-board')).toHaveCount(0);
});
test('projects page shows the no-results state and recovers when search is cleared', async ({ page }) => {
const projects = [
makeProjectsTabProject({
id: 'proj-search-1',
name: 'Searchable Prototype',
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await page.goto('/projects');
await expectDesignsView(page);
await expect(homeDesignCard(page, 'Searchable Prototype')).toBeVisible();
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('does-not-exist');
await expect(page.locator('.tab-empty')).toBeVisible();
await expect(page.locator('.tab-empty')).toContainText('No projects match your search');
await expect(homeDesignCard(page, 'Searchable Prototype')).toHaveCount(0);
await search.fill('');
await expect(homeDesignCard(page, 'Searchable Prototype')).toBeVisible();
});
test('projects grid overflow menu closes on outside click and Escape', async ({ page }) => {
const projects = [
makeProjectsTabProject({
id: 'proj-menu-1',
name: 'Menu Close Project',
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await page.goto('/projects');
await expectDesignsView(page);
const card = homeDesignCard(page, 'Menu Close Project');
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
const menu = page.locator('.design-card-menu');
await expect(menu).toBeVisible();
await page.mouse.click(20, 20);
await expect(menu).toHaveCount(0);
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
await expect(menu).toBeVisible();
await page.keyboard.press('Escape');
await expect(menu).toHaveCount(0);
});
test('projects kanban view groups cards into status columns', async ({ page }) => {
const now = Date.now();
const projects = [
makeProjectsTabProject({
id: 'proj-not-started',
name: 'Not Started Card',
createdAt: now - 50_000,
updatedAt: now - 45_000,
status: { value: 'not_started' },
}),
makeProjectsTabProject({
id: 'proj-running',
name: 'Running Card',
createdAt: now - 40_000,
updatedAt: now - 35_000,
status: { value: 'running' },
}),
makeProjectsTabProject({
id: 'proj-awaiting',
name: 'Awaiting Input Card',
createdAt: now - 30_000,
updatedAt: now - 25_000,
status: { value: 'awaiting_input' },
}),
makeProjectsTabProject({
id: 'proj-succeeded',
name: 'Succeeded Card',
createdAt: now - 20_000,
updatedAt: now - 15_000,
status: { value: 'succeeded' },
}),
makeProjectsTabProject({
id: 'proj-failed',
name: 'Failed Card',
createdAt: now - 10_000,
updatedAt: now - 5_000,
status: { value: 'failed' },
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await page.goto('/projects');
await expectDesignsView(page);
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.locator('.design-kanban-card.status-not_started')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-running')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-awaiting_input')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-succeeded')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-failed')).toHaveCount(1);
await expect(page.locator('.design-kanban-empty')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-running')).toContainText('Running Card');
await expect(page.locator('.design-kanban-card.status-awaiting_input')).toContainText(
'Awaiting Input Card',
);
await expect(page.locator('.design-kanban-card.status-succeeded')).toContainText(
'Succeeded Card',
);
});
test('projects page shows live artifact cards, supports search, and opens the live artifact project', async ({ page }) => {
const liveProject = makeProjectsTabProject({
id: 'proj-live',
name: 'Orbit Daily Digest',
createdAt: Date.now() - 60_000,
updatedAt: Date.now() - 30_000,
skillId: 'live-artifact',
metadata: { kind: 'orbit', intent: 'live-artifact' },
status: { value: 'succeeded' },
});
const regularProject = makeProjectsTabProject({
id: 'proj-regular',
name: 'Regular Prototype',
createdAt: Date.now() - 120_000,
updatedAt: Date.now() - 90_000,
});
const liveArtifact = {
id: 'artifact-1',
projectId: 'proj-live',
title: 'Orbit Daily Digest — 2026-05-15',
slug: 'orbit-daily-digest',
status: 'ready',
refreshStatus: 'succeeded',
pinned: false,
hasDocument: true,
updatedAt: new Date(Date.now() - 20_000).toISOString(),
createdAt: new Date(Date.now() - 50_000).toISOString(),
preview: {
kind: 'rendered',
url: '',
},
};
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [liveProject, regularProject] } });
return;
}
await route.continue();
});
await page.route('**/api/projects/proj-live', async (route) => {
await route.fulfill({ json: { project: liveProject } });
});
await page.route('**/api/projects/proj-live/files', async (route) => {
await route.fulfill({ json: { files: [] } });
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
const url = new URL(route.request().url());
const projectId = url.searchParams.get('projectId');
await route.fulfill({
json: {
liveArtifacts: projectId === 'proj-live' ? [liveArtifact] : [],
},
});
});
await page.route('**/api/live-artifacts/artifact-1', async (route) => {
await route.fulfill({ json: { liveArtifact } });
});
await page.route('**/api/live-artifacts/artifact-1/refreshes?projectId=*', async (route) => {
await route.fulfill({ json: { refreshes: [] } });
});
await page.route('**/api/live-artifacts/artifact-1/preview?projectId=*', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html' },
body: '<!doctype html><html><body><h1>Orbit Daily Digest</h1></body></html>',
});
});
await page.goto('/projects');
await expectDesignsView(page);
const liveCard = page.locator('.live-artifact-card', {
has: page.locator('.design-card-name', { hasText: 'Orbit Daily Digest' }),
});
await expect(liveCard).toBeVisible();
await expect(liveCard).toContainText(/Live Artifact/i);
await expect(liveCard).toContainText(/LIVE|Refreshed/i);
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('digest');
await expect(liveCard).toBeVisible();
await expect(homeDesignCard(page, 'Regular Prototype')).toHaveCount(0);
await liveCard.click();
await expect(page).toHaveURL(/\/projects\/proj-live$/);
await expect(page.getByTestId('project-title')).toContainText('Orbit Daily Digest');
});
async function createProject(
page: Page,
projectName: string,
) {
await openNewProjectPanel(page);
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill(projectName);
await page.getByTestId('create-project').click();
}
async function openNewProjectPanel(page: Page) {
if (await page.getByTestId('new-project-panel').isVisible().catch(() => false)) return;
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function expectDesignsView(page: Page) {
if (!/\/projects$/.test(new URL(page.url()).pathname)) {
await page.getByTestId('entry-nav-projects').click();
}
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator('.design-grid, .design-kanban-board')).toBeVisible();
}
async function expectWorkspaceReady(page: Page) {
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();
}
async function renameProjectTitle(
page: Page,
title: Locator,
nextName: string,
) {
await title.click();
await page.keyboard.press('Meta+A');
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? '');
if (selected.length === 0) {
await page.keyboard.press('Control+A');
}
await page.keyboard.type(nextName);
await page.keyboard.press('Enter');
}
async function uploadTinyPng(
page: Page,
name: string,
): Promise<string> {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
'base64',
);
await page.getByTestId('design-files-upload-input').setInputFiles({
name,
mimeType: 'image/png',
buffer: pngBytes,
});
await expect(tabBySuffix(page, name)).toBeVisible();
const { projectId } = getProjectContextFromUrl(page);
const files = await listProjectFiles(page, projectId);
const uploaded = files.find((file) => file.name.endsWith(name));
expect(uploaded?.name).toBeTruthy();
return uploaded!.name;
}
function tabBySuffix(page: Page, name: string): Locator {
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') });
}
function rowByFileName(page: Page, name: string): Locator {
return page.getByTestId(`design-file-row-${name}`);
}
function menuByFileName(page: Page, name: string): Locator {
return page.getByTestId(`design-file-menu-${name}`);
}
function homeDesignCard(page: Page, name: string): Locator {
return page.locator('.design-card', {
has: page.locator('.design-card-name', {
hasText: new RegExp(`^${escapeRegExp(name)}$`),
}),
});
}
async function fetchCurrentProject(page: Page) {
const { projectId } = getProjectContextFromUrl(page);
return fetchProjectById(page, projectId);
}
async function fetchProjectById(page: Page, projectId: string) {
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
project: {
id?: string;
name: string;
designSystemId: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
};
return body.project;
}
async function listProjectsFromApi(page: Page) {
const response = await page.request.get('/api/projects');
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
projects: Array<{ id: string; name: string }>;
};
return body.projects;
}
async function listProjectFiles(page: Page, projectId: string) {
const response = await page.request.get(`/api/projects/${projectId}/files`);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { files: Array<{ name: string }> };
return body.files;
}
function getProjectContextFromUrl(page: Page) {
const url = new URL(page.url());
const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? [];
if (!projectId) throw new Error(`unexpected project route: ${url.pathname}`);
return { projectId };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function makeProjectsTabProject({
id,
name,
createdAt,
updatedAt,
skillId = null,
metadata = { kind: 'prototype' as const },
status = { value: 'succeeded' as const },
}: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
skillId?: string | null;
metadata?: Record<string, unknown>;
status?: { value: string };
}) {
return {
id,
name,
createdAt,
updatedAt,
skillId,
designSystemId: null,
pendingPrompt: '',
customInstructions: null,
metadata,
status,
};
}