Files
open-design/e2e/ui/project-management-flows.test.ts
T
f1a0b60c6c chore: unify product name display to OpenDesign repo-wide (#6998)
* chore: unify product name display to OpenDesign repo-wide

Rename the user-facing product name from "Open Design" to "OpenDesign"
across documentation, web/desktop UI strings, i18n locales, prompts,
plugin/skill/design-system content, changelogs, and specs, matching the
landing-page rename already in flight.

Deliberately excluded so installed-app identity stays untouched:
- packages/release PRODUCT_NAME and everything derived from it
  (app bundle names, installer/registry identity, updater feeds)
- tools/pack, tools/release, apps/packaged, landing page (PR #6983)
- mocks/ recorded traces and LICENSE
- test fixtures and comments that encode real on-disk install paths
  (Open Design.app, Program Files\Open Design, AppData paths)

Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host
test runs over the renamed strings.

* fix(web): keep two-letter author monogram for CamelCase brand names

authorInitials split words on whitespace only, so the renamed single-token
author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper
CamelCase boundaries as word breaks so the avatar fallback stays "OD".

* fix(release): restore spaced Windows source artifact names in win.ps1

tools/pack still derives builder outputs from the unchanged PRODUCT_NAME
("Open Design"), so the asset script must look for the spaced filenames.
Matches the mac/mac-intel/linux asset scripts, which were already kept on
the original names.

---------

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2026-08-18 07:09:45 +00:00

4479 lines
166 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import { ensureRailOpen, openNewProjectModal } from '@/playwright/rail';
import { openAllProjectFiles } from '@/playwright/workspace';
import { T } from '@/timeouts';
import type { Locator, Page, Request } from '@playwright/test';
import { routeAgents, routeSuccessfulRuns } from '../lib/playwright/mock-factory.js';
import {
AMR_PERSONAL_WORKSPACE_CONTEXT,
AMR_PERSONAL_WORKSPACE_HEADERS,
mockAmrPersonalWorkspace,
openSettingsDialog,
settingsSurface,
} from '../lib/playwright/amr.js';
// The `/projects` view in `EntryShell` renders a `CenteredLoader` until
// `projectsLoading || skillsLoading || designSystemsLoading` all clear
// (`apps/web/src/components/EntryShell.tsx`). Tests that land on `/projects`
// should stub the catalog endpoints that are unrelated to the project-list
// behavior under test; otherwise a large registry response can keep the first
// assertion gated on daemon/catalog timing instead of the UI contract.
async function stubCatalogsEmpty(page: Page): Promise<void> {
await page.route('**/api/design-templates', async (route) => {
await route.fulfill({ json: { designTemplates: [] } });
});
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: [] } });
});
}
const STORAGE_KEY = 'open-design:config';
const ACTIVE_ARTIFACT_PREVIEW_SELECTOR = '[data-testid="artifact-preview-frame"]:visible, [data-testid="artifact-preview-frame-url-load"]:visible, [data-testid="artifact-preview-frame-srcdoc"]:visible, [data-testid="live-artifact-preview-frame"]:visible';
function projectDesignSystemTrigger(page: Page): Locator {
return page
.getByTestId('chat-composer')
.getByTestId('composer-design-system-trigger');
}
const AGENTS = [
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.134.0',
models: [
{ id: 'default', label: 'Default (CLI config)' },
{
id: 'gpt-5.5',
label: 'GPT 5.5',
additionalSpeedTiers: ['fast'],
serviceTierOptions: [{ id: 'priority', label: 'Fast' }],
},
],
},
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '2.1.131',
models: [
{ id: 'default', label: 'Default (CLI config)' },
{ id: 'sonnet', label: 'Sonnet (alias)' },
{ id: 'opus', label: 'Opus (alias)' },
],
},
];
const DESIGN_SYSTEMS = [
{
id: 'nexu-soft-tech',
title: 'Nexu Soft Tech',
category: 'Product',
summary: 'Warm utility system for product interfaces.',
swatches: ['#F7F4EE', '#D6CBBF', '#1F2937', '#D97757'],
},
{
id: 'editorial-noir',
title: 'Editorial Noir',
category: 'Editorial',
summary: 'High-contrast editorial system with expressive type.',
swatches: ['#111111', '#F6EFE6', '#C44536', '#F2C14E'],
},
{
id: 'data-mist',
title: 'Data Mist',
category: 'Analytics',
summary: 'Calm dashboard system for dense data products.',
swatches: ['#EAF4F4', '#5EAAA8', '#05668D', '#0B132B'],
},
];
async function stubEmptyProjectsNewProjectData(page: Page): Promise<void> {
await page.route('**/api/skills', async (route) => {
await route.fulfill({ json: { skills: TAB_SKILLS } });
});
await page.route('**/api/connectors', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({ json: { statuses: {} } });
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
}
async function openNewProjectFromEmptyProjects(page: Page): Promise<void> {
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
await expect(page.getByText('Loading OpenDesign…')).toHaveCount(0, { timeout: 15_000 });
await expect(page.locator('.designs-empty-state')).toBeVisible();
await page.getByTestId('designs-empty-new-project').click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
// #5517's rail has no "+ New project" button any more, so the Projects view's
// own CTA is the entry this flow starts from.
async function openNewProjectFromProjectsView(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem('od.entry.railOpen', 'true');
});
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
await expect(page.locator('.entry')).toHaveClass(/entry--rail-open/);
const projectsView = page.getByTestId('entry-view-projects');
await expect(projectsView).toBeVisible();
const createButton = projectsView
.getByTestId('designs-new-project')
.or(projectsView.getByTestId('designs-empty-new-project'))
.first();
await expect(createButton).toBeVisible();
await createButton.click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
const TAB_SKILLS = [
skillSummary('prototype-skill', 'Prototype Skill', 'prototype', 'web', ['prototype']),
skillSummary('live-artifact', 'live-artifact', 'prototype', 'web', []),
skillSummary('deck-skill', 'Deck Skill', 'deck', 'web', ['deck']),
skillSummary('image-skill', 'Image Skill', 'image', 'image', ['image']),
];
const COMPOSER_PLUS_PLUGIN = {
id: 'composer-context-plugin',
title: 'Composer Context Plugin',
version: '1.0.0',
trust: 'bundled',
sourceKind: 'bundled',
source: '/tmp/composer-context-plugin',
fsPath: '/tmp/composer-context-plugin',
capabilitiesGranted: ['prompt:inject'],
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'composer-context-plugin',
title: 'Composer Context Plugin',
version: '1.0.0',
description: 'Project composer context picker fixture.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Use the composer context plugin.',
},
},
},
};
test.beforeEach(async ({ page }) => {
let appConfig = {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: null,
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: { codex: { model: 'default' } },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const next = route.request().postDataJSON() as Record<string, unknown>;
appConfig = {
...appConfig,
...next,
};
await route.fulfill({ json: { config: appConfig } });
return;
}
await route.fulfill({
json: {
config: appConfig,
},
});
});
await routeAgents(page, AGENTS);
});
function artifactPreview(page: Page) {
return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first();
}
function artifactPreviewFrame(page: Page) {
return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR);
}
test.describe('new project modal from left rail', () => {
// Timeout-only configure: both tests open the modal themselves against
// stubbed data; serial would make the pair atomic within one CI shard.
test.describe.configure({ timeout: 60_000 });
test('[P1] new project tabs switch visible form sections and preserve drafts', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await expect(page.getByText('Fidelity', { exact: true })).toBeVisible();
await page.getByTestId('new-project-name').fill('Prototype draft survives');
await page.getByTestId('new-project-tab-live-artifact').click();
await expect(page.getByTestId('new-project-tab-live-artifact')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New live artifact');
await expect(page.locator('.newproj-title')).toContainText('Beta');
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
await expect(page.getByTestId('create-project')).toContainText('Create live artifact');
await page.getByTestId('new-project-tab-deck').click();
await expect(page.getByTestId('new-project-tab-deck')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New slide deck');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await expect(page.getByText('Use speaker notes')).toBeVisible();
await expect(page.getByTestId('new-project-connectors')).toHaveCount(0);
await page.getByTestId('new-project-tab-prototype').click();
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
await expect(page.getByTestId('new-project-name')).toHaveValue('Prototype draft survives');
});
test('[P1] new project media tab switches inner media surfaces', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
// Playwright auto-scrolls the tab into view; the consolidated media flow
// keeps image/video/audio as inner segmented surfaces.
await page.getByTestId('new-project-tab-media').click();
await expect(page.getByTestId('new-project-tab-media')).toHaveAttribute('aria-selected', 'true');
await page.getByTestId('new-project-media-surface-image').click();
await expect(page.getByTestId('new-project-media-surface-image')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New image');
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
await expect(page.getByText('Model', { exact: true })).toBeVisible();
await expect(page.getByText('Aspect', { exact: true })).toBeVisible();
await page.getByTestId('new-project-media-surface-video').click();
await expect(page.getByTestId('new-project-media-surface-video')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New video');
await page.getByTestId('new-project-media-surface-audio').click();
await expect(page.getByTestId('new-project-media-surface-audio')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New audio');
});
});
test('[P0] projects empty state create action opens the new project flow', async ({ page }) => {
await page.route('**/api/skills', async (route) => {
await route.fulfill({ json: { skills: TAB_SKILLS } });
});
await page.route('**/api/connectors', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({ json: { statuses: {} } });
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
await stubCatalogsEmpty(page);
await openNewProjectFromEmptyProjects(page);
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
});
test('[P0] UI-created Personal project recovers preview and write authority after reload', async ({ page }) => {
// Create + upload + gated reload needs more than the suite default once the
// post-reload waits use the long boot/route budgets under CI contention.
test.setTimeout(T.xlong);
await mockWritablePersonalProjectScope(page);
await stubCatalogsEmpty(page);
await page.goto('/');
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Reloaded Personal authority');
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click();
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'reload-personal-authority.html',
'<!doctype html><html><body><h1>Reloaded Personal preview</h1></body></html>',
{ headers: AMR_PERSONAL_WORKSPACE_HEADERS },
);
await openUploadedHtmlArtifactPreview(page, uploadedName);
await expect(artifactPreviewFrame(page).getByRole('heading', {
name: 'Reloaded Personal preview',
})).toBeVisible();
let releaseScope = () => {};
const scopeGate = new Promise<void>((resolve) => {
releaseScope = resolve;
});
let releaseStatus = () => {};
const statusGate = new Promise<void>((resolve) => {
releaseStatus = resolve;
});
await page.route('**/api/projects/*/workspace-scope', async (route) => {
await scopeGate;
const projectId = getProjectIdFromApiPath(route.request().url());
await route.fulfill({
json: {
scope: {
kind: 'personal',
projectId,
workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,
visibility: 'personal',
context: AMR_PERSONAL_WORKSPACE_CONTEXT,
},
},
});
});
await page.route('**/api/projects/*/collab/status', async (route) => {
await statusGate;
await route.fulfill({
json: {
publishedVersion: null,
materializedVersion: null,
syncState: 'local_only',
ownerMemberId: null,
},
});
});
const scopeRequested = page.waitForRequest(
(request) => new URL(request.url()).pathname.endsWith('/workspace-scope'),
{ timeout: T.long },
);
const statusRequested = page.waitForRequest(
(request) => new URL(request.url()).pathname.endsWith('/collab/status'),
{ timeout: T.long },
);
try {
// A hard reload drops the module-local same-session creation witness. Keep
// both authority reads unresolved long enough to observe the fail-closed
// state, then release them independently. The persisted Personal binding —
// not that ephemeral witness — must reconnect the already-ready artifact.
//
// Reload only reaches `domcontentloaded` while the dynamic App boot shell
// (`Loading OpenDesign…`) and the project-route workspace-context gate
// (`Loading workspace…`) may still own the page. Wait those out with the
// suite's long budget before asserting the fail-closed workspace chrome —
// the default expect timeout is 10s and is too short under CI contention.
await page.reload({ waitUntil: 'domcontentloaded' });
await page
.getByText('Loading OpenDesign…')
.waitFor({ state: 'hidden', timeout: T.long })
.catch(() => {});
await expect(page.getByText('Loading workspace…')).toHaveCount(0, { timeout: T.long });
await expect(page.getByTestId('file-workspace')).toBeVisible({ timeout: T.long });
await scopeRequested;
// The content-loading skeleton is intentionally transient: a restored
// iframe can become ready before this assertion runs. The authority gate is
// the durable user-visible contract while the scope request is unresolved.
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
await expect(page.getByRole('button', { name: /^Share$/i })).toBeDisabled();
releaseScope();
// Prevent a second release from the finally if the remaining asserts fail.
releaseScope = () => {};
await statusRequested;
await expect(artifactPreviewFrame(page).getByRole('heading', {
name: 'Reloaded Personal preview',
})).toBeVisible({ timeout: T.long });
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
releaseStatus();
releaseStatus = () => {};
await expect(page.getByTestId('chat-composer-input')).not.toHaveAttribute('aria-readonly', 'true');
await expect(page.getByRole('button', { name: /^Share$/i })).toBeEnabled();
} finally {
releaseScope();
releaseStatus();
}
});
test('[P1] design system multi-select stores primary and inspiration metadata', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system multi select metadata');
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('design-system-trigger').click();
const multiTab = page.getByRole('tab', { name: /multi/i });
await multiTab.click();
await expect(multiTab).toHaveAttribute('aria-selected', 'true');
await page.getByRole('option', { name: /Editorial Noir/i }).click();
await page.getByRole('option', { name: /Data Mist/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await expect(page.getByTestId('design-system-trigger')).toContainText('+2');
await page.getByTestId('design-system-trigger').click();
await expect(page.locator('.ds-picker-popover')).toHaveCount(0);
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBe('nexu-soft-tech');
expect(body.metadata?.inspirationDesignSystemIds).toEqual([
'editorial-noir',
'data-mist',
]);
});
test('[P1] design system picker searches and switches the single selected system', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system single switch flow');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await page.getByTestId('design-system-trigger').click();
await page.getByTestId('design-system-search').fill('mist');
await expect(page.getByRole('option', { name: /Data Mist/i })).toBeVisible();
await expect(page.getByRole('option', { name: /Nexu Soft Tech/i })).toHaveCount(0);
await page.getByRole('option', { name: /Data Mist/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('Data Mist');
await expect(page.getByTestId('design-system-trigger')).toContainText('Analytics');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBe('data-mist');
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] design system picker can clear the default system before creating a project', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system clear create flow');
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('design-system-trigger').click();
await page.getByRole('option', { name: /None.*freeform/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('None');
await expect(page.getByTestId('design-system-trigger')).not.toContainText('Nexu Soft Tech');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBeNull();
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] stale daemon default design system is not posted when creating a project', async ({ page }) => {
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: 'stale-design-system',
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: 'stale-design-system',
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
});
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Stale design system default flow');
await expect(page.getByTestId('design-system-trigger')).toContainText('None');
await expect(page.getByTestId('design-system-trigger')).not.toContainText('stale-design-system');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBeNull();
expect(body.designSystemId).not.toBe('stale-design-system');
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] project detail composer design system picker switches the active project design system', async ({ page }) => {
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.goto('/');
await createProject(page, 'Header design system switch');
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/No design system/i);
await trigger.click();
const popover = page.getByTestId('project-ds-picker-popover');
await expect(popover).toBeVisible();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
const patchRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === `/api/projects/${getProjectContextFromUrl(page).projectId}` && request.method() === 'PATCH';
});
await editorialOption.click();
await expect(popover).toHaveCount(0);
const request = await patchRequest;
const body = request.postDataJSON() as { designSystemId?: string | null };
expect(body.designSystemId).toBe('editorial-noir');
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
});
test('[P0] @critical project detail composer design system switch carries into the next run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'mock-run' });
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
// This helper creates through APIRequestContext, bypassing the browser-side
// same-session creation witness. Pin the scenario to an exact writable
// Personal owner so a slow catalog/status read cannot turn it viewer-only.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Header design system run context', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/No design system/i);
await trigger.click();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
const patchRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === `/api/projects/${getProjectContextFromUrl(page).projectId}` && request.method() === 'PATCH';
});
await editorialOption.click();
const patchBody = await patchRequest.then((request) => request.postDataJSON() as { designSystemId?: string | null });
expect(patchBody.designSystemId).toBe('editorial-noir');
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
const input = page.getByTestId('chat-composer-input');
await input.fill('Use the active design system in this layout.');
const sendButton = page.getByTestId('chat-send');
await expect(sendButton).toBeEnabled();
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
sendButton.click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.designSystemId).toBe('editorial-noir');
});
test('[P1] project detail design system picker stays inside the composer controls', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Composer design system position');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await expect(
composer.getByTestId('composer-design-system-trigger'),
).toHaveAccessibleName(/No design system/i);
});
test('[P1] project detail composer working directory picker opens without leaving chat', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Composer working directory picker');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await expect(page.getByTestId('composer-plus-working-dir-pick')).toBeVisible();
await expect(page).toHaveURL(/\/projects\//);
});
test('[P1] project detail composer plus menu exposes attachment, connector, plugin, and MCP entries', async ({ page }) => {
await routeComposerPlusFixtures(page);
await page.goto('/');
await createProject(page, 'Composer plus context menu');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await expect(page.getByTestId('composer-plus-attach')).toBeVisible();
await expect(page.getByTestId('composer-plus-connectors')).toBeVisible();
await expect(page.getByTestId('composer-plus-plugins')).toBeVisible();
await expect(page.getByTestId('composer-plus-mcp')).toBeVisible();
await page.getByTestId('composer-plus-connectors').click();
await expect(page.getByRole('menuitem', { name: /Figma Connector/i })).toBeVisible();
await page.getByTestId('composer-plus-plugins').click();
await expect(page.getByRole('menuitem', { name: /Composer Context Plugin/i })).toBeVisible();
await page.getByTestId('composer-plus-mcp').click();
await expect(page.getByRole('menuitem', { name: /Design Docs MCP/i })).toBeVisible();
});
test('[P1] project detail composer plus menu opens project, local code, Figma help, and design system context actions', async ({ page }) => {
const referenceProject = {
id: 'ref-project-context',
name: 'Reference Project Context',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: {
kind: 'prototype',
nameSource: 'user',
},
};
await routeComposerPlusFixtures(page);
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [referenceProject] } });
return;
}
await route.fallback();
});
await page.route('**/api/projects/ref-project-context**', async (route) => {
await route.fulfill({
json: {
project: referenceProject,
resolvedDir: '/tmp/open-design/reference-project-context',
},
});
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() !== 'PATCH') {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const id = decodeURIComponent(url.pathname.split('/').pop() ?? 'composer-plus-context-actions');
const body = route.request().postDataJSON() as { metadata?: Record<string, unknown> };
await route.fulfill({
json: {
project: {
id,
name: 'Composer plus context actions',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
});
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-project' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.goto('/');
await createProject(page, 'Composer plus context actions');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-reference-project').click();
const referenceDialog = page.getByRole('dialog', { name: 'Reference another project' });
await expect(referenceDialog).toBeVisible();
await expect(referenceDialog.getByRole('option', { name: /Reference Project Context/i })).toHaveAttribute('aria-selected', 'true');
await referenceDialog.getByRole('button', { name: 'Reference project' }).click();
await expect(referenceDialog).toHaveCount(0);
await expect(input).toContainText('Reference Project Context');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-project');
// The "查看方法" (.fig download guide) row was removed from the "+" menu: the
// menu lists things to ATTACH to the message, and a help article is not one.
await composer.getByTestId('chat-plus-trigger').click();
await expect(page.getByTestId('composer-plus-figma-help')).toHaveCount(0);
await page.keyboard.press('Escape');
await composer.getByTestId('composer-design-system-trigger').click();
await expect(page.getByTestId('project-ds-picker-popover')).toBeVisible();
});
test('[P1] project detail Figma import uploads a .fig file and stages the suggested prompt', async ({ page }) => {
test.setTimeout(60_000);
const importBodies: string[] = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
const suggestedPrompt = 'Build the current project from figma/DESIGN-context.md.';
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'figma-import-build-run' });
await page.route('**/api/projects/*/figma/import', async (route) => {
importBodies.push(route.request().postData() ?? '');
await route.fulfill({
json: {
snapshotDir: 'figma',
files: ['figma/tree.json', 'figma/DESIGN-context.md', 'figma/thumbnail.png'],
inventory: {
decoded: true,
source: 'fig-file',
nodeCount: 10,
pageCount: 1,
frameCount: 2,
componentCount: 2,
colors: ['#0B5FFF'],
fonts: [{ family: 'Inter', styles: ['Regular'] }],
assetCount: 1,
hasThumbnail: true,
warnings: [],
},
thumbnailPath: 'figma/thumbnail.png',
contextPath: 'figma/DESIGN-context.md',
suggestedPrompt,
label: 'project-flow.fig',
},
});
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Project Figma import success');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-figma').click();
const figmaImport = page.getByRole('dialog', { name: 'Import from Figma' });
await expect(figmaImport).toBeVisible();
await figmaImport.locator('input[type="file"]').setInputFiles({
name: 'project-flow.fig',
mimeType: 'application/octet-stream',
buffer: Buffer.from('fake project fig payload', 'utf8'),
});
await expect(figmaImport).toContainText('project-flow.fig');
await figmaImport.getByPlaceholder(/Optional: notes/i).fill('Keep the original hierarchy.');
await figmaImport.getByRole('button', { name: 'Import & build' }).click();
await expect.poll(() => importBodies.length, { timeout: 10_000 }).toBe(1);
expect(importBodies[0]).toContain('project-flow.fig');
expect(importBodies[0]).toContain('Keep the original hierarchy.');
await expect(figmaImport).toBeVisible();
await expect(input).toContainText(suggestedPrompt);
await figmaImport.getByRole('button', { name: 'Close' }).click();
await expect(figmaImport).toHaveCount(0);
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]?.message).toContain(suggestedPrompt);
});
test('[P1] project detail Figma import keeps the dialog open and retryable on decode failure', async ({ page }) => {
const importBodies: string[] = [];
await routeComposerPlusFixtures(page);
await page.route('**/api/projects/*/figma/import', async (route) => {
importBodies.push(route.request().postData() ?? '');
await route.fulfill({
status: 500,
json: { error: { message: 'Could not decode the Figma archive.' } },
});
});
await page.goto('/');
await createProject(page, 'Project Figma import failure');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-figma').click();
const figmaImport = page.getByRole('dialog', { name: 'Import from Figma' });
await figmaImport.locator('input[type="file"]').setInputFiles({
name: 'broken-project-flow.fig',
mimeType: 'application/octet-stream',
buffer: Buffer.from('broken fig payload', 'utf8'),
});
await figmaImport.getByRole('button', { name: 'Import & build' }).click();
await expect.poll(() => importBodies.length, { timeout: 10_000 }).toBe(1);
await expect(figmaImport).toBeVisible();
await expect(figmaImport).toContainText('Could not decode the Figma archive.');
await expect(figmaImport.getByRole('button', { name: 'Import & build' })).toBeEnabled();
await expect(input).not.toContainText('figma/DESIGN-context.md');
});
test('[P1] project detail composer sends referenced workspace contexts into the run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
const referenceProject = {
id: 'ref-project-payload',
name: 'Reference Project Payload',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: {
kind: 'prototype',
nameSource: 'user',
},
};
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-payload-run' });
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [referenceProject] } });
return;
}
await route.continue();
});
await page.route('**/api/projects/ref-project-payload**', async (route) => {
await route.fulfill({
json: {
project: referenceProject,
resolvedDir: '/tmp/open-design/reference-project-payload',
},
});
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer workspace context payload',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.fallback();
});
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-project-payload' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.goto('/');
await createProject(page, 'Composer workspace context payload');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-reference-project').click();
const referenceDialog = page.getByRole('dialog', { name: 'Reference another project' });
await expect(referenceDialog.getByRole('option', { name: /Reference Project Payload/i })).toHaveAttribute('aria-selected', 'true');
await referenceDialog.getByRole('button', { name: 'Reference project' }).click();
await expect(input).toContainText('Reference Project Payload');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-project-payload');
await input.fill('Use the referenced workspace contexts in this run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ id?: string; label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'project:ref-project-payload',
label: 'Reference Project Payload',
absolutePath: '/tmp/open-design/reference-project-payload',
}),
expect.objectContaining({
id: 'local-code:/tmp/open-design/local-code-project-payload',
label: 'local-code-project-payload',
absolutePath: '/tmp/open-design/local-code-project-payload',
}),
]),
);
});
test('[P1] project detail composer removing local-code context updates metadata and the next run request', async ({ page }) => {
test.fail(true, 'Deleting an inline workspace mention does not yet remove linkedDirs metadata');
const patchRequests: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-remove-run' });
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-remove' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchRequests.push(body);
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer remove context',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/');
await createProject(page, 'Composer remove context');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-remove');
await input.press('ControlOrMeta+A');
await input.press('Backspace');
await expect(input).not.toContainText('local-code-remove');
await expect.poll(() => patchRequests.length).toBeGreaterThanOrEqual(2);
expect((patchRequests.at(-1)?.metadata as { linkedDirs?: string[] } | undefined)?.linkedDirs ?? []).toEqual([]);
await input.fill('Run without the removed local code context.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual([]);
});
test('[P1] project detail keeps local-code context when linkedDirs PATCH removal fails', async ({ page }) => {
test.fail(true, 'Inline workspace mention deletion does not yet reach the linkedDirs PATCH path');
test.setTimeout(60_000);
const patchRequests: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-remove-failure-run' });
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-persist' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchRequests.push(body);
const linkedDirs = (body.metadata as { linkedDirs?: string[] } | undefined)?.linkedDirs ?? [];
if (linkedDirs.length === 0) {
await route.fulfill({
status: 400,
json: { error: { code: 'INVALID_LINKED_DIR', message: 'linked dir removal rejected' } },
});
return;
}
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer remove context failure',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Composer remove context failure');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByRole('menuitem', { name: /Link local code/i }).click();
await expect(input).toContainText('local-code-persist');
await input.press('ControlOrMeta+A');
await input.press('Backspace');
await expect.poll(() => patchRequests.length).toBeGreaterThanOrEqual(2);
await expect(input).toContainText('local-code-persist');
await input.fill('Run with the local code context after removal failed.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: 'local-code-persist',
absolutePath: '/tmp/open-design/local-code-persist',
}),
]),
);
});
test('[P1] project detail composer context actions emit analytics event fields', async ({ page }) => {
test.fail(true, 'Inline workspace mention deletion does not yet emit context_remove analytics');
const analyticsBodies: string[] = [];
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: { codex: { model: 'default', reasoning: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
},
},
});
});
await page.route('**/api/analytics/config', async (route) => {
await route.fulfill({
json: {
enabled: true,
env: 'e2e',
key: 'phc_e2e',
host: 'https://analytics.open-design.test',
installationId: 'e2e-installation',
},
});
});
await page.route('https://analytics.open-design.test/**', async (route) => {
analyticsBodies.push(route.request().postData() ?? '');
await route.fulfill({ status: 200, json: { status: 1 } });
});
await routeComposerPlusFixtures(page);
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-analytics' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer context analytics',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/');
await createProject(page, 'Composer context analytics');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-local-code').click();
const chip = composer.locator('.staged-context--workspace', { hasText: 'local-code-analytics' });
await expect(chip).toBeVisible();
await chip.getByRole('button', { name: /local-code-analytics/i }).click();
await expect(chip).toHaveCount(0);
await expect.poll(() => analyticsBodies.join('\n')).toContain('plus_pick');
const raw = analyticsBodies.join('\n');
expect(raw).toContain('context_remove');
expect(raw).toContain('workspace');
expect(raw).toContain('local-code');
});
const TEAM_RUN_CONTEXT = {
workspaceId: 'e2e-team-run-workspace',
workspaceName: 'E2E Team Run Workspace',
workspaceType: 'team' as const,
workspaceMemberId: 'e2e-team-run-member',
role: 'owner' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
billingState: 'active' as const,
planId: 'team_plus',
seatSummary: {
seatLimit: 5,
usedSeats: 2,
availableSeats: 3,
isSeatFull: false,
},
permissions: {
canInviteMembers: true,
canManageBilling: true,
canViewWorkspaceSettings: true,
canManageSharedResources: true,
canShareProjects: true,
canWriteSyncedFiles: true,
},
workspaceSettingsUrl: 'https://console.example.test/workspace/e2e-team-run-workspace',
};
async function wireTeamRunBalanceFixtures(
page: Page,
options: {
personalBalanceUsd: string;
teamBalanceUsd: string;
},
): Promise<{
personalWalletRequests: () => number;
resetBalanceRequests: () => void;
teamBillingRequests: () => number;
teamBillingQueries: () => Array<Record<string, string | null>>;
}> {
let personalWalletRequestCount = 0;
let teamBillingRequestCount = 0;
const teamBillingQueries: Array<Record<string, string | null>> = [];
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: {
config: {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'amr',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
},
},
});
});
await routeAgents(page, [
...AGENTS,
{
id: 'amr',
name: 'OpenDesign Cloud',
bin: 'amr',
available: true,
version: 'cloud',
models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }],
},
]);
await page.route('**/api/integrations/vela/status', async (route) => {
await route.fulfill({
json: {
loggedIn: true,
loginInFlight: false,
profile: 'test',
user: {
id: 'e2e-team-run-user',
email: 'team-run@example.com',
name: 'Team Run Owner',
plan: 'team_plus',
},
account: { plan: 'free', balanceUsd: options.personalBalanceUsd },
configPath: '/tmp/.amr/config.json',
},
});
});
await page.route('**/api/integrations/vela/wallet**', async (route) => {
if (new URL(route.request().url()).pathname === '/api/integrations/vela/wallet') {
personalWalletRequestCount += 1;
}
await route.fulfill({
json: {
status: 'available',
profile: 'local',
user: {
id: 'e2e-team-run-user',
email: 'team-run@example.com',
plan: 'free',
},
balanceUsd: options.personalBalanceUsd,
updatedAt: '2026-08-02T00:00:00.000Z',
fetchedAt: '2026-08-02T00:00:00.000Z',
stale: false,
source: 'vela_api',
},
});
});
await page.route('**/api/workspace/**', async (route) => {
const request = route.request();
const url = new URL(request.url());
const { pathname } = url;
if (request.method() !== 'GET') {
await route.fallback();
return;
}
if (pathname === '/api/workspace/context') {
await route.fulfill({ json: { context: TEAM_RUN_CONTEXT } });
return;
}
if (pathname === '/api/workspace/directory') {
await route.fulfill({
json: {
items: [{
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceName: TEAM_RUN_CONTEXT.workspaceName,
workspaceType: TEAM_RUN_CONTEXT.workspaceType,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
role: TEAM_RUN_CONTEXT.role,
memberStatus: TEAM_RUN_CONTEXT.memberStatus,
lifecycleState: TEAM_RUN_CONTEXT.lifecycleState,
}],
activeWorkspaceId: TEAM_RUN_CONTEXT.workspaceId,
},
});
return;
}
if (pathname === '/api/workspace/billing') {
teamBillingRequestCount += 1;
const query = {
scope: url.searchParams.get('scope'),
workspaceId: url.searchParams.get('workspaceId'),
freshness: url.searchParams.get('freshness'),
};
teamBillingQueries.push(query);
if (
query.scope !== 'workspace' ||
query.workspaceId !== TEAM_RUN_CONTEXT.workspaceId ||
(query.freshness !== null && query.freshness !== 'authoritative')
) {
await route.fulfill({ status: 400, json: { error: 'unexpected_billing_scope' } });
return;
}
await route.fulfill({
json: {
summary: null,
workspaceBalance: {
billingScopeVersion: 2,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
balanceUsd: options.teamBalanceUsd,
expiresAt: null,
updatedAt: '2026-08-02T00:00:00.000Z',
},
workspaceRuntime: {
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
status: 'fresh',
revision: '1',
observedAt: '2026-08-02T00:00:00.000Z',
softExpiresAt: '2099-08-02T00:00:30.000Z',
hardExpiresAt: '2099-08-02T00:02:00.000Z',
retryAt: null,
errorCode: null,
reason:
query.freshness === 'authoritative'
? 'authoritative-action-read'
: 'explicit-billing-read',
sourceGapDetected: false,
},
...(query.freshness === 'authoritative'
? {
authoritativeWorkspaceRead: {
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
observedAt: '2026-08-02T00:00:00.000Z',
},
}
: {}),
},
});
return;
}
if (pathname === '/api/workspace/projects/team') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
return {
personalWalletRequests: () => personalWalletRequestCount,
resetBalanceRequests: () => {
personalWalletRequestCount = 0;
teamBillingRequestCount = 0;
teamBillingQueries.length = 0;
},
teamBillingRequests: () => teamBillingRequestCount,
teamBillingQueries: () => [...teamBillingQueries],
};
}
async function createBoundTeamProject(
page: Page,
projectName: string,
): Promise<{ projectId: string; conversationId: string }> {
const response = await createProjectViaApi(page, projectName);
const created = (await response.json()) as {
project: Record<string, unknown> & { id: string };
conversationId: string;
};
const bindProject = (project: Record<string, unknown>) => ({
...project,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
createdByWorkspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
updatedByWorkspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
const responseFromDaemon = await route.fetch();
const body = (await responseFromDaemon.json()) as {
projects?: Array<Record<string, unknown> & { id?: string }>;
};
await route.fulfill({
response: responseFromDaemon,
json: {
...body,
projects: (body.projects ?? []).map((project) =>
project.id === created.project.id ? bindProject(project) : project),
},
});
});
await page.route(`**/api/projects/${created.project.id}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: { project: bindProject(created.project) },
});
});
await page.route(`**/api/projects/${created.project.id}/collab/status`, async (route) => {
await route.fulfill({
json: {
publishedVersion: 1,
materializedVersion: 1,
awaitingFirstMaterialization: false,
syncState: 'synced',
ownerMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
ownerDisplayName: 'Team Run Owner',
ownerRole: TEAM_RUN_CONTEXT.role,
contentTransferState: null,
},
});
});
return {
projectId: created.project.id,
conversationId: created.conversationId,
};
}
test('[P0] Team project send keeps exact Team run scope through project bootstrap', async ({ page }) => {
test.setTimeout(60_000);
const prompt = 'Run against the exact Team workspace established during project bootstrap.';
const balanceRequests = await wireTeamRunBalanceFixtures(page, {
personalBalanceUsd: '0.00',
teamBalanceUsd: '99.97',
});
const { projectId, conversationId } = await createBoundTeamProject(
page,
'Exact Team scope run witness',
);
let scopeRequests = 0;
let scopedReadHeaders: Record<string, string> | null = null;
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
scopeRequests += 1;
const requestHeaders = await route.request().allHeaders();
// Route bootstrap must finish before ProjectView mounts. Capture the first
// exact Team read without blocking it, then prove the same witness reaches
// the run and billing boundaries below.
if (
scopedReadHeaders === null
&& requestHeaders['x-od-workspace-id'] === TEAM_RUN_CONTEXT.workspaceId
&& requestHeaders['x-od-workspace-member-id'] === TEAM_RUN_CONTEXT.workspaceMemberId
) {
scopedReadHeaders = requestHeaders;
}
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
context: TEAM_RUN_CONTEXT,
},
},
});
});
const runBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, {
bodies: runBodies,
runIdPrefix: 'pending-team-scope-run',
events: false,
});
const runHeaders: Array<Record<string, string>> = [];
await page.route('**/api/runs', async (route) => {
if (route.request().method() === 'POST') {
runHeaders.push(await route.request().allHeaders());
}
await route.fallback();
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect.poll(() => scopeRequests).toBeGreaterThanOrEqual(2);
expect(scopedReadHeaders?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(scopedReadHeaders?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
await expect(page.getByTestId('chat-composer-input')).toBeEditable();
balanceRequests.resetBalanceRequests();
await page.getByTestId('chat-composer-input').fill(prompt);
await page.getByTestId('chat-send').click();
await expect.poll(() => runHeaders.length).toBe(1);
expect(runHeaders[0]?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(runHeaders[0]?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
// Run scope is an HTTP authority header contract. The daemon intentionally
// does not duplicate this mutable principal into ChatRequest JSON.
expect(runBodies[0]?.currentPrompt).toBe(prompt);
expect(balanceRequests.teamBillingRequests()).toBeGreaterThanOrEqual(1);
const teamBillingQueries = balanceRequests.teamBillingQueries();
expect(teamBillingQueries.length).toBeGreaterThanOrEqual(1);
for (const query of teamBillingQueries) {
expect(query.scope).toBe('workspace');
expect(query.workspaceId).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect([null, 'authoritative']).toContain(query.freshness);
}
expect(teamBillingQueries.some((query) => query.freshness === 'authoritative')).toBe(true);
// Team preflight reads the account snapshot once for signed-in identity
// metadata only; Personal $0 is not the balance oracle and cannot veto the
// Team-funded run proved above.
expect(balanceRequests.personalWalletRequests()).toBe(1);
await expect(page.getByTestId('amr-balance-dialog')).toHaveCount(0);
});
test('[P0] Team project balance gate ignores funded Personal wallet and blocks on empty Team wallet', async ({ page }) => {
test.setTimeout(60_000);
const balanceRequests = await wireTeamRunBalanceFixtures(page, {
personalBalanceUsd: '99.97',
teamBalanceUsd: '0.00',
});
const { projectId, conversationId } = await createBoundTeamProject(
page,
'Empty Team wallet run witness',
);
let scopeRequests = 0;
let scopedReadHeaders: Record<string, string> | null = null;
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
scopeRequests += 1;
const requestHeaders = await route.request().allHeaders();
if (
scopedReadHeaders === null
&& requestHeaders['x-od-workspace-id'] === TEAM_RUN_CONTEXT.workspaceId
&& requestHeaders['x-od-workspace-member-id'] === TEAM_RUN_CONTEXT.workspaceMemberId
) {
scopedReadHeaders = requestHeaders;
}
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
context: TEAM_RUN_CONTEXT,
},
},
});
});
const runRequests = await routeSuccessfulRuns(page, {
runIdPrefix: 'should-not-use-personal-wallet',
events: false,
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect.poll(() => scopeRequests).toBeGreaterThanOrEqual(2);
expect(scopedReadHeaders?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(scopedReadHeaders?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
await expect(page.getByTestId('chat-composer-input')).toBeEditable();
balanceRequests.resetBalanceRequests();
await page.getByTestId('chat-composer-input').fill(
'Do not charge the funded Personal wallet for this Team project.',
);
await page.getByTestId('chat-send').click();
const dialog = page.getByTestId('amr-balance-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('$0.00');
expect(balanceRequests.teamBillingRequests()).toBeGreaterThanOrEqual(1);
const teamBillingQueries = balanceRequests.teamBillingQueries();
expect(teamBillingQueries.length).toBeGreaterThanOrEqual(1);
for (const query of teamBillingQueries) {
expect(query.scope).toBe('workspace');
expect(query.workspaceId).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect([null, 'authoritative']).toContain(query.freshness);
}
expect(teamBillingQueries.some((query) => query.freshness === 'authoritative')).toBe(true);
// Conversely, funded Personal identity metadata cannot override Team $0.
expect(balanceRequests.personalWalletRequests()).toBe(1);
await runRequests.expectNone({
message: 'An empty Team wallet must block before POST /api/runs',
});
});
test('[P0] @critical project detail composer agent menu lets the user switch the model', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
await createProject(page, 'Composer agent switch');
await expectWorkspaceReady(page);
const { menu } = await openComposerAgentMenu(page);
const list = menu.getByTestId('avatar-model-list');
await expect(list).toBeVisible();
await expect(list.locator('.avatar-model-option.is-active')).toContainText(/default/i);
await list.getByRole('radio', { name: /^GPT 5\.5$/i }).click();
const { menu: reopened } = await openComposerAgentMenu(page);
await expect(
reopened.getByTestId('avatar-model-list').locator('.avatar-model-option.is-active'),
).toContainText(/GPT 5\.5/i);
});
test('[P0] project detail composer model and Plan mode switches carry into the next daemon run request', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'agent-model-run' });
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Composer agent switch run context', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
await pickComposerModel(page, /^GPT 5\.5$/i);
await selectComposerSessionMode(page, 'Plan mode');
const input = page.getByTestId('chat-composer-input');
await input.fill('Plan the selected local agent run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.agentId).toBe('codex');
expect(runRequestBodies[0]?.model).toBe('gpt-5.5');
expect(runRequestBodies[0]?.sessionMode).toBe('plan');
});
test('[P1] GPT 5.5 Fast service tier carries into the next Codex daemon run request', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'codex-fast-tier-run' });
await page.goto('/');
await createProject(page, 'Codex Fast service tier contract');
await expectWorkspaceReady(page);
await pickComposerModel(page, /^GPT 5\.5$/i);
const { menu } = await openComposerAgentMenu(page);
const serviceTierSelect = menu
.locator('label.avatar-select-row', { hasText: /Service tier/i })
.locator('select');
await expect(serviceTierSelect).toBeVisible();
await serviceTierSelect.selectOption('priority');
await expect(serviceTierSelect).toHaveValue('priority');
await page.keyboard.press('Escape');
await expect(page.locator('.avatar-popover[role="dialog"]')).toHaveCount(0);
await page.getByTestId('chat-composer-input').fill('Use GPT 5.5 Fast for this Codex run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'codex',
model: 'gpt-5.5',
serviceTier: 'priority',
});
});
test('[P1] project detail composer can alternate Design, Ask, and Plan modes across turns', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'mode-run' });
await page.goto('/');
await createProject(page, 'Composer session mode alternation');
await expectWorkspaceReady(page);
async function sendTurn(prompt: string) {
const input = page.getByTestId('chat-composer-input');
await expect(input).toBeVisible();
await input.fill(prompt);
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect(input).toHaveText('');
}
await selectComposerSessionMode(page, 'Design mode');
await sendTurn('Design the first iteration.');
await selectComposerSessionMode(page, 'Ask mode');
await sendTurn('Ask a clarifying question about the direction.');
await selectComposerSessionMode(page, 'Plan mode');
await sendTurn('Plan the implementation steps.');
await selectComposerSessionMode(page, 'Design mode');
await sendTurn('Design the final iteration.');
expect(runRequestBodies.map((body) => body.sessionMode)).toEqual(['design', 'chat', 'plan', 'design']);
});
test('[P1] project detail composer keeps the selected mode across consecutive turns', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'same-mode-run' });
await page.goto('/');
await createProject(page, 'Composer same session mode reuse');
await expectWorkspaceReady(page);
async function sendTurn(prompt: string) {
const input = page.getByTestId('chat-composer-input');
await expect(input).toBeVisible();
await input.fill(prompt);
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect(input).toHaveText('');
}
await selectComposerSessionMode(page, 'Plan mode');
await sendTurn('Plan the first pass.');
await sendTurn('Plan the second pass without changing mode.');
expect(runRequestBodies.map((body) => body.sessionMode)).toEqual(['plan', 'plan']);
await expect(page.getByTestId('chat-composer').getByTestId('composer-mode-trigger')).toHaveAttribute(
'aria-label',
'Mode: Plan',
);
});
test('[P0] @critical project detail composer opens Execution settings where BYOK model choice persists', async ({ page }) => {
test.setTimeout(60_000);
let config = {
mode: 'daemon',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-2024-05-13',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mediaProviders: {},
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as Record<string, unknown>;
config = { ...config, ...body };
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
});
await page.goto('/');
await createProject(page, 'Composer BYOK model switch');
await expectWorkspaceReady(page);
const { menu } = await openComposerAgentMenu(page);
await menu.getByTestId('avatar-open-execution-settings').click();
const settings = settingsSurface(page);
await expect(settings).toBeVisible();
await expect(settings.getByTestId('settings-nav-execution')).toBeVisible();
await settings.getByRole('tab', { name: 'API providers' }).click();
await settings.getByRole('tab', { name: 'OpenAI', exact: true }).click();
const modelSelect = settings.getByRole('combobox', { name: 'Model', exact: true });
await expect(modelSelect).toContainText('Custom (type below)…');
await expect(settings.getByRole('textbox', { name: 'Custom model id', exact: true }))
.toHaveValue('gpt-4o-2024-05-13');
await modelSelect.click();
const modelPopover = page.getByTestId('settings-byok-model-popover');
await expect(modelPopover.getByRole('option', { name: /^gpt-4o-mini$/i })).toBeVisible();
await expect(modelPopover.getByRole('option', { name: /deepseek/i })).toHaveCount(0);
await expect(modelPopover.getByRole('option', { name: /MiniMax/i })).toHaveCount(0);
await modelPopover.getByRole('option', { name: /^gpt-4o-mini$/i }).click();
await expect(modelSelect).toContainText('gpt-4o-mini');
await expect.poll(async () => page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY)).toMatchObject({
mode: 'api',
model: 'gpt-4o-mini',
});
});
test('[P0] @critical project detail composer keeps Local CLI and BYOK model choices isolated', async ({ page }) => {
test.setTimeout(60_000);
const config = {
mode: 'daemon',
apiKey: 'test-byok-key',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-2024-05-13',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mediaProviders: {},
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config: body }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
});
await page.goto('/');
await createProject(page, 'Composer model mode isolation');
await expectWorkspaceReady(page);
// Picking a Local CLI model must not touch the stored BYOK model: the two
// live in separate config slots (`agentModels[agentId].model` vs `model`).
await pickComposerModel(page, /^GPT 5\.5$/i);
await expect.poll(async () => page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
model: 'gpt-4o-2024-05-13',
agentModels: {
codex: { model: 'gpt-5.5' },
},
});
const { menu } = await openComposerAgentMenu(page);
await expect(
menu.getByTestId('avatar-model-list').locator('.avatar-model-option.is-active'),
).toContainText(/GPT 5\.5/i);
});
test('[P0] clearing the project design system removes designSystemId from the next run request', async ({ page }) => {
const patchBodies: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() !== 'PATCH') {
await route.continue();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
await route.continue();
});
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'design-system-clear-run' });
await page.goto('/');
await createProject(page, 'Header design system clear run context');
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/No design system/i);
await trigger.click();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
await editorialOption.click();
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
await trigger.click();
await page.locator('.project-ds-picker-option').first().click();
await expect(trigger).toHaveAccessibleName(/No design system/i);
expect(patchBodies.some((body) => Object.prototype.hasOwnProperty.call(body, 'designSystemId') && body.designSystemId === null)).toBe(true);
const input = page.getByTestId('chat-composer-input');
await input.fill('Generate this without an active design system.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.designSystemId).toBeNull();
});
test('[P1] a disabled project design system is omitted from the next run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'codex',
designSystemId: null,
disabledDesignSystems: ['editorial-noir'],
agentModels: { codex: { model: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
return;
}
await route.fallback();
});
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'disabled-design-system-run' });
await createProject(page, 'Disabled project design system');
await expectWorkspaceReady(page);
const projectId = getProjectContextFromUrl(page).projectId;
const projectResponse = await page.request.get(`/api/projects/${projectId}`);
expect(projectResponse.ok(), await projectResponse.text()).toBeTruthy();
const projectPayload = (await projectResponse.json()) as { project: Record<string, unknown> };
const disabledProject = {
...projectPayload.project,
designSystemId: 'editorial-noir',
};
await page.route(`**/api/projects/${projectId}`, async (route) => {
if (route.request().method() === 'GET' || route.request().method() === 'PATCH') {
await route.fulfill({ json: { project: disabledProject } });
return;
}
await route.fallback();
});
await page.reload();
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Generate without the disabled design system.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies).toHaveLength(1);
expect(runRequestBodies[0]?.designSystemId).toBeNull();
});
test('[P1] 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('[P1] project handoff CLI prompt copies the project path, framework, id, and target agent', async ({ page }) => {
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
await routeHandoffEditors(page);
await page.goto('/');
await createProject(page, 'Handoff CLI prompt contract');
await expectWorkspaceReady(page);
await uploadTinyHtml(
page,
'handoff-cli.html',
'<!doctype html><html><body><h1>Handoff CLI</h1></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
const menu = await openHandoffCliTab(page);
const pathButton = menu.locator('.handoff-path-button');
await expect(pathButton).toBeEnabled();
const projectDir = await pathButton.getAttribute('title');
expect(projectDir).toBeTruthy();
await menu.getByRole('button', { name: /^Next\.js$/ }).click();
await menu.getByTestId('handoff-cli-item-codex').click();
await expect(menu.getByTestId('handoff-cli-item-codex')).toContainText('Copied');
const copied = await page.evaluate(() => {
return (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? [];
});
const prompt = copied.at(-1) ?? '';
expect(prompt).toContain(projectDir as string);
expect(prompt).toContain('cd ');
expect(prompt).toContain('Target: Next.js / React');
expect(prompt).toContain('CLI: Codex CLI (codex)');
expect(prompt).toContain(`Project ID: ${projectId}`);
});
test('[P1] 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 openAllProjectFiles(page);
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('[P1] project detail workspace keeps design file tabs and preview controls visible for uploaded html artifacts', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Workspace preview structure');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(page, 'workspace-preview.html', '<!doctype html><html><body><main><h1>Workspace Preview Structure</h1><p>Preview stays visible.</p></main></body></html>');
const fileTab = tabBySuffix(page, uploadedName);
await expect(fileTab).toBeVisible();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
// #5517 replaced the tab strip's pages dropdown with a plain Design Files
// tab; that tab is the file-navigation entry this assertion guards now.
await expect(page.getByTestId('design-files-tab')).toBeVisible();
await openUploadedHtmlArtifactPreview(page, uploadedName);
await expect(page.getByRole('tablist', { name: 'View mode' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(artifactPreview(page)).toBeVisible();
await expect(
artifactPreviewFrame(page).getByRole('heading', { name: 'Workspace Preview Structure' }),
).toBeVisible();
await expect(page.getByRole('button', { name: /Preview viewport/i })).toBeVisible();
await expect(page.locator('pre.viewer-source')).toHaveCount(0);
});
test('[P1] project detail session mode switch carries Ask and Plan semantics into daemon runs', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'session-mode-run' });
await page.goto('/');
await createProject(page, 'Project session mode contract');
await expectWorkspaceReady(page);
const modeTrigger = page.getByTestId('composer-mode-trigger');
// Design is the app default and is represented as an explicit selection.
await expect(modeTrigger).toHaveAttribute('aria-label', 'Mode: Design');
await modeTrigger.click();
await page.getByTestId('composer-mode-menu-plan').click();
await expect(modeTrigger).toHaveAttribute('aria-label', 'Mode: Plan');
await expect(modeTrigger).toContainText('Plan');
await page.getByTestId('chat-composer-input').fill('Draft the plan before generating files.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]?.sessionMode).toBe('plan');
await expect(page.getByTestId('msg-session-mode-chip').last()).toContainText('Plan');
await modeTrigger.click();
await page.getByTestId('composer-mode-menu-chat').click();
await expect(modeTrigger).toHaveAttribute('aria-label', 'Mode: Ask');
await expect(modeTrigger).toContainText('Ask');
await page.getByTestId('chat-composer-input').fill('Just answer this without creating files.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(2);
expect(runRequestBodies[1]?.sessionMode).toBe('chat');
await expect(page.getByTestId('msg-session-mode-chip').last()).toContainText('Ask');
});
test('[P1] BYOK OpenCode project run sends provider config through the daemon contract', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: 'sk-openai-e2e',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: true,
version: '0.12.0',
models: [{ id: 'default', label: 'Default' }],
},
]);
await page.route('**/api/memory/extract', async (route) => {
await route.fulfill({ json: { ok: true, extracted: [] } });
});
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'byok-opencode-run' });
await page.goto('/');
await createProject(page, 'BYOK OpenCode daemon contract');
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Create a landing page with the configured BYOK provider.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'byok-opencode',
model: 'gpt-4o-mini',
byokProvider: {
protocol: 'openai',
apiKey: 'sk-openai-e2e',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiVersion: '',
},
analyticsHints: {
runtimeType: 'byok',
},
});
});
test('[P1] BYOK OpenCode keyless vLLM run keeps auth fields out of the daemon contract', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'http://127.0.0.1:8000/v1',
model: 'model',
apiProviderBaseUrl: 'http://127.0.0.1:8000/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: true,
version: '0.12.0',
models: [{ id: 'default', label: 'Default' }],
},
]);
await page.route('**/api/memory/extract', async (route) => {
await route.fulfill({ json: { ok: true, extracted: [] } });
});
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'byok-opencode-keyless-run' });
await page.goto('/');
await createProject(page, 'BYOK OpenCode keyless vLLM contract');
await expectWorkspaceReady(page);
await page.getByTestId('chat-composer-input').fill('Use the local vLLM BYOK endpoint.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'byok-opencode',
model: 'model',
byokProvider: {
protocol: 'openai',
apiKey: '',
baseUrl: 'http://127.0.0.1:8000/v1',
model: 'model',
requiresApiKey: false,
},
analyticsHints: {
runtimeType: 'byok',
},
});
});
test('[P1] BYOK OpenCode unavailable blocks the project run before daemon routing', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: 'sk-openai-e2e',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: false,
version: null,
models: [],
},
]);
const runRequests = await routeSuccessfulRuns(page, {
runIdPrefix: 'byok-opencode-unavailable-should-not-start',
events: false,
});
await page.goto('/');
await createProject(page, 'BYOK OpenCode unavailable contract');
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Create a landing page with unavailable OpenCode.');
await page.getByTestId('chat-send').click();
await expect(page.locator('.run-error__description')).toContainText(
/BYOK API runs require OpenCode/i,
);
await runRequests.expectNone({
message: 'unavailable BYOK OpenCode should fail preflight before POST /api/runs',
});
});
test('[P1] project detail active file context is sent with the run and shown on the user message', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-run' });
await page.goto('/');
await createProject(page, 'Workspace context chip contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'workspace-context.html',
'<!doctype html><html><body><main><h1>Workspace Context</h1></main></body></html>',
);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
await page.getByTestId('chat-composer-input').fill('Use the currently open file as context.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; id?: string }> } | undefined;
expect(context?.workspaceItems?.some((item) => item.label === uploadedName || item.id?.includes(uploadedName))).toBe(true);
const chip = page.getByTestId('msg-workspace-context-chip').last();
await expect(chip).toBeVisible();
await expect(chip).toContainText(uploadedName);
});
test('[P1] project detail session mode and active file context survive reload in message history', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-reload-run' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Workspace context reload contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'workspace-context-reload.html',
'<!doctype html><html><body><main><h1>Workspace Context Reload</h1></main></body></html>',
);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
const modeTrigger = page.getByTestId('composer-mode-trigger');
await modeTrigger.click();
await page.getByTestId('composer-mode-menu-plan').click();
await expect(modeTrigger).toHaveAttribute('aria-label', 'Mode: Plan');
await page.getByTestId('chat-composer-input').fill('Persist this file context through reload.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; id?: string }> } | undefined;
expect(runRequestBodies[0]?.sessionMode).toBe('plan');
expect(context?.workspaceItems?.some((item) => item.label === uploadedName || item.id?.includes(uploadedName))).toBe(true);
await expect(page.getByTestId('msg-session-mode-chip').last()).toContainText('Plan');
await expect(page.getByTestId('msg-workspace-context-chip').last()).toContainText(uploadedName);
await page.reload();
await expectWorkspaceReady(page);
await expect(page.getByTestId('msg-session-mode-chip').last()).toContainText('Plan');
await expect(page.getByTestId('msg-workspace-context-chip').last()).toContainText(uploadedName);
});
test('[P1] active project API defaults to the selected project file from the real workspace', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'MCP active context contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'mcp-active-context.html',
'<!doctype html><html><body><main><h1>MCP Active Context</h1></main></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
await expect
.poll(async () => {
const response = await page.request.get('/api/active');
const body = (await response.json()) as { projectId?: string; fileName?: string };
return `${body.projectId ?? ''}:${body.fileName ?? ''}`;
})
.toBe(`${projectId}:${uploadedName}`);
});
test('[P1] project detail HTML version manager previews and restores an older snapshot', async ({ page }) => {
const restoredRequests: string[] = [];
await page.goto('/');
await createProject(page, 'HTML version manager contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'version-manager.html',
'<!doctype html><html><body><main><h1>Current Version</h1></main></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
const now = Date.now();
const currentVersion = {
id: 'v-current',
fileName: uploadedName,
version: 2,
label: 'Current generated HTML',
createdAt: now,
source: 'ai',
prompt: 'Current generated HTML',
promptSource: 'message',
size: 82,
mime: 'text/html',
kind: 'html',
current: true,
};
const oldVersion = {
id: 'v-old',
fileName: uploadedName,
version: 1,
label: 'Initial generated HTML',
createdAt: now - 60_000,
source: 'manual',
prompt: 'Initial generated HTML',
promptSource: 'manual',
size: 78,
mime: 'text/html',
kind: 'html',
current: false,
};
const restoredVersion = {
...oldVersion,
id: 'v-restored',
version: 3,
source: 'restore',
current: true,
restoreFromVersionId: oldVersion.id,
};
const oldVersionContent = '<!doctype html><html><body><main><h1>Initial Version</h1></main></body></html>';
const restoredContent = '<!doctype html><html><body><main><h1>Restored Version</h1></main></body></html>';
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
file: { name: uploadedName, kind: 'html', mime: 'text/html', size: oldVersionContent.length, mtime: now },
versions: [currentVersion, oldVersion],
},
});
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions/v-old`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { version: oldVersion, content: oldVersionContent } });
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions/v-old/restore`, async (route) => {
if (route.request().method() === 'POST') {
restoredRequests.push(route.request().url());
await route.fulfill({
json: {
file: { name: uploadedName, kind: 'html', mime: 'text/html', size: restoredContent.length, mtime: Date.now() },
version: restoredVersion,
},
});
return;
}
await route.continue();
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await page.getByRole('button', { name: 'Versions' }).click();
const dialog = page.getByRole('dialog', { name: 'Versions' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('option', { name: /Current generated HTML/i })).toHaveAttribute('aria-selected', 'true');
await dialog.getByRole('option', { name: /Initial generated HTML/i }).click();
await expect(dialog.locator('iframe').first().contentFrame().getByRole('heading', { name: 'Initial Version' })).toBeVisible();
await dialog.getByRole('button', { name: 'Switch to this version' }).click();
await dialog.getByRole('dialog', { name: 'Switch to this version?' }).getByRole('button', { name: 'Switch' }).click();
await expect.poll(() => restoredRequests.length).toBe(1);
await expect(dialog).toHaveCount(0);
});
test('[P1] project detail assistant completion actions support copy, fork, and feedback', async ({ page }) => {
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
const { projectId, conversationId, assistantMessageId, assistantText } =
await seedProjectWithAssistantCompletion(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect(page.getByText('Assistant completion actions fixture')).toBeVisible();
const copyButton = page.getByTestId('assistant-copy-markdown');
await expect(copyButton).toBeVisible();
await copyButton.click();
await expect(copyButton).toHaveAttribute('data-copied', 'true');
const copied = await page.evaluate(() => {
return (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? [];
});
expect(copied.at(-1)).toBe(assistantText);
const positive = page.getByTestId('assistant-feedback-positive');
const negative = page.getByTestId('assistant-feedback-negative');
await expect(positive).toBeVisible();
await expect(negative).toBeVisible();
await positive.click();
await expect(positive).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.assistant-feedback-reasons')).toBeVisible();
await negative.click();
await expect(negative).toHaveAttribute('aria-pressed', 'true');
await expect(positive).toHaveAttribute('aria-pressed', 'false');
const forkRequestPromise = page.waitForRequest((request) => {
return request.method() === 'POST'
&& request.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').click();
const forkRequest = await forkRequestPromise;
const forkBody = forkRequest.postDataJSON() as {
forkAfterMessageId?: string;
seedFromConversationId?: string;
seedMessages?: unknown;
};
expect(forkBody.seedFromConversationId).toBe(conversationId);
expect(forkBody.forkAfterMessageId).toBe(assistantMessageId);
expect(forkBody.seedMessages).toBeUndefined();
await expect
.poll(() => getProjectContextFromUrl(page).conversationId)
.not.toBe(conversationId);
});
test('[P1] project detail fork emits correlated click and result analytics', async ({ page }) => {
const analyticsBodies: string[] = [];
await page.unroute('**/api/app-config').catch(() => {});
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
agentModels: { codex: { model: 'default' } },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: null,
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
});
await page.route('**/api/analytics/config', async (route) => {
await route.fulfill({
json: {
enabled: true,
env: 'e2e',
key: 'phc_e2e',
host: 'https://analytics.open-design.test',
installationId: 'e2e-installation',
},
});
});
await page.route('https://analytics.open-design.test/**', async (route) => {
analyticsBodies.push(route.request().postData() ?? '');
await route.fulfill({ status: 200, json: { status: 1 } });
});
const { projectId, conversationId } = await seedProjectWithAssistantCompletion(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
const forkResponsePromise = page.waitForResponse((response) => {
return response.request().method() === 'POST'
&& response.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').click();
expect((await forkResponsePromise).ok()).toBe(true);
await expect
.poll(() => analyticsBodies.join('\n'), { timeout: T.medium })
.toContain('conversation_fork_result');
const raw = analyticsBodies.join('\n');
expect(raw).toContain('assistant_fork_button');
expect(raw).toContain('fork_conversation');
expect(raw).toContain('"result":"success"');
expect(raw).toContain('"fork_point":"latest"');
expect(raw).toContain(projectId);
expect(raw).toContain(conversationId);
const requestIdCounts = new Map<string, number>();
for (const match of raw.matchAll(/"request_id":"([^"]+)"/g)) {
const requestId = match[1];
if (!requestId) continue;
requestIdCounts.set(requestId, (requestIdCounts.get(requestId) ?? 0) + 1);
}
expect([...requestIdCounts.values()].some((count) => count >= 2)).toBe(true);
});
test('[P1] project detail forks histories larger than the daemon JSON body limit', async ({ page }) => {
test.setTimeout(T.xlong);
const { projectId, conversationId, expectedContents } =
await seedProjectWithLargeAssistantHistory(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect(page.getByTestId('assistant-fork-button')).toHaveCount(3, {
timeout: T.long,
});
const forkResponsePromise = page.waitForResponse((response) => {
return response.request().method() === 'POST'
&& response.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').last().click();
const forkResponse = await forkResponsePromise;
expect(
forkResponse.ok(),
`fork large conversation: ${await forkResponse.text()}`,
).toBe(true);
const forkRequestBody = forkResponse.request().postDataJSON() as {
seedMessages?: unknown;
};
expect(forkRequestBody.seedMessages).toBeUndefined();
await expect
.poll(() => getProjectContextFromUrl(page).conversationId)
.not.toBe(conversationId);
const forkConversationId = getProjectContextFromUrl(page).conversationId;
expect(forkConversationId).toBeTruthy();
const forkRequestHeaders = forkResponse.request().headers();
const workspaceHeaders = Object.fromEntries(
['x-od-workspace-id', 'x-od-workspace-member-id']
.map((name) => [name, forkRequestHeaders[name]] as const)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
const forkMessagesResponse = await page.request.get(
`/api/projects/${projectId}/conversations/${forkConversationId}/messages`,
{ headers: workspaceHeaders },
);
expect(
forkMessagesResponse.ok(),
`load forked messages: ${await forkMessagesResponse.text()}`,
).toBe(true);
const forkMessagesBody = (await forkMessagesResponse.json()) as {
messages: Array<{ content: string }>;
};
expect(forkMessagesBody.messages.map((message) => message.content)).toEqual(expectedContents);
});
test('[P1] read-only project viewers do not see conversation fork actions', async ({ page }) => {
const { projectId, conversationId } = await seedProjectWithAssistantCompletion(page);
const readonlyTeamContext = {
...AMR_PERSONAL_WORKSPACE_CONTEXT,
workspaceId: 'workspace-readonly-fork',
workspaceType: 'team',
workspaceMemberId: 'member-readonly-fork',
role: 'member',
teamId: 'team-readonly-fork',
permissions: {
...AMR_PERSONAL_WORKSPACE_CONTEXT.permissions,
canWriteSyncedFiles: false,
},
};
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: readonlyTeamContext.workspaceId,
visibility: 'team',
context: readonlyTeamContext,
},
},
});
});
await page.route(`**/api/projects/${projectId}/collab/status`, async (route) => {
await route.fulfill({
json: {
publishedVersion: 1,
materializedVersion: 1,
syncState: 'synced',
ownerMemberId: 'member-project-owner',
},
});
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await page
.getByText('Loading OpenDesign…')
.waitFor({ state: 'hidden', timeout: T.long })
.catch(() => {});
const showChat = page.getByTestId('workspace-focus-toggle');
if (await showChat.isVisible()) {
await showChat.click();
}
const expandConversation = page.getByRole('button', { name: 'Expand the conversation pane' });
if (await expandConversation.isVisible()) {
await expandConversation.click();
}
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: T.long });
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
await expect(page.getByTestId('assistant-fork-button')).toHaveCount(0);
});
test('[P1] project detail conversations menu supports new chat, search, counts, and run duration metadata', async ({ page }) => {
const { projectId, conversations } = await seedProjectConversationHistory(page);
await routeConversationHistoryFixtures(page, projectId, conversations);
await page.goto(`/projects/${projectId}/conversations/${conversations[0]!.id}`);
await expectWorkspaceReady(page);
await page.getByTestId('conversation-history-trigger').click();
const menu = page.getByTestId('conversation-history-menu');
await expect(menu).toBeVisible();
await expect(page.getByTestId('conversation-history-count')).toHaveText('3');
await expect(page.getByTestId(`conversation-select-${conversations[0]!.id}`)).toContainText('Runway final polish');
await expect(page.getByTestId(`conversation-meta-${conversations[0]!.id}`)).toHaveText('8 msg · 5m 42s');
await expect(page.getByTestId(`conversation-meta-${conversations[1]!.id}`)).toHaveText('6 msg · 19m 00s');
await expect(page.getByTestId(`conversation-meta-${conversations[2]!.id}`)).toContainText('6 msg ·');
await page.getByTestId('conversation-history-search').fill('font audit');
await expect(page.getByTestId('conversation-history-count')).toHaveText('1 / 3');
await expect(page.getByTestId(`conversation-item-${conversations[1]!.id}`)).toBeVisible();
await expect(page.getByTestId(`conversation-item-${conversations[0]!.id}`)).toHaveCount(0);
await page.getByTestId('conversation-history-search').fill('');
const newConversationRequestPromise = page.waitForRequest((request) => {
return request.method() === 'POST'
&& request.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('conversation-history-new').click();
await newConversationRequestPromise;
await expect(page.getByTestId('conversation-history-menu')).toHaveCount(0);
await page.getByTestId('conversation-history-trigger').click();
await expect(page.getByTestId('conversation-history-count')).toHaveText('4');
await expect(page.getByTestId('conversation-select-conv-new-history')).toContainText('Untitled');
await expect(page.getByTestId('conversation-meta-conv-new-history')).toHaveText('0 msg · now');
});
test('[P0] project detail share menu copies the current share link for uploaded html artifacts', async ({ page }) => {
// Upload opens the file tab immediately, so the first deployment read may
// precede the upload helper's return. Seed the deterministic file name so
// that read receives the deployment instead of a stale empty fixture.
let uploadedName = 'share-link-copy.html';
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({
json: {
deployments: uploadedName
? [{
id: 'ready-share-link',
projectId: getProjectIdFromApiPath(route.request().url()),
fileName: uploadedName,
providerId: 'vercel-self',
url: 'https://share-preview.example',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 2,
}]
: [],
},
});
});
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Share link copy flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
uploadedName = await uploadTinyHtml(page, 'share-link-copy.html', '<!doctype html><html><body><h1>Share link copy</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('menuitem', { name: /^Copy share link$/i }).click();
await expect(page.getByRole('menuitem', { name: /^Copied!$/i })).toBeVisible();
const copied = await page.evaluate(() => (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? []);
expect(copied.at(-1)).toBe('https://share-preview.example');
});
test('[P0] project detail share menu opens the current share page for uploaded html artifacts', async ({ page }) => {
// See the copy-link case above: FileViewer can ask for deployments before
// the upload helper returns and assigns the persisted file name.
let uploadedName = 'share-page-open.html';
await page.addInitScript(() => {
const opened: string[] = [];
Object.defineProperty(window, '__openedUrls', {
value: opened,
configurable: true,
});
const originalOpen = window.open.bind(window);
window.open = ((...args: Parameters<typeof window.open>) => {
if (typeof args[0] === 'string') opened.push(args[0]);
return originalOpen(...args);
}) as typeof window.open;
});
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({
json: {
deployments: uploadedName
? [{
id: 'protected-share-link',
projectId: getProjectIdFromApiPath(route.request().url()),
fileName: uploadedName,
providerId: 'vercel-self',
url: 'https://protected-share.example',
deploymentCount: 1,
target: 'preview',
status: 'protected',
createdAt: 1,
updatedAt: 2,
}]
: [],
},
});
});
// This scenario creates through Playwright's APIRequestContext rather than
// the browser UI, so the Web cannot inherit its normal same-session creation
// witness. Give the page an exact writable Personal/owner identity and bind
// the project-scope bootstrap to that same identity. Do not make the share
// control writable by weakening the shared-project authority gate.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Open share page flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
uploadedName = await uploadTinyHtml(page, 'share-page-open.html', '<!doctype html><html><body><h1>Open share page</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('menuitem', { name: /Open share page/i }).click();
await expect
.poll(() =>
page.evaluate(() => (window as typeof window & { __openedUrls?: string[] }).__openedUrls ?? []),
)
.toContain('https://protected-share.example');
});
test('[P0] @critical project detail share menu publish action opens the deploy flow for the selected provider', async ({ page }) => {
let deployConfigUrl: string | null = null;
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({ json: { deployments: [] } });
});
await page.route('**/api/deploy/config?providerId=*', async (route) => {
deployConfigUrl = route.request().url();
const url = new URL(route.request().url());
await route.fulfill({
json: {
configured: false,
providerId: url.searchParams.get('providerId'),
tokenMask: '',
teamId: '',
teamSlug: '',
},
});
});
// Match the other writable share scenarios: APIRequestContext creation does
// not register the browser's same-session owner witness, so provide the
// exact Personal authority this test intends to exercise.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Deploy action flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(page, 'deploy-action.html', '<!doctype html><html><body><h1>Deploy action</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('menuitem', { name: /^Deploy to Vercel$/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: /Deploy to Vercel/i })).toBeVisible();
await expect(dialog.locator('select').first()).toHaveValue('vercel-self');
await expect
.poll(() => deployConfigUrl ?? '', { timeout: T.medium })
.toContain('providerId=vercel-self');
});
test('[P1] 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.goto('/projects');
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('[P2] 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.goto('/projects');
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('[P1] home designs search filters projects and recovers from no results', async ({ page }) => {
test.setTimeout(60_000);
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.goto('/projects');
await expectDesignsView(page);
await createProject(page, betaName);
await expectWorkspaceReady(page);
const betaProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
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('[P2] projects sub tabs switch between Recent and Your designs ordering', async ({ page }) => {
const now = Date.now();
const projects = [
makeProjectsTabProject({
id: 'proj-alpha',
name: 'Sort Alpha',
createdAt: now - 3 * 60_000,
updatedAt: now - 1 * 60_000,
}),
makeProjectsTabProject({
id: 'proj-beta',
name: 'Sort Beta',
createdAt: now - 1 * 60_000,
updatedAt: now - 3 * 60_000,
}),
makeProjectsTabProject({
id: 'proj-gamma',
name: 'Sort Gamma',
createdAt: now - 2 * 60_000,
updatedAt: now - 2 * 60_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 stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(0)).toContainText(
'Sort Alpha',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(1)).toContainText(
'Sort Gamma',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(2)).toContainText(
'Sort Beta',
);
await page.getByRole('button', { name: 'Your designs' }).click();
await expect(page.locator('.design-grid .design-card .design-card-name').nth(0)).toContainText(
'Sort Beta',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(1)).toContainText(
'Sort Gamma',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(2)).toContainText(
'Sort Alpha',
);
});
test('[P1] 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.goto('/projects');
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('[P1] 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.goto('/projects');
await expectDesignsView(page);
await createProject(page, secondName);
await expectWorkspaceReady(page);
const secondProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
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('[P1] 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.goto('/projects');
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}(/conversations/[^/]+)?$`));
await expect(page.getByTestId('project-title')).toContainText(projectName);
const openedProject = await fetchCurrentProject(page);
expect(openedProject.name).toBe(projectName);
await page.goto('/projects');
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('[P2] 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 stubCatalogsEmpty(page);
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('[P2] 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 stubCatalogsEmpty(page);
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('[P2] 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 stubCatalogsEmpty(page);
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.locator('.tab-panel-toolbar').click({ position: { x: 8, y: 8 } });
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('[P2] 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 stubCatalogsEmpty(page);
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);
const kanbanColumns = page.locator('.design-kanban-col');
await expect(kanbanColumns).toHaveCount(7);
await expect(
kanbanColumns.filter({ hasText: 'Incomplete' }).locator('.design-kanban-empty'),
).toHaveCount(1);
await expect(
kanbanColumns.filter({ hasText: 'Canceled' }).locator('.design-kanban-empty'),
).toHaveCount(1);
await expect(page.locator('.design-kanban-empty')).toHaveCount(2);
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('[P1] 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 stubCatalogsEmpty(page);
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\/files\/live%3Aartifact-1$/);
await expect(page.getByTestId('project-title')).toContainText('Orbit Daily Digest');
});
test('[P2] General settings updates the custom companion draft', async ({ page }) => {
await seedAdoptedPet(page);
await page.route('**/api/codex-pets', async (route) => {
await route.fulfill({ json: { pets: [], rootDir: '' } });
});
await page.goto('/');
const dialog = await openSettingsDialog(page);
await dialog.getByRole('button', { name: /^General$/i }).click();
await expect(dialog.getByRole('heading', { level: 3, name: 'Pets' })).toBeVisible();
await dialog.getByRole('tab', { name: 'Custom' }).click();
const customPanel = dialog.locator('.pet-custom');
await expect(customPanel).toBeVisible();
await customPanel.getByLabel('Name').fill('QA Turtle');
await customPanel.getByLabel('Glyph').fill('🐢');
await customPanel.getByLabel('Greeting').fill('Shell yeah, tests are green.');
await expect(customPanel.getByText('QA Turtle')).toBeVisible();
await expect(customPanel.getByText('Shell yeah, tests are green.')).toBeVisible();
await dialog.getByRole('button', { name: 'Back to home', exact: true }).click();
await expect(dialog).toHaveCount(0);
});
async function createProject(
page: Page,
projectName: string,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
const response = await createProjectViaApi(page, projectName, options);
const body = (await response.json()) as {
project: { id: string };
conversationId: string;
};
await page.goto(`/projects/${body.project.id}/conversations/${body.conversationId}`);
}
async function createProjectViaApi(
page: Page,
projectName: string,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
// The Playwright suite fixture waits on daemon `/api/health` before handing
// out a worker. Project create is therefore a single-shot completion signal
// (the HTTP response), not a call-site retry loop over an unknown race.
const response = await page.request.post('/api/projects', {
timeout: 15_000,
...(options.headers ? { headers: { ...options.headers } } : {}),
data: {
id: `project-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: projectName,
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(
response.ok(),
`create project "${projectName}": ${await response.text()}`,
).toBeTruthy();
return response;
}
async function seedProjectWithAssistantCompletion(
page: Page,
): Promise<{
projectId: string;
conversationId: string;
assistantMessageId: string;
assistantText: string;
}> {
const projectId = `assistant-actions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Assistant Completion Actions',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const fileResponse = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: 'index.html',
content: '<!doctype html><html><body><main><h1>Assistant actions preview</h1></main></body></html>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'index.html',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
},
});
expect(fileResponse.ok(), `seed index.html: ${await fileResponse.text()}`).toBeTruthy();
const createdAt = Date.now() - 2_000;
const userMessageId = `u-${projectId}`;
const userResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${userMessageId}`,
{
data: {
id: userMessageId,
role: 'user',
content: 'Create a tiny prototype.',
createdAt,
},
},
);
expect(userResponse.ok(), `seed user message: ${await userResponse.text()}`).toBeTruthy();
const assistantMessageId = `a-${projectId}`;
const assistantText = 'Assistant completion actions fixture.\n\nGenerated `index.html` for this turn.';
const assistantResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
data: {
id: assistantMessageId,
role: 'assistant',
content: assistantText,
runStatus: 'succeeded',
startedAt: createdAt + 500,
endedAt: createdAt + 1_500,
events: [
{ kind: 'text', text: assistantText },
],
createdAt: createdAt + 1_000,
},
},
);
expect(assistantResponse.ok(), `seed assistant message: ${await assistantResponse.text()}`).toBeTruthy();
return { projectId, conversationId, assistantMessageId, assistantText };
}
async function seedProjectWithLargeAssistantHistory(
page: Page,
): Promise<{
projectId: string;
conversationId: string;
expectedContents: string[];
}> {
const projectId = `assistant-large-fork-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Large Conversation Fork',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const expectedContents: string[] = [];
for (let index = 1; index <= 3; index += 1) {
const userMessageId = `large-user-${index}`;
const userContent = `Large fork request ${index}`;
const userResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${userMessageId}`,
{
data: {
id: userMessageId,
role: 'user',
content: userContent,
createdAt: Date.now() + index * 2,
},
},
);
expect(userResponse.ok(), `seed user ${index}: ${await userResponse.text()}`).toBeTruthy();
expectedContents.push(userContent);
const assistantMessageId = `large-assistant-${index}`;
const assistantContent = `Large fork point ${index}`;
const assistantResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
data: {
id: assistantMessageId,
role: 'assistant',
content: assistantContent,
runStatus: 'succeeded',
events: [{ kind: 'raw', line: 'x'.repeat(1_500_000) }],
createdAt: Date.now() + index * 2 + 1,
},
},
);
expect(
assistantResponse.ok(),
`seed assistant ${index}: ${await assistantResponse.text()}`,
).toBeTruthy();
expectedContents.push(assistantContent);
}
return { projectId, conversationId, expectedContents };
}
type ConversationHistoryFixture = {
id: string;
projectId: string;
title: string | null;
sessionMode: 'design' | 'ask' | 'plan';
messageCount: number;
createdAt: number;
updatedAt: number;
totalDurationMs?: number;
latestRun?: {
status: 'succeeded' | 'failed' | 'canceled';
durationMs?: number;
};
};
async function seedProjectConversationHistory(
page: Page,
): Promise<{
projectId: string;
conversations: ConversationHistoryFixture[];
}> {
const projectId = `conversation-history-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Conversation History Coverage',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const now = Date.now();
return {
projectId,
conversations: [
{
id: conversationId,
projectId,
title: 'Runway final polish',
sessionMode: 'design',
messageCount: 8,
createdAt: now - 90 * 60_000,
updatedAt: now - 30_000,
totalDurationMs: 342_000,
latestRun: {
status: 'succeeded',
durationMs: 330_000,
},
},
{
id: 'conv-font-audit',
projectId,
title: 'Font audit and brand pass',
sessionMode: 'design',
messageCount: 6,
createdAt: now - 80 * 60_000,
updatedAt: now - 2 * 60_000,
latestRun: {
status: 'succeeded',
durationMs: 1_140_000,
},
},
{
id: 'conv-slide-review',
projectId,
title: 'Slide review baseline',
sessionMode: 'ask',
messageCount: 6,
createdAt: now - 70 * 60_000,
updatedAt: now - 7 * 60_000,
},
],
};
}
async function routeConversationHistoryFixtures(
page: Page,
projectId: string,
initialConversations: ConversationHistoryFixture[],
) {
const conversations = [...initialConversations];
await page.route(`**/api/projects/${projectId}/conversations`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { conversations } });
return;
}
if (route.request().method() === 'POST') {
const now = Date.now();
const fresh: ConversationHistoryFixture = {
id: 'conv-new-history',
projectId,
title: null,
sessionMode: 'design',
messageCount: 0,
createdAt: now,
updatedAt: now,
};
conversations.unshift(fresh);
await route.fulfill({ json: { conversation: fresh } });
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/conversations/*/messages`, async (route) => {
if (route.request().method() === 'GET') {
const conversationId = conversationIdFromMessagesApiPath(route.request().url());
const conversation = conversations.find((item) => item.id === conversationId);
const count = conversation?.messageCount ?? 0;
await route.fulfill({
json: {
messages: Array.from({ length: count }, (_, index) => ({
id: `${conversationId}-m-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `Conversation ${conversationId} message ${index + 1}`,
createdAt: (conversation?.createdAt ?? Date.now()) + index,
})),
},
});
return;
}
await route.continue();
});
}
function conversationIdFromMessagesApiPath(url: string): string {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/conversations\/([^/]+)\/messages$/);
return match ? decodeURIComponent(match[1]!) : '';
}
async function openNewProjectPanel(page: Page) {
await openNewProjectModal(page);
}
async function expectDesignsView(page: Page) {
if (!/\/projects$/.test(new URL(page.url()).pathname)) {
// The rail's Projects destination went away in #5517; /projects is still a
// real route (Home's recent-projects "view all" is the in-product entry).
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
}
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator('.design-grid, .design-kanban-board')).toBeVisible();
}
/**
* Opens the composer's agent/model popover.
*
* The popover is a one-decision surface: pick the model for the agent that is
* already active. Which CLI agent runs, the execution mode, PATH rescan and
* reasoning effort are configuration and live in Settings → Execution, so
* tests that need a different agent/mode seed it into the stored config rather
* than clicking through this popover.
*/
async function openComposerAgentMenu(page: Page): Promise<{ menu: Locator }> {
const composer = page.getByTestId('chat-composer');
await expect(composer).toBeVisible();
const trigger = composer.locator('.avatar-menu .avatar-agent-trigger');
await expect(trigger).toBeVisible();
await trigger.click();
const menu = page.locator('.avatar-popover[role="dialog"]');
await expect(menu).toBeVisible();
return { menu };
}
/** Picks a model from the popover's always-expanded radio list. */
async function pickComposerModel(page: Page, name: RegExp): Promise<void> {
const { menu } = await openComposerAgentMenu(page);
const list = menu.getByTestId('avatar-model-list');
await expect(list).toBeVisible({ timeout: 20_000 });
await list.getByRole('radio', { name }).click();
// Selecting a model dismisses the popover.
await expect(page.locator('.avatar-popover[role="dialog"]')).toHaveCount(0);
}
async function selectAvatarModelOption(
page: Page,
modelSelect: Locator,
optionName: RegExp,
) {
await expect(modelSelect).toBeVisible();
const option = page.getByRole('option', { name: optionName });
for (let attempt = 0; attempt < 2; attempt += 1) {
await modelSelect.click();
if (await option.isVisible({ timeout: 2_000 }).catch(() => false)) break;
await page.keyboard.press('Escape').catch(() => {});
}
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click();
}
async function selectComposerSessionMode(page: Page, modeTitle: 'Ask mode' | 'Plan mode' | 'Design mode') {
// #5517 composer mode picker: Ask maps to the real `chat` session mode.
const modeId = modeTitle === 'Ask mode' ? 'chat' : modeTitle === 'Plan mode' ? 'plan' : 'design';
const modeName = modeTitle.replace(' mode', '');
const trigger = page.getByTestId('chat-composer').getByTestId('composer-mode-trigger');
await expect(trigger).toBeVisible();
await trigger.click();
const menu = page.getByTestId('composer-mode-menu');
await expect(menu).toBeVisible();
await expect(menu.getByTestId('composer-mode-menu-chat')).toBeVisible();
await expect(menu.getByTestId('composer-mode-menu-plan')).toBeVisible();
await expect(menu.getByTestId('composer-mode-menu-design')).toBeVisible();
await menu.getByTestId(`composer-mode-menu-${modeId}`).click();
await expect(trigger).toHaveAttribute('aria-label', `Mode: ${modeName}`);
}
async function routeComposerPlusFixtures(page: Page) {
await page.route('**/api/connectors', async (route) => {
await route.fulfill({
json: {
connectors: [
{
id: 'figma',
name: 'Figma Connector',
provider: 'Composio',
category: 'Design',
status: 'connected',
tools: [],
},
],
},
});
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({
json: {
statuses: {
figma: { status: 'connected', accountLabel: 'Design Team' },
},
},
});
});
await page.route('**/api/connectors/discovery**', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/plugins', async (route) => {
await route.fulfill({ json: { plugins: [COMPOSER_PLUS_PLUGIN] } });
});
await page.route('**/api/mcp/servers', async (route) => {
await route.fulfill({
json: {
servers: [
{
id: 'design-docs',
label: 'Design Docs MCP',
transport: 'stdio',
enabled: true,
command: 'npx',
},
],
templates: [],
},
});
});
}
async function expectWorkspaceReady(page: Page) {
await expect(page).toHaveURL(/\/projects\//);
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long }).catch(() => {});
await dismissPrivacyDialog(page);
await expect(page.getByTestId('project-title')).toBeVisible();
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
await expect(page.locator('.chat-loading-state')).toHaveCount(0, { timeout: T.medium });
await expect(page.getByTestId('file-workspace')).toBeVisible();
}
async function routeHandoffEditors(page: Page): Promise<void> {
await page.route('**/api/editors', async (route) => {
await route.fulfill({
json: {
platform: 'darwin',
editors: [
{
id: 'cursor',
label: 'Cursor',
icon: 'cursor',
available: true,
resolvedPath: '/Applications/Cursor.app',
platforms: ['darwin', 'win32', 'linux'],
},
{
id: 'finder',
label: 'Finder',
icon: 'finder',
available: true,
resolvedPath: '/System/Library/CoreServices/Finder.app',
platforms: ['darwin'],
},
],
},
});
});
}
async function openHandoffCliTab(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Share', exact: true }).click();
const unifiedPopover = page.locator('.chrome-unified-popover:visible');
await unifiedPopover.getByRole('tab', { name: 'Send to...' }).click();
const menu = unifiedPopover.getByTestId('handoff-menu');
await expect(menu).toBeVisible();
await menu.getByRole('tab', { name: /^Copy for CLI$/ }).click();
return menu;
}
async function dismissPrivacyDialog(page: Page) {
const privacyRegion = page.getByRole('region', { name: /Help us improve OpenDesign/i });
if (await privacyRegion.isVisible().catch(() => false)) {
await privacyRegion.getByRole('button', { name: /I get it|not now|got it/i }).click();
await expect(privacyRegion).toBeHidden();
}
}
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 uploadTinyHtml(
page: Page,
name: string,
content: string,
options: { headers?: Readonly<Record<string, string>> } = {},
): Promise<string> {
await page.getByTestId('design-files-upload-input').setInputFiles({
name,
mimeType: 'text/html',
buffer: Buffer.from(content),
});
const { projectId } = getProjectContextFromUrl(page);
let uploadedName = '';
await expect
.poll(async () => {
const files = await listProjectFiles(page, projectId, options);
uploadedName = files.find((file) => file.name.endsWith(name))?.name ?? '';
return uploadedName;
})
.not.toBe('');
await expect(tabBySuffix(page, uploadedName)).toBeVisible();
return uploadedName;
}
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;
}
async function openUploadedHtmlArtifactPreview(page: Page, uploadedName: string) {
await openAllProjectFiles(page);
const fileRow = rowByFileName(page, uploadedName);
await expect(fileRow).toBeVisible();
// #5517 deleted the preview card and its Open button: the row's primary
// target opens the artifact in a workspace tab on a single click.
await fileRow.getByRole('button').first().click();
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
}
function tabBySuffix(page: Page, name: string): Locator {
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}(?:\\s+Close tab)?$`, '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 seedAdoptedPet(page: Page) {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: { codex: { model: 'default' } },
pet: {
adopted: true,
enabled: true,
petId: 'custom',
custom: {
name: 'Original Buddy',
glyph: '🦄',
accent: '#c96442',
greeting: 'Ready to pair.',
},
},
}),
);
}, STORAGE_KEY);
}
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,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
const response = await page.request.get(
`/api/projects/${projectId}/files`,
options.headers ? { headers: { ...options.headers } } : undefined,
);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { files: Array<{ name: string }> };
return body.files;
}
async function mockWritablePersonalProjectScope(page: Page) {
await mockAmrPersonalWorkspace(page);
await page.route('**/api/projects/*/workspace-scope', async (route) => {
const projectId = getProjectIdFromApiPath(route.request().url());
await route.fulfill({
json: {
scope: {
kind: 'personal',
projectId,
workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,
visibility: 'personal',
context: AMR_PERSONAL_WORKSPACE_CONTEXT,
},
},
});
});
}
function isCreateProjectRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/projects' && request.method() === 'POST';
}
function getProjectContextFromUrl(page: Page) {
const url = new URL(page.url());
const [, projectId, conversationId] = url.pathname.match(
/\/projects\/([^/]+)(?:\/conversations\/([^/]+))?/,
) ?? [];
if (!projectId) throw new Error(`unexpected project route: ${url.pathname}`);
return { projectId, conversationId };
}
function getProjectIdFromApiPath(rawUrl: string) {
const url = new URL(rawUrl);
const [, projectId] = url.pathname.match(/\/api\/projects\/([^/]+)/) ?? [];
if (!projectId) throw new Error(`unexpected project api path: ${url.pathname}`);
return projectId;
}
// Share opens straight onto the link/asset-shaped rows — share link, share
// page, deploy targets, save-as-template. These used to live under the old
// popover's "Export" tab; the split moved them to Share and left Export as a
// pure file-format menu, so the callers below take the Share door now. The
// popover shell is still shared between the two, so the locator is unchanged.
async function openShareMenu(page: Page) {
await page.getByRole('button', { name: /^Share$/i }).click();
const menu = page.locator('.share-menu-popover[role="menu"]');
await expect(menu).toBeVisible();
return menu;
}
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,
};
}
function skillSummary(
id: string,
name: string,
mode: 'prototype' | 'deck' | 'image',
surface: 'web' | 'image',
defaultFor: string[],
) {
return {
id,
name,
description: `${name} for tab switching coverage.`,
triggers: [],
mode,
surface,
platform: 'desktop',
scenario: 'qa',
previewType: 'html',
designSystemRequired: mode !== 'image',
defaultFor,
upstream: null,
featured: null,
fidelity: null,
speakerNotes: null,
animations: null,
hasBody: true,
examplePrompt: '',
};
}