Files
open-design/e2e/ui/workspace-keyboard-flows.test.ts
T
lefarcen c14baf07d3 Merge origin/main into release/v0.8.0
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits
on top of 58 release-side commits accumulated during the 0.8.0 cycle.

Resolution summary:

Take main (theirs) where main carried deliberate forward progress:
- apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration:
  hardcoded English aria-labels/titles replaced with t() calls keyed
  on pluginCard.* (all 8 keys verified present in en.ts).
- apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion
  feature: sortedRoutines (newest-first), sourceIngestionTemplates,
  patchSourceForm, submitSourceIngestion. activeCount/pausedCount
  semantics preserved (now keyed on sortedRoutines, count unchanged).
- e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts
  imports needed by main-side test helpers.
- e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal
  helper block added by main.

Keep both sides where each added a different field to the same object
literal:
- apps/web/src/components/ProjectView.tsx (locale + analyticsHints
  spread).
- apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints).

Take release (ours) where release carried deliberate work that ships
0.8.0:
- CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's
  Unreleased section was the same body of work, now finalized.
- apps/landing-page/public/{apple-touch-icon,favicon}.png +
  apps/web/public/app-icon.svg — release-side visual refresh assets
  consistent with 0.8.0 stable ship.
- tools/pack/src/linux.ts — packageVersion const required by line 466;
  taking main's empty line would build-error.
- e2e/ui/project-management-flows.test.ts +
  e2e/ui/settings-api-protocol.test.ts +
  e2e/ui/settings-memory-routines.test.ts — release-side release-smoke
  hardening (shangxinyu1 + PerishFire) takes precedence on overlap.

Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
2026-05-23 12:17:18 +08:00

