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
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:
40
e2e/AGENTS.md
Normal file
40
e2e/AGENTS.md
Normal 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.
|
||||
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;
|
||||
}
|
||||
51
e2e/lib/playwright/resources.ts
Normal file
51
e2e/lib/playwright/resources.ts
Normal 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
1
e2e/lib/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
e2e/package.json
Normal file
20
e2e/package.json
Normal 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
48
e2e/playwright.config.ts
Normal 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
401
e2e/resources/playwright.ts
Normal 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
53
e2e/scripts/playwright.ts
Normal 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
579
e2e/specs/mac.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
176
e2e/tests/localized-content.test.ts
Normal file
176
e2e/tests/localized-content.test.ts
Normal 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
35
e2e/tsconfig.json
Normal 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
1158
e2e/ui/app.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
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: mode !== 'image',
|
||||
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, '\\$&');
|
||||
}
|
||||
14
e2e/vitest.config.ts
Normal file
14
e2e/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user