mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
test: add desktop settings and project flow e2e coverage (#306)
* test: add desktop settings regression coverage * test: stabilize desktop smoke interactions on latest main * fixer: address PR #306 follow-up items Generated-By: looper 0.2.7 (runner=fixer, agent=codex) * test: expand ui e2e automation suite * fix: add missing Ukrainian prompt template labels * chore: align desktop e2e helpers with layout guard * chore: move settings protocol e2e into ui suite * fix: preserve api provider settings across protocol switches * fix: avoid leaking api keys across protocol drafts * test: fold desktop smoke coverage into mac spec * fix: dedupe Ukrainian prompt template labels
This commit is contained in:
parent
3298cb3756
commit
8301bcd46e
8 changed files with 1656 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
209
e2e/lib/desktop/desktop-test-helpers.ts
Normal file
209
e2e/lib/desktop/desktop-test-helpers.ts
Normal file
|
|
@ -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<T = unknown>(expression: string): Promise<T> {
|
||||
const result = await runToolsDevJson<DesktopEvalResult>([
|
||||
'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<string, unknown>, 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>(`
|
||||
(() => 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<boolean>(`
|
||||
(() => {
|
||||
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<DesktopStatus> {
|
||||
return await runToolsDevJson<DesktopStatus>([
|
||||
'inspect',
|
||||
'desktop',
|
||||
'status',
|
||||
'--namespace',
|
||||
namespace,
|
||||
'--json',
|
||||
]);
|
||||
}
|
||||
|
||||
export async function waitFor(
|
||||
fn: () => void | Promise<void>,
|
||||
timeoutMs = 20_000,
|
||||
intervalMs = 250,
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
const { stdout } = await execFileAsync('pnpm', ['tools-dev', ...args], {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function runToolsDevJson<T>(args: string[]): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<T>(action: string, extraArgs: string[] = []): Promise<T> {
|
||||
const args = [
|
||||
'exec',
|
||||
|
|
@ -200,6 +322,155 @@ async function runToolsPackJson<T>(action: string, extraArgs: string[] = []): Pr
|
|||
}
|
||||
}
|
||||
|
||||
type DesktopHarness = ReturnType<typeof createDesktopHarness>;
|
||||
|
||||
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<string, unknown>,
|
||||
stableField: string,
|
||||
): Promise<void> {
|
||||
await desktop.seedConfigAndReload(config, stableField);
|
||||
}
|
||||
|
||||
async function openDesktopSettingsSection(
|
||||
desktop: DesktopHarness,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const clicked = await desktop.eval<boolean>(`
|
||||
(() => {
|
||||
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<void> {
|
||||
const clicked = await desktop.eval<boolean>(`
|
||||
(() => {
|
||||
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<void> {
|
||||
const clicked = await desktop.eval<boolean>(`
|
||||
(() => {
|
||||
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<void> {
|
||||
const clicked = await desktop.eval<boolean>(`
|
||||
(() => {
|
||||
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<DesktopSettingsSnapshot> {
|
||||
return await desktop.eval<DesktopSettingsSnapshot>(`
|
||||
(() => {
|
||||
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<DesktopAppearanceSnapshot> {
|
||||
return await desktop.eval<DesktopAppearanceSnapshot>(`
|
||||
(() => {
|
||||
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<MacInspectResult> {
|
||||
const timeoutMs = 90_000;
|
||||
const startedAt = Date.now();
|
||||
|
|
|
|||
239
e2e/ui/entry-configuration-flows.test.ts
Normal file
239
e2e/ui/entry-configuration-flows.test.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
544
e2e/ui/project-management-flows.test.ts
Normal file
544
e2e/ui/project-management-flows.test.ts
Normal file
|
|
@ -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<string> {
|
||||
const pngBytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
await page.getByTestId('design-files-upload-input').setInputFiles({
|
||||
name,
|
||||
mimeType: 'image/png',
|
||||
buffer: pngBytes,
|
||||
});
|
||||
await expect(tabBySuffix(page, name)).toBeVisible();
|
||||
const { projectId } = getProjectContextFromUrl(page);
|
||||
const files = await listProjectFiles(page, projectId);
|
||||
const uploaded = files.find((file) => file.name.endsWith(name));
|
||||
expect(uploaded?.name).toBeTruthy();
|
||||
return uploaded!.name;
|
||||
}
|
||||
|
||||
function tabBySuffix(page: Page, name: string): Locator {
|
||||
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}$`, 'i') });
|
||||
}
|
||||
|
||||
function rowByFileName(page: Page, name: string): Locator {
|
||||
return page.getByTestId(`design-file-row-${name}`);
|
||||
}
|
||||
|
||||
function menuByFileName(page: Page, name: string): Locator {
|
||||
return page.getByTestId(`design-file-menu-${name}`);
|
||||
}
|
||||
|
||||
function homeDesignCard(page: Page, name: string): Locator {
|
||||
return page.locator('.design-card', {
|
||||
has: page.locator('.design-card-name', { hasText: 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: '',
|
||||
};
|
||||
}
|
||||
90
e2e/ui/settings-api-protocol.test.ts
Normal file
90
e2e/ui/settings-api-protocol.test.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
) {
|
||||
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');
|
||||
});
|
||||
232
e2e/ui/workspace-keyboard-flows.test.ts
Normal file
232
e2e/ui/workspace-keyboard-flows.test.ts
Normal file
|
|
@ -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<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();
|
||||
}
|
||||
|
||||
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, '\\$&');
|
||||
}
|
||||
Loading…
Reference in a new issue