Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s

This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
marco
2026-05-06 20:50:24 +02:00
commit 5dd70b5016
1336 changed files with 287186 additions and 0 deletions

40
e2e/AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# e2e/AGENTS.md
Follow the root `AGENTS.md` first. This package owns user-level end-to-end smoke tests and Playwright UI automation only.
## Directory layout
- `specs/`: highest-ROI end-to-end smoke tests suitable for PR or release gating. Keep this layer small and expand it only for regressions that justify always-on signal.
- `tests/`: broader user-level end-to-end coverage, including Vitest checks that intentionally span app/package/resource boundaries. Add feature-depth scenarios here instead of bloating `specs/`.
- `ui/`: flat Playwright UI automation test files only. Keep helpers, resources, and non-Playwright harnesses out of this directory.
- `resources/`: declarative resources for e2e suites, such as Playwright UI scenario lists.
- `lib/shared.ts`: tiny cross-suite shared helpers only.
- `lib/vitest/`: Vitest-specific helpers.
- `lib/playwright/`: Playwright-specific fixtures, resource accessors, route helpers, and UI actions.
- `scripts/playwright.ts`: Playwright auxiliary subcommands such as artifact cleanup; it must not wrap `playwright test`.
## Naming and tools
- `specs/` files must be `*.spec.ts`.
- `tests/` files must be `*.test.ts`.
- `ui/` files must be flat `*.test.ts` Playwright tests. Do not add subdirectories, TSX, Vitest, jsdom, Testing Library, or React harness tests under `ui/`.
- E2E Vitest tests use Node APIs; do not add JSX/TSX, jsdom, or browser-component tests under `specs/` or `tests/`.
- Web component/runtime tests belong in `apps/web/tests/`, not `e2e/ui/`.
- E2E tests may validate cross-app/resource consistency, but must not treat one app's private implementation as a shared helper for another app. Keep test-only helpers local to `e2e/lib/` or promote reusable logic to a pure package such as `packages/contracts`.
- E2E imports may use `@/*` for `lib/*`; keep this alias local to the e2e package.
## Commands
Run commands from this directory:
```bash
pnpm test specs/mac.spec.ts
pnpm test specs
pnpm test tests
pnpm typecheck
pnpm exec tsx scripts/playwright.ts clean
pnpm exec playwright test -c playwright.config.ts --list
pnpm exec playwright test -c playwright.config.ts
```
Use a specific file path when validating a single case. Do not add root e2e aliases or extra package scripts for individual cases.

View 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;
}

View File

@@ -0,0 +1,51 @@
import { playwrightUiScenarios } from '../../resources/playwright.ts';
export type ScenarioKind = 'prototype' | 'deck' | 'template' | 'workspace';
export interface MockArtifactScenario {
identifier: string;
title: string;
html: string;
fileName: string;
heading: string;
}
export interface UiScenario {
id: string;
title: string;
kind: ScenarioKind;
flow?:
| 'standard'
| 'design-system-selection'
| 'example-use-prompt'
| 'conversation-persistence'
| 'file-mention'
| 'deep-link-preview'
| 'file-upload-send'
| 'design-files-upload'
| 'design-files-delete'
| 'design-files-tab-persistence'
| 'conversation-delete-recovery'
| 'question-form-selection-limit'
| 'question-form-submit-persistence'
| 'generation-does-not-create-extra-file'
| 'comment-attachment-flow'
| 'deck-pagination-next-prev-correctness'
| 'deck-pagination-per-file-isolated'
| 'uploaded-image-renders-in-preview'
| 'python-source-preview';
automated: boolean;
description: string;
create: {
projectName: string;
tab?: 'prototype' | 'deck' | 'template' | 'other';
};
prompt: string;
secondaryPrompt?: string;
mockArtifact?: MockArtifactScenario;
notes?: string[];
}
export function automatedUiScenarios(): UiScenario[] {
return playwrightUiScenarios.filter((scenario) => scenario.automated);
}

