mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
758 lines
27 KiB
TypeScript
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,
|
|
};
|
|
}
|