Merge origin/preview/v0.8.0 (PR #1830) into sync branch

Pulls in the teammate's PR #1830 — e2e align to redesigned entry flows
+ watcher and codex runtime test fixes. Conflicts resolved:

- apps/daemon/tests/runtimes/registry-and-args.test.ts: imported both
  withPlatform (HEAD, used 6x) and withEnvSnapshot (theirs, used 1x);
  both helpers are needed in the file.
- apps/web/src/components/ProjectView.tsx: kept both ChatPane-remount
  guards. Main's fix #1710 uses lastSyncedConversationIdRef tracking URL
  sync; the teammate added lastSeenRouteConversationIdRef tracking last
  observed route. Both refs are already defined in the file and the two
  checks are complementary defenses.
- e2e/ui/settings-*.test.ts (5 files): took teammate's version wholesale
  — they replaced inline `page.goto('/') + click Execution mode` with
  `gotoEntryHome + openSettingsDialogFromEntry` helpers, which is exactly
  the PR's intent.
This commit is contained in:
lefarcen 2026-05-15 19:10:39 +08:00
commit 91195bc4ac
21 changed files with 943 additions and 314 deletions

View file

@ -66,9 +66,19 @@ export const DEFAULT_AWAIT_WRITE_FINISH = {
};
const registry = new Map<string, WatcherEntry>();
const PREFERS_POLLING_IN_TESTS = process.env.NODE_ENV === 'test';
function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>): WatcherEntry {
const watcher = chokidar.watch(dir, {
function isPollingFallbackError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'EMFILE' || code === 'ENOSPC';
}
function createWatcher(
dir: string,
opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>,
usePolling: boolean,
): FSWatcher {
const watcherOptions = {
ignored: opts.ignored,
ignoreInitial: true,
awaitWriteFinish: opts.awaitWriteFinish,
@ -77,30 +87,32 @@ function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'igno
// path ignore predicate keeps emitted events project-scoped, an unhandled
// symlink would still cost descriptors and surface external FS activity.
followSymlinks: false,
});
// chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
// transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
// would surface as unhandled exceptions and could crash the daemon — taking
// every other route down with it. Log and keep the watcher alive; refcount
// cleanup is unaffected.
watcher.on('error', (err) => {
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] chokidar error in', dir, err);
}
});
usePolling,
...(usePolling ? { interval: 100, binaryInterval: 300 } : {}),
};
return chokidar.watch(dir, watcherOptions);
}
function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>): WatcherEntry {
let resolveReady: () => void;
const ready = new Promise<void>((resolve) => { resolveReady = resolve; });
watcher.once('ready', () => resolveReady());
let readyResolved = false;
const subscribers = new Set<ProjectWatchCallback>();
const entry: WatcherEntry = {
dir,
watcher,
watcher: createWatcher(dir, opts, PREFERS_POLLING_IN_TESTS),
ready,
subscribers: new Set(),
subscribers,
closing: null,
};
let usingPollingFallback = PREFERS_POLLING_IN_TESTS;
let switchingToPolling = false;
const resolveReadyOnce = () => {
if (readyResolved) return;
readyResolved = true;
resolveReady();
};
const broadcast = (kind: ProjectWatchKind) => (absPath: string) => {
const rel = path.relative(dir, absPath);
@ -119,9 +131,35 @@ function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'igno
}
};
watcher.on('add', broadcast('add'));
watcher.on('change', broadcast('change'));
watcher.on('unlink', broadcast('unlink'));
const attachWatcher = (watcher: FSWatcher) => {
watcher.once('ready', () => resolveReadyOnce());
watcher.on('add', broadcast('add'));
watcher.on('change', broadcast('change'));
watcher.on('unlink', broadcast('unlink'));
// chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
// transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
// would surface as unhandled exceptions and could crash the daemon.
watcher.on('error', (err) => {
if (isPollingFallbackError(err) && !usingPollingFallback && !switchingToPolling) {
switchingToPolling = true;
const next = createWatcher(dir, opts, true);
usingPollingFallback = true;
entry.watcher = next;
attachWatcher(next);
void watcher.close().catch(() => {});
switchingToPolling = false;
return;
}
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] chokidar error in', dir, err);
}
// A watcher that fails before it reaches ready would otherwise hang every
// caller awaiting `sub.ready`.
resolveReadyOnce();
});
};
attachWatcher(entry.watcher);
return entry;
}
@ -152,8 +190,8 @@ export function subscribe(projectsRoot: string, projectId: string, onEvent: Proj
if (!entry) {
const factory = opts._watcherFactory || makeEntry;
entry = factory(dir, {
ignored: opts.ignored || makeIgnored(dir),
awaitWriteFinish: opts.awaitWriteFinish || DEFAULT_AWAIT_WRITE_FINISH,
ignored: opts.ignored ?? makeIgnored(dir),
awaitWriteFinish: opts.awaitWriteFinish ?? DEFAULT_AWAIT_WRITE_FINISH,
});
registry.set(key, entry);
}

View file

@ -1,6 +1,6 @@
import { test } from 'vitest';
import {
AGENT_DEFS, assert, chmodSync, codex, detectAgents, join, mkdtempSync, rmSync, tmpdir, withPlatform, writeFileSync,
AGENT_DEFS, assert, chmodSync, codex, detectAgents, join, mkdtempSync, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
test('AGENT_DEFS ids are unique', () => {
@ -138,22 +138,24 @@ test('codex model picker includes current OpenAI choices in priority order', asy
const dir = mkdtempSync(join(tmpdir(), 'od-agents-codex-models-'));
try {
const codexBin = join(dir, 'codex');
writeFileSync(
codexBin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex 1.0.0"; exit 0; fi\nexit 0\n',
);
chmodSync(codexBin, 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const codexBin = join(dir, 'codex');
writeFileSync(
codexBin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex 1.0.0"; exit 0; fi\nexit 0\n',
);
chmodSync(codexBin, 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.version, 'codex 1.0.0');
assert.deepEqual(detected.models.map((m: { id: string }) => m.id), expectedModels);
assert.ok(detected);
assert.equal(detected.available, true);
assert.equal(detected.version, 'codex 1.0.0');
assert.deepEqual(detected.models.map((m: { id: string }) => m.id), expectedModels);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}

View file

@ -572,7 +572,10 @@ export function ProjectView({
// active id. Falls through to a no-op for stale / missing routes so
// the default picker above keeps its result.
useEffect(() => {
if (!routeConversationId) return;
if (!routeConversationId) {
lastSeenRouteConversationIdRef.current = null;
return;
}
if (conversations.length === 0) return;
if (routeConversationId === activeConversationId) return;
// When the route still points at the conversation this view last
@ -582,8 +585,11 @@ export function ProjectView({
// route here would fight that sync and remount ChatPane in a loop,
// so only react to a genuinely external navigation.
if (routeConversationId === lastSyncedConversationIdRef.current) return;
if (lastSeenRouteConversationIdRef.current === routeConversationId) return;
lastSeenRouteConversationIdRef.current = routeConversationId;
const match = conversations.find((c) => c.id === routeConversationId);
if (match) setActiveConversationId(match.id);
if (!match) return;
setActiveConversationId(routeConversationId);
}, [routeConversationId, conversations, activeConversationId]);
useEffect(() => {
@ -922,6 +928,7 @@ export function ProjectView({
// target lost that update because the early-return saw `target` unchanged
// and skipped the navigate (lefarcen P1 on PR #1508).
const lastSyncedRouteKeyRef = useRef<string | null>(null);
const lastSeenRouteConversationIdRef = useRef<string | null>(null);
useEffect(() => {
const target = openTabsState.active && (
openTabsState.tabs.includes(openTabsState.active)

View file

@ -43,7 +43,7 @@ export interface UiScenario {
tab?: 'prototype' | 'deck' | 'template' | 'other';
railChip?: 'prototype' | 'deck' | 'hyperframes';
expectedProjectKind?: 'prototype' | 'deck' | 'video';
expectedPluginId?: 'od-new-generation' | 'example-hyperframes';
expectedPluginId?: 'example-web-prototype' | 'example-simple-deck' | 'example-hyperframes';
};
prompt: string;
secondaryPrompt?: string;

View file

@ -64,7 +64,7 @@ export const playwrightUiScenarios: UiScenario[] = [
projectName: 'Rail prototype project',
railChip: 'prototype',
expectedProjectKind: 'prototype',
expectedPluginId: 'od-new-generation',
expectedPluginId: 'example-web-prototype',
},
prompt: 'Create a compact onboarding prototype from the Home rail prototype chip',
mockArtifact: {
@ -92,7 +92,7 @@ export const playwrightUiScenarios: UiScenario[] = [
projectName: 'Rail slide deck project',
railChip: 'deck',
expectedProjectKind: 'deck',
expectedPluginId: 'od-new-generation',
expectedPluginId: 'example-simple-deck',
},
prompt: 'Create a three-slide strategy deck from the Home rail slide deck chip',
mockArtifact: {

View file

@ -3,6 +3,8 @@ import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: 30_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
@ -18,9 +20,31 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: null,
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
test('API empty stream shows No output instead of Done', async ({ page }) => {
@ -35,7 +59,7 @@ test('API empty stream shows No output instead of Done', async ({ page }) => {
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'API empty response smoke');
await expectWorkspaceReady(page);
await sendPrompt(page, 'Create a login page');
@ -46,31 +70,56 @@ test('API empty stream shows No output instead of Done', async ({ page }) => {
});
async function createProject(page: Page, name: string) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function expectWorkspaceReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();
}
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
const streamResponse = page.waitForResponse(
(response) => {
const url = new URL(response.url());
return url.pathname === '/api/proxy/openai/stream' && response.request().method() === 'POST';
},
{ timeout: 5_000 },
);
await expect(input).toBeVisible({ timeout: 3_000 });
await input.fill(prompt);
await expect(sendButton).toBeEnabled();
await sendButton.click();
await streamResponse;
await Promise.all([
page.waitForResponse(
(response) => {
const url = new URL(response.url());
return url.pathname === '/api/proxy/openai/stream' && response.request().method() === 'POST';
},
{ timeout: 10_000 },
),
sendButton.click(),
]);
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}

View file

@ -5,6 +5,8 @@ import type { UiScenario } from '@/playwright/resources';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: 30_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
@ -19,9 +21,31 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
const designFileFlows = new Set([
@ -51,7 +75,7 @@ for (const entry of automatedUiScenarios().filter((scenario) => designFileFlows.
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
@ -83,14 +107,32 @@ async function createProject(page: Page, entry: UiScenario) {
}
async function createProjectNameOnly(page: Page, entry: UiScenario) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await openNewProjectModal(page);
if (entry.create.tab) {
await page.getByTestId(`new-project-tab-${entry.create.tab}`).click();
}
await page.getByTestId('new-project-name').fill(entry.create.projectName);
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function expectWorkspaceReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
@ -125,40 +167,62 @@ async function seedProjectFile(
encoding?: 'base64',
artifactManifest?: Record<string, unknown>,
) {
const response = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name,
content,
...(encoding ? { encoding } : {}),
...(artifactManifest ? { artifactManifest } : {}),
const response = await page.request.post(
`/api/projects/${projectId}/files`,
{
data: {
name,
content,
...(encoding ? { encoding } : {}),
...(artifactManifest ? { artifactManifest } : {}),
},
timeout: 15_000,
},
});
);
expect(response.ok()).toBeTruthy();
}
async function seedHtmlArtifact(page: Page, projectId: string, fileName: string, content: string) {
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
const resp = await page.request.post(
`/api/projects/${projectId}/files`,
{
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
timeout: 15_000,
},
});
);
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Page, fileName: string) {
const preview = page.getByTestId('artifact-preview-frame');
if (await preview.isVisible().catch(() => false)) return;
const fileTab = page.getByRole('tab', { name: new RegExp(fileName.replace('.', '\\.'), 'i') });
if (await fileTab.isVisible().catch(() => false)) {
await fileTab.click();
return;
}
await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}
async function runUploadedImageRendersInPreviewFlow(page: Page) {
const { projectId } = await getCurrentProjectContext(page);
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=';
@ -286,11 +350,23 @@ async function runDesignFilesTabPersistenceFlow(page: Page) {
await page.reload();
const restoredFirstTab = page.getByRole('tab', { name: /first-tab\.png/i });
const restoredSecondTab = page.getByRole('tab', { name: /second-tab\.png/i });
await expect(restoredFirstTab).toBeVisible();
await expect(restoredSecondTab).toBeVisible();
await expect(restoredFirstTab).toHaveAttribute('aria-selected', 'true');
await expect(restoredSecondTab).toHaveAttribute('aria-selected', 'false');
// The refreshed workspace restores the active file tab, while other project files
// remain available from the Design Files list until the user reopens them.
await page.getByTestId('design-files-tab').click();
const secondFileRow = page.locator('[data-testid^="design-file-row-"]', {
hasText: 'second-tab.png',
});
await expect(secondFileRow).toBeVisible();
await secondFileRow.getByRole('button').first().click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
const restoredSecondTab = page.getByRole('tab', { name: /second-tab\.png/i });
await expect(restoredSecondTab).toBeVisible();
await expect(restoredSecondTab).toHaveAttribute('aria-selected', 'true');
await expect(restoredFirstTab).toHaveAttribute('aria-selected', 'false');
}
function homeDesignCard(page: Page, name: string): Locator {

View file

@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: 15_000 });
test.describe.configure({ timeout: 30_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
@ -19,9 +19,31 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
test('manual edit inspector previews and persists page and selected element styles', async ({ page }) => {
@ -200,10 +222,11 @@ async function routeMockAgents(page: Page) {
}
async function createEmptyProject(page: Page, name: string): Promise<string> {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await gotoEntryHome(page);
await openNewProjectModal(page);
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
@ -211,29 +234,72 @@ async function createEmptyProject(page: Page, name: string): Promise<string> {
return projectId;
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function seedHtmlArtifact(page: Page, projectId: string, fileName: string, content: string) {
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
const resp = await page.request.post(
`/api/projects/${projectId}/files`,
{
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
timeout: 15_000,
},
});
);
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Page, fileName: string) {
await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click();
const preview = page.getByTestId('artifact-preview-frame');
if (await preview.isVisible().catch(() => false)) {
return;
}
const fileTab = page.getByRole('tab', { name: new RegExp(fileName.replace('.', '\\.'), 'i') });
if (
await fileTab
.waitFor({ state: 'visible', timeout: 2_000 })
.then(() => true)
.catch(() => false)
) {
await fileTab.click();
return;
}
const fileButton = page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.'), 'i') });
await fileButton.click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}
async function expectFileSource(page: Page, projectId: string, fileName: string, snippets: string[]) {
await expect
.poll(async () => {

View file

@ -5,7 +5,7 @@ import type { UiScenario } from '@/playwright/resources';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: 15_000 });
test.describe.configure({ timeout: 30_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
@ -21,9 +21,31 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
test('workspace restores the last manually selected file tab after reload instead of jumping back to the generated artifact', async ({ page }) => {
await page.route('**/api/agents', async (route) => {
@ -79,9 +101,8 @@ test('workspace restores the last manually selected file tab after reload instea
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Workspace active tab restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Workspace active tab restore');
await expectWorkspaceReady(page);
await sendPrompt(page, 'Create a workspace persistence artifact');
@ -109,12 +130,28 @@ test('workspace restores the last manually selected file tab after reload instea
await page.reload();
await expect(page.getByTestId('file-workspace')).toBeVisible();
const restoredArtifactTab = page.getByRole('tab', { name: /workspace-artifact\.html/i });
const restoredManualFileTab = tabBySuffix(page, 'manual-reference.png');
await expect(restoredArtifactTab).toBeVisible();
await expect(restoredManualFileTab).toBeVisible();
await expect(restoredManualFileTab).toHaveAttribute('aria-selected', 'true');
const restoredArtifactTab = page.getByRole('tab', { name: /workspace-artifact\.html/i });
if ((await restoredArtifactTab.count()) === 0) {
await page.getByTestId('design-files-tab').click();
const artifactFileRow = page.locator('[data-testid^="design-file-row-"]', {
hasText: 'workspace-artifact.html',
});
await expect(artifactFileRow).toBeVisible();
await artifactFileRow.getByRole('button').first().click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
await expect(restoredArtifactTab).toBeVisible();
await expect(restoredArtifactTab).toHaveAttribute('aria-selected', 'true');
await expect(restoredManualFileTab).toHaveAttribute('aria-selected', 'false');
return;
}
await expect(restoredArtifactTab).toBeVisible();
await expect(restoredArtifactTab).toHaveAttribute('aria-selected', 'false');
await expect(restoredManualFileTab).toHaveAttribute('aria-selected', 'true');
});
test('switching between projects restores each project workspace to its last active file tab', async ({ page }) => {
@ -142,10 +179,8 @@ test('switching between projects restores each project workspace to its last act
const alphaName = `Workspace Alpha ${Date.now()}`;
const betaName = `Workspace Beta ${Date.now()}`;
await page.goto('/');
await page.getByTestId('new-project-name').fill(alphaName);
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, alphaName);
await expectWorkspaceReady(page);
const alphaPrimaryUpload = page.waitForResponse(
@ -178,10 +213,9 @@ test('switching between projects restores each project workspace to its last act
await expect(alphaSecondaryTab).toHaveAttribute('aria-selected', 'false');
await page.getByRole('button', { name: /back to projects/i }).click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expectProjectsView(page);
await page.getByTestId('new-project-name').fill(betaName);
await page.getByTestId('create-project').click();
await createPrototypeProject(page, betaName);
await expectWorkspaceReady(page);
const betaPrimaryUpload = page.waitForResponse(
@ -214,7 +248,7 @@ test('switching between projects restores each project workspace to its last act
await expect(betaSecondaryTab).toHaveAttribute('aria-selected', 'false');
await page.getByRole('button', { name: /back to projects/i }).click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expectProjectsView(page);
await homeDesignCard(page, alphaName).click();
await expectWorkspaceReady(page);
@ -222,7 +256,7 @@ test('switching between projects restores each project workspace to its last act
await expect(tabBySuffix(page, 'alpha-secondary.png')).toHaveAttribute('aria-selected', 'false');
await page.getByRole('button', { name: /back to projects/i }).click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expectProjectsView(page);
await homeDesignCard(page, betaName).click();
await expectWorkspaceReady(page);
@ -248,9 +282,8 @@ test('visiting an uploaded design file route restores its tab and file workspace
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Uploaded file deep link');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Uploaded file deep link');
await expectWorkspaceReady(page);
await page.getByTestId('design-files-upload-input').setInputFiles({
@ -279,7 +312,7 @@ test('visiting an uploaded design file route restores its tab and file workspace
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}/files/deep-linked-reference.png`);
await gotoProjectRoute(page, `/projects/${projectId}/files/deep-linked-reference.png`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
@ -304,9 +337,8 @@ test('returning from an uploaded design file route to the project root keeps the
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Uploaded file root route restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Uploaded file root route restore');
await expectWorkspaceReady(page);
await page.getByTestId('design-files-upload-input').setInputFiles({
@ -334,9 +366,9 @@ test('returning from an uploaded design file route to the project root keeps the
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}/files/root-design-reference.png`);
await gotoProjectRoute(page, `/projects/${projectId}/files/root-design-reference.png`);
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
@ -397,9 +429,8 @@ test('returning from an artifact file route to the project root keeps the artifa
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Artifact root route restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Artifact root route restore');
await expectWorkspaceReady(page);
await sendPrompt(page, 'Create the artifact that should survive a root-route hop');
@ -418,7 +449,7 @@ test('returning from an artifact file route to the project root keeps the artifa
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(artifactTab).toHaveAttribute('aria-selected', 'true');
@ -477,9 +508,8 @@ test('returning from an older conversation route to the project root keeps the c
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation root route restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation root route restore');
await expectWorkspaceReady(page);
const firstPrompt = 'First conversation should stay selected';
@ -497,14 +527,14 @@ test('returning from an older conversation route to the project root keeps the c
const secondContext = await getCurrentProjectContext(page);
expect(secondContext.conversationId).not.toBe(firstContext.conversationId);
await page.goto(`/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
await gotoProjectRoute(page, `/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await page.getByTestId('conversation-history-trigger').click();
const routeHistoryList = page.getByTestId('conversation-list');
await expect(routeHistoryList).toBeVisible();
await expect(routeHistoryList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible();
await page.goto(`/projects/${firstContext.projectId}`);
await gotoProjectRoute(page, `/projects/${firstContext.projectId}`);
await expect(page.getByTestId('chat-composer')).toBeVisible();
});
@ -555,9 +585,8 @@ test('switching between conversations keeps the composer usable while navigating
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation draft restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation draft restore');
await expectWorkspaceReady(page);
const firstPrompt = 'First conversation anchor';
@ -663,9 +692,8 @@ test('reloading an older conversation route keeps the composer visible on that r
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation reload draft restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation reload draft restore');
await expectWorkspaceReady(page);
const firstPrompt = 'Reloaded conversation anchor';
@ -682,7 +710,7 @@ test('reloading an older conversation route keeps the composer visible on that r
await sendPrompt(page, secondPrompt);
await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt }).first()).toBeVisible();
await page.goto(`/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
await gotoProjectRoute(page, `/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
const composerInput = page.getByTestId('chat-composer-input');
await page.getByTestId('conversation-history-trigger').click();
const routeHistoryList = page.getByTestId('conversation-list');
@ -747,9 +775,8 @@ test('switching between conversations keeps staged attachments UI available', as
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation attachment restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation attachment restore');
await expectWorkspaceReady(page);
const firstPrompt = 'Attachment conversation one';
@ -860,9 +887,8 @@ test('reloading an older conversation route keeps the composer available after s
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation attachment reload restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation attachment reload restore');
await expectWorkspaceReady(page);
const firstPrompt = 'Attachment reload conversation one';
@ -878,7 +904,7 @@ test('reloading an older conversation route keeps the composer available after s
await sendPrompt(page, secondPrompt);
await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt }).first()).toBeVisible();
await page.goto(`/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
await gotoProjectRoute(page, `/projects/${firstContext.projectId}/conversations/${firstContext.conversationId}`);
await page.getByTestId('conversation-history-trigger').click();
const routeHistoryList = page.getByTestId('conversation-list');
await expect(routeHistoryList).toBeVisible();
@ -947,9 +973,8 @@ test('reloading the project keeps the latest conversation selected in history',
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation history reload selection');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation history reload selection');
await expectWorkspaceReady(page);
const firstPrompt = 'History selection first conversation';
@ -1028,9 +1053,8 @@ test('deleting the active conversation selects the remaining conversation in his
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation history delete selection');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation history delete selection');
await expectWorkspaceReady(page);
const firstPrompt = 'Delete selection first conversation';
@ -1108,9 +1132,8 @@ test('returning from workspace surfaces keeps the older conversation reachable f
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation history surface restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation history surface restore');
await expectWorkspaceReady(page);
const firstPrompt = 'Surface restore first conversation';
@ -1156,7 +1179,7 @@ test('returning from workspace surfaces keeps the older conversation reachable f
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await page.getByTestId('conversation-history-trigger').click();
@ -1211,9 +1234,8 @@ test('reloading the project root keeps conversation history accessible', async (
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation root reload preserve selection');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation root reload preserve selection');
await expectWorkspaceReady(page);
const firstPrompt = 'Root reload first conversation';
@ -1295,9 +1317,8 @@ test('opening an uploaded file route keeps the older conversation present in his
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation file surface selection');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation file surface selection');
await expectWorkspaceReady(page);
const firstPrompt = 'File surface first conversation';
@ -1340,7 +1361,7 @@ test('opening an uploaded file route keeps the older conversation present in his
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}/files/conversation-surface-reference.png`);
await gotoProjectRoute(page, `/projects/${projectId}/files/conversation-surface-reference.png`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(tabBySuffix(page, 'conversation-surface-reference.png')).toHaveAttribute('aria-selected', 'true');
@ -1404,9 +1425,8 @@ test('opening an artifact file route keeps the older conversation present in his
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation artifact surface selection');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation artifact surface selection');
await expectWorkspaceReady(page);
const firstPrompt = 'Artifact surface first conversation';
@ -1443,7 +1463,7 @@ test('opening an artifact file route keeps the older conversation present in his
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}/files/conversation-surface-artifact.html`);
await gotoProjectRoute(page, `/projects/${projectId}/files/conversation-surface-artifact.html`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(artifactTab).toBeVisible();
@ -1505,9 +1525,8 @@ test('returning from a file deep-link to the project root keeps the chosen file
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation file surface root restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation file surface root restore');
await expectWorkspaceReady(page);
const firstPrompt = 'First conversation should survive file root restore';
@ -1545,13 +1564,13 @@ test('returning from a file deep-link to the project root keeps the chosen file
await expect(page.locator('.msg.user .user-text').filter({ hasText: firstPrompt }).first()).toBeVisible();
await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt })).toHaveCount(0);
await page.goto(`/projects/${projectId}/files/conversation-root-file.png`);
await gotoProjectRoute(page, `/projects/${projectId}/files/conversation-root-file.png`);
const fileTab = tabBySuffix(page, 'conversation-root-file.png');
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false');
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
@ -1612,9 +1631,8 @@ test('returning from an artifact deep-link to the project root keeps the artifac
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Conversation artifact surface root restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Conversation artifact surface root restore');
await expectWorkspaceReady(page);
const firstPrompt = 'First conversation should survive artifact root restore';
@ -1646,7 +1664,7 @@ test('returning from an artifact deep-link to the project root keeps the artifac
await expect(page.locator('.msg.user .user-text').filter({ hasText: firstPrompt }).first()).toBeVisible();
await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt })).toHaveCount(0);
await page.goto(`/projects/${projectId}/files/conversation-root-artifact.html`);
await gotoProjectRoute(page, `/projects/${projectId}/files/conversation-root-artifact.html`);
await expect(artifactTab).toBeVisible();
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
@ -1656,7 +1674,7 @@ test('returning from an artifact deep-link to the project root keeps the artifac
}),
).toBeVisible();
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(artifactTab).toBeVisible();
@ -1720,9 +1738,8 @@ test('a later completed run updates the workspace to the newest artifact tab and
});
});
await page.goto('/');
await page.getByTestId('new-project-name').fill('Workspace latest artifact sync');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Workspace latest artifact sync');
await expectWorkspaceReady(page);
await sendPrompt(page, 'Create the first workspace artifact');
@ -1751,9 +1768,8 @@ test('reloading a project keeps the Design Files entry reachable when it was the
'base64',
);
await page.goto('/');
await page.getByTestId('new-project-name').fill('Workspace design files restore');
await page.getByTestId('create-project').click();
await gotoEntryHome(page);
await createPrototypeProject(page, 'Workspace design files restore');
await expectWorkspaceReady(page);
await page.getByTestId('design-files-upload-input').setInputFiles({
@ -1834,7 +1850,7 @@ test('daemon error details persist between failed sends', async ({ page }) => {
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
@ -1921,7 +1937,7 @@ test('a successful retry after a failed send restores the workspace to a fresh a
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
@ -1962,8 +1978,8 @@ async function routeMockAgents(page: Page) {
}
async function createEmptyProject(page: Page, name: string): Promise<string> {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await gotoEntryHome(page);
await openNewProjectModal(page);
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await expect(page).toHaveURL(/\/projects\//);
@ -2066,6 +2082,7 @@ async function createProject(
}
async function expectWorkspaceReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
@ -2076,35 +2093,33 @@ async function sendPrompt(
page: Page,
prompt: string,
) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
for (let attempt = 0; attempt < 3; attempt++) {
await input.click();
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
await expect(input).toBeVisible({ timeout: 3_000 });
await input.fill(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
isCreateRunResponse,
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
sendButton.evaluate((button: HTMLButtonElement) => button.click()),
]);
return;
} catch (error) {
await input.click();
await input.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`);
await input.press('Backspace');
await input.pressSequentially(prompt);
const retryInput = page.getByTestId('chat-composer-input');
const retrySendButton = page.getByTestId('chat-send');
await expect(retryInput).toBeVisible({ timeout: 3_000 });
await retryInput.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`);
await retryInput.press('Backspace');
await retryInput.pressSequentially(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
isCreateRunResponse,
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
await expect(retryInput).toHaveValue(prompt, { timeout: 1500 });
await expect(retrySendButton).toBeEnabled({ timeout: 1500 });
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
retrySendButton.evaluate((button: HTMLButtonElement) => button.click()),
]);
return;
} catch (retryError) {
if (attempt === 2) throw retryError;
@ -2461,13 +2476,53 @@ async function createProjectNameOnly(
page: Page,
entry: UiScenario,
) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await openNewProjectModal(page);
if (entry.create.tab) {
await page.getByTestId(`new-project-tab-${entry.create.tab}`).click();
}
await page.getByTestId('new-project-name').fill(entry.create.projectName);
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function gotoProjectRoute(page: Page, path: string) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
}
async function createPrototypeProject(page: Page, projectName: string) {
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill(projectName);
await page.getByTestId('create-project').click();
}
async function expectProjectsView(page: Page) {
if ((await page.locator('.tab-panel-toolbar').count()) === 0) {
await page.getByTestId('entry-nav-projects').click();
}
await expect(page.locator('.tab-panel-toolbar')).toBeVisible();
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}
async function getCurrentProjectContext(
page: Page,
): Promise<{ projectId: string; conversationId: string }> {
@ -2593,10 +2648,10 @@ async function runDeepLinkPreviewFlow(
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}`);
await gotoProjectRoute(page, `/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await page.goto(`/projects/${projectId}/files/${fileName}`);
await gotoProjectRoute(page, `/projects/${projectId}/files/${fileName}`);
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: entry.mockArtifact!.heading })).toBeVisible();

View file

@ -11,6 +11,7 @@ const APP_OWNED_SCENARIO_FLOWS = new Set([
'design-files-upload',
'design-files-delete',
'design-files-tab-persistence',
'example-use-prompt',
]);
const QUERY_PLUGIN_MANIFEST = {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
@ -59,6 +60,8 @@ const QUERY_PLUGIN_SKILL = [
'Use this fixture to verify that a user-installed plugin can render a starter query and bind that query to a project run.',
].join('\n');
test.describe.configure({ timeout: 45_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
@ -73,9 +76,31 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
for (const entry of automatedUiScenarios().filter(
@ -288,7 +313,7 @@ for (const entry of automatedUiScenarios().filter(
});
}
await page.goto('/');
await gotoEntryHome(page);
if (entry.flow === 'design-system-selection') {
await runDesignSystemSelectionFlow(page, entry);
@ -530,6 +555,7 @@ async function createProject(
}
async function expectWorkspaceReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
@ -537,6 +563,7 @@ async function expectWorkspaceReady(page: Page) {
}
async function expectProjectShellReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();
@ -546,35 +573,35 @@ async function sendPrompt(
page: Page,
prompt: string,
) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
for (let attempt = 0; attempt < 3; attempt++) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
await expect(input).toBeVisible({ timeout: 3_000 });
await input.click();
await input.fill(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
isCreateRunResponse,
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
sendButton.evaluate((button: HTMLButtonElement) => button.click()),
]);
return;
} catch (error) {
await input.click();
await input.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`);
await input.press('Backspace');
await input.pressSequentially(prompt);
const retryInput = page.getByTestId('chat-composer-input');
const retrySendButton = page.getByTestId('chat-send');
await expect(retryInput).toBeVisible({ timeout: 3_000 });
await retryInput.click();
await retryInput.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`);
await retryInput.press('Backspace');
await retryInput.pressSequentially(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
isCreateRunResponse,
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
await expect(retryInput).toHaveValue(prompt, { timeout: 1500 });
await expect(retrySendButton).toBeEnabled({ timeout: 1500 });
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
retrySendButton.evaluate((button: HTMLButtonElement) => button.click()),
]);
return;
} catch (retryError) {
if (attempt === 2) throw retryError;
@ -624,8 +651,14 @@ async function runExampleUsePromptFlow(
page: Page,
entry: UiScenario,
) {
await page.getByTestId('entry-tab-templates').click();
await expect(page.getByTestId('example-card-warm-utility-example')).toBeVisible();
const exampleCard = page.getByTestId('example-card-warm-utility-example');
if ((await exampleCard.count()) === 0) {
const examplesTab = page.getByTestId('entry-tab-examples');
if ((await examplesTab.count()) > 0) {
await examplesTab.click();
}
}
await expect(exampleCard).toBeVisible();
await page.getByTestId('example-use-prompt-warm-utility-example').click();
await expect(page).toHaveURL(/\/projects\//);
@ -723,13 +756,13 @@ async function runPluginCreateImportFlow(
await page.getByTestId('plugins-create-button').click();
const homeInput = page.getByTestId('home-hero-input');
await expect(homeInput).toHaveValue(/Create an Open Design plugin/);
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Plugin authoring');
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Create plugin');
await page.getByTestId('entry-nav-plugins').click();
await expect(page.getByTestId('plugins-import-button')).toBeVisible();
await page.getByTestId('plugins-import-button').click();
const dialog = page.getByRole('dialog', { name: 'Create or import a plugin' });
const dialog = page.getByRole('dialog', { name: 'Import a plugin' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: /From GitHub/i })).toBeVisible();
@ -758,8 +791,8 @@ async function runPluginCreateImportFlow(
await expect(queryOption).toBeEnabled();
await queryOption.click();
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Query Plugin');
await expect(homeInput).toHaveValue('Generate a release QA brief for general.');
await expect(page.getByRole('button', { name: '@Query Plugin' }).first()).toBeVisible();
await expect(homeInput).toHaveValue(/@Query Plugin/);
await homeInput.fill(entry.prompt);
await expect(page.getByTestId('home-hero-submit')).toBeEnabled();
@ -773,7 +806,6 @@ async function runPluginCreateImportFlow(
pendingPrompt?: string;
metadata?: { kind?: string };
};
expect(projectBody.pluginId).toBe('query-plugin');
expect(projectBody.pendingPrompt).toBe(entry.prompt);
expect(projectBody.metadata?.kind).toBe('other');
@ -782,7 +814,7 @@ async function runPluginCreateImportFlow(
const runRequest = await runRequestPromise;
const runBody = runRequest.postDataJSON() as { message?: string };
expect(runBody.message).toContain(entry.prompt);
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
await expect(page.locator('.msg.user .user-text').filter({ hasText: entry.prompt }).first()).toBeVisible();
} finally {
await rm(queryPluginFixture, { recursive: true, force: true });
}
@ -1030,6 +1062,7 @@ async function createProjectNameOnly(
page: Page,
entry: UiScenario,
) {
await openNewProjectModal(page);
await expect(page.getByTestId('new-project-panel')).toBeVisible();
if (entry.create.tab) {
await page.getByTestId(`new-project-tab-${entry.create.tab}`).click();
@ -1037,6 +1070,28 @@ async function createProjectNameOnly(
await page.getByTestId('new-project-name').fill(entry.create.projectName);
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}
async function getCurrentProjectContext(
page: Page,
): Promise<{ projectId: string; conversationId: string }> {
@ -1196,18 +1251,21 @@ async function runDeepLinkPreviewFlow(
await expectArtifactVisible(page, entry);
const fileName = entry.mockArtifact!.fileName;
await expect(page).toHaveURL(new RegExp(`/projects/[^/]+/files/${fileName.replace('.', '\\.')}$`));
await expect(page).toHaveURL(
new RegExp(`/projects/[^/]+(?:/conversations/[^/]+)?/files/${fileName.replace('.', '\\.')}$`),
);
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
const [, projects, projectId, maybeConversations, conversationId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}`);
await page.goto(`/projects/${projectId}`, { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await page.goto(`/projects/${projectId}/files/${fileName}`);
await page.goto(`/projects/${projectId}/files/${fileName}`, { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: entry.mockArtifact!.heading })).toBeVisible();

View file

@ -230,7 +230,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => {
await stubProjectEvents(page, FULL_TRANSCRIPT);
const projectId = await seedProject(page, 'shipped');
await page.goto(`/projects/${projectId}`);
await expect(page.getByText('Shipped')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('.theater-collapsed-badge').getByText('Shipped', { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(/Shipped at round 1/)).toBeVisible();
await expect(page.getByText(/composite 8\.6/)).toBeVisible();
});
@ -244,7 +244,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => {
await expect(interruptBtn).toBeVisible();
await interruptBtn.focus();
await page.keyboard.press('Escape');
await expect(page.getByText('Interrupted')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('.theater-collapsed-badge').getByText('Interrupted', { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(/Interrupted at round/)).toBeVisible();
});

View file

@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
@ -16,6 +17,8 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
@ -36,12 +39,35 @@ test.beforeEach(async ({ page }) => {
},
});
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
test('entry chrome settings menu opens with brand header and no pet rail', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.entry-nav-rail')).toBeVisible();
await gotoEntryHome(page);
await expect(page.getByTestId('entry-star-badge')).toBeVisible();
await expect(page.getByTestId('entry-use-everywhere-button')).toBeVisible();
await expect(page.getByTestId('entry-nav-logo')).toBeVisible();
await expect(page.getByTestId('recent-projects-strip')).toBeVisible();
await expect(page.locator('.entry-nav-rail')).toBeVisible();
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.locator('.entry-brand')).toHaveCount(0);
@ -50,43 +76,63 @@ test('entry chrome settings menu opens with brand header and no pet rail', async
// entry layout.
await expect(page.locator('.pet-rail')).toHaveCount(0);
const openSettings = page.getByRole('button', { name: /open settings/i });
await openSettings.click();
await page.locator('.avatar-menu .settings-icon-btn').click();
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
await expect(settingsMenu).toBeVisible();
await expect(settingsMenu.getByRole('button', { name: /^settings$/i })).toBeVisible();
await expect(settingsMenu.getByRole('button', { name: /hide pet picker/i })).toHaveCount(0);
await expect(settingsMenu.getByRole('button', { name: /show pet picker/i })).toHaveCount(0);
});
test('entry top navigation matches the current home tab structure', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await gotoEntryHome(page);
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-projects')).toBeVisible();
await expect(page.getByTestId('entry-nav-tasks')).toBeVisible();
await expect(page.getByTestId('entry-nav-plugins')).toBeVisible();
await expect(page.getByTestId('entry-nav-design-systems')).toBeVisible();
await expect(page.getByTestId('entry-nav-integrations')).toBeVisible();
await expect(page.getByTestId('home-hero-rail')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-prototype')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-live-artifact')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-deck')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-image')).toBeVisible();
await expect(page.getByTestId('home-hero-rail-video')).toBeVisible();
});
test('entry chrome avoids horizontal overflow on compact desktop width', async ({ page }) => {
await page.setViewportSize({ width: 820, height: 900 });
await page.goto('/');
await expect(page.locator('.entry-nav-rail')).toBeVisible();
await gotoEntryHome(page);
await expect(page.locator('.entry-main__topbar')).toBeVisible();
// The shared app chrome header should stay one row and avoid pushing
// the entry layout sideways on compact desktop widths.
const headerOverflow = await page.evaluate(() => {
const header = document.querySelector('.entry-main__topbar');
if (!(header instanceof HTMLElement)) return null;
return Math.max(0, header.scrollWidth - header.clientWidth);
const { pageOverflow, topbarOverflow } = await page.evaluate(() => {
const topbar = document.querySelector('.entry-main__topbar');
return {
pageOverflow: Math.max(
0,
document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
topbarOverflow:
topbar instanceof HTMLElement
? Math.max(0, topbar.scrollWidth - topbar.clientWidth)
: null,
};
});
expect(headerOverflow).not.toBeNull();
expect(headerOverflow!).toBeLessThanOrEqual(2);
const pageOverflow = await page.evaluate(() =>
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
);
expect(topbarOverflow).not.toBeNull();
expect(topbarOverflow!).toBeLessThanOrEqual(2);
expect(pageOverflow).toBeLessThanOrEqual(2);
});
async function gotoEntryHome(page: Page) {
await page.goto('/');
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}

View file

@ -15,6 +15,8 @@ type ExampleSkill = {
examplePrompt?: string;
};
test.describe.configure({ timeout: 30_000 });
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
@ -29,10 +31,32 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
@ -51,7 +75,7 @@ test.beforeEach(async ({ page }) => {
});
});
test.describe('examples preview core flows', () => {
test.describe.skip('examples preview core flows', () => {
test('opens a shipped HTML example preview', async ({ page }) => {
await routeExampleSkills(page, [
{
@ -218,9 +242,27 @@ test.describe('examples preview core flows', () => {
});
async function gotoExamples(page: Page) {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByRole('tab', { name: /^Templates$/i }).click();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
const firstExampleCard = page.locator('.example-card').first();
if (await firstExampleCard.isVisible().catch(() => false)) {
return;
}
const templatesTab = page.getByRole('tab', { name: /^Templates$/i });
if (await templatesTab.isVisible().catch(() => false)) {
await templatesTab.click();
return;
}
await page.getByRole('button', { name: /from template/i }).click();
}
async function openPreview(page: Page, skillName: string) {
@ -288,3 +330,8 @@ async function fetchCurrentProject(page: Page) {
};
return body.project;
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}

View file

@ -50,7 +50,7 @@ test.beforeEach(async ({ page }) => {
});
});
test.describe('examples preview share and fullscreen flows', () => {
test.describe.skip('examples preview share and fullscreen flows', () => {
test('opens the share menu for shipped previews and exposes export actions', async ({ page }) => {
await routeExampleSkills(page, [
{

View file

@ -156,6 +156,7 @@ test('real daemon run supports fake non-Codex runtime protocols', async ({ page
});
async function createProject(page: Page, name: string) {
await openNewProjectModal(page);
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill(name);
@ -178,6 +179,7 @@ async function createProjectViaApi(page: Page, projectId: string, name: string)
}
async function expectWorkspaceReady(page: Page) {
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
@ -187,16 +189,38 @@ async function expectWorkspaceReady(page: Page) {
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
await expect(input).toBeVisible({ timeout: 5_000 });
await input.click();
await input.fill(prompt);
await expect(input).toHaveValue(prompt);
await expect(sendButton).toBeEnabled();
const chatResponse = page.waitForResponse(isCreateRunResponse);
await sendButton.click();
const response = await chatResponse;
const response = await Promise.race([
page.waitForResponse(isCreateRunResponse, { timeout: 10_000 }),
(async () => {
await sendButton.click();
return page.waitForResponse(isCreateRunResponse, { timeout: 10_000 });
})(),
]);
expect(response.ok()).toBeTruthy();
}
async function openNewProjectModal(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function waitForLoadingToClear(page: Page) {
const loading = page.getByText('Loading Open Design…');
await loading.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
}
async function configureFakeAgent(page: Page, agentId: FakeAgentId) {
const runtime = fakeRuntimes[agentId];
const response = await page.request.put('/api/app-config', {

View file

@ -2,6 +2,35 @@ import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
test.describe.configure({ timeout: 30_000 });
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
}
async function openSettingsDialogFromEntry(page: Page) {
await waitForLoadingToClear(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function openExecutionSettings(
page: Page,
@ -18,9 +47,8 @@ async function openExecutionSettings(
await route.fulfill({ status: 503, body: 'offline' });
});
await page.goto('/');
await page.getByTitle('Execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
}
async function readSavedConfig(page: Page) {
@ -56,9 +84,8 @@ async function openExecutionSettingsWithAgents(
await route.fulfill({ json: { agents } });
});
await page.goto('/');
await page.getByTitle('Execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
await gotoEntryHome(page);
await openSettingsDialogFromEntry(page);
}
test('legacy known OpenAI provider switches to the matching Anthropic preset', async ({ page }) => {
@ -185,8 +212,7 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await page.getByTitle('Execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
await openSettingsDialogFromEntry(page);
const reopenedDialog = page.getByRole('dialog');
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(reopenedDialog.getByLabel('Quick fill provider')).toHaveValue('1');
@ -351,8 +377,8 @@ test('saving Local CLI updates the entry status pill with the selected agent', a
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const executionPill = page.getByTitle('Execution mode');
await expect(executionPill).toContainText('Local CLI');
const executionPill = page.locator('.inline-switcher__chip');
await expect(executionPill).toContainText(/Local CLI|本机 CLI/i);
await expect(executionPill).toContainText('Codex CLI');
await expect(executionPill).toContainText('0.80.0');
await expect(executionPill).toContainText('default');
});

View file

@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
test.describe.configure({ timeout: 30_000 });
const CONNECTORS = [
{
@ -50,6 +54,31 @@ function connectorCard(scope: Page | Locator, id: string) {
return scope.locator(`article.connector-card[data-connector-id="${id}"]`);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
}
async function openSettingsDialogFromEntry(page: Page) {
await waitForLoadingToClear(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function openConnectorsSettings(
page: Page,
{
@ -174,11 +203,8 @@ async function openConnectorsSettings(
});
});
await page.goto('/');
await page.getByTitle('Execution mode').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await gotoEntryHome(page);
const dialog = await openSettingsDialogFromEntry(page);
await dialog.getByRole('button', { name: /Connectors|连接器/i }).click();
await expect(dialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(connectorCard(dialog, 'github')).toBeVisible();

View file

@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
test.describe.configure({ timeout: 30_000 });
const CONNECTORS = [
{
@ -58,6 +62,31 @@ function connectorCard(scope: Page | Locator, id: string) {
return scope.locator(`article.connector-card[data-connector-id="${id}"]`);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
}
async function openSettingsDialogFromEntry(page: Page) {
await waitForLoadingToClear(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function openConnectorsSettings(
page: Page,
{
@ -197,11 +226,8 @@ async function openConnectorsSettings(
});
});
await page.goto('/');
await page.getByTitle('Execution mode').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await gotoEntryHome(page);
const dialog = await openSettingsDialogFromEntry(page);
await dialog.getByRole('button', { name: /Connectors|连接器/i }).click();
await expect(dialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(connectorCard(dialog, 'github')).toBeVisible();
@ -300,11 +326,8 @@ test('clears pending authorization when OAuth launch is blocked after redirect_r
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await page.reload();
await page.getByTitle('Execution mode').click();
const reloadedDialog = page.getByRole('dialog');
await expect(reloadedDialog).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
const reloadedDialog = await openSettingsDialogFromEntry(page);
await reloadedDialog.getByRole('button', { name: /^Connectors\b/ }).click();
const reloadedGithubCard = connectorCard(reloadedDialog, 'github');

View file

@ -3,6 +3,10 @@ import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const LOCALE_KEY = 'open-design:locale';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
test.describe.configure({ timeout: 30_000 });
type AppConfigSeed = Record<string, unknown>;
@ -64,6 +68,20 @@ async function readSavedConfig(page: Page) {
}, STORAGE_KEY);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
}
async function openLocalCliSettings(
page: Page,
{
@ -135,10 +153,11 @@ async function openLocalCliSettings(
});
});
await page.goto('/');
await page
.getByRole('button', { name: /Execution mode|执行模式/i })
.click();
await gotoEntryHome(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();

View file

@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
const SETTINGS_MENU_LABEL = /^Settings$|^设置$|^設定$/i;
test.describe.configure({ timeout: 30_000 });
function baseConfig(): Record<string, unknown> {
return {
@ -53,9 +57,26 @@ async function seedSettingsBase(page: Page) {
});
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByRole('button', { name: OPEN_SETTINGS_LABEL })).toBeVisible();
}
async function openSettings(page: Page) {
await page.goto('/');
await page.getByTitle('Execution mode').click();
await gotoEntryHome(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('button', { name: SETTINGS_MENU_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;

View file

@ -18,6 +18,8 @@ test.beforeEach(async ({ page }) => {
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
@ -38,10 +40,30 @@ test.beforeEach(async ({ page }) => {
},
});
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
});
test('quick switcher opens from keyboard and activates the selected file', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher keyboard flow');
await expectWorkspaceReady(page);
@ -76,7 +98,7 @@ test('quick switcher opens from keyboard and activates the selected file', async
});
test('quick switcher keeps the current file when search has no matches', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher empty search flow');
await expectWorkspaceReady(page);
@ -105,7 +127,7 @@ test('quick switcher keeps the current file when search has no matches', async (
});
test('quick switcher arrow keys move selection before opening a file', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher arrow navigation flow');
await expectWorkspaceReady(page);
@ -133,7 +155,7 @@ test('quick switcher arrow keys move selection before opening a file', async ({
});
test('keyboard chat panel resize persists after reload', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Chat panel resize persistence');
await expectWorkspaceReady(page);
@ -169,7 +191,7 @@ test('keyboard chat panel resize persists after reload', async ({ page }) => {
});
test('quick switcher still activates another file after the project reloads', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher after reload');
await expectWorkspaceReady(page);
@ -201,14 +223,14 @@ test('quick switcher still activates another file after the project reloads', as
});
test('quick switcher only lists files from the active project after switching projects', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher Project Alpha');
await expectWorkspaceReady(page);
await uploadTinyPng(page, 'alpha-project-file.png');
await uploadTinyPng(page, 'alpha-project-secondary.png');
await page.getByRole('button', { name: /back to projects/i }).click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expectProjectsView(page);
await createProject(page, 'Quick switcher Project Beta');
await expectWorkspaceReady(page);
@ -232,7 +254,7 @@ test('quick switcher only lists files from the active project after switching pr
});
test('quick switcher leaves the Design Files panel and opens the selected file tab', async ({ page }) => {
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher from Design Files');
await expectWorkspaceReady(page);
@ -301,7 +323,7 @@ test('quick switcher can switch from a design file tab back to a generated artif
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher artifact mix');
await expectWorkspaceReady(page);
@ -372,7 +394,7 @@ test('quick switcher can restore a generated artifact tab after reload in a mixe
});
});
await page.goto('/');
await gotoEntryHome(page);
await createProject(page, 'Quick switcher mixed reload');
await expectWorkspaceReady(page);
@ -388,7 +410,7 @@ test('quick switcher can restore a generated artifact tab after reload in a mixe
await page.reload();
await expectWorkspaceReady(page);
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
await expect(artifactTab).toHaveAttribute('aria-selected', 'false');
await expect(artifactTab).toHaveCount(0);
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
@ -414,14 +436,38 @@ async function createProject(
page: Page,
projectName: string,
) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill(projectName);
await page.getByTestId('create-project').click();
}
async function gotoEntryHome(page: Page) {
await page.goto('/');
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function openNewProjectModal(page: Page) {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
async function expectProjectsView(page: Page) {
if ((await page.locator('.tab-panel-toolbar').count()) === 0) {
await page.getByTestId('entry-nav-projects').click();
}
await expect(page.locator('.tab-panel-toolbar')).toBeVisible();
}
async function expectWorkspaceReady(page: Page) {
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByText('Loading Open Design…')).toHaveCount(0);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();