1
e2e/lib/shared.ts Normal file
View File

@@ -0,0 +1 @@
export {};

20
e2e/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@open-design/e2e",
"version": "0.4.1",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "^20.17.10",
"tsx": "4.21.0",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}

48
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import { defineConfig, devices } from '@playwright/test';
const daemonPort = Number(process.env.OD_PORT) || 17_456;
const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
const baseURL = `http://127.0.0.1:${webPort}`;
export default defineConfig({
testDir: './ui',
outputDir: './ui/reports/test-results',
timeout: 30_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
reporter: process.env.CI
? [
['github'],
['list'],
['html', { open: 'never', outputFolder: './ui/reports/playwright-html-report' }],
['json', { outputFile: './ui/reports/results.json' }],
['junit', { outputFile: './ui/reports/junit.xml' }],
]
: [
['list'],
['html', { open: 'never', outputFolder: './ui/reports/playwright-html-report' }],
['json', { outputFile: './ui/reports/results.json' }],
['junit', { outputFile: './ui/reports/junit.xml' }],
],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command:
`OD_DATA_DIR=e2e/ui/.od-data ` +
`pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

401
e2e/resources/playwright.ts Normal file
View File

@@ -0,0 +1,401 @@
import type { UiScenario } from '@/playwright/resources';
export const playwrightUiScenarios: UiScenario[] = [
{
id: 'prototype-basic',
title: 'Prototype project creates and previews a generated artifact',
kind: 'prototype',
flow: 'standard',
automated: true,
description:
'Validates the primary happy path: create a prototype project, send one prompt, persist the generated HTML, and render it in the preview iframe.',
create: {
projectName: 'UI automation smoke',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'This is the seed smoke test and should stay fast.',
'It uses mocked SSE so the UI path stays deterministic.',
],
},
{
id: 'deck-basic',
title: 'Deck project renders a mocked slide artifact',
kind: 'deck',
flow: 'standard',
automated: true,
description:
'Covers the deck tab in project creation and verifies that a deck artifact lands in the workspace preview.',
create: {
projectName: 'Deck automation smoke',
tab: 'deck',
},
prompt: 'Create a short deck with two slides',
mockArtifact: {
identifier: 'mock-deck',
title: 'Mock Deck',
fileName: 'mock-deck.html',
heading: 'Mock Deck',
html:
'<!doctype html><html><body><section class="slide"><h1>Mock Deck</h1></section></body></html>',
},
notes: [
'Confirms the deck creation tab still routes into the same generation path.',
],
},
{
id: 'comment-attachment-flow',
title: 'Preview comments attach to chat and send as structured context',
kind: 'prototype',
flow: 'comment-attachment-flow',
automated: true,
description:
'Exercises V1 comment mode: save a latest element comment, attach/remove it from the composer, and send it as an empty visible prompt with structured comment context.',
create: {
projectName: 'Comment attachment flow',
tab: 'prototype',
},
prompt: 'Create a commentable preview artifact',
mockArtifact: {
identifier: 'commentable-artifact',
title: 'Commentable Artifact',
fileName: 'commentable-artifact.html',
heading: 'Prototype headline',
html:
'<!doctype html><html><body><main data-od-id="hero-section"><h1 data-od-id="hero-title" data-screen-label="Hero title">Prototype headline</h1><p data-od-id="hero-copy">Preview copy for comment mode.</p></main></body></html>',
},
notes: [
'The composer textarea stays empty; selected preview comments are sent through commentAttachments.',
],
},
{
id: 'design-system-selection',
title: 'Selecting a design system carries through project creation',
kind: 'prototype',
flow: 'design-system-selection',
automated: true,
description:
'Verifies that a chosen design system is selectable in the new-project panel and remains visible in project metadata after creation.',
create: {
projectName: 'Design system selection',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
notes: [
'Uses a mocked design-system list so the picker stays deterministic across environments.',
'Focuses on creation and metadata persistence instead of generation output.',
],
},
{
id: 'example-use-prompt',
title: 'Using an example prompt creates a project with a seeded draft',
kind: 'prototype',
flow: 'example-use-prompt',
automated: true,
description:
'Verifies the Examples tab fast path: click Use this prompt, create a project immediately, and carry the example prompt into the chat composer.',
create: {
projectName: 'Example prompt project',
tab: 'prototype',
},
prompt: 'Draft a warm utility landing page for a productivity app',
notes: [
'Uses a mocked skills list so the examples gallery stays deterministic.',
'Targets the pendingPrompt fast-create path instead of the standard new-project form.',
],
},
{
id: 'conversation-persistence',
title: 'Conversation history survives refresh and switching',
kind: 'workspace',
flow: 'conversation-persistence',
automated: true,
description:
'Exercises conversation creation, persistence, refresh reload, and switching between threads in one project.',
create: {
projectName: 'Conversation persistence',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
secondaryPrompt: 'Create another artifact in a fresh conversation',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Should use the same mock SSE flow as the prototype smoke path.',
'Reload should keep the original conversation content available from the history menu.',
],
},
{
id: 'file-mention',
title: 'Uploaded files can be mentioned and sent back to the agent',
kind: 'workspace',
flow: 'file-mention',
automated: true,
description:
'Validates the upload, staged attachment, and @ mention flow inside the chat composer.',
create: {
projectName: 'File mention flow',
tab: 'prototype',
},
prompt: 'Review @reference.txt and use it as context',
notes: [
'Seeds a tiny text fixture through the project file API, then exercises the composer mention flow.',
],
},
{
id: 'deep-link-preview',
title: 'Deep-linking to a file route opens the expected preview tab',
kind: 'workspace',
flow: 'deep-link-preview',
automated: true,
description:
'Verifies that /projects/:id/files/:name restores the matching open tab and preview frame after navigation or refresh.',
create: {
projectName: 'Deep link preview',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Can reuse the generated HTML from prototype-basic, then revisit with a routed URL.',
],
},
{
id: 'file-upload-send',
title: 'Composer file picker uploads a file and sends it with the prompt',
kind: 'workspace',
flow: 'file-upload-send',
automated: true,
description:
'Exercises the real attach button and hidden file input, then verifies the staged file is sent and shown back on the user message.',
create: {
projectName: 'File upload send flow',
tab: 'prototype',
},
prompt: 'Use the uploaded reference as context',
notes: [
'Uses Playwright setInputFiles on the hidden composer picker instead of seeding through the API.',
],
},
{
id: 'design-files-upload',
title: 'Design Files panel uploads an image and opens it in the workspace',
kind: 'workspace',
flow: 'design-files-upload',
automated: true,
description:
'Exercises the Design Files upload flow in the workspace, then verifies the uploaded image can be previewed and opened as a tab.',
create: {
projectName: 'Design files upload flow',
tab: 'prototype',
},
prompt: 'Upload an image through the design files browser',
notes: [
'Uses the FileWorkspace upload input rather than the chat composer upload path.',
],
},
{
id: 'design-files-delete',
title: 'Design Files panel deletes an uploaded file and clears its tab',
kind: 'workspace',
flow: 'design-files-delete',
automated: true,
description:
'Uploads a file through the Design Files panel, deletes it from the row menu, and verifies it disappears from both the list and open tabs.',
create: {
projectName: 'Design files delete flow',
tab: 'prototype',
},
prompt: 'Delete an uploaded image through the design files browser',
notes: [
'Builds on the same workspace file flow as design-files-upload, then verifies cleanup behavior.',
],
},
{
id: 'design-files-tab-persistence',
title: 'Open file tabs survive refresh with the correct active tab',
kind: 'workspace',
flow: 'design-files-tab-persistence',
automated: true,
description:
'Uploads multiple files through the Design Files flow, switches the active tab, reloads the page, and verifies both the tab set and selected tab are restored.',
create: {
projectName: 'Design files tab persistence',
tab: 'prototype',
},
prompt: 'Restore open file tabs after refresh',
notes: [
'Covers the persisted tabs state stored by ProjectView and restored by FileWorkspace.',
],
},
{
id: 'conversation-delete-recovery',
title: 'Deleting the active conversation falls back cleanly',
kind: 'workspace',
flow: 'conversation-delete-recovery',
automated: true,
description:
'Creates multiple conversations, deletes the active one, and verifies the UI falls back to the remaining thread instead of getting stuck.',
create: {
projectName: 'Conversation delete recovery',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
secondaryPrompt: 'Create another artifact before deleting this thread',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Confirms the project still has a live conversation after deleting the current thread.',
],
},
{
id: 'question-form-selection-limit',
title: 'Question form checkbox limits block selecting more than the allowed maximum',
kind: 'workspace',
flow: 'question-form-selection-limit',
automated: true,
description:
'Verifies that a discovery-style checkbox question with maxSelections=2 cannot be pushed past two selected options.',
create: {
projectName: 'Question form selection limit',
tab: 'prototype',
},
prompt: 'Help me plan a restaurant homepage',
notes: [
'Mocks a question-form response instead of an artifact so the test can exercise the inline clarifying UI.',
'Confirms both the interaction guard and the rendered checked state stay capped at two options.',
],
},
{
id: 'question-form-submit-persistence',
title: 'Question form answers persist into chat history and reload in a locked state',
kind: 'workspace',
flow: 'question-form-submit-persistence',
automated: true,
description:
'Verifies that answering a question form writes a user follow-up message, then rehydrates the form in an answered and locked state after reload.',
create: {
projectName: 'Question form submit persistence',
tab: 'prototype',
},
prompt: 'Plan a small restaurant homepage',
notes: [
'Mocks an inline question form on the first assistant turn and a plain acknowledgment on the follow-up turn.',
'Confirms the answered state survives a full page reload instead of relying only on local submit state.',
],
},
{
id: 'generation-does-not-create-extra-file',
title: 'Generated artifacts stay stable when no new prompt is sent',
kind: 'workspace',
flow: 'generation-does-not-create-extra-file',
automated: true,
description:
'Generates one HTML artifact, then verifies reload and idle time do not create any additional project files without a new user prompt.',
create: {
projectName: 'No extra generated file',
tab: 'prototype',
},
prompt: 'Create one landing page artifact',
mockArtifact: {
identifier: 'stable-artifact',
title: 'Stable Artifact',
fileName: 'stable-artifact.html',
heading: 'Stable Artifact',
html:
'<!doctype html><html><body><main><h1>Stable Artifact</h1><p>Only one file should exist.</p></main></body></html>',
},
notes: [
'Targets the trust-sensitive bug where a project can appear to generate a fresh file on its own.',
'Uses the files API after reload to assert the project file set is unchanged.',
],
},
{
id: 'deck-pagination-next-prev-correctness',
title: 'Deck preview previous and next controls move in the correct direction',
kind: 'deck',
flow: 'deck-pagination-next-prev-correctness',
automated: false,
description:
'Should verify that deck preview pagination moves to the actual previous and next slide instead of routing both actions to the same page.',
create: {
projectName: 'Deck pagination controls',
tab: 'deck',
},
prompt: 'Review pagination behavior in a multi-slide deck preview',
},
{
id: 'deck-pagination-per-file-isolated',
title: 'Each HTML deck tab preserves its own pagination state',
kind: 'deck',
flow: 'deck-pagination-per-file-isolated',
automated: false,
description:
'Should verify that switching between multiple deck HTML files does not leak page position across tabs or reset both files to page 1.',
create: {
projectName: 'Deck pagination isolation',
tab: 'deck',
},
prompt: 'Keep pagination state isolated per generated deck file',
},
{
id: 'uploaded-image-renders-in-preview',
title: 'Uploaded reference images render correctly in generated deck preview',
kind: 'workspace',
flow: 'uploaded-image-renders-in-preview',
automated: false,
description:
'Should verify that uploaded images resolve to loadable src paths inside generated HTML instead of rendering as broken images.',
create: {
projectName: 'Uploaded image preview render',
tab: 'prototype',
},
prompt: 'Use uploaded brand images inside a generated deck preview',
},
{
id: 'python-source-preview',
title: 'Python files should open with a readable inline source preview',
kind: 'workspace',
flow: 'python-source-preview',
automated: false,
description:
'Should verify that opening a .py file in the main workspace renders a readable source/code preview instead of an unsupported blank state.',
create: {
projectName: 'Python source preview',
tab: 'prototype',
},
prompt: 'Open a generated Python file and inspect its source inline',
notes: [
'Candidate follow-up to the Python preview gap in the file viewer.',
'Likely automation shape: seed a .py file through the project files API, open it, and assert the viewer renders code text.',
],
},
];

53
e2e/scripts/playwright.ts Normal file
View File

@@ -0,0 +1,53 @@
import { mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const e2eDir = path.resolve(scriptDir, '..');
const uiDir = path.join(e2eDir, 'ui');
type Command = () => Promise<void>;
const commands: Record<string, Command> = {
clean: cleanArtifacts,
help: async () => printUsage(),
};
const commandName = process.argv[2] ?? 'help';
const command = commands[commandName];
if (command == null) {
console.error(`Unknown e2e Playwright helper command: ${commandName}`);
printUsage();
process.exitCode = 1;
} else {
await command();
}
async function cleanArtifacts(): Promise<void> {
const targets = [
path.join(uiDir, '.od-data'),
path.join(uiDir, 'test-results'),
path.join(uiDir, 'reports', 'test-results'),
path.join(uiDir, 'reports', 'html'),
path.join(uiDir, 'reports', 'playwright-html-report'),
path.join(uiDir, 'reports', 'results.json'),
path.join(uiDir, 'reports', 'junit.xml'),
path.join(uiDir, '.DS_Store'),
];
await Promise.all(targets.map((target) => rm(target, { recursive: true, force: true })));
await mkdir(path.join(uiDir, 'reports', 'test-results'), { recursive: true });
await mkdir(path.join(uiDir, '.od-data'), { recursive: true });
console.log('Cleaned e2e UI Playwright artifacts.');
}
function printUsage(): void {
console.log(`Usage: tsx scripts/playwright.ts <command>
Commands:
clean Remove e2e UI Playwright runtime data and reports
help Show this help
`);
}

579
e2e/specs/mac.spec.ts Normal file
View File

@@ -0,0 +1,579 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
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)));
const workspaceRoot = dirname(e2eRoot);
const toolsPackDir = resolveFromWorkspace(process.env.OD_PACKAGED_E2E_TOOLS_PACK_DIR ?? '.tmp/tools-pack');
const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'release-beta';
const pnpmCommand = process.env.OD_E2E_PNPM_COMMAND ?? 'pnpm';
const outputNamespaceRoot = join(toolsPackDir, 'out', 'mac', 'namespaces', namespace);
const runtimeNamespaceRoot = join(toolsPackDir, 'runtime', 'mac', 'namespaces', namespace);
const healthExpression = `
(async () => {
const response = await fetch('/api/health');
return {
health: await response.json(),
href: location.href,
status: response.status,
title: document.title,
};
})()
`;
type DesktopStatus = {
state?: string;
title?: string | null;
url?: string | null;
windowVisible?: boolean;
};
type MacInstallResult = {
detached: boolean;
dmgPath: string;
installedAppPath: string;
mountPoint: string;
namespace: string;
};
type MacStartResult = {
appPath: string;
executablePath: string;
logPath: string;
namespace: string;
pid: number;
source: string;
status: DesktopStatus | null;
};
type MacStopResult = {
namespace: string;
remainingPids: number[];
status: string;
};
type MacUninstallResult = {
installedAppPath: string;
namespace: string;
removed: boolean;
stop: MacStopResult;
};
type MacInspectResult = {
eval?: {
error?: string;
ok: boolean;
value?: unknown;
};
status: DesktopStatus | null;
};
type LogsResult = {
logs: Record<string, { lines: string[]; logPath: string }>;
namespace: string;
};
type HealthEvalValue = {
health: {
ok?: unknown;
service?: unknown;
version?: unknown;
};
href: string;
status: number;
title: string;
};
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;
let started = false;
test('installs, starts, inspects, stops, and uninstalls the built mac artifact', async () => {
let passed = false;
try {
const install = await runToolsPackJson<MacInstallResult>('install');
installedAppPath = install.installedAppPath;
expect(install.namespace).toBe(namespace);
expect(install.detached).toBe(true);
expectPathInside(install.dmgPath, join(outputNamespaceRoot, 'dmg'));
expectPathInside(install.installedAppPath, join(outputNamespaceRoot, 'install', 'Applications'));
const start = await runToolsPackJson<MacStartResult>('start');
started = true;
expect(start.namespace).toBe(namespace);
expect(start.source).toBe('installed');
expect(start.appPath).toBe(install.installedAppPath);
expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
expect(start.status).not.toBeNull();
expect(start.status?.state).toBe('running');
const inspect = await waitForHealthyDesktop();
expect(inspect.status?.state).toBe('running');
expect(inspect.status?.url).toMatch(/^(od:\/\/app\/|http:\/\/127\.0\.0\.1:\d+\/)/);
const value = assertHealthEvalValue(inspect.eval?.value);
expect(value.href).toMatch(/^(od:\/\/app\/|http:\/\/127\.0\.0\.1:\d+\/)/);
expect(value.status).toBe(200);
expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String));
assertLogPathsAndContent(await runToolsPackJson<LogsResult>('logs'));
const stop = await runToolsPackJson<MacStopResult>('stop');
started = false;
expect(stop.namespace).toBe(namespace);
expect(stop.status).not.toBe('partial');
expect(stop.remainingPids).toEqual([]);
const uninstall = await runToolsPackJson<MacUninstallResult>('uninstall');
installedAppPath = null;
expect(uninstall.namespace).toBe(namespace);
expect(uninstall.installedAppPath).toBe(install.installedAppPath);
expect(uninstall.removed).toBe(true);
expect(await pathExists(install.installedAppPath)).toBe(false);
passed = true;
} finally {
if (!passed) {
await printPackagedLogs().catch((error: unknown) => {
console.error('failed to read packaged mac logs after failure', error);
});
}
if (started || installedAppPath != null) {
await runToolsPackJson<MacUninstallResult>('uninstall').catch((error: unknown) => {
console.error('failed to uninstall packaged mac app during cleanup', error);
});
started = false;
installedAppPath = null;
}
}
}, 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',
'tools-pack',
'mac',
action,
'--dir',
toolsPackDir,
'--namespace',
namespace,
'--json',
...extraArgs,
];
const result = await execFileAsync(pnpmCommand, args, {
cwd: workspaceRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}).catch((error: unknown) => {
if (isExecError(error)) {
throw new Error(
[
`tools-pack mac ${action} failed`,
`stdout:\n${error.stdout}`,
`stderr:\n${error.stderr}`,
].join('\n'),
);
}
throw error;
});
try {
return JSON.parse(result.stdout) as T;
} catch (error) {
throw new Error(`tools-pack mac ${action} did not print JSON: ${String(error)}\n${result.stdout}`);
}
}
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();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<MacInspectResult>('inspect', ['--expr', healthExpression]);
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asHealthEvalValue(inspect.eval.value);
if (value?.status === 200 && value.health.ok === true && typeof value.health.version === 'string') {
return inspect;
}
}
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged mac runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
function assertLogPathsAndContent(result: LogsResult): void {
expect(result.namespace).toBe(namespace);
for (const app of ['desktop', 'web', 'daemon']) {
const entry = result.logs[app];
if (entry == null) {
throw new Error(`expected ${app} log entry`);
}
expectPathInside(entry.logPath, join(runtimeNamespaceRoot, 'logs', app));
}
const combined = Object.values(result.logs)
.flatMap((entry) => entry.lines)
.join('\n');
expect(combined).not.toMatch(/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
expect(combined).not.toMatch(/packaged runtime failed/i);
}
async function printPackagedLogs(): Promise<void> {
const result = await runToolsPackJson<LogsResult>('logs');
for (const [app, entry] of Object.entries(result.logs)) {
console.error(`[${app}] ${entry.logPath}`);
console.error(entry.lines.join('\n') || '(no log lines)');
}
}
function assertHealthEvalValue(value: unknown): HealthEvalValue {
const normalized = asHealthEvalValue(value);
if (normalized == null) {
throw new Error(`unexpected health eval value: ${formatUnknown(value)}`);
}
return normalized;
}
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
if (!isRecord(value.health)) return null;
return value as HealthEvalValue;
}
function expectPathInside(filePath: string, expectedRoot: string): void {
const normalizedPath = resolve(filePath);
const normalizedRoot = resolve(expectedRoot);
expect(
normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`),
`${normalizedPath} should be inside ${normalizedRoot}`,
).toBe(true);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function resolveFromWorkspace(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
}
function delay(ms: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function isExecError(value: unknown): value is { stderr: string; stdout: string } {
return isRecord(value) && typeof value.stdout === 'string' && typeof value.stderr === 'string';
}
function formatUnknown(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -0,0 +1,176 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
declare global {
interface ImportMeta {
glob<T = unknown>(pattern: string, options: { eager: true }): Record<string, T>;
}
}
type LocalizedContentIds = {
skills: string[];
designSystems: string[];
designSystemCategories: string[];
promptTemplates: string[];
promptTemplateCategories: string[];
promptTemplateTags: string[];
};
type LocalizedContentModule = {
LOCALIZED_CONTENT_IDS: Record<string, LocalizedContentIds>;
};
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
const webContentModules = import.meta.glob<LocalizedContentModule>(
'../../apps/web/src/i18n/content.ts',
{ eager: true },
);
const localizedContentModule = Object.values(webContentModules)[0];
if (localizedContentModule == null) {
throw new Error('Failed to load apps/web localized content ids');
}
const { LOCALIZED_CONTENT_IDS } = localizedContentModule;
function sorted(values: Iterable<string>): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
}
async function entriesWithFile(root: string, fileName: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const ids: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const filePath = path.join(root, entry.name, fileName);
try {
if ((await stat(filePath)).isFile()) {
ids.push(entry.name);
}
} catch {
// Missing optional registry files are ignored, matching resource discovery.
}
}
return sorted(ids);
}
async function readSkillIds(): Promise<string[]> {
const skillsRoot = path.join(repoRoot, 'skills');
const dirs = await entriesWithFile(skillsRoot, 'SKILL.md');
const ids = await Promise.all(
dirs.map(async (dir) => {
const raw = await readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf8');
return readFrontmatterName(raw) ?? dir;
}),
);
return sorted(ids);
}
async function readDesignSystemIds(): Promise<string[]> {
return entriesWithFile(path.join(repoRoot, 'design-systems'), 'DESIGN.md');
}
async function readDesignSystemCategories(): Promise<string[]> {
const systemsRoot = path.join(repoRoot, 'design-systems');
const ids = await readDesignSystemIds();
const categories = await Promise.all(
ids.map(async (id) => {
const raw = await readFile(path.join(systemsRoot, id, 'DESIGN.md'), 'utf8');
return /^>\s*Category:\s*(.+?)\s*$/im.exec(raw)?.[1] ?? 'Uncategorized';
}),
);
return sorted(new Set(categories));
}
async function readPromptTemplateSummaries(): Promise<
Array<{ id: string; category: string; tags: string[] }>
> {
const templatesRoot = path.join(repoRoot, 'prompt-templates');
const summaries: Array<{ id: string; category: string; tags: string[] }> = [];
for (const surface of ['image', 'video']) {
const dir = path.join(templatesRoot, surface);
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const raw = JSON.parse(await readFile(path.join(dir, entry.name), 'utf8')) as {
id?: unknown;
category?: unknown;
tags?: unknown;
};
if (typeof raw.id !== 'string' || !raw.id) continue;
summaries.push({
id: raw.id,
category: typeof raw.category === 'string' ? raw.category : 'General',
tags: Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === 'string') : [],
});
}
}
return summaries;
}
function readFrontmatterName(src: string): string | null {
const text = src.replace(/^\uFEFF/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
if (match == null) return null;
const nameMatch = /^name:\s*(.*?)\s*$/im.exec(match[1] ?? '');
if (nameMatch == null) return null;
const name = unquoteYamlScalar(nameMatch[1] ?? '').trim();
return name || null;
}
function unquoteYamlScalar(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
describe('localized display content coverage', () => {
for (const [locale, ids] of Object.entries(LOCALIZED_CONTENT_IDS)) {
it(`covers every curated skill, design system, and prompt template for ${locale}`, async () => {
const [skillIds, designSystemIds, promptTemplateSummaries] = await Promise.all([
readSkillIds(),
readDesignSystemIds(),
readPromptTemplateSummaries(),
]);
expect(sorted(ids.skills), 'skills display copy').toEqual(skillIds);
expect(sorted(ids.designSystems), 'design-system summaries').toEqual(
designSystemIds,
);
expect(sorted(ids.promptTemplates), 'prompt-template metadata').toEqual(
sorted(promptTemplateSummaries.map((template) => template.id)),
);
});
it(`covers every curated display category and prompt tag for ${locale}`, async () => {
const [designSystemCategories, promptTemplateSummaries] = await Promise.all([
readDesignSystemCategories(),
readPromptTemplateSummaries(),
]);
const promptTemplateCategories = new Set(
promptTemplateSummaries.map((template) => template.category),
);
const promptTemplateTags = new Set(
promptTemplateSummaries.flatMap((template) => template.tags),
);
expect(sorted(ids.designSystemCategories)).toEqual(
expect.arrayContaining(designSystemCategories),
);
expect(sorted(ids.promptTemplateCategories)).toEqual(
expect.arrayContaining(sorted(promptTemplateCategories)),
);
expect(sorted(ids.promptTemplateTags)).toEqual(
expect.arrayContaining(sorted(promptTemplateTags)),
);
});
}
});

35
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["lib/*.ts"]
},
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node", "vitest"]
},
"include": [
"playwright.config.ts",
"vitest.config.ts",
"lib/**/*.ts",
"resources/**/*.ts",
"scripts/**/*.ts",
"specs/**/*.ts",
"tests/**/*.ts",
"ui/**/*.ts"
],
"exclude": ["node_modules", "reports", ".od-data"]
}

1158
e2e/ui/app.test.ts Normal file

File diff suppressed because it is too large Load Diff

View 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;
}

View 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: mode !== 'image',
defaultFor,
upstream: null,
featured: null,
fidelity: null,
speakerNotes: null,
animations: null,
hasBody: true,
examplePrompt: '',
};
}

View 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');
});

View 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, '\\$&');
}

14
e2e/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./lib', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['specs/**/*.spec.ts', 'tests/**/*.test.ts'],
},
});