mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
95bd7e5373
commit
f1cdb2844a
49 changed files with 1174 additions and 2178 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/release-beta.yml
vendored
8
.github/workflows/release-beta.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
4
.github/workflows/release-stable.yml
vendored
4
.github/workflows/release-stable.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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.
|
||||
|
|
|
|||
19
AGENTS.md
19
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-design/packaged",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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(
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -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: [
|
||||
|
|
@ -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
40
e2e/AGENTS.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# e2e/AGENTS.md
|
||||
|
||||
Follow the root `AGENTS.md` first. This package owns user-level end-to-end smoke tests and Playwright UI automation only.
|
||||
|
||||
## Directory layout
|
||||
|
||||
- `specs/`: highest-ROI end-to-end smoke tests suitable for PR or release gating. Keep this layer small and expand it only for regressions that justify always-on signal.
|
||||
- `tests/`: broader user-level end-to-end coverage, including Vitest checks that intentionally span app/package/resource boundaries. Add feature-depth scenarios here instead of bloating `specs/`.
|
||||
- `ui/`: flat Playwright UI automation test files only. Keep helpers, resources, and non-Playwright harnesses out of this directory.
|
||||
- `resources/`: declarative resources for e2e suites, such as Playwright UI scenario lists.
|
||||
- `lib/shared.ts`: tiny cross-suite shared helpers only.
|
||||
- `lib/vitest/`: Vitest-specific helpers.
|
||||
- `lib/playwright/`: Playwright-specific fixtures, resource accessors, route helpers, and UI actions.
|
||||
- `scripts/playwright.ts`: Playwright auxiliary subcommands such as artifact cleanup; it must not wrap `playwright test`.
|
||||
|
||||
## Naming and tools
|
||||
|
||||
- `specs/` files must be `*.spec.ts`.
|
||||
- `tests/` files must be `*.test.ts`.
|
||||
- `ui/` files must be flat `*.test.ts` Playwright tests. Do not add subdirectories, TSX, Vitest, jsdom, Testing Library, or React harness tests under `ui/`.
|
||||
- E2E Vitest tests use Node APIs; do not add JSX/TSX, jsdom, or browser-component tests under `specs/` or `tests/`.
|
||||
- Web component/runtime tests belong in `apps/web/tests/`, not `e2e/ui/`.
|
||||
- E2E tests may validate cross-app/resource consistency, but must not treat one app's private implementation as a shared helper for another app. Keep test-only helpers local to `e2e/lib/` or promote reusable logic to a pure package such as `packages/contracts`.
|
||||
- E2E imports may use `@/*` for `lib/*`; keep this alias local to the e2e package.
|
||||
|
||||
## Commands
|
||||
|
||||
Run commands from this directory:
|
||||
|
||||
```bash
|
||||
pnpm test specs/mac.spec.ts
|
||||
pnpm test specs
|
||||
pnpm test tests
|
||||
pnpm typecheck
|
||||
pnpm exec tsx scripts/playwright.ts clean
|
||||
pnpm exec playwright test -c playwright.config.ts --list
|
||||
pnpm exec playwright test -c playwright.config.ts
|
||||
```
|
||||
|
||||
Use a specific file path when validating a single case. Do not add root e2e aliases or extra package scripts for individual cases.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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. 刷新页面后再次确认锁定态和已选答案仍然正确
|
||||
|
||||
## 推荐后续补充
|
||||
|
||||
- 会话重命名
|
||||
- 删除最后一个会话后的自动重建
|
||||
- 历史菜单关闭/重新打开后的状态一致性
|
||||
- 长会话列表滚动与选中态
|
||||
- 多轮对话后的会话标题生成或更新策略
|
||||
|
|
@ -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 清理策略
|
||||
|
|
@ -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 项目创建
|
||||
- 创建项目后的刷新恢复
|
||||
- 创建失败或必填校验
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
1
e2e/lib/shared.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/`:记录“这次实际测了什么、结果如何”
|
||||
|
||||
这两层分开以后,既能看覆盖设计,也能看真实执行结果。
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
53
e2e/scripts/playwright.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const e2eDir = path.resolve(scriptDir, '..');
|
||||
const uiDir = path.join(e2eDir, 'ui');
|
||||
|
||||
type Command = () => Promise<void>;
|
||||
|
||||
const commands: Record<string, Command> = {
|
||||
clean: cleanArtifacts,
|
||||
help: async () => printUsage(),
|
||||
};
|
||||
|
||||
const commandName = process.argv[2] ?? 'help';
|
||||
const command = commands[commandName];
|
||||
|
||||
if (command == null) {
|
||||
console.error(`Unknown e2e Playwright helper command: ${commandName}`);
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
await command();
|
||||
}
|
||||
|
||||
async function cleanArtifacts(): Promise<void> {
|
||||
const targets = [
|
||||
path.join(uiDir, '.od-data'),
|
||||
path.join(uiDir, 'test-results'),
|
||||
path.join(uiDir, 'reports', 'test-results'),
|
||||
path.join(uiDir, 'reports', 'html'),
|
||||
path.join(uiDir, 'reports', 'playwright-html-report'),
|
||||
path.join(uiDir, 'reports', 'results.json'),
|
||||
path.join(uiDir, 'reports', 'junit.xml'),
|
||||
path.join(uiDir, '.DS_Store'),
|
||||
];
|
||||
|
||||
await Promise.all(targets.map((target) => rm(target, { recursive: true, force: true })));
|
||||
await mkdir(path.join(uiDir, 'reports', 'test-results'), { recursive: true });
|
||||
await mkdir(path.join(uiDir, '.od-data'), { recursive: true });
|
||||
|
||||
console.log('Cleaned e2e UI Playwright artifacts.');
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage: tsx scripts/playwright.ts <command>
|
||||
|
||||
Commands:
|
||||
clean Remove e2e UI Playwright runtime data and reports
|
||||
help Show this help
|
||||
`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
308
e2e/specs/mac.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
scripts/guard.ts
139
scripts/guard.ts
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue