test(e2e): gate beta packaged runtime (#637)

* test(e2e): gate beta mac packaged runtime

* test(e2e): separate ui automation layout

* test(e2e): move localized content coverage

* chore(release): prepare packaged 0.4.1 beta validation

* test(e2e): keep ui lane playwright-only

* fix(web): keep chat recoverable after conversation load failure

* fix(desktop): honor native mac quit
This commit is contained in:
PerishFire 2026-05-06 17:44:29 +08:00 committed by GitHub
parent 95bd7e5373
commit f1cdb2844a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1174 additions and 2178 deletions

View file

@ -48,6 +48,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared
# sidecar/platform imports. It intentionally skips app outputs because
@ -55,8 +58,8 @@ jobs:
# app build, even when a developer only needs packages/tools.
#
# Fresh CI typecheck/test still need these specific generated declarations:
# - `apps/daemon/dist/*.d.ts` for e2e runtime-adapter tests that import
# daemon runtime modules
# - `apps/daemon/dist/*.d.ts` for packaged/runtime consumers of the daemon
# package export
# - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of
# `@open-design/desktop/main`
# - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of
@ -81,6 +84,8 @@ jobs:
- name: Test
run: |
pnpm --filter @open-design/e2e test
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.config.ts
pnpm --filter @open-design/contracts test
pnpm --filter @open-design/platform test
pnpm --filter @open-design/sidecar test

View file

@ -129,6 +129,14 @@ jobs:
--json \
$signed_flag
- name: Smoke beta mac packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_NAMESPACE: release-beta
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: pnpm test specs/mac.spec.ts
- name: Prepare beta assets
id: assets
run: |

View file

@ -73,8 +73,8 @@ jobs:
# `scripts/postinstall.mjs` auto-builds `packages/*` and `tools/*`, but
# `apps/daemon` and `apps/desktop` are not in that list. On a fresh clone
# (every CI run), workspace typecheck fails because:
# - `e2e/scripts/runtime-adapter.e2e.live.test.ts` imports types from
# `apps/daemon/dist/*.js`
# - packaged/runtime consumers resolve the daemon package export through
# generated `apps/daemon/dist/*.d.ts`
# - `apps/packaged/src/index.ts` dynamic-imports `@open-design/desktop/main`
# which resolves to `apps/desktop/dist/main/index.d.ts`
# Build them explicitly here. Keeps the root `typecheck` script untouched.

4
.gitignore vendored
View file

@ -25,6 +25,10 @@ e2e/reports/test-results
e2e/reports/results.json
e2e/reports/junit.xml
e2e/reports/latest.md
e2e/ui/.od-data
e2e/ui/reports
e2e/ui/test-results
apps/web/playwright/
# Legacy folder name from before the rename; keep ignored so existing
# clones don't accidentally stage stale runtime data.

View file

@ -1,6 +1,6 @@
# Directory guide
This file is the single source of truth for agents entering this repository. Read this file first; after entering `apps/`, `packages/`, or `tools/`, read that layer's `AGENTS.md` for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands.
This file is the single source of truth for agents entering this repository. Read this file first; after entering `apps/`, `packages/`, `tools/`, or `e2e/`, read that layer's `AGENTS.md` for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands.
## Core documentation index
@ -8,7 +8,7 @@ This file is the single source of truth for agents entering this repository. Rea
- Contribution and environment: `CONTRIBUTING.md`, `CONTRIBUTING.zh-CN.md`.
- Architecture and protocols: `docs/spec.md`, `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`.
- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `specs/current/maintainability-roadmap.md`.
- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`.
- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`, `e2e/AGENTS.md`.
## Workspace directories
@ -22,12 +22,12 @@ This file is the single source of truth for agents entering this repository. Rea
- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives.
- `tools/dev` is the local development lifecycle control plane.
- `tools/pack` is the local packaged build/start/stop/logs control plane and mac beta release artifact preparation surface.
- `e2e` contains Playwright UI specs and Vitest/jsdom integration tests.
- `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands.
## Inactive or placeholder directories
- `apps/nextjs` and `packages/shared` have been removed; do not recreate or reference them.
- `.od/`, `.tmp/`, `e2e/.od-data`, Playwright reports, and agent scratch directories are local runtime data and must stay out of git.
- `.od/`, `.tmp/`, Playwright reports, and agent scratch directories are local runtime data and must stay out of git.
# Development workflow
@ -48,11 +48,13 @@ This file is the single source of truth for agents entering this repository. Rea
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, and `pnpm tools-pack`.
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...`).
- E2E commands must be run from the e2e package boundary with `pnpm -C e2e ...`; do not add root e2e aliases.
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
## Boundary constraints
- Tests under `apps/`, `packages/`, and `tools/` live in a package/app/tool-level `tests/` directory sibling to `src/`; keep `src/` source-only and do not add new `*.test.ts` or `*.test.tsx` files under `src/`.
- Tests under `apps/`, `packages/`, and `tools/` live in a package/app/tool-level `tests/` directory sibling to `src/`; keep `src/` source-only and do not add new `*.test.ts` or `*.test.tsx` files under `src/`. Playwright UI automation belongs to `e2e/ui/`, not app packages.
- App packages must not import another app's private `src/` or `tests/` implementation as a shared helper. In particular, `apps/web/**` must not import `apps/daemon/src/**`; web/daemon integration belongs behind HTTP APIs, `packages/contracts`, and app-local provider boundaries.
- Cross-app, cross-runtime, or repository-resource consistency checks belong in `e2e/tests/` when they need to observe more than one app/package boundary; promote reusable logic to a pure package instead of borrowing another app's private source.
- Keep shared API DTOs, SSE event unions, error shapes, task shapes, and example payloads in `packages/contracts`; update contracts before wiring divergent web/daemon request or response shapes.
- Keep `packages/contracts` pure TypeScript and free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, and sidecar control-plane dependencies.
- Keep project-owned entrypoints, modules, scripts, tests, reporters, and configs TypeScript-first; generated `dist/*.js` is runtime output, and source edits belong in `.ts` files.
@ -72,7 +74,7 @@ This file is the single source of truth for agents entering this repository. Rea
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
- Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases.
- For the web/e2e loop, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
- For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`.
- Stamp/namespace changes must validate two concurrent namespaces and run desktop `inspect eval` plus `inspect screenshot` for each namespace.
- Path/log changes must run `pnpm tools-dev logs --namespace <name> --json` and confirm log paths are under `.tmp/tools-dev/<namespace>/...`.
@ -95,9 +97,6 @@ pnpm tools-dev check
```bash
pnpm guard
pnpm typecheck
pnpm -C e2e test:ui
pnpm -C e2e test:ui:headed
pnpm -C e2e test:e2e:live
```
```bash

View file

@ -20,6 +20,8 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie
- App tests live in each app's `tests/` directory, sibling to `src/`; preserve source-relative subpaths inside `tests/` when useful.
- Keep app `src/` directories source-only; do not add new `*.test.ts` or `*.test.tsx` files under `src/`.
- `apps/web/tests/` contains web-owned Vitest tests and uses `*.test.ts` / `*.test.tsx`.
- Playwright UI automation belongs in `e2e/ui/`; do not add Playwright suites or UI automation helper scripts under `apps/web`.
## Sidecar awareness

View file

@ -125,6 +125,15 @@ export async function writeTranscript(
return { path: 'transcript.ndjson', bytes: totalBytes, gzipped: false };
}
} catch (err) {
// Ensure the write stream has fully closed before unlinking. If the
// iterable fails before the lazy open completes, unlinking immediately can
// race with createWriteStream and leave a late-created temp file behind.
ws.destroy();
if (!ws.closed) {
await new Promise<void>((resolve) => {
ws.once('close', resolve);
});
}
// Ensure temp file is cleaned up on any failure.
await rm(tempPath, { force: true });
throw err;
@ -167,4 +176,3 @@ export async function* readTranscript(
yield event;
}
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createClaudeStreamHandler } from '../../apps/daemon/src/claude-stream.js';
import { createCopilotStreamHandler } from '../../apps/daemon/src/copilot-stream.js';
import { mapPiRpcEvent } from '../../apps/daemon/src/pi-rpc.js';
import { createClaudeStreamHandler } from '../src/claude-stream.js';
import { createCopilotStreamHandler } from '../src/copilot-stream.js';
import { mapPiRpcEvent } from '../src/pi-rpc.js';
describe('structured agent stream fixtures', () => {
it('emits TodoWrite tool_use from Claude Code stream JSON', () => {

View file

@ -101,6 +101,10 @@ export async function runDesktopMain(
app.quit();
}
function shutdownAndExit(): void {
void shutdown().finally(() => process.exit(0));
}
attachParentMonitor(shutdown);
ipcServer = await createJsonIpcServer({
@ -120,15 +124,21 @@ export async function runDesktopMain(
return await desktop.click(request.input as DesktopClickInput);
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
void shutdown().finally(() => process.exit(0));
shutdownAndExit();
});
return { accepted: true };
}
},
});
app.on("before-quit", (event) => {
if (shuttingDown) return;
event.preventDefault();
shutdownAndExit();
});
app.on("window-all-closed", () => {
void shutdown().finally(() => process.exit(0));
shutdownAndExit();
});
app.on("activate", () => {
@ -137,7 +147,7 @@ export async function runDesktopMain(
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void shutdown().finally(() => process.exit(0));
shutdownAndExit();
});
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/packaged",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"main": "./dist/index.mjs",

View file

@ -39,6 +39,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/jsdom": "^28.0.1",
"@types/node": "^20.17.10",
"@types/react": "^18.3.12",

View file

@ -2909,6 +2909,7 @@ function HtmlViewer({
<button
type="button"
className={`viewer-toggle${boardMode ? ' active' : ''}`}
data-testid="board-mode-toggle"
title={t('fileViewer.tweaks')}
aria-pressed={boardMode}
disabled={!boardAvailable}

View file

@ -85,6 +85,7 @@ import { AvatarMenu } from './AvatarMenu';
import { ChatPane } from './ChatPane';
import { decideAutoOpenAfterWrite } from './auto-open-file';
import { FileWorkspace } from './FileWorkspace';
import { CenteredLoader } from './Loading';
interface Props {
project: Project;
@ -236,6 +237,7 @@ export function ProjectView({
const [activeConversationId, setActiveConversationId] = useState<string | null>(
null,
);
const [conversationLoadError, setConversationLoadError] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [previewComments, setPreviewComments] = useState<PreviewComment[]>([]);
const [attachedComments, setAttachedComments] = useState<PreviewComment[]>([]);
@ -304,19 +306,31 @@ export function ProjectView({
// dropped), create one on the fly.
useEffect(() => {
let cancelled = false;
setConversationLoadError(null);
(async () => {
const list = await listConversations(project.id);
if (cancelled) return;
if (list.length === 0) {
const fresh = await createConversation(project.id);
try {
const list = await listConversations(project.id);
if (cancelled) return;
if (fresh) {
setConversations([fresh]);
setActiveConversationId(fresh.id);
if (list.length === 0) {
const fresh = await createConversation(project.id);
if (cancelled) return;
if (fresh) {
setConversations([fresh]);
setActiveConversationId(fresh.id);
} else {
throw new Error('Could not create a conversation for this project.');
}
} else {
setConversations(list);
setActiveConversationId(list[0]!.id);
}
} else {
setConversations(list);
setActiveConversationId(list[0]!.id);
} catch (err) {
if (cancelled) return;
const message = err instanceof Error ? err.message : 'Could not load conversations for this project.';
setConversations([]);
setActiveConversationId(null);
setConversationLoadError(message);
setError(message);
}
})();
return () => {
@ -1473,10 +1487,18 @@ export function ProjectView({
}, [cancelSendTextBuffer, cancelReattachTextBuffers, persistMessage]);
const handleNewConversation = useCallback(async () => {
const fresh = await createConversation(project.id);
if (!fresh) return;
setConversations((curr) => [fresh, ...curr]);
setActiveConversationId(fresh.id);
setConversationLoadError(null);
try {
const fresh = await createConversation(project.id);
if (!fresh) throw new Error('Could not create a conversation for this project.');
setConversations((curr) => [fresh, ...curr]);
setActiveConversationId(fresh.id);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not create a conversation for this project.';
setConversationLoadError(message);
setError(message);
}
}, [project.id]);
const handleSelectConversation = useCallback((id: string) => {
@ -1783,47 +1805,53 @@ export function ProjectView({
}}
>
<div className="split-chat-slot" hidden={workspaceFocused}>
<ChatPane
// The conversation id is part of the key so switching conversations
// resets internal scroll/draft state inside ChatPane and ChatComposer.
key={activeConversationId ?? 'no-conv'}
messages={messages}
streaming={streaming}
error={error}
projectId={project.id}
projectFiles={projectFiles}
projectFileNames={projectFileNames}
onEnsureProject={handleEnsureProject}
previewComments={previewComments}
attachedComments={attachedComments}
onAttachComment={attachPreviewComment}
onDetachComment={detachPreviewComment}
onDeleteComment={(commentId) => void removePreviewComment(commentId)}
onSend={handleSend}
onStop={handleStop}
onRequestOpenFile={requestOpenFile}
initialDraft={initialDraft}
onSubmitForm={(text) => {
if (streaming) return;
void handleSend(text, [], []);
}}
onContinueRemainingTasks={handleContinueRemainingTasks}
onNewConversation={handleNewConversation}
conversations={conversations}
activeConversationId={activeConversationId}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
onOpenSettings={onOpenSettings}
petConfig={config.pet}
onAdoptPet={onAdoptPetInline}
onTogglePet={onTogglePet}
onOpenPetSettings={onOpenPetSettings}
projectMetadata={project.metadata}
onProjectMetadataChange={(metadata) => {
onProjectChange({ ...project, metadata });
}}
/>
{activeConversationId || conversationLoadError ? (
<ChatPane
// The conversation id is part of the key so switching conversations
// resets internal scroll/draft state inside ChatPane and ChatComposer.
key={activeConversationId ?? 'conversation-unavailable'}
messages={messages}
streaming={streaming}
error={conversationLoadError ?? error}
projectId={project.id}
projectFiles={projectFiles}
projectFileNames={projectFileNames}
onEnsureProject={handleEnsureProject}
previewComments={previewComments}
attachedComments={attachedComments}
onAttachComment={attachPreviewComment}
onDetachComment={detachPreviewComment}
onDeleteComment={(commentId) => void removePreviewComment(commentId)}
onSend={handleSend}
onStop={handleStop}
onRequestOpenFile={requestOpenFile}
initialDraft={initialDraft}
onSubmitForm={(text) => {
if (streaming) return;
void handleSend(text, [], []);
}}
onContinueRemainingTasks={handleContinueRemainingTasks}
onNewConversation={handleNewConversation}
conversations={conversations}
activeConversationId={activeConversationId}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
onOpenSettings={onOpenSettings}
petConfig={config.pet}
onAdoptPet={onAdoptPetInline}
onTogglePet={onTogglePet}
onOpenPetSettings={onOpenPetSettings}
projectMetadata={project.metadata}
onProjectMetadataChange={(metadata) => {
onProjectChange({ ...project, metadata });
}}
/>
) : (
<div className="pane" data-testid="chat-pane-loading">
<CenteredLoader />
</div>
)}
</div>
{!workspaceFocused ? (
<div

View file

@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../apps/web/src/components/AssistantMessage';
import type { AgentEvent, ChatMessage } from '../../apps/web/src/types';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { AgentEvent, ChatMessage } from '../../src/types';
function messageWithEvents(events: AgentEvent[]): ChatMessage {
return {

View file

@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../apps/web/src/components/ChatPane';
import type { ChatMessage } from '../../apps/web/src/types';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';
function renderChatPane(messages: ChatMessage[]) {
return render(

View file

@ -1,13 +1,15 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileViewer } from '../../apps/web/src/components/FileViewer';
import type { ProjectFile } from '../../apps/web/src/types';
import { fetchProjectFileText } from '../../apps/web/src/providers/registry';
import { FileViewer } from '../../src/components/FileViewer';
import type { ProjectFile } from '../../src/types';
import { fetchProjectFileText } from '../../src/providers/registry';
vi.mock('../../apps/web/src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../apps/web/src/providers/registry')>(
'../../apps/web/src/providers/registry',
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
@ -64,7 +66,7 @@ describe('FileViewer markdown code block copy', () => {
if (originalExecCommand) {
Object.defineProperty(document, 'execCommand', originalExecCommand);
} else {
delete (document as Document & { execCommand?: typeof document.execCommand }).execCommand;
delete (document as { execCommand?: typeof document.execCommand }).execCommand;
}
cleanup();
vi.clearAllMocks();

View file

@ -1,6 +1,8 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../apps/web/src/components/PreviewModal';
import { PreviewModal } from '../../src/components/PreviewModal';
// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen
// used to require two presses because the browser exits its native fullscreen

View file

@ -3,8 +3,8 @@ import {
latestTodosFromEvents,
parseTodoWriteInput,
unfinishedTodosFromEvents,
} from '../../apps/web/src/runtime/todos';
import type { AgentEvent } from '../../apps/web/src/types';
} from '../../src/runtime/todos';
import type { AgentEvent } from '../../src/types';
const firstTodoInput = {
todos: [

View file

@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.{ts,tsx,js,mjs,cjs}'],
include: ['tests/**/*.test.{ts,tsx}'],
},
});

40
e2e/AGENTS.md Normal file
View file

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

View file

@ -1,115 +0,0 @@
# Biblioteca de casos de UI
Este diretório é a biblioteca de origem dos cenários de automação de UI.
## Objetivo
A biblioteca de casos separa estas três camadas:
- Desenho do cenário
- Implementação da automação
- Insumos de teste e dados de execução
Assim, os specs do Playwright não viram aos poucos um amontoado de prompts hardcoded e asserções pontuais.
## Estrutura atual do diretório
- [index.ts](index.ts): definições dos casos
- [types.ts](types.ts): schema dos casos
- [modules/project-and-generation.md](modules/project-and-generation.md): casos de criação de projeto e fluxo de geração
- [modules/conversations.md](modules/conversations.md): casos de ciclo de vida de conversas
- [modules/files.md](modules/files.md): casos de upload de arquivos, mention e restauração de preview
- [../reports/README.pt-BR.md](../reports/README.pt-BR.md): documentação dos resultados e relatórios de teste
- [../specs/app.spec.ts](../specs/app.spec.ts): entrypoint Playwright que executa os casos já automatizados
## Sobre o schema
Cada caso é um `UICase`.
- `id`: identificador estável do caso, usado em specs e relatórios de teste
- `title`: nome legível do caso
- `kind`: tipo de projeto, por exemplo `prototype`, `deck`, `workspace`
- `flow`: ramo de fluxo de automação correspondente no Playwright
- `automated`: se é executado atualmente por `pnpm run test:ui`
- `description`: alvo de cobertura e descrição do cenário
- `create`: entradas necessárias na criação do projeto
- `prompt`: conteúdo principal de entrada
- `secondaryPrompt`: entrada subsequente em fluxos com múltiplos passos
- `mockArtifact`: artifact esperado quando o SSE é mockado
- `notes`: detalhes de implementação ou observações de manutenção
## Flows suportados atualmente
- `standard`: cria projeto, envia prompt, valida o artifact gerado
- `conversation-persistence`: cria várias conversas, restaura após refresh, alterna histórico
- `file-mention`: pré-popula arquivos, seleciona via mention `@` e valida o anexo staged
- `deep-link-preview`: abre o preview pela rota de arquivo e valida a restauração
- `file-upload-send`: passa pelo seletor de arquivos real, valida upload e envio
- `conversation-delete-recovery`: deleta a conversa ativa e valida o fallback
## Regras de divisão da documentação
- `README.pt-BR.md` mantém apenas visão geral, estrutura e regras de manutenção
- A lista detalhada de casos é dividida por módulo no diretório `modules/`
- Um módulo por arquivo Markdown, com possibilidade de subdivisão futura
- Quando um módulo cresce demais, divida-o em submódulos
## Como adicionar um caso
1. Acrescente um `UICase` em [index.ts](index.ts).
2. Descreva o cenário no documento do módulo correspondente; se ainda for só design, mantenha `automated: false`.
3. Reutilize um `flow` existente sempre que possível.
4. Só adicione um novo tipo em [types.ts](types.ts) se realmente precisar de um novo caminho de automação.
5. Implemente o fluxo em [app.spec.ts](../specs/app.spec.ts).
6. Quando o caso estiver estável, troque `automated` para `true`.
## Workflow recomendado
1. Descreva o cenário primeiro em linguagem de produto.
2. Decida em qual documento de módulo ele entra.
3. Avalie se cabe em algum flow de automação existente.
4. Adicione `data-testid` apenas onde for de fato necessário.
5. Prefira mockar o SSE de `/api/chat` para garantir estabilidade.
6. Mantenha caminhos reais para criação de projeto, rotas, persistência e API de arquivos.
## Escopo apropriado
Bom encaixe:
- Fluxo principal de criação de projeto
- Fluxo de geração e preview do artifact
- Fluxo de ciclo de vida de conversa
- Fluxo de upload, mention e reabertura de arquivos
- Fluxo de deep link e restauração após refresh
Evite priorizar:
- Verificações puramente visuais e instáveis
- Avaliação de qualidade de modelo
- Testes fortemente dependentes de CLIs reais de agentes externos
## Como executar
```bash
pnpm -C e2e test:ui
```
Após a execução são gerados automaticamente:
- `e2e/reports/latest.md`
- `e2e/reports/ui-test-report.html`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
Antes de cada execução, dados de runtime e o relatório anterior são limpos automaticamente para evitar:
- Diretórios de projeto vazios acumulados em `.od-data`
- Screenshots antigos de falhas em `e2e/reports/test-results`
- Conteúdo de relatório inconsistente com a execução atual
Para depurar com interface gráfica:
```bash
pnpm run test:ui:headed
```

View file

@ -1,115 +0,0 @@
# UI 用例库
这个目录是 UI 自动化场景的来源库。
## 目的
用例库把这三层拆开:
- 场景设计
- 自动化实现
- 测试素材和运行数据
这样 Playwright spec 不会慢慢变成一堆写死的 prompt 和一次性断言。
## 当前目录结构
- [index.ts](/Users/mac/open-design/open-design/e2e/cases/index.ts):用例定义
- [types.ts](/Users/mac/open-design/open-design/e2e/cases/types.ts):用例 schema
- [modules/project-and-generation.md](/Users/mac/open-design/open-design/e2e/cases/modules/project-and-generation.md):项目创建与生成链路用例
- [modules/conversations.md](/Users/mac/open-design/open-design/e2e/cases/modules/conversations.md):会话生命周期用例
- [modules/files.md](/Users/mac/open-design/open-design/e2e/cases/modules/files.md)文件上传、mention、预览恢复用例
- [../reports/README.zh-CN.md](/Users/mac/open-design/open-design/e2e/reports/README.zh-CN.md):测试结果与报告说明
- [../specs/app.spec.ts](/Users/mac/open-design/open-design/e2e/specs/app.spec.ts):执行已自动化用例的 Playwright 入口
## Schema 说明
每条用例都是一个 `UICase`
- `id`:稳定的用例标识,用于 spec 和测试报告
- `title`:人可读的用例名称
- `kind`:项目类型,比如 `prototype`、`deck`、`workspace`
- `flow`Playwright 里对应的自动化流程分支
- `automated`:当前是否会被 `pnpm run test:ui` 执行
- `description`:覆盖目标和场景说明
- `create`:创建项目时要用到的输入
- `prompt`:主输入内容
- `secondaryPrompt`:多步骤流程里的后续输入
- `mockArtifact`mock SSE 时预期生成的 artifact
- `notes`:实现细节或维护备注
## 当前支持的 Flow
- `standard`:创建项目,发送 prompt校验生成 artifact
- `conversation-persistence`:创建多会话,刷新后恢复,再切换历史
- `file-mention`:预置文件后通过 `@` mention 选中并校验 staged attachment
- `deep-link-preview`:通过文件路由打开预览并校验恢复
- `file-upload-send`:走真实文件选择器,校验上传和发送
- `conversation-delete-recovery`:删除当前活跃会话后校验回退
## 文档拆分规则
- `README.zh-CN.md` 只保留总览、结构和维护规则
- 具体用例清单按模块拆到 `modules/` 目录
- 一个模块一个 Markdown后面可以继续细分
- 当单个模块内容变长时,再继续按子模块拆分
## 新增用例的方式
1. 在 [index.ts](/Users/mac/open-design/open-design/e2e/cases/index.ts) 里新增一条 `UICase`
2. 先把场景写进对应模块文档,如果只是设计阶段,保持 `automated: false`
3. 能复用已有 `flow` 就优先复用。
4. 只有在确实需要新自动化路径时,才去 [types.ts](/Users/mac/open-design/open-design/e2e/cases/types.ts) 增加新的 `flow` 类型。
5. 在 [app.spec.ts](/Users/mac/open-design/open-design/e2e/specs/app.spec.ts) 里实现这个流程。
6. 用例稳定后,再把 `automated` 改成 `true`
## 推荐工作流
1. 先用产品语言把场景写清楚。
2. 先决定它归哪个模块文档。
3. 判断它能不能归到已有的自动化 flow。
4. 只在确实需要的节点补 `data-testid`
5. 优先 mock `/api/chat` 的 SSE保证稳定性。
6. 项目创建、路由、持久化、文件 API 尽量走真实链路。
## 适合放进来的范围
适合:
- 项目创建主流程
- 生成与 artifact 预览流程
- 会话生命周期流程
- 文件上传、mention、重新打开流程
- deep link 和刷新恢复流程
不建议优先放:
- 纯视觉、容易抖的检查
- 模型质量评估
- 强依赖真实外部 agent CLI 的测试
## 运行方式
```bash
pnpm -C e2e test:ui
```
运行完成后会自动生成:
- `e2e/reports/latest.md`
- `e2e/reports/ui-test-report.html`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
运行开始前会自动清理旧的 e2e 运行时数据和上一次报告,避免:
- `.od-data` 里累积空 project 目录
- `e2e/reports/test-results` 混入旧失败截图
- 报告内容和本次执行结果不一致
如果要带界面调试:
```bash
pnpm run test:ui:headed
```

View file

@ -1,67 +0,0 @@
# 会话生命周期
这个模块聚焦项目内聊天会话的生命周期:
- 新建会话
- 切换会话
- 刷新恢复
- 删除会话
- 后续可扩展重命名等场景
## 当前用例
### `conversation-persistence`
- 状态:已自动化
- 对应 flow`conversation-persistence`
- 目标:覆盖会话创建、刷新恢复、历史切换
- 核心步骤:
1. 在第一个会话里发送 prompt
2. 新建第二个会话
3. 在第二个会话里发送新的 prompt
4. 刷新页面
5. 校验当前会话内容仍在
6. 打开历史菜单切回第一个会话
### `conversation-delete-recovery`
- 状态:已自动化
- 对应 flow`conversation-delete-recovery`
- 目标:覆盖删除当前活跃会话后的回退逻辑
- 核心步骤:
1. 创建两个会话
2. 删除当前活跃会话
3. 校验界面自动回退到剩余会话
4. 校验项目仍然保有可用会话
### `question-form-selection-limit`
- 状态:已自动化
- 对应 flow`question-form-selection-limit`
- 目标:覆盖快速确认里 checkbox 多选上限约束
- 核心步骤:
1. 创建项目并发送一条 prompt
2. mock 返回带 `maxSelections: 2` 的 question form
3. 连续点击三个视觉风格选项
4. 校验始终只有两个选项处于选中态
5. 校验第三个选项不会被错误选中
### `question-form-submit-persistence`
- 状态:已自动化
- 对应 flow`question-form-submit-persistence`
- 目标:覆盖 question form 提交后的用户回答落盘、锁定态与刷新回填
- 核心步骤:
1. mock 返回一个带必填项的 question form
2. 选择答案并点击提交
3. 校验会话里写入了用户回答消息
4. 校验原表单进入 answered / locked 状态
5. 刷新页面后再次确认锁定态和已选答案仍然正确
## 推荐后续补充
- 会话重命名
- 删除最后一个会话后的自动重建
- 历史菜单关闭/重新打开后的状态一致性
- 长会话列表滚动与选中态
- 多轮对话后的会话标题生成或更新策略

View file

@ -1,121 +0,0 @@
# 文件链路
这个模块聚焦项目文件相关的主链路:
- 文件上传
- 文件 mention
- staged attachment
- 文件路由打开
- 预览恢复
## 当前用例
### `file-mention`
- 状态:已自动化
- 对应 flow`file-mention`
- 目标:覆盖 `@` mention 选择文件并加入 staged attachment
- 核心步骤:
1. 通过项目文件 API 预置 `reference.txt`
2. 在聊天输入框中输入 `@ref`
3. 选择 mention popover 里的文件
4. 校验输入框中插入 `@reference.txt`
5. 校验 staged attachment 显示正确
### `file-upload-send`
- 状态:已自动化
- 对应 flow`file-upload-send`
- 目标:覆盖聊天区真实上传文件并发送
- 核心步骤:
1. 通过 composer 的隐藏 file input 上传文件
2. 校验 staged attachment 出现
3. 发送 prompt
4. 校验用户消息里带上上传文件
### `deep-link-preview`
- 状态:已自动化
- 对应 flow`deep-link-preview`
- 目标:覆盖文件路由直达和预览恢复
- 核心步骤:
1. 生成 artifact
2. 校验 URL 进入 `/projects/:id/files/:name`
3. 离开项目文件路由
4. 再次通过文件路由进入
5. 校验预览 iframe 正常恢复
### `design-files-upload`
- 状态:已自动化
- 对应 flow`design-files-upload`
- 目标:覆盖 Design Files 面板真实上传、预览与打开
- 核心步骤:
1. 通过 Design Files 面板的上传入口选择图片
2. 校验文件行出现在列表中
3. 校验右侧预览信息出现
4. 双击文件行
5. 校验文件以 tab 形式打开
### `design-files-delete`
- 状态:已自动化
- 对应 flow`design-files-delete`
- 目标:覆盖 Design Files 面板删除文件以及打开 tab 的清理
- 核心步骤:
1. 先上传一张图片
2. 回到 Design Files 面板
3. 打开文件行菜单并执行删除
4. 确认文件行从列表中消失
5. 确认对应文件 tab 也被清理
### `design-files-tab-persistence`
- 状态:已自动化
- 对应 flow`design-files-tab-persistence`
- 目标:覆盖多个打开文件 tab 在刷新后的恢复
- 核心步骤:
1. 先上传两张图片
2. 确认两张图片都打开为 tab
3. 切换当前 active tab
4. 刷新页面
5. 确认两个 tab 都被恢复
6. 确认刷新前的 active tab 仍然是 active
## 推荐后续补充
### `deck-pagination-per-file-isolated`
- 状态:待自动化
- 对应 flow`deck-pagination-per-file-isolated`
- 目标:覆盖多个 deck HTML 之间的分页状态隔离
- 核心步骤:
1. 打开两个多页 deck 文件
2. 分别停留在不同页码
3. 来回切换文件 tab
4. 校验每个文件维持自己的页码
### `uploaded-image-renders-in-preview`
- 状态:待自动化
- 对应 flow`uploaded-image-renders-in-preview`
- 目标:覆盖上传图片参与生成后,预览中的图片真实可加载
- 核心步骤:
1. 上传图片作为参考素材
2. 生成引用该图片的 HTML artifact
3. 进入预览 iframe
4. 校验对应 `img``src` 可解析且不是 broken image
### `python-source-preview`
- 状态:待自动化
- 对应 flow`python-source-preview`
- 目标:覆盖 `.py` 文件在主工作区中的源码预览能力
- 核心步骤:
1. 通过项目文件 API 预置一个 `.py` 文件
2. 在主工作区打开该文件
3. 校验文件查看器进入源码/文本预览模式
4. 校验能看到 Python 源码内容,而不是空白或不支持状态
- 图片文件上传与缩略图展示
- 刷新后 staged attachment 清理策略

View file

@ -1,88 +0,0 @@
# 项目创建与生成
这个模块聚焦主入口链路:
- 创建项目
- 进入工作区
- 发送 prompt
- 生成 artifact
- 打开预览
## 当前用例
### `prototype-basic`
- 状态:已自动化
- 对应 flow`standard`
- 目标:覆盖 prototype 项目的主 happy path
- 核心步骤:
1. 创建 `prototype` 项目
2. 输入 prompt
3. mock `/api/chat` SSE 返回 HTML artifact
4. 校验生成文件出现在工作区
5. 校验 iframe 预览正常
### `deck-basic`
- 状态:已自动化
- 对应 flow`standard`
- 目标:覆盖 deck 项目创建分支
- 核心步骤:
1. 切换到 `deck` 创建 tab
2. 创建项目
3. 发送 prompt
4. mock 返回 deck artifact
5. 校验预览正常
### `design-system-selection`
- 状态:已自动化
- 对应 flow`design-system-selection`
- 目标:覆盖设计系统选择后创建项目,并确认项目元信息保留了该选择
- 核心步骤:
1. mock 设计系统列表
2. 打开设计系统选择器
3. 搜索并选择指定设计系统
4. 创建项目
5. 校验项目页 meta 中出现设计系统名称
### `example-use-prompt`
- 状态:已自动化
- 对应 flow`example-use-prompt`
- 目标:覆盖 Examples 页的快捷创建链路
- 核心步骤:
1. mock skills 列表,提供一个示例卡片
2. 切到 Examples 页
3. 点击 `Use this prompt`
4. 校验项目被直接创建
5. 校验聊天输入框预填了 example prompt
### `generation-does-not-create-extra-file`
- 状态:已自动化
- 对应 flow`generation-does-not-create-extra-file`
- 目标:覆盖“没有新 prompt 却自己多生成一个 HTML 文件”的回归风险
- 核心步骤:
1. 生成一个 mocked artifact
2. 通过 files API 记录当前项目文件集合
3. 刷新页面但不发送新 prompt
4. 再次读取 files API
5. 校验文件集合没有变化,也没有新增 HTML 文件
## 推荐后续补充
### `deck-pagination-next-prev-correctness`
- 状态:待自动化
- 对应 flow`deck-pagination-next-prev-correctness`
- 目标:覆盖 deck 预览上一页 / 下一页按钮的方向正确性
- 核心步骤:
1. 打开多页 deck HTML
2. 进入中间页
3. 点击上一页并校验页码递减
4. 点击下一页并校验页码递增
- template 项目创建
- 创建项目后的刷新恢复
- 创建失败或必填校验

View file

@ -1,134 +0,0 @@
export interface ReportCaseMetadata {
module: string;
assertions: string[];
}
const caseMetadata: Record<string, ReportCaseMetadata> = {
'prototype-basic': {
module: '项目创建与生成',
assertions: [
'可以创建 prototype 项目并进入工作区',
'发送 prompt 后会收到 mocked artifact',
'生成文件会出现在工作区',
'预览 iframe 中能看到期望标题',
],
},
'deck-basic': {
module: '项目创建与生成',
assertions: [
'可以通过 deck tab 创建项目',
'发送 prompt 后会收到 deck artifact',
'deck 文件会出现在工作区',
'预览 iframe 中能看到期望标题',
],
},
'design-system-selection': {
module: '项目创建与生成',
assertions: [
'设计系统选择器可以搜索并选中目标设计系统',
'创建项目后项目 meta 会保留设计系统名称',
'项目成功进入工作区而不是停留在创建页',
],
},
'example-use-prompt': {
module: '项目创建与生成',
assertions: [
'Examples 页的 Use this prompt 可以直接创建项目',
'创建后的项目标题与 meta 会带上对应 skill 名称',
'聊天输入框会预填 example prompt',
],
},
'conversation-persistence': {
module: '会话生命周期',
assertions: [
'可以创建第二个会话并发送新的 prompt',
'刷新后当前会话消息仍然存在',
'历史菜单中可以切回旧会话',
'切回后旧会话内容仍然正确显示',
],
},
'conversation-delete-recovery': {
module: '会话生命周期',
assertions: [
'删除当前活跃会话后不会卡死在空状态',
'界面会回退到剩余会话',
'被删除会话的消息不会继续显示',
],
},
'question-form-selection-limit': {
module: '会话生命周期',
assertions: [
'question form 中声明 maxSelections=2 的 checkbox 题目最多只能选中两个选项',
'达到上限后新的未选项不会被选中',
'界面中的已选数量会保持在约束范围内',
],
},
'question-form-submit-persistence': {
module: '会话生命周期',
assertions: [
'提交 question form 后会写入一条用户回答消息',
'表单会立即进入 answered / locked 状态',
'刷新页面后表单仍会根据历史答案正确回填并保持锁定',
],
},
'generation-does-not-create-extra-file': {
module: '项目创建与生成',
assertions: [
'第一次生成后项目中只出现预期的 artifact 文件',
'在没有发送新 prompt 的情况下刷新页面不会新增文件',
'files API 返回的文件集合在前后两次检查中保持一致',
],
},
'file-mention': {
module: '文件链路',
assertions: [
'预置文件后 mention popover 可以搜索并选中文件',
'输入框会插入 @filename',
'staged attachment 会显示对应文件',
],
},
'file-upload-send': {
module: '文件链路',
assertions: [
'聊天区 file input 可以上传文件',
'上传后 staged attachment 会显示文件',
'发送消息后用户消息中会保留该附件',
],
},
'deep-link-preview': {
module: '文件链路',
assertions: [
'生成 artifact 后 URL 会进入文件路由',
'离开项目文件路由后可再次通过文件路由进入',
'重新进入后预览 iframe 仍能恢复到正确文件',
],
},
'design-files-upload': {
module: '文件链路',
assertions: [
'Design Files 面板可以真实上传图片',
'上传后文件行会出现在 Design Files 列表',
'右侧预览面板会显示文件信息',
'双击文件行会把文件打开成 tab',
],
},
'design-files-delete': {
module: '文件链路',
assertions: [
'Design Files 行级菜单可以触发删除',
'删除确认后文件行会从列表消失',
'如果文件已打开,对应 tab 也会被清理',
],
},
'design-files-tab-persistence': {
module: '文件链路',
assertions: [
'多个文件 tab 可以同时打开',
'切换 active tab 后状态会被持久化',
'刷新页面后 tab 集合会恢复',
'刷新前选中的 active tab 仍然保持选中',
],
},
} satisfies Record<string, ReportCaseMetadata>;
export default caseMetadata;

View file

@ -1,6 +1,8 @@
export type CaseKind = 'prototype' | 'deck' | 'template' | 'workspace';
import { playwrightUiScenarios } from '../../resources/playwright.ts';
export interface MockArtifactCase {
export type ScenarioKind = 'prototype' | 'deck' | 'template' | 'workspace';
export interface MockArtifactScenario {
identifier: string;
title: string;
html: string;
@ -8,10 +10,10 @@ export interface MockArtifactCase {
heading: string;
}
export interface UICase {
export interface UiScenario {
id: string;
title: string;
kind: CaseKind;
kind: ScenarioKind;
flow?:
| 'standard'
| 'design-system-selection'
@ -40,6 +42,10 @@ export interface UICase {
};
prompt: string;
secondaryPrompt?: string;
mockArtifact?: MockArtifactCase;
mockArtifact?: MockArtifactScenario;
notes?: string[];
}
export function automatedUiScenarios(): UiScenario[] {
return playwrightUiScenarios.filter((scenario) => scenario.automated);
}

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

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

View file

@ -5,19 +5,12 @@
"type": "module",
"scripts": {
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:ui:clean": "node --experimental-strip-types scripts/reset-artifacts.ts",
"test:ui": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts",
"test:ui:headed": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed",
"test:e2e:live": "corepack pnpm --filter @open-design/daemon build && node --experimental-strip-types --test scripts/runtime-adapter.e2e.live.test.ts"
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.17.10",
"jsdom": "^29.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsx": "4.21.0",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},

View file

@ -5,8 +5,8 @@ const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
const baseURL = `http://127.0.0.1:${webPort}`;
export default defineConfig({
testDir: './specs',
outputDir: './reports/test-results',
testDir: './ui',
outputDir: './ui/reports/test-results',
timeout: 30_000,
expect: {
timeout: 10_000,
@ -16,17 +16,15 @@ export default defineConfig({
? [
['github'],
['list'],
['html', { open: 'never', outputFolder: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
['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: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
['html', { open: 'never', outputFolder: './ui/reports/playwright-html-report' }],
['json', { outputFile: './ui/reports/results.json' }],
['junit', { outputFile: './ui/reports/junit.xml' }],
],
use: {
baseURL,
@ -35,7 +33,7 @@ export default defineConfig({
},
webServer: {
command:
`OD_DATA_DIR=e2e/.od-data ` +
`OD_DATA_DIR=e2e/ui/.od-data ` +
`pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`,
url: baseURL,
reuseExistingServer: false,

View file

@ -1,289 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import type { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import caseMetadata from '../cases/report-metadata.ts';
interface MarkdownReporterOptions {
outputFile?: string;
}
interface CaseRow {
caseId: string;
title: string;
module: string;
assertions: string[];
status: string;
durationMs: number;
retries: number;
file: string;
line: number | null;
attachments: Array<{ name: string; contentType: string; path: string }>;
error: string | null;
}
interface Summary {
total: number;
passed: number;
failed: number;
flaky: number;
skipped: number;
timedOut: number;
interrupted: number;
durationMs: number;
}
interface MarkdownInput {
startedAt: Date;
finishedAt: Date;
summary: Summary;
rows: CaseRow[];
outputFile: string;
}
class MarkdownReporter implements Reporter {
private rootSuite: Suite | null = null;
private startedAt: Date | null = null;
private readonly options: MarkdownReporterOptions;
constructor(options: MarkdownReporterOptions = {}) {
this.options = options;
}
onBegin(_config: FullConfig, suite: Suite): void {
this.rootSuite = suite;
this.startedAt = new Date();
}
async onEnd(): Promise<void> {
if (!this.rootSuite) return;
const rows: CaseRow[] = [];
visitSuite(this.rootSuite, rows);
rows.sort((a, b) => a.caseId.localeCompare(b.caseId));
const summary = summarize(rows);
const startedAt = this.startedAt ?? new Date();
const finishedAt = new Date();
const outputFile = this.options.outputFile || './reports/latest.md';
const resolvedOutput = path.resolve(process.cwd(), outputFile);
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
fs.writeFileSync(
resolvedOutput,
buildMarkdown({
startedAt,
finishedAt,
summary,
rows,
outputFile,
}),
'utf8',
);
}
}
function visitSuite(suite: Suite, rows: CaseRow[]): void {
for (const child of suite.suites || []) {
visitSuite(child, rows);
}
for (const test of suite.tests || []) {
const finalResult = test.results[test.results.length - 1];
if (!finalResult) continue;
rows.push(buildCaseRow(test, finalResult));
}
}
function buildCaseRow(test: TestCase, finalResult: TestResult): CaseRow {
const parsed = parseCaseTitle(test.title);
const metadata = caseMetadata[parsed.caseId];
return {
caseId: parsed.caseId,
title: parsed.title,
module: metadata?.module || '未分组',
assertions: metadata?.assertions || [],
status: normalizeStatus(finalResult.status, test.outcome?.()),
durationMs: finalResult.duration ?? 0,
retries: Math.max(0, test.results.length - 1),
file: test.location?.file ?? '',
line: test.location?.line ?? null,
attachments: (finalResult.attachments || [])
.map((entry) => ({
name: entry.name || '',
contentType: entry.contentType || '',
path: entry.path ? toRelative(entry.path) : '',
}))
.filter((entry) => entry.path.length > 0),
error: compactError(finalResult.error),
};
}
function parseCaseTitle(title: string): { caseId: string; title: string } {
const idx = title.indexOf(': ');
if (idx === -1) {
return { caseId: title, title };
}
return {
caseId: title.slice(0, idx).trim(),
title: title.slice(idx + 2).trim(),
};
}
function normalizeStatus(status: string | undefined, outcome: string | undefined): string {
if (outcome === 'flaky') return 'flaky';
return status || 'unknown';
}
function compactError(error: TestResult['error']): string | null {
if (!error) return null;
const raw = [error.message, error.value, error.stack]
.filter(Boolean)
.join('\n')
.trim();
if (!raw) return null;
return raw.split('\n').slice(0, 8).join('\n');
}
function summarize(rows: CaseRow[]): Summary {
const summary = {
total: rows.length,
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
timedOut: 0,
interrupted: 0,
durationMs: rows.reduce((sum, row) => sum + row.durationMs, 0),
};
for (const row of rows) {
if (row.status === 'passed') summary.passed += 1;
else if (row.status === 'failed') summary.failed += 1;
else if (row.status === 'flaky') summary.flaky += 1;
else if (row.status === 'skipped') summary.skipped += 1;
else if (row.status === 'timedOut') summary.timedOut += 1;
else if (row.status === 'interrupted') summary.interrupted += 1;
}
return summary;
}
function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }: MarkdownInput): string {
const lines: string[] = [];
lines.push('# UI 自动化测试报告');
lines.push('');
lines.push(`- 生成时间:${finishedAt.toISOString()}`);
lines.push(`- 开始时间:${startedAt.toISOString()}`);
lines.push(`- 结束时间:${finishedAt.toISOString()}`);
lines.push(`- 报告文件:\`${outputFile}\``);
lines.push(`- 执行结果:${summary.failed === 0 && summary.timedOut === 0 ? '通过' : '失败'}`);
lines.push('');
lines.push('## 汇总');
lines.push('');
lines.push(`- 总用例:${summary.total}`);
lines.push(`- 通过:${summary.passed}`);
lines.push(`- 失败:${summary.failed}`);
lines.push(`- Flaky${summary.flaky}`);
lines.push(`- 跳过:${summary.skipped}`);
lines.push(`- 超时:${summary.timedOut}`);
lines.push(`- 中断:${summary.interrupted}`);
lines.push(`- 总耗时:${formatDuration(summary.durationMs)}`);
lines.push('');
lines.push('## 用例结果');
lines.push('');
lines.push('| Case ID | 模块 | 标题 | 状态 | 耗时 | 重试 |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const row of rows) {
lines.push(
`| \`${escapeCell(row.caseId)}\` | ${escapeCell(row.module)} | ${escapeCell(row.title)} | ${statusLabel(row.status)} | ${formatDuration(row.durationMs)} | ${row.retries} |`,
);
}
lines.push('');
lines.push('## 关键断言');
lines.push('');
for (const row of rows) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 模块:${row.module}`);
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
if (row.assertions.length > 0) {
lines.push('- 本次验证点:');
for (const assertion of row.assertions) {
lines.push(` - ${assertion}`);
}
} else {
lines.push('- 本次验证点:未配置');
}
lines.push('');
}
const problematic = rows.filter((row) => row.status !== 'passed');
if (problematic.length > 0) {
lines.push('');
lines.push('## 异常详情');
lines.push('');
for (const row of problematic) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
lines.push(`- 位置:\`${toRelative(row.file)}${row.line ? `:${row.line}` : ''}\``);
if (row.error) {
lines.push('- 错误:');
lines.push('```text');
lines.push(row.error);
lines.push('```');
}
if (row.attachments.length > 0) {
lines.push('- 附件:');
for (const attachment of row.attachments) {
lines.push(` - \`${attachment.name}\` · \`${attachment.path}\``);
}
}
lines.push('');
}
}
lines.push('## 原始产物');
lines.push('');
lines.push('- HTML 报告入口:`e2e/reports/ui-test-report.html`');
lines.push('- Playwright HTML 底层目录:`e2e/reports/playwright-html-report/`');
lines.push('- JSON 结果:`e2e/reports/results.json`');
lines.push('- JUnit 结果:`e2e/reports/junit.xml`');
lines.push('- Playwright 附件:`e2e/reports/test-results/`');
lines.push('');
lines.push('## 说明');
lines.push('');
lines.push('- 这份报告记录的是本次实际执行到的 UI 自动化用例。');
lines.push('- 用例设计来源见 `e2e/cases/` 以及各模块文档。');
lines.push('- 如果用例失败,优先查看本报告中的附件路径和 HTML 报告。');
lines.push('');
return `${lines.join('\n')}\n`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusLabel(status: string): string {
if (status === 'passed') return 'passed';
if (status === 'failed') return 'failed';
if (status === 'flaky') return 'flaky';
if (status === 'skipped') return 'skipped';
if (status === 'timedOut') return 'timedOut';
if (status === 'interrupted') return 'interrupted';
return status;
}
function toRelative(filePath: string): string {
if (!filePath) return '';
return path.relative(process.cwd(), filePath) || filePath;
}
function escapeCell(value: string): string {
return String(value).replace(/\|/g, '\\|');
}
export default MarkdownReporter;

View file

@ -1,49 +0,0 @@
# Relatórios de testes de UI
Este diretório guarda os resultados de execução e relatórios legíveis dos testes automatizados de UI.
## O que cada item é
- `latest.md`: relatório resumido em Markdown da última execução
- `ui-test-report.html`: ponto de entrada HTML do relatório, pensado para abrir direto
- `playwright-html-report/`: diretório do relatório HTML nativo do Playwright; o entrypoint interno continua sendo `index.html`
- `results.json`: resultado bruto em JSON do Playwright
- `junit.xml`: resultado em JUnit, prático para integrar com CI
- `test-results/`: anexos brutos dos casos com falha (screenshots, traces, error-context)
Antes de cada execução de `pnpm -C e2e test:ui`, o sistema limpa automaticamente:
- `e2e/.od-data/`
- `e2e/reports/test-results/`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
- `e2e/reports/latest.md`
Assim, por padrão, os relatórios e dados de teste refletem apenas a última execução, sem mistura com resíduos anteriores.
## Como ler
Para responder rapidamente "o que foi testado e passou?", comece por:
- [latest.md](latest.md)
- [ui-test-report.html](ui-test-report.html)
Eles incluem:
- Horário da execução
- Total de casos, aprovados e falhos
- Resultado, duração e número de retries por caso
- Resumo do erro e caminhos dos anexos quando falha
Para um contexto mais detalhado das falhas, consulte:
- `e2e/reports/playwright-html-report/`
- `e2e/reports/test-results/`
## Relação com a biblioteca de casos
- `e2e/cases/`: define "o que deveria ser testado"
- `e2e/reports/`: registra "o que foi testado e qual foi o resultado"
Com essas duas camadas separadas, dá para inspecionar o desenho da cobertura e o resultado real da execução.

View file

@ -1,49 +0,0 @@
# UI 测试报告
这个目录存放 UI 自动化测试的运行结果和可读报告。
## 目录说明
- `latest.md`:最近一次测试运行的 Markdown 汇总报告
- `ui-test-report.html`:给人直接打开的 HTML 报告入口
- `playwright-html-report/`Playwright 原生 HTML 报告目录,内部入口仍是 `index.html`
- `results.json`Playwright JSON 原始结果
- `junit.xml`JUnit 格式结果,方便接 CI
- `test-results/`失败用例的截图、trace、error-context 等原始附件
每次执行 `pnpm -C e2e test:ui` 前,系统会先自动清理旧的:
- `e2e/.od-data/`
- `e2e/reports/test-results/`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
- `e2e/reports/latest.md`
这样报告和测试数据默认只反映最近一次执行结果,不会把上一次残留混进来。
## 怎么看
如果你想快速判断“这次到底测了什么、有没有过”,先看:
- [latest.md](/Users/mac/open-design/open-design/e2e/reports/latest.md)
- [ui-test-report.html](/Users/mac/open-design/open-design/e2e/reports/ui-test-report.html)
它会包含:
- 本次执行时间
- 总用例数、通过数、失败数
- 每条 case 的结果、耗时、重试次数
- 失败时对应的错误摘要和附件路径
如果你想看更细的失败上下文,再看:
- `e2e/reports/playwright-html-report/`
- `e2e/reports/test-results/`
## 和用例库的关系
- `e2e/cases/`:定义“应该测什么”
- `e2e/reports/`:记录“这次实际测了什么、结果如何”
这两层分开以后,既能看覆盖设计,也能看真实执行结果。

View file

@ -1,52 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Open Design UI Test Report</title>
<meta http-equiv="refresh" content="0; url=./playwright-html-report/index.html" />
<style>
:root {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f6f3ee;
color: #201d18;
}
main {
width: min(560px, calc(100vw - 48px));
padding: 28px 32px;
border: 1px solid #ddd4c7;
border-radius: 18px;
background: #fffdfa;
box-shadow: 0 16px 40px rgba(32, 29, 24, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: 22px;
}
p {
margin: 0;
line-height: 1.6;
}
a {
color: #b45b33;
}
</style>
</head>
<body>
<main>
<h1>Open Design UI Test Report</h1>
<p>
正在跳转到 Playwright HTML 报告。
如果没有自动跳转,请打开
<a href="./playwright-html-report/index.html">playwright-html-report/index.html</a>
</p>
</main>
</body>
</html>

View file

@ -1,6 +1,6 @@
import type { UICase } from './types';
import type { UiScenario } from '@/playwright/resources';
export const uiCases: UICase[] = [
export const playwrightUiScenarios: UiScenario[] = [
{
id: 'prototype-basic',
title: 'Prototype project creates and previews a generated artifact',
@ -399,7 +399,3 @@ export const uiCases: UICase[] = [
],
},
];
export function automatedCases(): UICase[] {
return uiCases.filter((entry) => entry.automated);
}

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

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

View file

@ -1,53 +0,0 @@
import { mkdir, readdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const e2eDir = path.resolve(__dirname, '..');
const targets = [
path.join(e2eDir, '.od-data'),
path.join(e2eDir, 'test-results'),
path.join(e2eDir, 'reports', 'test-results'),
path.join(e2eDir, 'reports', 'html'),
path.join(e2eDir, 'reports', 'playwright-html-report'),
path.join(e2eDir, 'reports', 'results.json'),
path.join(e2eDir, 'reports', 'junit.xml'),
path.join(e2eDir, 'reports', 'latest.md'),
path.join(e2eDir, '.DS_Store'),
];
for (const target of targets) {
await rm(target, { recursive: true, force: true });
}
await mkdir(path.join(e2eDir, 'reports'), { recursive: true });
// Recreate runtime roots so local inspection stays predictable even before
// Playwright or the daemon materializes them.
await mkdir(path.join(e2eDir, '.od-data'), { recursive: true });
await mkdir(path.join(e2eDir, 'reports', 'test-results'), {
recursive: true,
});
// Best-effort removal of accidental empty directories directly under the
// test data root. This keeps old project ids from piling up across runs.
const projectsRoot = path.join(e2eDir, '.od-data', 'projects');
try {
const entries = await readdir(projectsRoot, { withFileTypes: true });
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map((entry) =>
rm(path.join(projectsRoot, entry.name), {
recursive: true,
force: true,
}),
),
);
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined;
if (code !== 'ENOENT') {
console.warn('Failed to clean stale e2e project dirs:', error);
}
}

View file

@ -1,319 +0,0 @@
import type http from 'node:http';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
interface AgentInfo {
id: string;
name: string;
bin: string;
available: boolean;
path?: string;
version?: string | null;
models?: Array<{ id: string; label?: string }>;
streamFormat?: string;
}
interface AgentsResponse {
agents: AgentInfo[];
}
interface ParsedSseEvent {
event: string;
data: Record<string, unknown>;
}
type StartServer = (options: { port: number; returnServer: true }) => Promise<http.Server | undefined>;
type CloseDatabase = () => void;
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES);
const maxRuntimeCount = 8;
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
let baseUrl: string;
let server: http.Server | undefined;
let startServer: StartServer;
let closeDatabase: CloseDatabase | undefined;
let detectedAgents: AgentInfo[] | undefined;
let dataDir: string;
test.before(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-'));
process.env.OD_DATA_DIR = dataDir;
({ startServer } = await import('../../apps/daemon/dist/server.js') as { startServer: StartServer });
({ closeDatabase } = await import('../../apps/daemon/dist/db.js') as { closeDatabase: CloseDatabase });
const started = await startServer({ port: 0, returnServer: true });
if (started == null) {
throw new Error('startServer did not return a server handle');
}
const address = started.address();
if (address == null || typeof address === 'string') {
throw new Error('startServer did not bind to a TCP port');
}
server = started;
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {
if (server) {
await new Promise<void>((resolve, reject) => {
server?.close((err) => (err ? reject(err) : resolve()));
});
}
closeDatabase?.();
if (dataDir) {
await fs.rm(dataDir, { recursive: true, force: true });
}
});
test('runtime adapter live detection flow exposes installed runtimes', async () => {
log('detect', 'starting runtime detection via /api/agents');
const res = await fetch(`${baseUrl}/api/agents`);
assert.equal(res.status, 200);
const body = await readAgentsResponse(res);
assert.ok(Array.isArray(body.agents));
assert.ok(body.agents.length > 0);
detectedAgents = body.agents;
const available = body.agents.filter((agent) => agent.available);
for (const agent of body.agents) {
const status = agent.available ? 'available' : 'unavailable';
const version = agent.version ? ` version=${agent.version}` : '';
const resolvedPath = agent.path ? ` path=${agent.path}` : '';
log(
'detect',
`${agent.id}: ${status}${version}${resolvedPath} models=${agent.models?.length ?? 0} stream=${agent.streamFormat}`,
);
}
assert.ok(
available.length > 0,
'Install at least one supported runtime CLI on PATH: claude, codex, gemini, opencode, hermes, kimi, cursor-agent, or qwen.',
);
for (const agent of body.agents) {
assert.equal(typeof agent.id, 'string');
assert.equal(typeof agent.name, 'string');
assert.equal(typeof agent.bin, 'string');
assert.equal(typeof agent.available, 'boolean');
assert.ok(Array.isArray(agent.models));
assert.ok(agent.models.some((model) => model.id === 'default'));
assert.equal(typeof agent.streamFormat, 'string');
if (agent.available) {
assert.equal(typeof agent.path, 'string');
const resolvedPath = agent.path;
assert.ok(resolvedPath && resolvedPath.length > 0);
}
}
});
test('runtime adapter live run flow streams a successful response for every available runtime', { timeout: liveTimeoutMs * maxRuntimeCount + 30_000 }, async () => {
if (!detectedAgents) {
log('run', 'detection cache empty; fetching /api/agents before run flow');
const res = await fetch(`${baseUrl}/api/agents`);
detectedAgents = (await readAgentsResponse(res)).agents;
}
const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null;
const availableAgents = detectedAgents.filter(
(agent) => agent.available && (!requestedSet || requestedSet.has(agent.id)),
);
if (requestedSet) {
log('run', `runtime filter=${requestedRuntimeIds?.join(',')}`);
for (const id of requestedSet) {
assert.ok(
detectedAgents.some((agent) => agent.id === id),
`Requested runtime ${id} is missing from /api/agents.`,
);
}
}
for (const agent of detectedAgents) {
if (agent.available) {
if (!requestedSet || requestedSet.has(agent.id)) {
log('run', `${agent.id}: queued`);
} else {
log('run', `${agent.id}: skipped by runtime filter`);
}
} else {
log('run', `${agent.id}: skipped because runtime is unavailable`);
}
}
assert.ok(
availableAgents.length > 0,
requestedSet
? `Requested runtimes unavailable: ${requestedRuntimeIds?.join(',')}.`
: 'Available runtime required from /api/agents.',
);
for (const agent of availableAgents) {
await runRuntime(agent);
}
});
async function runRuntime(agent: AgentInfo): Promise<void> {
const startedAt = Date.now();
log('run', `${agent.id}: starting /api/chat live run`);
const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const events: ParsedSseEvent[] = [];
const abort = AbortSignal.timeout(liveTimeoutMs);
try {
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: abort,
body: JSON.stringify({
agentId: agent.id,
projectId,
model: 'default',
message: `Reply with exactly this token and nothing else: ${marker}`,
systemPrompt: [
'You are running a local runtime-adapter live smoke test.',
'Produce a minimal text-only response.',
'Do not create, edit, delete, or inspect files.',
].join('\n'),
}),
});
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/event-stream/);
assert.ok(res.body, 'SSE response should include a readable body.');
await collectSseEvents(res, events, agent.id);
} finally {
await fs.rm(path.join(dataDir, 'projects', projectId), {
recursive: true,
force: true,
});
}
const start = events.find((event) => event.event === 'start');
assert.ok(start, 'SSE stream should include a start event.');
assert.equal(start.data.agentId, agent.id);
assert.equal(start.data.projectId, projectId);
log('run', `${agent.id}: start event cwd=${String(start.data.cwd ?? '')}`);
const end = events.find((event) => event.event === 'end');
assert.ok(end, 'SSE stream should include an end event.');
assert.equal(end.data.code, 0, renderEvents(events));
log('run', `${agent.id}: end event code=${String(end.data.code)} signal=${String(end.data.signal ?? 'none')}`);
const text = events
.map((event) => {
if (event.event === 'stdout') return stringData(event.data.chunk);
if (event.event === 'agent') return stringData(event.data.text) || stringData(event.data.delta);
return '';
})
.join('');
assert.match(text, new RegExp(marker), renderEvents(events));
log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`);
}
async function collectSseEvents(res: Response, events: ParsedSseEvent[], agentId: string): Promise<void> {
const reader = res.body?.getReader();
assert.ok(reader, 'SSE response should include a readable body.');
const decoder = new TextDecoder();
let buffer = '';
const seen = new Set<string>();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() || '';
for (const chunk of chunks) {
const parsed = parseSseEvent(chunk);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const parsed = parseSseEvent(buffer);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
function parseSseEvent(chunk: string): ParsedSseEvent | null {
const lines = chunk.split('\n');
if (lines.every((line) => line === '' || line.startsWith(':'))) return null;
const eventLine = lines.find((line) => line.startsWith('event: '));
const dataLine = lines.find((line) => line.startsWith('data: '));
if (!eventLine || !dataLine) return null;
return {
event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
};
}
function renderEvents(events: ParsedSseEvent[]): string {
return JSON.stringify(events, null, 2).slice(0, 8000);
}
function parseRuntimeIds(value: string | undefined): string[] | null {
if (!value) return null;
const ids = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return ids.length > 0 ? ids : null;
}
function log(stage: string, message: string): void {
console.log(`[runtime-adapter:e2e:${stage}] ${message}`);
}
function logSseProgress(agentId: string, event: ParsedSseEvent, seen: Set<string>): void {
if (event.event === 'start' && !seen.has('start')) {
seen.add('start');
log('run', `${agentId}: received start event`);
return;
}
if (event.event === 'stdout' && !seen.has('stdout')) {
seen.add('stdout');
log('run', `${agentId}: received stdout stream`);
return;
}
const type = stringData(event.data.type) || 'event';
if (event.event === 'agent' && !seen.has(`agent:${type}`)) {
seen.add(`agent:${type}`);
log('run', `${agentId}: received agent event type=${type || 'unknown'}`);
return;
}
if (event.event === 'stderr' && !seen.has('stderr')) {
seen.add('stderr');
log('run', `${agentId}: received stderr stream`);
return;
}
if (event.event === 'error') {
log('run', `${agentId}: received error event ${stringData(event.data.message)}`.trim());
return;
}
if (event.event === 'end' && !seen.has('end')) {
seen.add('end');
log('run', `${agentId}: received end event`);
}
}
async function readAgentsResponse(res: Response): Promise<AgentsResponse> {
const body = await res.json() as Partial<AgentsResponse>;
return { agents: Array.isArray(body.agents) ? body.agents : [] };
}
function stringData(value: unknown): string {
return typeof value === 'string' ? value : '';
}

308
e2e/specs/mac.spec.ts Normal file
View file

@ -0,0 +1,308 @@
// @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 { describe, expect, test } from 'vitest';
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;
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);
});
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}`);
}
}
async function waitForHealthyDesktop(): Promise<MacInspectResult> {
const timeoutMs = 90_000;
const startedAt = Date.now();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<MacInspectResult>('inspect', ['--expr', healthExpression]);
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asHealthEvalValue(inspect.eval.value);
if (value?.status === 200 && value.health.ok === true && typeof value.health.version === 'string') {
return inspect;
}
}
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged mac runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
function assertLogPathsAndContent(result: LogsResult): void {
expect(result.namespace).toBe(namespace);
for (const app of ['desktop', 'web', 'daemon']) {
const entry = result.logs[app];
if (entry == null) {
throw new Error(`expected ${app} log entry`);
}
expectPathInside(entry.logPath, join(runtimeNamespaceRoot, 'logs', app));
}
const combined = Object.values(result.logs)
.flatMap((entry) => entry.lines)
.join('\n');
expect(combined).not.toMatch(/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
expect(combined).not.toMatch(/packaged runtime failed/i);
}
async function printPackagedLogs(): Promise<void> {
const result = await runToolsPackJson<LogsResult>('logs');
for (const [app, entry] of Object.entries(result.logs)) {
console.error(`[${app}] ${entry.logPath}`);
console.error(entry.lines.join('\n') || '(no log lines)');
}
}
function assertHealthEvalValue(value: unknown): HealthEvalValue {
const normalized = asHealthEvalValue(value);
if (normalized == null) {
throw new Error(`unexpected health eval value: ${formatUnknown(value)}`);
}
return normalized;
}
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
if (!isRecord(value.health)) return null;
return value as HealthEvalValue;
}
function expectPathInside(filePath: string, expectedRoot: string): void {
const normalizedPath = resolve(filePath);
const normalizedRoot = resolve(expectedRoot);
expect(
normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`),
`${normalizedPath} should be inside ${normalizedRoot}`,
).toBe(true);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function resolveFromWorkspace(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
}
function delay(ms: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function isExecError(value: unknown): value is { stderr: string; stdout: string } {
return isRecord(value) && typeof value.stdout === 'string' && typeof value.stderr === 'string';
}
function formatUnknown(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View file

@ -1,215 +0,0 @@
import { expect, test } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
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('manual edit mode applies content, style, attribute, HTML, source, undo, and redo patches', async ({ page }) => {
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/manual-edit.html`);
await openDesignFile(page, 'manual-edit.html');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await page.getByTestId('manual-edit-mode-toggle').click();
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
await page.locator('.manual-edit-modal textarea').first().fill('Edited Hero');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('heading', { name: 'Edited Hero' })).toBeVisible();
await expectFileSource(page, projectId, 'manual-edit.html', ['Edited Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Style', exact: true }).click();
await page.locator('.manual-edit-field').filter({ hasText: 'Font size' }).locator('input').fill('48px');
await page.getByRole('button', { name: 'Apply Style' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Primary CTA' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Content', exact: true }).click();
const contentFields = page.locator('.manual-edit-tab-body');
await contentFields.locator('textarea').fill('Launch now');
await contentFields.locator('input').fill('/launch');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('link', { name: 'Launch now' })).toHaveAttribute('href', /\/launch$/);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero image' }).click();
await contentFields.locator('input').first().fill('/edited.png');
await contentFields.locator('input').nth(1).fill('Edited alt');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['/edited.png', 'Edited alt']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero title' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Attributes', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('{"aria-label":"Edited headline"}');
await page.getByRole('button', { name: 'Apply Attributes' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['aria-label="Edited headline"', 'font-size: 48px']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Html', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('<h1 class="replacement">HTML Hero</h1>');
await page.getByRole('button', { name: 'Apply HTML' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['data-od-id="hero-title"', 'HTML Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Source', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill(manualEditHtml().replace('Original Hero', 'Full Source Hero'));
await page.getByRole('button', { name: 'Apply Source' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Undo' }).click();
await expect(frame.getByRole('heading', { name: 'HTML Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Redo' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: /Tweaks/ }).click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
await frame.getByRole('heading', { name: 'Full Source Hero' }).click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByRole('button', { name: /^Share$/ }).click();
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
});
test('manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => {
const projectId = await createEmptyProject(page, 'Manual edit deck smoke');
await seedHtmlArtifact(page, projectId, 'manual-deck.html', deckHtml());
await page.goto(`/projects/${projectId}/files/manual-deck.html`);
await openDesignFile(page, 'manual-deck.html');
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByText('Slide One')).toBeVisible();
await page.getByLabel('Next slide').click();
await expect(frame.getByText('Slide Two')).toBeVisible();
});
async function createEmptyProject(page: Parameters<typeof test>[0]['page'], name: string): Promise<string> {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await expect(page).toHaveURL(/\/projects\//);
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) throw new Error(`unexpected project route: ${current.pathname}`);
return projectId;
}
async function seedHtmlArtifact(
page: Parameters<typeof test>[0]['page'],
projectId: string,
fileName: string,
content: string,
) {
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
});
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Parameters<typeof test>[0]['page'], fileName: string) {
await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
}
async function expectFileSource(
page: Parameters<typeof test>[0]['page'],
projectId: string,
fileName: string,
snippets: string[],
) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => source.includes(snippet));
})
.toBe(true);
}
function manualEditHtml(): string {
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Manual Edit</title></head>
<body>
<main>
<section data-od-id="hero" data-od-label="Hero section">
<h1 data-od-id="hero-title" data-od-label="Hero title">Original Hero</h1>
<a data-od-id="cta" data-od-label="Primary CTA" href="/start">Start now</a>
<img data-od-id="hero-image" data-od-label="Hero image" src="/hero.png" alt="Hero" style="width:64px;height:64px;">
</section>
</main>
</body>
</html>`;
}
function deckHtml(): string {
return `<!doctype html>
<html>
<body>
<section class="slide" data-od-id="slide-1"><h1>Slide One</h1></section>
<section class="slide" data-od-id="slide-2" hidden><h1>Slide Two</h1></section>
<script>
let active = 0;
const slides = Array.from(document.querySelectorAll('.slide'));
function render() { slides.forEach((slide, index) => { slide.hidden = index !== active; }); }
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'od:slide') return;
if (event.data.action === 'next') active = Math.min(slides.length - 1, active + 1);
if (event.data.action === 'prev') active = Math.max(0, active - 1);
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
});
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
</script>
</body>
</html>`;
}

View file

@ -1,11 +1,40 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseFrontmatter } from '../../../daemon/src/frontmatter';
import { LOCALIZED_CONTENT_IDS } from '../../src/i18n/content';
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.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));
@ -22,7 +51,7 @@ async function entriesWithFile(root: string, fileName: string): Promise<string[]
ids.push(entry.name);
}
} catch {
// Missing optional registry files are ignored, matching daemon discovery.
// Missing optional registry files are ignored, matching resource discovery.
}
}
return sorted(ids);
@ -34,9 +63,7 @@ async function readSkillIds(): Promise<string[]> {
const ids = await Promise.all(
dirs.map(async (dir) => {
const raw = await readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf8');
const { data } = parseFrontmatter(raw) as { data: { name?: unknown } };
const name = data.name;
return typeof name === 'string' && name.trim() ? name : dir;
return readFrontmatterName(raw) ?? dir;
}),
);
return sorted(ids);
@ -84,7 +111,28 @@ async function readPromptTemplateSummaries(): Promise<
return summaries;
}
describe('Localized display content coverage', () => {
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([

View file

@ -1,307 +0,0 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import React, { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
let lastChatPaneProps: any | null = null;
const saveMessageSpy = vi.fn(async () => {});
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock('../../apps/web/src/i18n', () => ({
useT: () => ((key: string) => key) as any,
}));
vi.mock('../../apps/web/src/components/ChatPane', async () => {
const mod = await vi.importActual<any>('../../apps/web/src/components/ChatPane');
return {
...mod,
ChatPane: (props: any) => {
lastChatPaneProps = props;
return null;
},
};
});
vi.mock('../../apps/web/src/components/AppChromeHeader', () => ({
AppChromeHeader: () => null,
}));
vi.mock('../../apps/web/src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
vi.mock('../../apps/web/src/components/FileWorkspace', () => ({
FileWorkspace: () => null,
}));
vi.mock('../../apps/web/src/router', () => ({
navigate: () => {},
}));
vi.mock('../../apps/web/src/utils/notifications', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../apps/web/src/utils/notifications')>();
return {
...actual,
playSound: () => {},
showCompletionNotification: async () => {},
};
});
vi.mock('../../apps/web/src/providers/registry', () => ({
fetchPreviewComments: async () => [],
fetchDesignSystem: async () => null,
fetchLiveArtifacts: async () => [],
fetchProjectFiles: async () => [],
fetchSkill: async () => null,
patchPreviewCommentStatus: async () => true,
upsertPreviewComment: async () => null,
deletePreviewComment: async () => true,
writeProjectTextFile: async () => null,
}));
vi.mock('../../apps/web/src/providers/project-events', () => ({
useProjectFileEvents: () => ({ events: [] }),
}));
vi.mock('../../apps/web/src/state/projects', () => ({
listConversations: async () => [{ id: 'conv-1', title: 'Conversation', createdAt: 0 }],
createConversation: async () => ({ id: 'conv-1', title: 'Conversation', createdAt: 0 }),
listMessages: async () => [],
loadTabs: async () => ({ tabs: [], active: null }),
saveTabs: async () => {},
saveMessage: saveMessageSpy,
patchConversation: async () => null,
deleteConversation: async () => true,
patchProject: async () => null,
getTemplate: async () => null,
}));
vi.mock('../../apps/web/src/providers/daemon', () => ({
fetchChatRunStatus: async () => null,
listActiveChatRuns: async () => [],
reattachDaemonRun: async () => {},
streamViaDaemon: async ({ handlers, onRunCreated, onRunStatus }: any) => {
onRunCreated?.('run-1');
onRunStatus?.('running');
handlers.onError(new Error('connection refused'));
},
}));
async function loadProjectView() {
const { ProjectView } = await import('../../apps/web/src/components/ProjectView');
return ProjectView;
}
function baseProject() {
return {
id: 'proj-1',
name: 'Project',
createdAt: 0,
updatedAt: 0,
skillId: null,
designSystemId: null,
metadata: {} as any,
};
}
describe('ProjectView daemon error persistence', () => {
afterEach(() => {
cleanup();
saveMessageSpy.mockClear();
});
it('stores daemon failure details on the assistant message events', async () => {
lastChatPaneProps = null;
const ProjectView = await loadProjectView();
render(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'claude',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={[{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] }] as any}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps).not.toBeNull();
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('hi', []);
});
await waitFor(() => {
const assistant = (lastChatPaneProps!.messages as any[]).find((m: any) => m.role === 'assistant');
expect(assistant).toBeTruthy();
const events = assistant!.events ?? [];
expect(
events.some(
(e: any) => e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
await waitFor(() => {
expect(saveMessageSpy).toHaveBeenCalled();
});
});
it('keeps prior error details after switching agents and sending again', async () => {
lastChatPaneProps = null;
const ProjectView = await loadProjectView();
const view = render(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'claude',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={
[
{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] },
{ id: 'opencode', name: 'OpenCode', detected: true, version: '1.0.0', models: [] },
] as any
}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps).not.toBeNull();
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('first', []);
});
let firstAssistantId = '';
await waitFor(() => {
const assistants = (lastChatPaneProps!.messages as any[]).filter(
(m: any) => m.role === 'assistant',
);
expect(assistants.length).toBe(1);
const first = assistants[0]!;
firstAssistantId = first.id;
const events = first.events ?? [];
expect(
events.some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
// Switch agent (simulates picking a different CLI), then send again.
view.rerender(
<ProjectView
project={baseProject()}
routeFileName={null}
config={
{
mode: 'daemon',
agentId: 'opencode',
skillId: null,
designSystemId: null,
disabledSkills: [],
disabledDesignSystems: [],
} as any
}
agents={
[
{ id: 'claude', name: 'Claude', detected: true, version: '1.0.0', models: [] },
{ id: 'opencode', name: 'OpenCode', detected: true, version: '1.0.0', models: [] },
] as any
}
skills={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(lastChatPaneProps!.activeConversationId).toBe('conv-1');
});
await act(async () => {
await lastChatPaneProps!.onSend('second', []);
});
await waitFor(() => {
const messages = lastChatPaneProps!.messages as any[];
const firstAssistant = messages.find((m: any) => m.id === firstAssistantId);
expect(firstAssistant).toBeTruthy();
expect(
(firstAssistant!.events ?? []).some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
const assistants = messages.filter((m: any) => m.role === 'assistant');
expect(assistants.length).toBe(2);
const secondAssistant = assistants[1]!;
expect(
(secondAssistant.events ?? []).some(
(e: any) =>
e.kind === 'status' && e.label === 'error' && e.detail === 'connection refused',
),
).toBe(true);
});
});
});

View file

@ -5,6 +5,10 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["lib/*.ts"]
},
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
@ -20,9 +24,12 @@
"include": [
"playwright.config.ts",
"vitest.config.ts",
"cases/report-metadata.ts",
"reporters/**/*.ts",
"scripts/**/*.ts"
"lib/**/*.ts",
"resources/**/*.ts",
"scripts/**/*.ts",
"specs/**/*.ts",
"tests/**/*.ts",
"ui/**/*.ts"
],
"exclude": ["node_modules", "reports", ".od-data"]
}

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { automatedCases } from '../cases';
import type { UICase } from '../cases/types';
import type { Dialog, Page, Request, Response } from '@playwright/test';
import { automatedUiScenarios } from '@/playwright/resources';
import type { UiScenario } from '@/playwright/resources';
const STORAGE_KEY = 'open-design:config';
@ -23,7 +24,7 @@ test.beforeEach(async ({ page }) => {
}, STORAGE_KEY);
});
for (const entry of automatedCases()) {
for (const entry of automatedUiScenarios()) {
test(`${entry.id}: ${entry.title}`, async ({ page }) => {
await page.route('**/api/agents', async (route) => {
await route.fulfill({
@ -299,15 +300,279 @@ for (const entry of automatedCases()) {
});
}
test('daemon error details persist between failed sends', async ({ page }) => {
const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic');
if (!entry) throw new Error('prototype-basic scenario missing');
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' }],
},
],
},
});
});
let runCount = 0;
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `error-run-${runCount}` }),
});
});
await page.route('**/api/runs/*/events', async (route) => {
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: error',
'data: {"message":"connection refused"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await page.goto('/');
await createProject(page, entry);
await expectWorkspaceReady(page);
await sendPrompt(page, 'first failing prompt');
const errorPills = page.locator('.status-pill', { hasText: 'connection refused' });
await expect(errorPills).toHaveCount(1);
await expect(page.locator('.msg.error')).toContainText('connection refused');
await expect(page.getByText('first failing prompt')).toBeVisible();
await sendPrompt(page, 'second failing prompt');
await expect(errorPills).toHaveCount(2);
await expect(page.getByText('first failing prompt')).toBeVisible();
await expect(page.getByText('second failing prompt')).toBeVisible();
});
test('manual edit mode applies content, style, attribute, HTML, source, undo, and redo patches', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/manual-edit.html`);
await openDesignFile(page, 'manual-edit.html');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await page.getByTestId('manual-edit-mode-toggle').click();
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
await page.locator('.manual-edit-modal textarea').first().fill('Edited Hero');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('heading', { name: 'Edited Hero' })).toBeVisible();
await expectFileSource(page, projectId, 'manual-edit.html', ['Edited Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Style', exact: true }).click();
await page.locator('.manual-edit-field').filter({ hasText: 'Font size' }).locator('input').fill('48px');
await page.getByRole('button', { name: 'Apply Style' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Primary CTA' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Content', exact: true }).click();
const contentFields = page.locator('.manual-edit-tab-body');
await contentFields.locator('textarea').fill('Launch now');
await contentFields.locator('input').fill('/launch');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('link', { name: 'Launch now' })).toHaveAttribute('href', /\/launch$/);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero image' }).click();
await contentFields.locator('input').first().fill('/edited.png');
await contentFields.locator('input').nth(1).fill('Edited alt');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['/edited.png', 'Edited alt']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero title' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Attributes', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('{"aria-label":"Edited headline"}');
await page.getByRole('button', { name: 'Apply Attributes' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['aria-label="Edited headline"', 'font-size: 48px']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Html', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('<h1 class="replacement">HTML Hero</h1>');
await page.getByRole('button', { name: 'Apply HTML' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['data-od-id="hero-title"', 'HTML Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Source', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill(manualEditHtml().replace('Original Hero', 'Full Source Hero'));
await page.getByRole('button', { name: 'Apply Source' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Undo' }).click();
await expect(frame.getByRole('heading', { name: 'HTML Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Redo' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: /Tweaks/ }).click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
await frame.getByRole('heading', { name: 'Full Source Hero' }).click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByRole('button', { name: /^Share$/ }).click();
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
});
test('manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit deck smoke');
await seedHtmlArtifact(page, projectId, 'manual-deck.html', deckHtml());
await page.goto(`/projects/${projectId}/files/manual-deck.html`);
await openDesignFile(page, 'manual-deck.html');
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByText('Slide One')).toBeVisible();
await page.getByLabel('Next slide').click();
await expect(frame.getByText('Slide Two')).toBeVisible();
});
async function routeMockAgents(page: Page) {
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' }],
},
],
},
});
});
}
async function createEmptyProject(page: Page, name: string): Promise<string> {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await expect(page).toHaveURL(/\/projects\//);
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) throw new Error(`unexpected project route: ${current.pathname}`);
return projectId;
}
async function seedHtmlArtifact(
page: Page,
projectId: string,
fileName: string,
content: string,
) {
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
});
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Page, fileName: string) {
await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
}
async function expectFileSource(
page: Page,
projectId: string,
fileName: string,
snippets: string[],
) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => source.includes(snippet));
})
.toBe(true);
}
function manualEditHtml(): string {
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Manual Edit</title></head>
<body>
<main>
<section data-od-id="hero" data-od-label="Hero section">
<h1 data-od-id="hero-title" data-od-label="Hero title">Original Hero</h1>
<a data-od-id="cta" data-od-label="Primary CTA" href="/start">Start now</a>
<img data-od-id="hero-image" data-od-label="Hero image" src="/hero.png" alt="Hero" style="width:64px;height:64px;">
</section>
</main>
</body>
</html>`;
}
function deckHtml(): string {
return `<!doctype html>
<html>
<body>
<section class="slide" data-od-id="slide-1"><h1>Slide One</h1></section>
<section class="slide" data-od-id="slide-2" hidden><h1>Slide Two</h1></section>
<script>
let active = 0;
const slides = Array.from(document.querySelectorAll('.slide'));
function render() { slides.forEach((slide, index) => { slide.hidden = index !== active; }); }
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'od:slide') return;
if (event.data.action === 'next') active = Math.min(slides.length - 1, active + 1);
if (event.data.action === 'prev') active = Math.max(0, active - 1);
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
});
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
</script>
</body>
</html>`;
}
async function createProject(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await createProjectNameOnly(page, entry);
await page.getByTestId('create-project').click();
}
async function expectWorkspaceReady(page: Parameters<typeof test>[0]['page']) {
async function expectWorkspaceReady(page: Page) {
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();
@ -315,7 +580,7 @@ async function expectWorkspaceReady(page: Parameters<typeof test>[0]['page']) {
}
async function sendPrompt(
page: Parameters<typeof test>[0]['page'],
page: Page,
prompt: string,
) {
const input = page.getByTestId('chat-composer-input');
@ -327,7 +592,7 @@ async function sendPrompt(
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
(resp: Response) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
@ -342,7 +607,7 @@ async function sendPrompt(
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
(resp: Response) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
@ -356,8 +621,8 @@ async function sendPrompt(
}
async function runDesignSystemSelectionFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await createProjectNameOnly(page, entry);
await page.getByTestId('design-system-trigger').click();
@ -373,8 +638,8 @@ async function runDesignSystemSelectionFlow(
}
async function runExampleUsePromptFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await page.getByTestId('entry-tab-examples').click();
await expect(page.getByTestId('example-card-warm-utility-example')).toBeVisible();
@ -388,8 +653,8 @@ async function runExampleUsePromptFlow(
}
async function runQuestionFormSelectionLimitFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
@ -425,8 +690,8 @@ async function runQuestionFormSelectionLimitFlow(
}
async function runQuestionFormSubmitPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
@ -463,8 +728,8 @@ async function runQuestionFormSubmitPersistenceFlow(
}
async function runGenerationDoesNotCreateExtraFileFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
@ -482,24 +747,25 @@ async function runGenerationDoesNotCreateExtraFileFlow(
}
async function runCommentAttachmentFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
await page.getByTestId('board-mode-toggle').click();
await page.getByTestId('comment-mode-toggle').click();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await frame.locator('[data-od-id="hero-title"]').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByTestId('comment-popover-input').fill('Make the headline more specific.');
await page.getByTestId('comment-add-send').click();
await page.getByTestId('comment-popover').getByRole('button', { name: 'Save comment' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
await expect(page.getByTestId('staged-comment-attachments')).toContainText('Make the headline more specific.');
await expect(page.getByTestId('chat-composer-input')).toHaveValue('');
await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toHaveCount(0);
await expect(page.getByTestId('chat-composer-input')).toHaveValue('');
await expect(page.getByTestId('chat-send')).toBeDisabled();
await page.getByTestId('comment-popover').getByRole('button', { name: 'Close' }).click();
await frame.locator('[data-od-id="hero-copy"]').hover();
await expect(page.getByTestId('comment-target-overlay')).toBeVisible();
@ -512,9 +778,18 @@ async function runCommentAttachmentFlow(
await page.getByRole('tab', { name: 'Comments' }).click();
await expect(page.getByTestId('comments-panel')).toBeVisible();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Attached to chat' })).toBeVisible();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Saved comments' })).toBeVisible();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Add' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
await expect(page.getByTestId('staged-comment-attachments')).toContainText('Make the headline more specific.');
await page.getByRole('tab', { name: 'Comments' }).click();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Attached to chat' })).toBeVisible();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Remove' })
@ -532,7 +807,7 @@ async function runCommentAttachmentFlow(
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
const runRequest = page.waitForRequest(
(request) => request.url().includes('/api/runs') && request.method() === 'POST',
(request: Request) => request.url().includes('/api/runs') && request.method() === 'POST',
);
await page.getByTestId('chat-send').click();
const request = await runRequest;
@ -553,8 +828,8 @@ async function runCommentAttachmentFlow(
}
async function createProjectNameOnly(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
if (entry.create.tab) {
@ -564,7 +839,7 @@ async function createProjectNameOnly(
}
async function getCurrentProjectContext(
page: Parameters<typeof test>[0]['page'],
page: Page,
): Promise<{ projectId: string; conversationId: string }> {
const current = new URL(page.url());
const [, projects, projectId, maybeConversations, conversationId] = current.pathname.split('/');
@ -586,7 +861,7 @@ async function getCurrentProjectContext(
}
async function listProjectFilesFromApi(
page: Parameters<typeof test>[0]['page'],
page: Page,
projectId: string,
): Promise<Array<{ name: string; kind: string }>> {
const response = await page.request.get(`/api/projects/${projectId}/files`);
@ -596,8 +871,8 @@ async function listProjectFilesFromApi(
}
async function expectArtifactVisible(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
const artifact = entry.mockArtifact!;
await expect(page.getByText(artifact.fileName, { exact: true })).toBeVisible();
@ -607,8 +882,8 @@ async function expectArtifactVisible(
}
async function runConversationPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
@ -640,8 +915,8 @@ async function runConversationPersistenceFlow(
}
async function runFileMentionFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
@ -672,8 +947,8 @@ async function runFileMentionFlow(
}
async function runDeepLinkPreviewFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
@ -697,11 +972,11 @@ async function runDeepLinkPreviewFlow(
}
async function runFileUploadSendFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
const uploadResponse = page.waitForResponse(
(resp) => resp.url().includes('/upload') && resp.request().method() === 'POST',
(resp: Response) => resp.url().includes('/upload') && resp.request().method() === 'POST',
{ timeout: 5000 },
);
await page.getByTestId('chat-file-input').setInputFiles({
@ -723,7 +998,7 @@ async function runFileUploadSendFlow(
}
async function runDesignFilesUploadFlow(
page: Parameters<typeof test>[0]['page'],
page: Page,
) {
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'moodboard.png',
@ -750,9 +1025,9 @@ async function runDesignFilesUploadFlow(
}
async function runDesignFilesDeleteFlow(
page: Parameters<typeof test>[0]['page'],
page: Page,
) {
page.on('dialog', async (dialog) => {
page.on('dialog', async (dialog: Dialog) => {
await dialog.accept();
});
@ -803,7 +1078,7 @@ async function runDesignFilesDeleteFlow(
}
async function runDesignFilesTabPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
page: Page,
) {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
@ -842,10 +1117,10 @@ async function runDesignFilesTabPersistenceFlow(
}
async function runConversationDeleteRecoveryFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
page: Page,
entry: UiScenario,
) {
page.on('dialog', async (dialog) => {
page.on('dialog', async (dialog: Dialog) => {
await dialog.accept();
});

View file

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

View file

@ -193,6 +193,9 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/jsdom':
specifier: ^28.0.1
version: 28.0.1
@ -220,21 +223,12 @@ importers:
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/node':
specifier: ^20.17.10
version: 20.19.39
jsdom:
specifier: ^29.1.0
version: 29.1.0
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
tsx:
specifier: 4.21.0
version: 4.21.0
typescript:
specifier: ^5.6.3
version: 5.9.3

View file

@ -1,4 +1,4 @@
import { readdir } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
@ -69,6 +69,10 @@ const residualAllowedPathPrefixes = [
"e2e/reports/html/",
"e2e/reports/playwright-html-report/",
"e2e/reports/test-results/",
"e2e/ui/.od-data/",
"e2e/ui/reports/playwright-html-report/",
"e2e/ui/reports/test-results/",
"e2e/ui/test-results/",
// Vendored upstream HyperFrames skill helper scripts.
"skills/hyperframes/scripts/",
// Vendored upstream html-ppt skill runtime assets (lewislulu/html-ppt-skill).
@ -136,7 +140,7 @@ async function checkResidualJavaScript(): Promise<boolean> {
}
const testLayoutScopedDirectories = ["apps", "packages", "tools"];
const testLayoutSkippedDirectories = new Set([".next", "dist", "node_modules", "out"]);
const testLayoutSkippedDirectories = new Set([".next", ".od-data", "dist", "node_modules", "out", "reports", "test-results"]);
function isTestFile(fileName: string): boolean {
return /\.test\.tsx?$/.test(fileName);
@ -205,9 +209,140 @@ async function checkTestLayout(): Promise<boolean> {
return true;
}
const e2ePackageJsonPath = path.join(repoRoot, "e2e", "package.json");
const e2eSkippedDirectories = new Set([".od-data", "node_modules", "reports", "test-results"]);
const e2eAllowedScripts = ["test", "typecheck"];
async function collectRepositoryFiles(directory: string, skippedDirectoryNames = new Set<string>()): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectoryNames.has(entry.name)) continue;
files.push(...(await collectRepositoryFiles(fullPath, skippedDirectoryNames)));
continue;
}
if (entry.isFile()) files.push(toRepositoryPath(fullPath));
}
return files;
}
async function checkE2eLayout(): Promise<boolean> {
const violations: string[] = [];
const packageJson = JSON.parse(await readFile(e2ePackageJsonPath, "utf8")) as {
scripts?: Record<string, unknown>;
};
const scriptNames = Object.keys(packageJson.scripts ?? {}).sort();
if (scriptNames.join("\0") !== e2eAllowedScripts.join("\0")) {
violations.push(
`e2e/package.json scripts must be exactly ${e2eAllowedScripts.join(", ")} (found: ${scriptNames.join(", ")})`,
);
}
const e2eRoot = path.join(repoRoot, "e2e");
for (const repositoryPath of await collectRepositoryFiles(e2eRoot, e2eSkippedDirectories)) {
if (
repositoryPath === "e2e/package.json" ||
repositoryPath === "e2e/tsconfig.json" ||
repositoryPath === "e2e/vitest.config.ts" ||
repositoryPath === "e2e/playwright.config.ts" ||
repositoryPath === "e2e/AGENTS.md"
) {
continue;
}
if (repositoryPath.startsWith("e2e/specs/")) {
if (!/\.spec\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e specs must be *.spec.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/tests/")) {
if (!/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e tests must be *.test.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/ui/")) {
const relativePath = repositoryPath.slice("e2e/ui/".length);
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/resources/")) {
const relativePath = repositoryPath.slice("e2e/resources/".length);
if (relativePath.includes("/") || !/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e resources must be flat TypeScript files under resources/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/lib/")) {
if (!/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e lib files must be TypeScript`);
}
continue;
}
if (repositoryPath.startsWith("e2e/scripts/")) {
if (repositoryPath !== "e2e/scripts/playwright.ts") {
violations.push(`${repositoryPath} -> e2e scripts currently allow only scripts/playwright.ts`);
}
continue;
}
violations.push(`${repositoryPath} -> e2e source files must live in specs/, tests/, ui/, resources/, lib/, or scripts/playwright.ts`);
}
if (violations.length > 0) {
console.error("E2E package layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
return true;
}
const webTestSkippedDirectories = new Set([".od-data", "reports", "test-results"]);
async function checkWebTestLayout(): Promise<boolean> {
const violations: string[] = [];
const webTestsRoot = path.join(repoRoot, "apps", "web", "tests");
for (const repositoryPath of await collectRepositoryFiles(webTestsRoot, webTestSkippedDirectories)) {
if (repositoryPath.startsWith("apps/web/tests/vitest/") || repositoryPath.startsWith("apps/web/tests/playwright/")) {
violations.push(`${repositoryPath} -> web tests should stay lightweight under apps/web/tests/ without vitest/playwright nesting`);
continue;
}
if (/\.(spec|test)\.tsx?$/.test(repositoryPath) && !/\.test\.tsx?$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> web Vitest test files must be *.test.ts or *.test.tsx`);
}
}
if (violations.length > 0) {
console.error("Web test layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("Web test layout check passed: web tests stay lightweight and Vitest-only.");
return true;
}
const checks: GuardCheck[] = [
{ name: "residual JavaScript", run: checkResidualJavaScript },
{ name: "test layout", run: checkTestLayout },
{ name: "e2e layout", run: checkE2eLayout },
{ name: "web test layout", run: checkWebTestLayout },
];
const results: boolean[] = [];

View file

@ -5,6 +5,7 @@ import { resolveToolPackConfig, type ToolPackCliOptions, type ToolPackPlatform }
import {
cleanupPackedMacNamespace,
installPackedMacDmg,
inspectPackedMacApp,
packMac,
readPackedMacLogs,
startPackedMacApp,
@ -94,7 +95,7 @@ function addWinLifecycleOptions(command: CacCommand) {
const cli = cac("tools-pack");
addMacBuildOptions(addSharedOptions(cli.command("mac <action>", "Mac packaging commands: build|install|start|stop|logs|uninstall|cleanup"))).action(
addMacBuildOptions(addSharedOptions(cli.command("mac <action>", "Mac packaging commands: build|install|start|stop|logs|uninstall|cleanup|inspect"))).action(
async (action: string, options: CliOptions) => {
const config = resolveToolPackConfig("mac", options);
switch (action) {
@ -113,6 +114,9 @@ addMacBuildOptions(addSharedOptions(cli.command("mac <action>", "Mac packaging c
case "logs":
printLogs(await readPackedMacLogs(config), options);
return;
case "inspect":
printJson(await inspectPackedMacApp(config, options));
return;
case "uninstall":
printJson(await uninstallPackedMacApp(config));
return;

View file

@ -11,6 +11,8 @@ import {
SIDECAR_MESSAGES,
SIDECAR_MODES,
SIDECAR_SOURCES,
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type SidecarStamp,
} from "@open-design/sidecar-proto";
@ -94,6 +96,12 @@ export type MacStartResult = {
status: DesktopStatusSnapshot | null;
};
export type MacInspectResult = {
eval?: DesktopEvalResult;
screenshot?: DesktopScreenshotResult;
status: DesktopStatusSnapshot | null;
};
type DesktopRootIdentityMarker = {
appPath: string;
executablePath: string;
@ -1199,6 +1207,33 @@ export async function readPackedMacLogs(config: ToolPackConfig) {
};
}
export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise<MacInspectResult> {
const stamp = desktopStamp(config);
const status = await requestJsonIpc<DesktopStatusSnapshot>(
stamp.ipc,
{ type: SIDECAR_MESSAGES.STATUS },
{ timeoutMs: 2000 },
).catch(() => null);
return {
...(options.expr == null ? {} : {
eval: await requestJsonIpc<DesktopEvalResult>(
stamp.ipc,
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
{ timeoutMs: 5000 },
),
}),
...(options.path == null ? {} : {
screenshot: await requestJsonIpc<DesktopScreenshotResult>(
stamp.ipc,
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
{ timeoutMs: 10000 },
),
}),
status,
};
}
export async function uninstallPackedMacApp(config: ToolPackConfig): Promise<MacUninstallResult> {
const paths = resolveMacPaths(config);
const stop = await stopPackedMacApp(config);