diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index d48931715d..018ae65867 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -9,7 +9,8 @@ import { isCustomModel, renderModelOptions, } from './modelOptions'; -import { KNOWN_PROVIDERS } from '../state/config'; +import { DEFAULT_NOTIFICATIONS, KNOWN_PROVIDERS } from '../state/config'; +import type { KnownProvider } from '../state/config'; import { MAX_MAX_TOKENS, MIN_MAX_TOKENS, @@ -20,7 +21,6 @@ import { MEDIA_PROVIDERS } from '../media/models'; import type { MediaProvider } from '../media/models'; import { PetSettings } from './pet/PetSettings'; import { LibrarySection } from './LibrarySection'; -import { DEFAULT_NOTIFICATIONS } from '../state/config'; import { FAILURE_SOUNDS, SUCCESS_SOUNDS, @@ -163,6 +163,63 @@ function defaultApiProtocolConfig(protocol: ApiProtocol): ApiProtocolConfig { }; } +function providerFamilyLabel(provider: KnownProvider): string { + return provider.label.replace(/\s+—\s+(Anthropic|OpenAI)$/u, ''); +} + +function siblingProviderForProtocol( + providerBaseUrl: string | null | undefined, + protocol: ApiProtocol, +): KnownProvider | null { + if (!providerBaseUrl) return null; + const currentProvider = KNOWN_PROVIDERS.find( + (p) => p.baseUrl === providerBaseUrl, + ); + if (!currentProvider) return null; + + const currentFamily = providerFamilyLabel(currentProvider); + return ( + KNOWN_PROVIDERS.find( + (p) => p.protocol === protocol && providerFamilyLabel(p) === currentFamily, + ) ?? null + ); +} + +function nextApiProtocolConfig( + config: AppConfig, + protocol: ApiProtocol, +): ApiProtocolConfig { + const savedConfig = config.apiProtocolConfigs?.[protocol]; + if (savedConfig) return savedConfig; + + const currentConfig = currentApiProtocolConfig(config); + const siblingProvider = siblingProviderForProtocol( + currentConfig.apiProviderBaseUrl, + protocol, + ); + if (siblingProvider) { + return { + ...defaultApiProtocolConfig(protocol), + baseUrl: siblingProvider.baseUrl, + model: siblingProvider.model, + apiProviderBaseUrl: siblingProvider.baseUrl, + }; + } + + if (currentConfig.apiProviderBaseUrl === null) { + return { + ...currentConfig, + apiKey: '', + apiVersion: protocol === 'azure' ? currentConfig.apiVersion : '', + apiProviderBaseUrl: null, + }; + } + + return { + ...defaultApiProtocolConfig(protocol), + }; +} + function currentApiProtocolConfig(config: AppConfig): ApiProtocolConfig { return { apiKey: config.apiKey, @@ -279,8 +336,13 @@ export function switchApiProtocolConfig( ...(config.apiProtocolConfigs ?? {}), [currentProtocol]: currentApiProtocolConfig(config), }; - const nextApiConfig = - apiProtocolConfigs[protocol] ?? defaultApiProtocolConfig(protocol); + const nextApiConfig = nextApiProtocolConfig( + { + ...config, + apiProtocolConfigs, + }, + protocol, + ); return applyApiProtocolConfig( { ...config, diff --git a/apps/web/tests/components/SettingsDialog.test.ts b/apps/web/tests/components/SettingsDialog.test.ts index edbc948b68..cdbec6240c 100644 --- a/apps/web/tests/components/SettingsDialog.test.ts +++ b/apps/web/tests/components/SettingsDialog.test.ts @@ -21,7 +21,7 @@ const baseConfig: AppConfig = { }; describe('SettingsDialog API protocol switching', () => { - it('stores the current custom protocol config before loading another protocol', () => { + it('stores the current custom protocol config while preserving custom endpoint details', () => { const config: AppConfig = { ...baseConfig, apiKey: 'anthropic-key', @@ -36,8 +36,9 @@ describe('SettingsDialog API protocol switching', () => { mode: 'api', apiProtocol: 'openai', apiKey: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4o', + baseUrl: 'https://my-proxy.example.com', + model: 'my-model', + apiProviderBaseUrl: null, }); expect(next.apiProtocolConfigs?.anthropic).toMatchObject({ apiKey: 'anthropic-key', diff --git a/e2e/lib/desktop/desktop-test-helpers.ts b/e2e/lib/desktop/desktop-test-helpers.ts new file mode 100644 index 0000000000..b638818969 --- /dev/null +++ b/e2e/lib/desktop/desktop-test-helpers.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolveRepoRoot(__dirname); +const screenshotDir = path.join(os.tmpdir(), 'open-design-e2e-screenshots'); + +export const STORAGE_KEY = 'open-design:config'; + +export type DesktopStatus = { + pid?: number; + state: 'idle' | 'running' | 'unknown'; + title?: string | null; + updatedAt?: string; + url?: string | null; + windowVisible?: boolean; +}; + +type DesktopEvalResult = { + ok: boolean; + value?: unknown; + error?: string; +}; + +function resolveRepoRoot(startDir: string): string { + let currentDir = startDir; + while (true) { + if (fs.existsSync(path.join(currentDir, 'package.json'))) { + return currentDir; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + throw new Error(`Unable to locate repo root from ${startDir}.`); + } + currentDir = parentDir; + } +} + +export function createDesktopHarness(name: string) { + const namespace = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + return { + namespace, + async start() { + await runToolsDev(['start', '--namespace', namespace]); + await waitFor(async () => { + const status = await desktopStatus(namespace); + assert.equal(status.state, 'running'); + assert.equal(status.windowVisible, true); + assert.ok(status.url); + }, 60_000); + }, + async stop() { + await runToolsDev(['stop', '--namespace', namespace]).catch(() => undefined); + }, + async screenshot(fileName: string) { + const outputPath = path.join(screenshotDir, `${fileName}.png`); + await runToolsDev([ + 'inspect', + 'desktop', + 'screenshot', + '--namespace', + namespace, + '--path', + outputPath, + ]); + return outputPath; + }, + async eval(expression: string): Promise { + const result = await runToolsDevJson([ + 'inspect', + 'desktop', + 'eval', + '--namespace', + namespace, + '--expr', + expression, + '--json', + ]); + assert.equal(result.ok, true, result.error ?? 'desktop eval failed'); + return result.value as T; + }, + async seedConfigAndReload(config: Record, stableField: string) { + const value = JSON.stringify(config); + await this.eval(` + (() => { + window.localStorage.setItem(${JSON.stringify(STORAGE_KEY)}, ${JSON.stringify(value)}); + window.location.reload(); + return true; + })() + `); + + await waitFor(async () => { + const loaded = await this.eval(` + (() => { + const raw = window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)}); + return Boolean(raw && JSON.parse(raw)[${JSON.stringify(stableField)}] === ${JSON.stringify(config[stableField])}); + })() + `); + assert.equal(loaded, true); + }); + }, + async openSettings() { + await waitFor(async () => { + const ready = await this.eval(` + (() => Boolean( + document.querySelector('[role="dialog"]') || + document.querySelector('button[title="Configure execution mode"]') || + document.querySelector('.settings-icon-btn') + ))() + `); + assert.equal(ready, true); + }); + + const clicked = await this.eval(` + (() => { + if (document.querySelector('[role="dialog"]')) return true; + const homeButton = document.querySelector('button[title="Configure execution mode"]'); + if (homeButton instanceof HTMLElement) { + homeButton.click(); + return true; + } + const projectButton = document.querySelector('.settings-icon-btn'); + if (projectButton instanceof HTMLElement) { + projectButton.click(); + return true; + } + return false; + })() + `); + assert.equal(clicked, true); + + await waitFor(async () => { + const dialogOpen = await this.eval(` + (() => { + const dialog = document.querySelector('[role="dialog"]'); + if (dialog) return true; + const settingsItem = Array.from(document.querySelectorAll('.avatar-popover .avatar-item')) + .find((node) => node.textContent?.trim() === 'Settings'); + if (!(settingsItem instanceof HTMLElement)) return false; + settingsItem.click(); + return Boolean(document.querySelector('[role="dialog"]')); + })() + `); + assert.equal(dialogOpen, true); + }); + }, + }; +} + +export async function desktopStatus(namespace: string): Promise { + return await runToolsDevJson([ + 'inspect', + 'desktop', + 'status', + '--namespace', + namespace, + '--json', + ]); +} + +export async function waitFor( + fn: () => void | Promise, + timeoutMs = 20_000, + intervalMs = 250, +): Promise { + const startedAt = Date.now(); + let lastError: unknown; + while (Date.now() - startedAt < timeoutMs) { + try { + await fn(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + throw lastError instanceof Error + ? lastError + : new Error(`Timed out after ${timeoutMs}ms waiting for condition.`); +} + +async function runToolsDev(args: string[]): Promise { + const { stdout } = await execFileAsync('pnpm', ['tools-dev', ...args], { + cwd: repoRoot, + env: process.env, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; +} + +async function runToolsDevJson(args: string[]): Promise { + const stdout = await runToolsDev(args); + const trimmed = stdout.trim(); + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed) as T; + } + const jsonStart = stdout.lastIndexOf('\n{'); + if (jsonStart < 0) { + throw new Error(`Expected JSON output from tools-dev, got: ${stdout}`); + } + return JSON.parse(stdout.slice(jsonStart + 1)) as T; +} diff --git a/e2e/specs/mac.spec.ts b/e2e/specs/mac.spec.ts index e452c3eb76..89a6dcd6ec 100644 --- a/e2e/specs/mac.spec.ts +++ b/e2e/specs/mac.spec.ts @@ -6,7 +6,9 @@ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +import { createDesktopHarness, STORAGE_KEY, waitFor } from '../lib/desktop/desktop-test-helpers.ts'; const execFileAsync = promisify(execFile); const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -94,6 +96,8 @@ type HealthEvalValue = { const shouldRunPackagedMacSmoke = process.platform === 'darwin' && process.env.OD_PACKAGED_E2E_MAC === '1'; const macDescribe = shouldRunPackagedMacSmoke ? describe : describe.skip; +const shouldRunDesktopMacSmoke = process.platform === 'darwin' && process.env.OD_DESKTOP_SMOKE === '1'; +const desktopMacDescribe = shouldRunDesktopMacSmoke ? describe : describe.skip; macDescribe('packaged mac runtime smoke', () => { let installedAppPath: string | null = null; @@ -163,6 +167,124 @@ macDescribe('packaged mac runtime smoke', () => { }, 180_000); }); +desktopMacDescribe('mac desktop settings smoke', () => { + const desktop = createDesktopHarness('mac-settings-smoke'); + + beforeAll(async () => { + await desktop.start(); + }, 75_000); + + afterAll(async () => { + await desktop.stop(); + }, 30_000); + + test('opens the current API configuration from the desktop shell', async () => { + await seedDesktopConfig(desktop, { + mode: 'api', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + apiProtocol: 'anthropic', + apiProviderBaseUrl: 'https://api.anthropic.com', + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + theme: 'system', + }, 'model'); + + await desktop.openSettings(); + await openDesktopSettingsSection(desktop, 'Configure execution mode'); + + await waitFor(async () => { + const snapshot = await readDesktopSettingsSnapshot(desktop); + expect(snapshot.dialogOpen).toBe(true); + expect(snapshot.heading).toBe('Execution & model'); + expect(snapshot.selectedProtocol).toBe('Anthropic API'); + expect(snapshot.quickFillProvider).toBe('Anthropic (Claude)'); + expect(snapshot.baseUrl).toBe('https://api.anthropic.com'); + expect(snapshot.model).toBe('claude-sonnet-4-5'); + }); + }, 45_000); + + test('keeps legacy provider tracking coherent when switching API protocols', async () => { + await seedDesktopConfig(desktop, { + mode: 'api', + apiKey: 'sk-test', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + }, 'baseUrl'); + + await desktop.openSettings(); + await openDesktopSettingsSection(desktop, 'Configure execution mode'); + + await waitFor(async () => { + const snapshot = await readDesktopSettingsSnapshot(desktop); + expect(snapshot.dialogOpen).toBe(true); + expect(snapshot.selectedProtocol).toBe('OpenAI API'); + expect(snapshot.quickFillProvider).toBe('DeepSeek — OpenAI'); + expect(snapshot.baseUrl).toBe('https://api.deepseek.com'); + }); + + await clickDesktopProtocolTab(desktop, 'Anthropic'); + + await waitFor(async () => { + const snapshot = await readDesktopSettingsSnapshot(desktop); + expect(snapshot.selectedProtocol).toBe('Anthropic API'); + expect(snapshot.quickFillProvider).toBe('DeepSeek — Anthropic'); + expect(snapshot.baseUrl).toBe('https://api.deepseek.com/anthropic'); + expect(snapshot.model).toBe('deepseek-chat'); + }); + }, 45_000); + + test('previews and saves the desktop appearance preference', async () => { + await seedDesktopConfig(desktop, { + mode: 'api', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + apiProtocol: 'anthropic', + apiProviderBaseUrl: 'https://api.anthropic.com', + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + theme: 'system', + }, 'theme'); + + await desktop.openSettings(); + await openDesktopSettingsSection(desktop, 'Appearance'); + await clickDesktopSegmentButton(desktop, 'Dark'); + + await waitFor(async () => { + const snapshot = await readDesktopAppearanceSnapshot(desktop); + expect(snapshot.dialogOpen).toBe(true); + expect(snapshot.activeTheme).toBe('Dark'); + expect(snapshot.documentTheme).toBe('dark'); + expect(snapshot.savedTheme).toBe('system'); + }); + + await clickDesktopSettingsFooterButton(desktop, 'primary'); + + await waitFor(async () => { + const snapshot = await readDesktopAppearanceSnapshot(desktop); + expect(snapshot.dialogOpen).toBe(false); + expect(snapshot.documentTheme).toBe('dark'); + expect(snapshot.savedTheme).toBe('dark'); + }); + }, 45_000); +}); + async function runToolsPackJson(action: string, extraArgs: string[] = []): Promise { const args = [ 'exec', @@ -200,6 +322,155 @@ async function runToolsPackJson(action: string, extraArgs: string[] = []): Pr } } +type DesktopHarness = ReturnType; + +type DesktopSettingsSnapshot = { + baseUrl: string | null; + dialogOpen: boolean; + heading: string | null; + model: string | null; + quickFillProvider: string | null; + selectedProtocol: string | null; +}; + +type DesktopAppearanceSnapshot = { + activeTheme: string | null; + dialogOpen: boolean; + documentTheme: string | null; + savedTheme: string | null; +}; + +async function seedDesktopConfig( + desktop: DesktopHarness, + config: Record, + stableField: string, +): Promise { + await desktop.seedConfigAndReload(config, stableField); +} + +async function openDesktopSettingsSection( + desktop: DesktopHarness, + label: string, +): Promise { + const clicked = await desktop.eval(` + (() => { + const section = Array.from(document.querySelectorAll('[role="dialog"] button')) + .find((node) => node.textContent?.includes(${JSON.stringify(label)})); + if (!(section instanceof HTMLElement)) return false; + section.click(); + return true; + })() + `); + expect(clicked).toBe(true); +} + +async function clickDesktopProtocolTab( + desktop: DesktopHarness, + label: 'Anthropic' | 'OpenAI', +): Promise { + const clicked = await desktop.eval(` + (() => { + const protocolTabs = Array.from(document.querySelectorAll('[role="tablist"]')) + .find((node) => node.getAttribute('aria-label') === 'API protocol'); + const tab = Array.from(protocolTabs?.querySelectorAll('[role="tab"]') ?? []) + .find((node) => node.textContent?.trim() === ${JSON.stringify(label)}); + if (!(tab instanceof HTMLElement)) return false; + tab.click(); + return true; + })() + `); + expect(clicked).toBe(true); +} + +async function clickDesktopSegmentButton( + desktop: DesktopHarness, + label: string, +): Promise { + const clicked = await desktop.eval(` + (() => { + const button = Array.from(document.querySelectorAll('[role="dialog"] button')) + .find((node) => node.textContent?.trim() === ${JSON.stringify(label)}); + if (!(button instanceof HTMLElement)) return false; + button.click(); + return true; + })() + `); + expect(clicked).toBe(true); +} + +async function clickDesktopSettingsFooterButton( + desktop: DesktopHarness, + className: 'ghost' | 'primary', +): Promise { + const clicked = await desktop.eval(` + (() => { + const footerButton = document.querySelector('.modal-foot button.${className}'); + if (!(footerButton instanceof HTMLElement)) return false; + footerButton.click(); + return true; + })() + `); + expect(clicked).toBe(true); +} + +async function readDesktopSettingsSnapshot( + desktop: DesktopHarness, +): Promise { + return await desktop.eval(` + (() => { + const labelFields = Array.from(document.querySelectorAll('[role="dialog"] label.field')); + const getField = (label) => { + const field = labelFields.find((node) => + node.querySelector('.field-label')?.textContent?.trim() === label, + ); + if (!field) return null; + const control = field.querySelector('input, select, textarea'); + if (!(control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement)) { + return null; + } + if (control instanceof HTMLSelectElement) { + return control.selectedOptions.item(0)?.textContent?.trim() ?? control.value; + } + return control.value; + }; + const activeProtocol = Array.from(document.querySelectorAll('[role="tablist"][aria-label="API protocol"] [role="tab"]')) + .find((node) => node.getAttribute('aria-selected') === 'true'); + const protocolText = activeProtocol?.textContent?.trim() ?? null; + + return { + baseUrl: getField('Base URL'), + dialogOpen: Boolean(document.querySelector('[role="dialog"]')), + heading: document.querySelector('[role="dialog"] h2')?.textContent?.trim() ?? null, + model: getField('Model'), + quickFillProvider: getField('Quick fill provider'), + selectedProtocol: protocolText === 'OpenAI' || protocolText === 'Anthropic' + ? protocolText + ' API' + : protocolText, + }; + })() + `); +} + +async function readDesktopAppearanceSnapshot( + desktop: DesktopHarness, +): Promise { + return await desktop.eval(` + (() => { + const raw = window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)}); + const config = raw ? JSON.parse(raw) : {}; + const activeButton = Array.from(document.querySelectorAll('[role="dialog"] button[aria-pressed="true"]')) + .find((node) => ['Light', 'Dark', 'System'].includes(node.textContent?.trim() ?? '')); + + return { + activeTheme: activeButton?.textContent?.trim() ?? null, + dialogOpen: Boolean(document.querySelector('[role="dialog"]')), + documentTheme: document.documentElement.getAttribute('data-theme'), + savedTheme: typeof config.theme === 'string' ? config.theme : null, + }; + })() + `); +} + async function waitForHealthyDesktop(): Promise { const timeoutMs = 90_000; const startedAt = Date.now(); diff --git a/e2e/ui/entry-configuration-flows.test.ts b/e2e/ui/entry-configuration-flows.test.ts new file mode 100644 index 0000000000..9154c63663 --- /dev/null +++ b/e2e/ui/entry-configuration-flows.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const STORAGE_KEY = 'open-design:config'; + +const CONNECTORS = [ + { + id: 'github', + name: 'GitHub', + provider: 'composio', + category: 'Developer tools', + description: 'Read repository issues and pull requests.', + status: 'available', + auth: { provider: 'composio', configured: true }, + tools: [ + { + name: 'list_issues', + title: 'List issues', + description: 'List recent issues from a repository.', + safety: { + sideEffect: 'read', + approval: 'auto', + reason: 'Read-only issue lookup.', + }, + refreshEligible: true, + }, + ], + }, + { + id: 'slack', + name: 'Slack', + provider: 'composio', + category: 'Communication', + description: 'Search channels and messages.', + status: 'connected', + accountLabel: 'design-team', + auth: { provider: 'composio', configured: true }, + tools: [], + }, +]; + +const IMAGE_TEMPLATE = { + id: 'editorial-poster', + surface: 'image', + title: 'Editorial Poster', + summary: 'A punchy launch poster for a product announcement.', + category: 'Marketing', + tags: ['poster', 'launch'], + model: 'gpt-image-1', + aspect: '4:5', + source: { + repo: 'open-design/test-prompts', + license: 'MIT', + author: 'Open Design QA', + }, +}; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((key) => { + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + }), + ); + }, STORAGE_KEY); + + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + json: { + agents: [ + { + id: 'mock', + name: 'Mock Agent', + bin: 'mock-agent', + available: true, + version: 'test', + models: [{ id: 'default', label: 'Default' }], + }, + ], + }, + }); + }); +}); + +test('prompt template retry preserves the edited body in project metadata', async ({ page }) => { + let detailRequests = 0; + await page.route('**/api/prompt-templates', async (route) => { + await route.fulfill({ json: { promptTemplates: [IMAGE_TEMPLATE] } }); + }); + await page.route('**/api/prompt-templates/image/editorial-poster', async (route) => { + detailRequests += 1; + if (detailRequests === 1) { + await route.fulfill({ status: 500, body: 'template unavailable' }); + return; + } + await route.fulfill({ + json: { + promptTemplate: { + ...IMAGE_TEMPLATE, + prompt: 'Original poster prompt with dramatic type and product photography.', + }, + }, + }); + }); + + await page.goto('/'); + await page.getByTestId('new-project-tab-image').click(); + await page.getByTestId('new-project-name').fill('Prompt template retry metadata'); + + await page.getByTestId('prompt-template-trigger').click(); + await page.getByTestId('prompt-template-search').fill('poster'); + await page.getByRole('option', { name: /Editorial Poster/i }).click(); + + await expect(page.getByTestId('prompt-template-error')).toBeVisible(); + await page.getByTestId('prompt-template-retry').click(); + await expect(page.getByTestId('prompt-template-error')).toHaveCount(0); + await expect(page.getByTestId('prompt-template-body')).toContainText('Original poster prompt'); + + await page.getByTestId('prompt-template-body').fill(''); + await expect(page.getByTestId('prompt-template-empty-hint')).toBeVisible(); + await page.getByTestId('prompt-template-body').fill( + 'Edited QA prompt: bold poster, one hero product, crisp headline.', + ); + await page.getByTestId('create-project').click(); + + const project = await fetchCurrentProject(page); + expect(project.metadata?.promptTemplate).toMatchObject({ + id: 'editorial-poster', + surface: 'image', + title: 'Editorial Poster', + prompt: 'Edited QA prompt: bold poster, one hero product, crisp headline.', + }); +}); + +test('live artifact empty connector CTA opens the gated connector setup path', async ({ page }) => { + await routeConnectors(page, []); + + await page.goto('/'); + await page.getByTestId('new-project-tab-live-artifact').click(); + await expect(page.getByTestId('new-project-connectors')).toBeVisible(); + + await page.getByTestId('new-project-connectors-empty').click(); + await expect(page.getByTestId('entry-tab-connectors')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('connector-gate')).toBeVisible(); + + await page.getByTestId('connector-gate-action').click(); + const settingsDialog = page.getByRole('dialog'); + await expect(settingsDialog).toBeVisible(); + await expect(settingsDialog.getByRole('heading', { name: 'Connectors' })).toBeVisible(); + await expect(settingsDialog.getByPlaceholder('Paste Composio API key')).toBeVisible(); +}); + +test('connectors search supports empty results and keyboard-closeable details', async ({ page }) => { + await routeConnectors(page, CONNECTORS); + + await page.goto('/'); + await page.getByTestId('entry-tab-connectors').click(); + await expect(page.getByTestId('connector-grid-wrap')).toBeVisible(); + + const search = page.getByTestId('connectors-search-input'); + await search.fill('git'); + await expect(connectorCard(page, 'github')).toBeVisible(); + await expect(connectorCard(page, 'slack')).toHaveCount(0); + + await search.fill('missing connector'); + await expect(page.getByTestId('connectors-empty')).toBeVisible(); + await search.press('Escape'); + await expect(page.getByTestId('connectors-empty')).toHaveCount(0); + await expect(connectorCard(page, 'github')).toBeVisible(); + await expect(connectorCard(page, 'slack')).toBeVisible(); + + await connectorCard(page, 'github').click(); + await expect(page.getByTestId('connector-drawer')).toBeVisible(); + await expect(page.getByTestId('connector-drawer')).toContainText('List issues'); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('connector-drawer')).toHaveCount(0); +}); + +async function routeConnectors(page: Page, connectors: typeof CONNECTORS) { + await page.route('**/api/connectors', async (route) => { + await route.fulfill({ json: { connectors } }); + }); + await page.route('**/api/connectors/status', async (route) => { + const statuses = Object.fromEntries( + connectors.map((connector) => [ + connector.id, + { + status: connector.status, + accountLabel: connector.accountLabel, + }, + ]), + ); + await route.fulfill({ json: { statuses } }); + }); + await page.route('**/api/connectors/discovery*', async (route) => { + await route.fulfill({ + json: { + connectors, + meta: { provider: 'composio' }, + }, + }); + }); +} + +function connectorCard(page: Page, id: string) { + return page.locator(`article.connector-card[data-connector-id="${id}"]`); +} + +async function fetchCurrentProject(page: Page) { + await expect(page).toHaveURL(/\/projects\/[^/]+/); + const url = new URL(page.url()); + const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? []; + expect(projectId).toBeTruthy(); + + const response = await page.request.get(`/api/projects/${projectId}`); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { + project: { + metadata?: { + promptTemplate?: { + id: string; + surface: string; + title: string; + prompt: string; + }; + }; + }; + }; + return body.project; +} diff --git a/e2e/ui/project-management-flows.test.ts b/e2e/ui/project-management-flows.test.ts new file mode 100644 index 0000000000..620e327ca4 --- /dev/null +++ b/e2e/ui/project-management-flows.test.ts @@ -0,0 +1,544 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +const STORAGE_KEY = 'open-design:config'; + +const DESIGN_SYSTEMS = [ + { + id: 'nexu-soft-tech', + title: 'Nexu Soft Tech', + category: 'Product', + summary: 'Warm utility system for product interfaces.', + swatches: ['#F7F4EE', '#D6CBBF', '#1F2937', '#D97757'], + }, + { + id: 'editorial-noir', + title: 'Editorial Noir', + category: 'Editorial', + summary: 'High-contrast editorial system with expressive type.', + swatches: ['#111111', '#F6EFE6', '#C44536', '#F2C14E'], + }, + { + id: 'data-mist', + title: 'Data Mist', + category: 'Analytics', + summary: 'Calm dashboard system for dense data products.', + swatches: ['#EAF4F4', '#5EAAA8', '#05668D', '#0B132B'], + }, +]; + +const TAB_SKILLS = [ + skillSummary('prototype-skill', 'Prototype Skill', 'prototype', 'web', ['prototype']), + skillSummary('live-artifact', 'live-artifact', 'prototype', 'web', []), + skillSummary('deck-skill', 'Deck Skill', 'deck', 'web', ['deck']), + skillSummary('image-skill', 'Image Skill', 'image', 'image', ['image']), +]; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((key) => { + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + }), + ); + }, STORAGE_KEY); + + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + json: { + agents: [ + { + id: 'mock', + name: 'Mock Agent', + bin: 'mock-agent', + available: true, + version: 'test', + models: [{ id: 'default', label: 'Default' }], + }, + ], + }, + }); + }); +}); + +test('new project tabs switch visible form sections and preserve drafts', async ({ page }) => { + await page.route('**/api/skills', async (route) => { + await route.fulfill({ json: { skills: TAB_SKILLS } }); + }); + await page.route('**/api/connectors', async (route) => { + await route.fulfill({ json: { connectors: [] } }); + }); + await page.route('**/api/connectors/status', async (route) => { + await route.fulfill({ json: { statuses: {} } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.newproj-title')).toContainText('New prototype'); + await expect(page.getByTestId('design-system-trigger')).toBeVisible(); + await expect(page.getByText('Fidelity', { exact: true })).toBeVisible(); + await page.getByTestId('new-project-name').fill('Prototype draft survives'); + + await page.getByTestId('new-project-tab-live-artifact').click(); + await expect(page.getByTestId('new-project-tab-live-artifact')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.newproj-title')).toContainText('New live artifact'); + await expect(page.locator('.newproj-title')).toContainText('Beta'); + await expect(page.getByTestId('design-system-picker')).toHaveCount(0); + await expect(page.getByTestId('new-project-connectors')).toBeVisible(); + await expect(page.getByTestId('create-project')).toContainText('Create live artifact'); + + await page.getByTestId('new-project-tab-deck').click(); + await expect(page.getByTestId('new-project-tab-deck')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.newproj-title')).toContainText('New slide deck'); + await expect(page.getByTestId('design-system-trigger')).toBeVisible(); + await expect(page.getByText('Use speaker notes')).toBeVisible(); + await expect(page.getByTestId('new-project-connectors')).toHaveCount(0); + + await page.getByTestId('new-project-tab-prototype').click(); + await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.newproj-title')).toContainText('New prototype'); + await expect(page.getByTestId('new-project-name')).toHaveValue('Prototype draft survives'); + + await page.getByRole('button', { name: 'Scroll project types right' }).click(); + await page.getByTestId('new-project-tab-image').click(); + await expect(page.getByTestId('new-project-tab-image')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.newproj-title')).toContainText('New image'); + await expect(page.getByTestId('design-system-picker')).toHaveCount(0); + await expect(page.getByText('Model', { exact: true })).toBeVisible(); + await expect(page.getByText('Aspect', { exact: true })).toBeVisible(); +}); + +test('design system multi-select stores primary and inspiration metadata', async ({ page }) => { + await page.route('**/api/design-systems', async (route) => { + await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } }); + }); + + await page.goto('/'); + await page.getByTestId('new-project-tab-prototype').click(); + await page.getByTestId('new-project-name').fill('Design system multi select metadata'); + + await page.getByTestId('design-system-trigger').click(); + await page.getByRole('tab', { name: /multi/i }).click(); + await page.getByRole('option', { name: /Nexu Soft Tech/i }).click(); + await page.getByRole('option', { name: /Editorial Noir/i }).click(); + await page.getByRole('option', { name: /Data Mist/i }).click(); + + await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech'); + await expect(page.getByTestId('design-system-trigger')).toContainText('+2'); + await page.keyboard.press('Escape'); + await page.getByTestId('create-project').click(); + await expectWorkspaceReady(page); + + const project = await fetchCurrentProject(page); + expect(project.designSystemId).toBe('nexu-soft-tech'); + expect(project.metadata?.inspirationDesignSystemIds).toEqual([ + 'editorial-noir', + 'data-mist', + ]); +}); + +test('design system picker searches and switches the single selected system', async ({ page }) => { + await page.route('**/api/design-systems', async (route) => { + await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } }); + }); + + await page.goto('/'); + await page.getByTestId('new-project-tab-prototype').click(); + await page.getByTestId('new-project-name').fill('Design system single switch flow'); + await expect(page.getByTestId('design-system-trigger')).toBeVisible(); + + await page.getByTestId('design-system-trigger').click(); + await page.getByTestId('design-system-search').fill('mist'); + await expect(page.getByRole('option', { name: /Data Mist/i })).toBeVisible(); + await expect(page.getByRole('option', { name: /Nexu Soft Tech/i })).toHaveCount(0); + await page.getByRole('option', { name: /Data Mist/i }).click(); + + await expect(page.getByTestId('design-system-trigger')).toContainText('Data Mist'); + await expect(page.getByTestId('design-system-trigger')).toContainText('Analytics'); + await page.getByTestId('create-project').click(); + await expectWorkspaceReady(page); + + const project = await fetchCurrentProject(page); + expect(project.designSystemId).toBe('data-mist'); + expect(project.metadata?.inspirationDesignSystemIds).toBeUndefined(); +}); + +test('project title rename persists after reload and ignores blank titles', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Original rename title'); + await expectWorkspaceReady(page); + + const title = page.getByTestId('project-title'); + await renameProjectTitle(page, title, 'Renamed persistent title'); + await expect(title).toContainText('Renamed persistent title'); + + await page.reload(); + await expectWorkspaceReady(page); + await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title'); + + await renameProjectTitle(page, page.getByTestId('project-title'), ' '); + await page.reload(); + await expectWorkspaceReady(page); + await expect(page.getByTestId('project-title')).toContainText('Renamed persistent title'); + + const project = await fetchCurrentProject(page); + expect(project.name).toBe('Renamed persistent title'); +}); + +test('canceling design file deletion keeps the file and open tab', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Design file delete cancel flow'); + await expectWorkspaceReady(page); + + const uploadedName = await uploadTinyPng(page, 'delete-cancel.png'); + const fileTab = tabBySuffix(page, uploadedName); + await expect(fileTab).toHaveAttribute('aria-selected', 'true'); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('delete-cancel.png'); + await dialog.dismiss(); + }); + await page.getByTestId('design-files-tab').click(); + await rowByFileName(page, uploadedName).hover(); + await menuByFileName(page, uploadedName).click(); + await page.getByTestId(`design-file-delete-${uploadedName}`).click(); + + await expect(rowByFileName(page, uploadedName)).toBeVisible(); + await expect(fileTab).toBeVisible(); + + const { projectId } = getProjectContextFromUrl(page); + const files = await listProjectFiles(page, projectId); + expect(files.map((file) => file.name)).toContain(uploadedName); +}); + +test('home design card deletion supports cancel and confirm flows', async ({ page }) => { + const projectName = `Home delete design flow ${Date.now()}`; + await page.goto('/'); + await createProject(page, projectName); + await expectWorkspaceReady(page); + + const { projectId } = getProjectContextFromUrl(page); + await page.getByRole('button', { name: /back to projects/i }).click(); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + + const designCard = homeDesignCard(page, projectName); + await expect(designCard).toBeVisible(); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain(projectName); + await dialog.dismiss(); + }); + await designCard.hover(); + await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click(); + await expect(designCard).toBeVisible(); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain(projectName); + await dialog.accept(); + }); + await designCard.hover(); + await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click(); + await expect(homeDesignCard(page, projectName)).toHaveCount(0); + + const response = await page.request.get(`/api/projects/${projectId}`); + expect(response.status()).toBe(404); +}); + +test('home designs view toggle switches between grid and kanban and persists', async ({ page }) => { + const projectName = `Home view toggle flow ${Date.now()}`; + await page.goto('/'); + await createProject(page, projectName); + await expectWorkspaceReady(page); + + await page.getByRole('button', { name: /back to projects/i }).click(); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await expect(homeDesignCard(page, projectName)).toBeVisible(); + await expect(page.locator('.design-grid')).toBeVisible(); + await expect(page.locator('.design-kanban-board')).toHaveCount(0); + await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true'); + + await page.getByTestId('designs-view-kanban').click(); + await expect(page.locator('.design-kanban-board')).toBeVisible(); + await expect(page.locator('.design-grid')).toHaveCount(0); + await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('.design-kanban-card', { hasText: projectName })).toBeVisible(); + + await page.reload(); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await expect(page.locator('.design-kanban-board')).toBeVisible(); + await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true'); + + await page.getByTestId('designs-view-grid').click(); + await expect(page.locator('.design-grid')).toBeVisible(); + await expect(homeDesignCard(page, projectName)).toBeVisible(); + await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true'); +}); + +test('home designs search filters projects and recovers from no results', async ({ page }) => { + const stamp = Date.now(); + const alphaName = `Home search alpha ${stamp}`; + const betaName = `Home search beta ${stamp}`; + await page.goto('/'); + + await createProject(page, alphaName); + await expectWorkspaceReady(page); + await page.getByRole('button', { name: /back to projects/i }).click(); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + + await createProject(page, betaName); + await expectWorkspaceReady(page); + await page.getByRole('button', { name: /back to projects/i }).click(); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await expect(homeDesignCard(page, alphaName)).toBeVisible(); + await expect(homeDesignCard(page, betaName)).toBeVisible(); + + const search = page.locator('.tab-panel-toolbar .toolbar-search input'); + await search.fill('alpha'); + await expect(homeDesignCard(page, alphaName)).toBeVisible(); + await expect(homeDesignCard(page, betaName)).toHaveCount(0); + + await search.fill(`missing-${stamp}`); + await expect(homeDesignCard(page, alphaName)).toHaveCount(0); + await expect(homeDesignCard(page, betaName)).toHaveCount(0); + await expect(page.locator('.tab-empty')).toBeVisible(); + + await search.fill(''); + await expect(homeDesignCard(page, alphaName)).toBeVisible(); + await expect(homeDesignCard(page, betaName)).toBeVisible(); +}); + +test('change pet opens pet settings and saves a custom companion', async ({ page }) => { + await seedAdoptedPet(page); + await page.route('**/api/codex-pets', async (route) => { + await route.fulfill({ json: { pets: [], rootDir: '' } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + + await page + .locator('.entry-side-foot') + .getByRole('button', { name: /change pet/i }) + .click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: 'Pets' })).toBeVisible(); + + await dialog.getByRole('tab', { name: 'Custom' }).click(); + const customPanel = dialog.locator('.pet-custom'); + await expect(customPanel).toBeVisible(); + + await customPanel.getByLabel('Name').fill('QA Turtle'); + await customPanel.getByLabel('Glyph').fill('🐢'); + await customPanel.getByLabel('Greeting').fill('Shell yeah, tests are green.'); + await expect(customPanel.getByRole('button', { name: /adopted/i })).toBeVisible(); + + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator('.pet-overlay .pet-sprite')).toHaveAttribute( + 'aria-label', + /QA Turtle/i, + ); + + const petConfig = await readPetConfig(page); + expect(petConfig).toMatchObject({ + adopted: true, + enabled: true, + petId: 'custom', + custom: { + name: 'QA Turtle', + glyph: '🐢', + greeting: 'Shell yeah, tests are green.', + }, + }); +}); + +async function createProject( + page: Page, + projectName: string, +) { + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await page.getByTestId('new-project-tab-prototype').click(); + await page.getByTestId('new-project-name').fill(projectName); + await page.getByTestId('create-project').click(); +} + +async function expectWorkspaceReady(page: Page) { + await expect(page).toHaveURL(/\/projects\//); + await expect(page.getByTestId('chat-composer')).toBeVisible(); + await expect(page.getByTestId('file-workspace')).toBeVisible(); + await expect(page.getByText('Start a conversation')).toBeVisible(); +} + +async function renameProjectTitle( + page: Page, + title: Locator, + nextName: string, +) { + await title.click(); + await page.keyboard.press('Meta+A'); + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ''); + if (selected.length === 0) { + await page.keyboard.press('Control+A'); + } + await page.keyboard.type(nextName); + await page.keyboard.press('Enter'); +} + +async function uploadTinyPng( + page: Page, + name: string, +): Promise { + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=', + 'base64', + ); + await page.getByTestId('design-files-upload-input').setInputFiles({ + name, + mimeType: 'image/png', + buffer: pngBytes, + }); + await expect(tabBySuffix(page, name)).toBeVisible(); + const { projectId } = getProjectContextFromUrl(page); + const files = await listProjectFiles(page, projectId); + const uploaded = files.find((file) => file.name.endsWith(name)); + expect(uploaded?.name).toBeTruthy(); + return uploaded!.name; +} + +function tabBySuffix(page: Page, name: string): Locator { + return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') }); +} + +function rowByFileName(page: Page, name: string): Locator { + return page.getByTestId(`design-file-row-${name}`); +} + +function menuByFileName(page: Page, name: string): Locator { + return page.getByTestId(`design-file-menu-${name}`); +} + +function homeDesignCard(page: Page, name: string): Locator { + return page.locator('.design-card', { + has: page.locator('.design-card-name', { hasText: name }), + }); +} + +async function seedAdoptedPet(page: Page) { + await page.addInitScript((key) => { + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + pet: { + adopted: true, + enabled: true, + petId: 'custom', + custom: { + name: 'Original Buddy', + glyph: '🦄', + accent: '#c96442', + greeting: 'Ready to pair.', + }, + }, + }), + ); + }, STORAGE_KEY); +} + +async function readPetConfig(page: Page) { + return page.evaluate((key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw).pet : null; + }, STORAGE_KEY) as Promise<{ + adopted: boolean; + enabled: boolean; + petId: string; + custom: { + name: string; + glyph: string; + greeting: string; + }; + } | null>; +} + +async function fetchCurrentProject(page: Page) { + const { projectId } = getProjectContextFromUrl(page); + const response = await page.request.get(`/api/projects/${projectId}`); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { + project: { + name: string; + designSystemId: string | null; + metadata?: { + inspirationDesignSystemIds?: string[]; + }; + }; + }; + return body.project; +} + +async function listProjectFiles(page: Page, projectId: string) { + const response = await page.request.get(`/api/projects/${projectId}/files`); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { files: Array<{ name: string }> }; + return body.files; +} + +function getProjectContextFromUrl(page: Page) { + const url = new URL(page.url()); + const [, projectId] = url.pathname.match(/\/projects\/([^/]+)/) ?? []; + if (!projectId) throw new Error(`unexpected project route: ${url.pathname}`); + return { projectId }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function skillSummary( + id: string, + name: string, + mode: 'prototype' | 'deck' | 'image', + surface: 'web' | 'image', + defaultFor: string[], +) { + return { + id, + name, + description: `${name} for tab switching coverage.`, + triggers: [], + mode, + surface, + platform: 'desktop', + scenario: 'qa', + previewType: 'html', + designSystemRequired: false, + defaultFor, + upstream: null, + featured: null, + fidelity: null, + speakerNotes: null, + animations: null, + hasBody: true, + examplePrompt: '', + }; +} diff --git a/e2e/ui/settings-api-protocol.test.ts b/e2e/ui/settings-api-protocol.test.ts new file mode 100644 index 0000000000..8601ed3041 --- /dev/null +++ b/e2e/ui/settings-api-protocol.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const STORAGE_KEY = 'open-design:config'; + +async function bootstrapWithLegacyConfig( + page: Page, + config: Record, +) { + await page.addInitScript( + ({ key, value }) => { + window.localStorage.setItem(key, JSON.stringify(value)); + }, + { key: STORAGE_KEY, value: config }, + ); + + await page.route('**/api/health', async (route) => { + await route.fulfill({ status: 503, body: 'offline' }); + }); + + await page.goto('/'); + await page.getByTitle('Configure execution mode').click(); + await expect(page.getByRole('dialog')).toBeVisible(); +} + +test('legacy known OpenAI provider switches to the matching Anthropic preset', async ({ page }) => { + await bootstrapWithLegacyConfig(page, { + mode: 'api', + apiKey: 'sk-test', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + }); + + const protocolTabs = page.getByRole('tablist', { name: 'API protocol' }); + const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true }); + const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true }); + const baseUrlInput = page.getByLabel('Base URL'); + const modelSelect = page.getByLabel('Model'); + + await expect(openAiTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', { name: 'OpenAI API' })).toBeVisible(); + await expect(baseUrlInput).toHaveValue('https://api.deepseek.com'); + await expect(modelSelect).toHaveValue('deepseek-chat'); + + await anthropicTab.click(); + + await expect(anthropicTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', { name: 'Anthropic API' })).toBeVisible(); + await expect(baseUrlInput).toHaveValue('https://api.deepseek.com/anthropic'); + await expect(modelSelect).toHaveValue('deepseek-chat'); +}); + +test('legacy custom provider preserves custom baseUrl and model when switching protocols', async ({ page }) => { + await bootstrapWithLegacyConfig(page, { + mode: 'api', + apiKey: 'sk-test', + baseUrl: 'https://my-proxy.example.com/v1', + model: 'my-custom-model', + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + }); + + const protocolTabs = page.getByRole('tablist', { name: 'API protocol' }); + const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true }); + const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true }); + const baseUrlInput = page.getByLabel('Base URL'); + const customModelInput = page.getByLabel(/Custom model id/i); + + await expect(openAiTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', { name: 'OpenAI API' })).toBeVisible(); + await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1'); + await expect(customModelInput).toHaveValue('my-custom-model'); + + await anthropicTab.click(); + + await expect(anthropicTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', { name: 'Anthropic API' })).toBeVisible(); + await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1'); + await expect(customModelInput).toHaveValue('my-custom-model'); +}); diff --git a/e2e/ui/workspace-keyboard-flows.test.ts b/e2e/ui/workspace-keyboard-flows.test.ts new file mode 100644 index 0000000000..58ba6e9d91 --- /dev/null +++ b/e2e/ui/workspace-keyboard-flows.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +const STORAGE_KEY = 'open-design:config'; +const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((key) => { + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + }), + ); + }, STORAGE_KEY); + + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + json: { + agents: [ + { + id: 'mock', + name: 'Mock Agent', + bin: 'mock-agent', + available: true, + version: 'test', + models: [{ id: 'default', label: 'Default' }], + }, + ], + }, + }); + }); +}); + +test('quick switcher opens from keyboard and activates the selected file', async ({ page }) => { + await page.goto('/'); + 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 page.goto('/'); + 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 page.goto('/'); + 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 page.goto('/'); + 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); +}); + +async function createProject( + page: Page, + projectName: string, +) { + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await page.getByTestId('new-project-tab-prototype').click(); + await page.getByTestId('new-project-name').fill(projectName); + await page.getByTestId('create-project').click(); +} + +async function expectWorkspaceReady(page: Page) { + await expect(page).toHaveURL(/\/projects\//); + await expect(page.getByTestId('chat-composer')).toBeVisible(); + await expect(page.getByTestId('file-workspace')).toBeVisible(); + await expect(page.getByText('Start a conversation')).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 readChatPanelWidth(handle: Locator): Promise { + 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(); +} + +function tabBySuffix(page: Page, name: string): Locator { + return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') }); +} + +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, '\\$&'); +}