446 lines
17 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Locator, Page, Response } from '@playwright/test';
import { applyStandardMocks } from '@/playwright/mock-factory';
const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth';
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
});
test('quick switcher opens from keyboard and activates the selected file', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher keyboard flow');
await expectWorkspaceReady(page);
await uploadTinyPng(page, 'alpha-file.png');
await uploadTinyPng(page, 'beta-file.png');
const alphaTab = tabBySuffix(page, 'alpha-file.png');
const betaTab = tabBySuffix(page, 'beta-file.png');
await expect(alphaTab).toBeVisible();
await expect(betaTab).toBeVisible();
await alphaTab.click();
await expect(alphaTab).toHaveAttribute('aria-selected', 'true');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await expect(quickSwitcherInput).toBeVisible();
await quickSwitcherInput.fill('beta');
await expect(page.getByRole('option', { name: /beta-file\.png/i })).toBeVisible();
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeHidden();
await expect(betaTab).toHaveAttribute('aria-selected', 'true');
await expect(alphaTab).toHaveAttribute('aria-selected', 'false');
await openQuickSwitcher(page);
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.press('Escape');
await expect(quickSwitcher).toBeHidden();
});
test('quick switcher keeps the current file when search has no matches', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher empty search flow');
await expectWorkspaceReady(page);
await uploadTinyPng(page, 'alpha-empty-search.png');
await uploadTinyPng(page, 'beta-empty-search.png');
const alphaTab = tabBySuffix(page, 'alpha-empty-search.png');
await expect(alphaTab).toBeVisible();
await alphaTab.click();
await expect(alphaTab).toHaveAttribute('aria-selected', 'true');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.fill('no-file-with-this-name');
await expect(page.locator('.qs-empty')).toBeVisible();
await expect(page.getByRole('option')).toHaveCount(0);
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.press('Escape');
await expect(quickSwitcher).toBeHidden();
await expect(alphaTab).toHaveAttribute('aria-selected', 'true');
});
test('quick switcher arrow keys move selection before opening a file', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher arrow navigation flow');
await expectWorkspaceReady(page);
await uploadTinyPng(page, 'arrow-alpha.png');
await uploadTinyPng(page, 'arrow-beta.png');
await uploadTinyPng(page, 'arrow-gamma.png');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
const selectedOption = page.getByRole('option', { selected: true });
await expect(quickSwitcher).toBeVisible();
await expect(page.getByRole('option')).toHaveCount(3);
const initialSelection = await selectedOption.textContent();
await quickSwitcherInput.press('ArrowDown');
const nextSelection = await selectedOption.textContent();
expect(nextSelection).not.toBe(initialSelection);
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeHidden();
const selectedFileName = selectedBaseName(nextSelection);
await expect(tabBySuffix(page, selectedFileName)).toHaveAttribute('aria-selected', 'true');
});
test('keyboard chat panel resize persists after reload', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Chat panel resize persistence');
await expectWorkspaceReady(page);
await page.evaluate((key) => {
window.localStorage.removeItem(key);
}, CHAT_PANEL_WIDTH_STORAGE_KEY);
await page.reload();
await expectWorkspaceReady(page);
const handle = page.locator('.split-resize-handle');
await expect(handle).toBeVisible();
const initialWidth = await readChatPanelWidth(handle);
await handle.focus();
await page.keyboard.press('End');
let resizedWidth = await readChatPanelWidth(handle);
if (resizedWidth === initialWidth) {
await page.keyboard.press('Home');
resizedWidth = await readChatPanelWidth(handle);
}
expect(resizedWidth).not.toBe(initialWidth);
const savedWidth = await page.evaluate(
(key) => window.localStorage.getItem(key),
CHAT_PANEL_WIDTH_STORAGE_KEY,
);
expect(savedWidth).toBe(String(resizedWidth));
await page.reload();
await expectWorkspaceReady(page);
const restoredWidth = await readChatPanelWidth(handle);
expect(restoredWidth).toBe(resizedWidth);
});
test('quick switcher still activates another file after the project reloads', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher after reload');
await expectWorkspaceReady(page);
const projectId = currentProjectId(page);
await uploadTinyPng(page, 'reload-alpha.png');
await uploadTinyPng(page, 'reload-beta.png');
const alphaTab = tabBySuffix(page, 'reload-alpha.png');
const betaTab = tabBySuffix(page, 'reload-beta.png');
await alphaTab.click();
await expect(alphaTab).toHaveAttribute('aria-selected', 'true');
await expect(betaTab).toHaveAttribute('aria-selected', 'false');
await page.reload();
await expectWorkspaceReady(page);
await expect(alphaTab).toHaveAttribute('aria-selected', 'true');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.fill('reload-beta');
await expect(page.getByRole('option', { name: /reload-beta\.png/i })).toBeVisible();
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeHidden();
await expect(betaTab).toHaveAttribute('aria-selected', 'true');
await expect(alphaTab).toHaveAttribute('aria-selected', 'false');
await expectProjectFilesToIncludeSuffixes(page, projectId, ['reload-alpha.png', 'reload-beta.png']);
});
test('quick switcher only lists files from the active project after switching projects', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher Project Alpha');
await expectWorkspaceReady(page);
const alphaProjectId = currentProjectId(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 expectProjectsView(page);
await createProject(page, 'Quick switcher Project Beta');
await expectWorkspaceReady(page);
const betaProjectId = currentProjectId(page);
await uploadTinyPng(page, 'beta-project-file.png');
await uploadTinyPng(page, 'beta-project-secondary.png');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.fill('project');
await expect(page.getByRole('option', { name: /beta-project-file\.png/i })).toBeVisible();
await expect(page.getByRole('option', { name: /beta-project-secondary\.png/i })).toBeVisible();
await expect(page.getByRole('option', { name: /alpha-project-file\.png/i })).toHaveCount(0);
await expect(page.getByRole('option', { name: /alpha-project-secondary\.png/i })).toHaveCount(0);
await expectProjectFilesToIncludeSuffixes(page, betaProjectId, ['beta-project-file.png', 'beta-project-secondary.png']);
await expectProjectFilesToIncludeSuffixes(page, alphaProjectId, ['alpha-project-file.png', 'alpha-project-secondary.png']);
await quickSwitcherInput.press('Escape');
await expect(quickSwitcher).toBeHidden();
});
test('quick switcher leaves the Design Files panel and opens the selected file tab', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher from Design Files');
await expectWorkspaceReady(page);
await uploadTinyPng(page, 'design-files-alpha.png');
await uploadTinyPng(page, 'design-files-beta.png');
await page.getByTestId('design-files-tab').click();
await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'true');
const betaRow = page.locator('[data-testid^="design-file-row-"]', {
hasText: 'design-files-beta.png',
});
await expect(betaRow).toBeVisible();
await betaRow.getByRole('button').first().click();
await expect(page.getByTestId('design-file-preview')).toBeVisible();
await expect(page.getByTestId('design-file-preview').getByText(/design-files-beta\.png/i)).toBeVisible();
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.fill('design-files-alpha');
await expect(page.getByRole('option', { name: /design-files-alpha\.png/i })).toBeVisible();
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeHidden();
await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false');
await expect(page.getByRole('tab', { name: /design-files-alpha\.png/i })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('design-file-preview')).toHaveCount(0);
});
test('quick switcher can switch from a design file tab back to a generated artifact tab', async ({ page }) => {
await page.route('**/api/runs', async (route) => {
await route.fulfill({
status: 202,
contentType: 'application/json',
body: '{"runId":"mock-run"}',
});
});
await page.route('**/api/runs/*/events', async (route) => {
const artifact =
'<artifact identifier="quick-switcher-artifact" type="text/html" title="Quick Switcher Artifact">' +
'<!doctype html><html><body><main><h1>Quick Switcher Artifact</h1></main></body></html>' +
'</artifact>';
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({ chunk: artifact })}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await gotoEntryHome(page);
await createProject(page, 'Quick switcher artifact mix');
await expectWorkspaceReady(page);
await sendPrompt(page, 'Create a quick switcher artifact');
const artifactTab = page.getByRole('tab', { name: /quick-switcher-artifact\.html/i });
await expect(artifactTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
await uploadTinyPng(page, 'artifact-mix-file.png');
const fileTab = tabBySuffix(page, 'artifact-mix-file.png');
await fileTab.click();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
await expect(artifactTab).toHaveAttribute('aria-selected', 'false');
await openQuickSwitcher(page);
const quickSwitcher = page.locator('.qs-overlay');
const quickSwitcherInput = page.locator('.qs-input');
await expect(quickSwitcher).toBeVisible();
await quickSwitcherInput.fill('quick-switcher-artifact');
await expect(page.getByRole('option', { name: /quick-switcher-artifact\.html/i })).toBeVisible();
await quickSwitcherInput.press('Enter');
await expect(quickSwitcher).toBeHidden();
await expect(artifactTab).toHaveAttribute('aria-selected', 'true');
await expect(fileTab).toHaveAttribute('aria-selected', 'false');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', {
name: 'Quick Switcher Artifact',
}),
).toBeVisible();
});
async function createProject(
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 gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.getByText('Loading Open Design…').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible()) {
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-modal')).toBeVisible();
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();
}
async function uploadTinyPng(
page: Page,
name: 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();
}
async function listProjectFiles(page: Page, projectId: string) {
const response = await page.request.get(`/api/projects/${projectId}/files`);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { files: Array<{ name: string }> };
return body.files;
}
async function expectProjectFilesToIncludeSuffixes(
page: Page,
projectId: string,
suffixes: string[],
) {
await expect
.poll(async () => {
const names = (await listProjectFiles(page, projectId)).map((file) => file.name);
return suffixes.every((suffix) => names.some((name) => name.endsWith(suffix)));
})
.toBe(true);
}
async function readChatPanelWidth(handle: Locator): Promise<number> {
const raw = await handle.getAttribute('aria-valuenow');
const parsed = Number.parseInt(raw ?? '', 10);
expect(Number.isFinite(parsed)).toBeTruthy();
return parsed;
}
async function openQuickSwitcher(page: Page) {
const quickSwitcher = page.locator('.qs-overlay');
await page.keyboard.press('Meta+P');
if (await quickSwitcher.isVisible()) return;
await page.keyboard.press('Control+P');
await expect(quickSwitcher).toBeVisible();
}
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
await input.click();
await input.fill(prompt);
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;
}
function tabBySuffix(page: Page, name: string): Locator {
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') });
}
function currentProjectId(page: Page): string {
const url = new URL(page.url());
const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? [];
expect(projectId).toBeTruthy();
return projectId!;
}
function selectedBaseName(selectionText: string | null): string {
const normalized = selectionText?.replace(/\s+/g, ' ').trim() ?? '';
const match = normalized.match(/arrow-(alpha|beta|gamma)\.png/i);
expect(match?.[0]).toBeTruthy();
return match![0];
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isCreateRunResponse(resp: Response): boolean {
const url = new URL(resp.url());
return url.pathname === '/api/runs' && resp.request().method() === 'POST';
}