Add launch review regression coverage and stabilize daemon tests (#3207)

* Add launch review E2E regression coverage

* Harden daemon launch review regressions

* Stabilize daemon runtime tests

* fix(tests): restore e2e preflight typing

Generated-By: looper 0.8.1 (runner=fixer, agent=codex)

* fix(tests): make fake plugin runtime ESM-safe

Generated-By: looper 0.8.1 (runner=fixer, agent=codex)

* Stabilize e2e fake agent and regression tests

* fix(tests): repair fake agent cjs runtime

Generated-By: looper 0.8.1 (runner=fixer, agent=codex)

* fix(review): harden plugin authoring checks

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)

* fix(tests): bind plugin authoring run to seeded conversation

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)
This commit is contained in:
Amy 2026-05-29 10:39:33 +08:00 committed by GitHub
parent 82203fe4a7
commit 1c2a1c4459
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2382 additions and 77 deletions

View file

@ -47,6 +47,7 @@ export function normalizeVelaModelId(rawId: string): string | null {
: withoutProvider;
if (!withoutPrefix) return null;
if (/^deepseek_v3_2$/i.test(withoutPrefix)) return 'deepseek-v3.2';
if (/^deepseek-v3-2$/i.test(withoutPrefix)) return 'deepseek-v3.2';
if (/^kimi_k2_6$/i.test(withoutPrefix)) return 'kimi-k2.6';
if (/^glm_5_1$/i.test(withoutPrefix)) return 'glm-5.1';
if (/^glm_5$/i.test(withoutPrefix)) return 'glm-5';

View file

@ -2094,6 +2094,33 @@ function reconcileAssistantMessageOnRunEnd(db, runs, run) {
});
}
function isPluginAuthoringRun(db, run) {
if (run?.pluginId === 'od-plugin-authoring') return true;
if (
typeof run?.appliedPluginSnapshotId === 'string'
&& run.appliedPluginSnapshotId.length > 0
) {
const snapshot = getSnapshot(db, run.appliedPluginSnapshotId);
return snapshot?.pluginId === 'od-plugin-authoring';
}
return false;
}
async function hasGeneratedPluginArtifacts(projectRoot) {
if (!projectRoot || typeof projectRoot !== 'string') return false;
const required = [
path.join(projectRoot, 'generated-plugin', 'open-design.json'),
path.join(projectRoot, 'generated-plugin', 'SKILL.md'),
];
try {
await Promise.all(required.map((file) => fs.promises.access(file, fs.constants.F_OK)));
return true;
} catch {
return false;
}
}
function detectSkillPluginCandidateOnRunSuccess(db, runs, run, input, projectRoot) {
if (!run.projectId || !run.conversationId) return;
void runs
@ -11838,7 +11865,7 @@ export async function startServer({
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message));
design.runs.finish(run, 'failed', 1, null);
});
child.on('close', (code, signal) => {
child.on('close', async (code, signal) => {
clearInactivityWatchdog();
revokeToolToken('child_exit');
unregisterChatAgentEventSink();
@ -11896,6 +11923,19 @@ export async function startServer({
));
return design.runs.finish(run, 'failed', code, signal);
}
if (
code === 0 &&
!run.cancelRequested &&
isPluginAuthoringRun(db, run) &&
!(await hasGeneratedPluginArtifacts(cwd))
) {
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
'Plugin authoring ended before generating the required generated-plugin artifacts.',
{ retryable: true },
));
return design.runs.finish(run, 'failed', code, signal);
}
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
// Terminal) are forced to exit via SIGTERM from attachAcpSession after
// a clean prompt completion. Without an override, the chat run would
@ -13116,7 +13156,8 @@ export async function startServer({
};
let server;
try {
server = app.listen(port, host, () => {
server = app.listen(port, host);
server.once('listening', () => {
// Widen the between-request idle window so kept-alive sockets
// belonging to chat/SSE clients survive the gaps between bursts.
//
@ -13139,10 +13180,8 @@ export async function startServer({
//
// `headersTimeout` must exceed `keepAliveTimeout` per the Node
// docs; otherwise a slow-loris client can stall request parsing.
if (server) {
server.keepAliveTimeout = 120_000;
server.headersTimeout = 125_000;
}
server.keepAliveTimeout = 120_000;
server.headersTimeout = 125_000;
const address = server.address();
// `address()` can in theory return `string | AddressInfo | null`. For
// a TCP listener it's always `AddressInfo` with a `.port` — the guard

View file

@ -106,6 +106,8 @@ describe('AMR runtime def', () => {
expect(normalizeVelaModelId('public_model_qwen3_235b_a22b')).toBe('qwen3-235b-a22b');
expect(normalizeVelaModelId('deepseek-v3.2')).toBe('deepseek-v3.2');
expect(normalizeVelaModelId('vela/deepseek-v3.2')).toBe('deepseek-v3.2');
expect(normalizeVelaModelId('deepseek-v3-2')).toBe('deepseek-v3.2');
expect(normalizeVelaModelId('vela/deepseek-v3-2')).toBe('deepseek-v3.2');
});
it('parses `vela models` output with fast chat defaults and plain canonical labels', () => {

View file

@ -281,6 +281,78 @@ child.on('exit', (code, signal) => {
}
});
it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => {
const projectId = `proj-plugin-authoring-${randomUUID()}`;
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Plugin authoring completion fixture',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResponse.status).toBe(200);
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
expect(conversationsResponse.status).toBe(200);
const conversationsBody = await conversationsResponse.json() as {
conversations: Array<{ id: string }>;
};
const conversationId = conversationsBody.conversations[0]?.id;
expect(conversationId).toBeTruthy();
await withFakeAgent(
'opencode',
`
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范,再生成插件文件。' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
conversationId,
pluginId: 'od-plugin-authoring',
message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。',
}),
});
expect(createResponse.status).toBe(202);
const {
runId,
pluginId,
appliedPluginSnapshotId,
} = await createResponse.json() as {
runId: string;
pluginId: string | null;
appliedPluginSnapshotId: string | null;
};
expect(pluginId).toBe('od-plugin-authoring');
expect(appliedPluginSnapshotId).toBeTruthy();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('先读取文档规范,再生成插件文件');
expect(statusBody.status).not.toBe('succeeded');
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
expect(filesResponse.status).toBe(200);
const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
expect(filesBody.files.some((file) => file.name.startsWith('generated-plugin/'))).toBe(false);
},
);
});
it('closes the # Instructions block with an explicit "do not echo" guard so models do not parrot the prompt back', async () => {
// claude-opus-4-7 (and a few other instruction-tuned models) start
// their reply by echoing the # Instructions block verbatim, which

View file

@ -2974,16 +2974,18 @@ process.stdin.on('end', () => {
});
it('reports an early-phase diagnostics block when the agent CLI is missing (#2248)', async () => {
// Clear PATH so the daemon cannot locate `claude`. We restore the
// env in `finally` to avoid leaking the empty PATH to later tests.
// Depending on whether the resolver short-circuits or the spawn
// itself ENOENTs, the kind may be agent_not_installed or
// agent_spawn_failed and the phase may be 'binary_resolution' or
// 'spawn'. Both are valid "we never reached the smoke test" shapes
// — the actionable bit for the UI is that diagnostics arrived at
// all and that the phase is one of the two early values.
// Isolate every resolver input so the daemon truly cannot locate
// `claude`, even on machines that have a pinned CLAUDE_BIN or an
// alternate user toolchain home configured. PATH alone is no longer
// sufficient because runtime resolution also consults CLI env
// overrides and OD_AGENT_HOME-scoped toolchain bins.
const oldPath = process.env.PATH;
const oldClaudeBin = process.env.CLAUDE_BIN;
const oldAgentHome = process.env.OD_AGENT_HOME;
const emptyHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-missing-claude-home-'));
process.env.PATH = '';
delete process.env.CLAUDE_BIN;
process.env.OD_AGENT_HOME = emptyHome;
try {
const result = await testAgentConnection({ agentId: 'claude' });
expect(result.ok).toBe(false);
@ -2992,6 +2994,11 @@ process.stdin.on('end', () => {
expect(['binary_resolution', 'spawn']).toContain(result.diagnostics?.phase);
} finally {
process.env.PATH = oldPath;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
await fsp.rm(emptyHome, { recursive: true, force: true });
}
});

View file

@ -127,4 +127,46 @@ describe('diagnostics export handler — packaged (runtime) layout', () => {
await rm(root, { recursive: true, force: true });
}
});
it('reports missing packaged log files under logical log paths without duplicating runtime segments', async () => {
const root = join(tmpdir(), `od-diag-missing-${randomUUID()}`);
const namespaceRoot = join(root, 'namespaces', 'release-beta');
const daemonLogPath = join(namespaceRoot, 'logs', APP_KEYS.DAEMON, 'latest.log');
try {
await mkdir(dirname(daemonLogPath), { recursive: true });
await writeFile(daemonLogPath, 'daemon ok\n', 'utf8');
const runtime: SidecarRuntimeContext<SidecarStamp> = {
app: APP_KEYS.DAEMON,
base: join(namespaceRoot, 'runtime'),
ipc: '/tmp/od-diag-missing.sock',
mode: SIDECAR_MODES.RUNTIME,
namespace: 'release-beta',
source: SIDECAR_SOURCES.PACKAGED,
};
const handler = createDiagnosticsExportHandler({ runtime, projectRoot: '/tmp/test-project' });
const res = mockResponse();
await handler({} as never, res as never, () => undefined);
expect(res.capturedStatus).toBe(200);
const zip = await JSZip.loadAsync(res.capturedPayload!);
const manifest = JSON.parse(await zip.file('summary/manifest.json')!.async('string')) as {
files: Array<{ name: string; bytes?: number; error?: string }>;
};
const fileNames = manifest.files.map((file) => file.name);
expect(fileNames).toContain('logs/daemon/latest.log');
expect(fileNames).toContain('logs/web/latest.log');
expect(fileNames).toContain('logs/desktop/latest.log');
expect(fileNames.some((name) => name.includes('runtime/release-beta/logs'))).toBe(false);
const webLog = manifest.files.find((file) => file.name === 'logs/web/latest.log');
const desktopLog = manifest.files.find((file) => file.name === 'logs/desktop/latest.log');
expect(webLog?.error).toBeTruthy();
expect(desktopLog?.error).toBeTruthy();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View file

@ -21,6 +21,8 @@ import { startServer } from '../src/server.js';
async function withFakeClaude<T>(run: () => Promise<T>): Promise<T> {
const dir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-spawn-bin-'));
const oldPath = process.env.PATH;
const oldClaudeBin = process.env.CLAUDE_BIN;
const oldAgentHome = process.env.OD_AGENT_HOME;
// Fake `claude` that prints stream-json the daemon understands and exits 0.
// The single result frame is enough to drive the run to `succeeded`.
const script = `
@ -50,9 +52,15 @@ process.exit(0);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = `${dir}${delimiter}${oldPath ?? ''}`;
delete process.env.CLAUDE_BIN;
process.env.OD_AGENT_HOME = dir;
return await run();
} finally {
process.env.PATH = oldPath;
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
else process.env.CLAUDE_BIN = oldClaudeBin;
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
else process.env.OD_AGENT_HOME = oldAgentHome;
await fsp.rm(dir, { recursive: true, force: true });
}
}
@ -61,13 +69,13 @@ async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
for (let attempt = 0; attempt < 60; attempt += 1) {
for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('run did not finish');
throw new Error('run did not finish within 5s of polling');
}
describe('spawn writes external MCP config for Claude Code', () => {

View file

@ -159,6 +159,37 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(await fileResp.text()).toContain('<h1>ok</h1>');
});
it('serves nested project html files through the raw route and allows Origin: null', async () => {
const projectId = `proj-raw-nested-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Nested raw route fixture',
skillId: null,
designSystemId: null,
}),
});
expect(createResp.status).toBe(200);
const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'nested/demo/index.html', content: '<!doctype html><h1>nested ok</h1>' }),
});
expect(writeResp.status).toBe(200);
const rawResp = await fetch(`${baseUrl}/api/projects/${projectId}/raw/nested/demo/index.html`, {
headers: { Origin: 'null' },
});
expect(rawResp.status).toBe(200);
expect(rawResp.headers.get('content-type')).toContain('text/html');
expect(rawResp.headers.get('access-control-allow-origin')).toBe('*');
expect(await rawResp.text()).toContain('<h1>nested ok</h1>');
});
it('rejects non-boolean skipDiscoveryBrief on POST /api/projects', async () => {
const projectId = `proj-skip-discovery-bad-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {

View file

@ -35,6 +35,22 @@ describe('chat run service shutdown', () => {
});
});
it('ignores subsequent finish attempts after the run reaches a terminal state', async () => {
const runs = createRuns();
const run = runs.create({ projectId: 'project-1', conversationId: 'conv-1' });
const wait = runs.wait(run);
runs.finish(run, 'succeeded', 0, null);
runs.finish(run, 'failed', 1, 'SIGTERM');
expect(run.status).toBe('succeeded');
expect(run.exitCode).toBe(0);
expect(run.signal).toBeNull();
expect(run.events.filter((event: { event: string }) => event.event === 'end')).toHaveLength(1);
await expect(wait).resolves.toMatchObject({ status: 'succeeded', exitCode: 0, signal: null });
});
it('filters active runs by conversation within the same project', () => {
const runs = createRuns();
const runA = runs.create({ projectId: 'project-1', conversationId: 'conv-a' });

View file

@ -265,7 +265,7 @@ test('codex model picker includes current OpenAI choices in priority order', asy
const dir = mkdtempSync(join(tmpdir(), 'od-agents-codex-models-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'CODEX_BIN'], async () => {
const codexBin = join(dir, 'codex');
writeFileSync(
codexBin,
@ -274,6 +274,7 @@ test('codex model picker includes current OpenAI choices in priority order', asy
chmodSync(codexBin, 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
delete process.env.CODEX_BIN;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');
@ -320,7 +321,7 @@ test('codex parses live model catalog from debug models JSON', () => {
test('codex detection surfaces live debug models separately from fallback models', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agents-codex-live-models-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'CODEX_BIN'], async () => {
const codexBin = join(dir, 'codex');
writeFileSync(
codexBin,
@ -336,6 +337,7 @@ exit 2
chmodSync(codexBin, 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
delete process.env.CODEX_BIN;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'codex');

View file

@ -0,0 +1,376 @@
# Launch Review 后新增 E2E 回归覆盖main
## 概述
这份文档汇总 `main` 分支基于 `launch-review-since-0.8.0.md` 补上的页面级 E2E 回归。
目标不是把所有 launch review 条目都硬塞进 Playwright而是优先补
- 跨页面状态联动
- daemon / workspace / preview 真实链路
- 容易回归且仅靠单测拦不住的问题
本文只记录已经落地、已经通过本地定向验证的新增覆盖。
## 当前新增覆盖
### 1. 项目聊天输入与运行状态
文件:
- [e2e/ui/workspace-keyboard-flows.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/workspace-keyboard-flows.test.ts)
- [e2e/ui/app-restoration.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/app-restoration.test.ts)
新增用例:
1. `project chat Enter sends while Shift+Enter inserts a newline`
- 覆盖项目聊天输入框的键盘提交行为
- 防止 `Enter` / `Shift+Enter` 语义回退
2. `retrying a failed run does not duplicate the original user message`
- 覆盖失败后重试不会重复插入同一条 user message
- 防止 retry 链路制造重复消息
3. `sending another prompt while a run is active queues it and starts it after the first run finishes`
- 覆盖运行中继续发送的排队行为
- 校验 queued strip 和顺序执行
### 2. 聊天文件链接与 HTML 预览恢复
文件:
- [e2e/ui/app-restoration.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/app-restoration.test.ts)
- [e2e/ui/app-manual-edit.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/app-manual-edit.test.ts)
新增用例:
1. `chat file links open project files in the workspace and keep trailing punctuation out of hrefs`
- 覆盖项目内文件链接在右侧 workspace 打开
- 覆盖裸链接尾部中文/英文标点不进入 `href`
2. `HTML preview stays rendered after switching from Preview to Code and back`
- 覆盖 `Preview -> Code -> Preview` 后 iframe 不白屏
- 防止 HTML transport/reactivation 回归
### 3. Plugin authoring 必须真实产生产物
文件:
- [e2e/lib/fake-agents.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/lib/fake-agents.ts)
- [e2e/ui/real-daemon-run.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/real-daemon-run.test.ts)
新增用例:
1. `plugin authoring produces a generated-plugin scaffold with action cards`
- 覆盖首页 `create-plugin` 入口
- 覆盖 `generated-plugin/` 真实落地
- 覆盖 action cards 和 Design Files 中的插件条目
当前 fake runtime 会真实写出:
- `generated-plugin/open-design.json`
- `generated-plugin/SKILL.md`
- `generated-plugin/examples/demo.md`
### 4. 评论模式与预览联动
文件:
- [e2e/ui/app.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/app.test.ts)
新增用例:
1. `sending preview comments keeps the preview live and refreshes it with the follow-up artifact`
- 覆盖 comment mode 发送后 preview 持续刷新
- 防止评论模式切断预览更新链路
### 5. Diagnostics 导出完整性
文件:
- [e2e/ui/diagnostics-export.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/diagnostics-export.test.ts)
新增用例:
1. `diagnostics export zip includes the primary daemon, web, and desktop logs`
- 覆盖 diagnostics zip 不再只带 renderer log
- 明确要求主日志存在:
- `daemon/latest.log`
- `web/latest.log`
- `desktop/latest.log`
### 6. Automations 页面顺序与摘要
文件:
- [e2e/ui/automations-page.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/automations-page.test.ts)
新增用例:
1. `places a newly created automation at the top of the list and highlights it`
- 覆盖新建后置顶与聚焦态
2. `keeps saved automations ordered by newest createdAt first`
- 覆盖多条 automation 混排时按 `createdAt` 倒序稳定排序
3. `renders the routine target and last-run status in the row summary`
- 覆盖 row summary 的 target / status 信息
### 7. Integrations连接、恢复、退化状态
文件:
- [e2e/ui/settings-connectors-auth-happy-path.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-connectors-auth-happy-path.test.ts)
- [e2e/ui/settings-connectors-auth-recovery.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-connectors-auth-recovery.test.ts)
新增 happy-path 覆盖:
1. `disconnecting and reconnecting keeps the connector usable without stale pending state`
- 覆盖 connect -> disconnect -> reconnect 后不残留 pending 状态
新增 recovery 覆盖:
1. `keeps a pending authorization visible when the connector enters authorization-pending state`
2. `shows a continue-in-browser CTA for pending authorizations that include a redirect URL`
3. `settles a pending authorization into Disconnect when status polling reports the connector as connected`
4. `returns a pending authorization to Connect and clears session storage after a successful cancel`
5. `surfaces a connector error state when credentials have degraded`
### 8. Design systemsSettings 导入/重命名/坏导入
文件:
- [e2e/ui/settings-design-systems.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-design-systems.test.ts)
新增用例:
1. `imports a local design system and makes it visible immediately`
2. `renames an editable design system and keeps the new title after reopening settings`
3. `shows an inline error when importing a broken local design system package`
### 9. Design systems manager发布、过滤、删除 fallback
文件:
- [e2e/ui/design-systems-manager.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/design-systems-manager.test.ts)
新增用例:
1. `publishing a user design system promotes it to the default system in the manager`
2. `filters user design systems by draft and published status in the manager`
3. `deleting the active design system falls back to another user system`
## 当前覆盖对应的产品结论
这批用例重点拦住的是下面这些历史高频回归:
- 项目聊天输入行为漂移
- retry 造成重复消息
- 聊天文件链接错误打开外部窗口
- HTML 预览切换白屏
- Plugin authoring 只说不做、没有产物
- 评论模式下预览不再刷新
- diagnostics 导出丢主日志
- automations 新建后顺序/摘要不稳定
- connector pending / degraded / reconnect 状态错乱
- design systems 导入、重命名、发布和删除 fallback 回归
## 运行命令
仓库根目录:
```bash
cd /Users/mac/open-design/open-design-amr-runtime-acp/e2e
```
### 聊天输入 / retry / queued / links
```bash
pnpm exec playwright test -c playwright.config.ts ui/workspace-keyboard-flows.test.ts --grep "project chat Enter sends while Shift\\+Enter inserts a newline"
```
```bash
pnpm exec playwright test -c playwright.config.ts ui/app-restoration.test.ts --grep "retrying a failed run does not duplicate the original user message|chat file links open project files in the workspace and keep trailing punctuation out of hrefs|sending another prompt while a run is active queues it and starts it after the first run finishes"
```
### HTML 预览恢复
```bash
pnpm exec playwright test -c playwright.config.ts ui/app-manual-edit.test.ts --grep "HTML preview stays rendered after switching from Preview to Code and back"
```
### Plugin authoring
```bash
pnpm exec playwright test -c playwright.config.ts ui/real-daemon-run.test.ts --grep "plugin authoring produces a generated-plugin scaffold with action cards"
```
### 评论模式 + 预览刷新
```bash
pnpm exec playwright test -c playwright.config.ts ui/app.test.ts --grep "sending preview comments keeps the preview live and refreshes it with the follow-up artifact"
```
### Diagnostics 导出
```bash
pnpm exec playwright test -c playwright.config.ts ui/diagnostics-export.test.ts
```
### Automations
```bash
pnpm exec playwright test -c playwright.config.ts ui/automations-page.test.ts --grep "places a newly created automation at the top of the list and highlights it|keeps saved automations ordered by newest createdAt first|renders the routine target and last-run status in the row summary"
```
### Connectorshappy path + recovery
```bash
pnpm exec playwright test -c playwright.config.ts ui/settings-connectors-auth-happy-path.test.ts --grep "switches from Connect to Disconnect on success, then returns to Connect after a successful disconnect|disconnecting and reconnecting keeps the connector usable without stale pending state"
```
```bash
pnpm exec playwright test -c playwright.config.ts ui/settings-connectors-auth-recovery.test.ts
```
### Design systemssettings + manager
```bash
pnpm exec playwright test -c playwright.config.ts ui/settings-design-systems.test.ts
```
```bash
pnpm exec playwright test -c playwright.config.ts ui/design-systems-manager.test.ts
```
## 当前测试依赖的产品事实
这批用例不是建立在“理想设计”上,而是建立在当前主线真实实现上。后续改产品时,需要一起更新测试假设。
### Connectors
- Connectors 页面是否可用,当前取决于 `savedApiKeyConfigured`
- `GitHub` 这类 Composio connector 不展示 `accountLabel`
- degraded/error connector 当前会显示:
- `status-error`
- error pill
- 无 `Disconnect`
- 当前 UI 不保证 degraded 卡片一定有 `is-locked`
### Automations
- 保存后的排序规则当前是 `createdAt` 倒序
- row summary 稳定展示的是 `target``last-run status`
### Design systems
- 发布后会把 `designSystemId` 写回 `app-config`
- 删除当前 active system 时,当前行为是 fallback 到另一条 user system
## 建议的后续维护方式
后续新增 launch review 条目时,优先按下面 3 类拆分,而不是一股脑补 Playwright
1. **页面级 E2E**
- 只补跨状态、跨 surface、靠单测拦不住的回归
2. **组件/契约测试**
- 文案、细粒度状态分支、纯视图逻辑优先放这里
3. **packaged / daemon / tools-dev**
- 真实 run、打包态、导出与系统集成问题放这层
这样 Playwright 不会膨胀成一套难维护的全能回归集。
## 新增 daemon 契约回归
这批 launch review 补测不只停留在 Playwright。对于前端 E2E 无法替代的契约层问题,当前已补 5 条 daemon 定向回归。
### 1. Diagnostics 导出路径与缺失日志清单
文件:
- [apps/daemon/tests/diagnostics-export.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/tests/diagnostics-export.test.ts)
新增用例:
1. `reports missing packaged log files under logical log paths without duplicating runtime segments`
- 覆盖 packaged runtime 下 manifest 仍使用逻辑路径:
- `logs/daemon/latest.log`
- `logs/web/latest.log`
- `logs/desktop/latest.log`
- 防止路径回退成错误的 `runtime/<namespace>/logs/...`
- 覆盖缺失日志时 manifest 会留下结构化 `error`
### 2. nested raw HTML route 契约
文件:
- [apps/daemon/tests/projects-routes.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/tests/projects-routes.test.ts)
新增用例:
1. `serves nested project html files through the raw route and allows Origin: null`
- 覆盖 `nested/demo/index.html` 这类深层项目文件
- 覆盖 `/api/projects/:id/raw/*` 路由对 HTML 的 `content-type`
- 覆盖 sandboxed iframe 场景下 `Origin: null` 的允许策略
- 当前实现的 `Access-Control-Allow-Origin` 真实契约是 `*`
### 3. run 终态幂等
文件:
- [apps/daemon/tests/runs.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/tests/runs.test.ts)
新增用例:
1. `ignores subsequent finish attempts after the run reaches a terminal state`
- 覆盖 run 一旦进入 terminal state就不会再被后续 `finish()` 覆盖
- 覆盖 terminal `end` 事件只会发一次
- 防止失败/取消/成功之间被重复收尾导致状态漂移
### 4. AMR model id 归一化回归
文件:
- [apps/daemon/tests/amr-acp-integration.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/tests/amr-acp-integration.test.ts)
- [apps/daemon/src/runtimes/defs/amr.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/src/runtimes/defs/amr.ts)
新增覆盖:
1. `deepseek-v3-2` / `vela/deepseek-v3-2` 会被归一化成 `deepseek-v3.2`
- 直接对应最近 beta 包里出现的:
- `Model not found: vela/deepseek-v3-2`
- 防止 daemon 把展示值或旧值错误地下发到 ACP `session/set_model`
### 5. Plugin authoring 完成性判定
文件:
- [apps/daemon/tests/chat-route.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/tests/chat-route.test.ts)
- [apps/daemon/src/server.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon/src/server.ts)
新增覆盖:
1. `does not report plugin authoring as succeeded when the agent only emits planning text without artifacts`
- 覆盖 `Plugin authoring` 这类必须落地产物的任务不能只凭一条计划文本成功收尾
- 当 agent 退出码为 `0`,但项目目录里缺少:
- `generated-plugin/open-design.json`
- `generated-plugin/SKILL.md`
- daemon 会把本轮转成 `failed`,而不是错误地保留 `succeeded`
### daemon 定向运行命令
仓库根目录:
```bash
cd /Users/mac/open-design/open-design-amr-runtime-acp/apps/daemon
```
```bash
pnpm exec vitest run tests/chat-route.test.ts tests/diagnostics-export.test.ts tests/projects-routes.test.ts tests/runs.test.ts tests/amr-acp-integration.test.ts tests/runtimes/env-and-detection.test.ts tests/runtimes/resolve-model.test.ts
```
## 这批 daemon 补测当前没有覆盖的点
下面这些仍然值得继续补,但这轮没有为了追求数量硬塞进去:
1. AMR / agent 运行结束态收敛
- 例如“工作完成但没有 terminal event最后被 watchdog 打成 failed”
- 例如“有有效产物但收尾阶段卡住”的 terminal-state 修正
2. AMR auth / model discovery 的更完整契约
- 例如 auth probe 与真实 launch path / env 必须同源
- 例如 live models 成功时不能回退到假默认模型
3. queued / retry 的持久化语义
- 前端行为已覆盖
- daemon 侧仍可继续锁住 message 关联和队列启动顺序

View file

@ -26,15 +26,15 @@ export type FakeAgentRuntimeOptions = {
};
const AGENT_BIN_NAMES: Record<FakeAgentId, string> = {
claude: 'claude-e2e.js',
codex: 'codex-e2e.js',
copilot: 'copilot-e2e.js',
'cursor-agent': 'cursor-agent-e2e.js',
deepseek: 'deepseek-e2e.js',
gemini: 'gemini-e2e.js',
opencode: 'opencode-e2e.js',
qoder: 'qodercli-e2e.js',
qwen: 'qwen-e2e.js',
claude: 'claude-e2e.cjs',
codex: 'codex-e2e.cjs',
copilot: 'copilot-e2e.cjs',
'cursor-agent': 'cursor-agent-e2e.cjs',
deepseek: 'deepseek-e2e.cjs',
gemini: 'gemini-e2e.cjs',
opencode: 'opencode-e2e.cjs',
qoder: 'qodercli-e2e.cjs',
qwen: 'qwen-e2e.cjs',
};
const AGENT_BIN_ENV_KEYS: Record<FakeAgentId, string> = {
@ -79,12 +79,13 @@ export async function createFakeAgentRuntimes(
const runtimes = {} as Record<FakeAgentId, FakeAgentRuntime>;
for (const agentId of runtimeIds) {
const script = path.join(root, AGENT_BIN_NAMES[agentId]);
const parsedScript = path.parse(script);
const bin = process.platform === 'win32'
? script.replace(/\.js$/i, '.cmd')
? path.join(parsedScript.dir, `${parsedScript.name}.cmd`)
: script;
await writeFile(script, renderFakeAgentScript(agentId), 'utf8');
if (process.platform === 'win32') {
await writeFile(bin, '@echo off\r\nnode "%~dp0%~n0.js" %*\r\n', 'utf8');
await writeFile(bin, '@echo off\r\nnode "%~dp0%~n0.cjs" %*\r\n', 'utf8');
} else {
await chmod(bin, 0o755);
}
@ -98,6 +99,8 @@ function renderFakeAgentScript(agentId: FakeAgentId): string {
return `#!/usr/bin/env node
const agentId = ${JSON.stringify(agentId)};
const args = process.argv.slice(2);
const { mkdir, writeFile: writeFileFs } = require('node:fs/promises');
const { join } = require('node:path');
if (args.includes('--version')) {
process.stdout.write(agentId + '-e2e 0.0.0\\n');
@ -142,6 +145,13 @@ async function emitRun(promptText) {
emitEmptySuccess();
return;
}
if (
promptText.includes('Create an Open Design plugin for:') &&
promptText.includes('produce a folder named generated-plugin')
) {
await emitPluginAuthoringRun();
return;
}
const isDelayed = promptText.includes('Create a delayed deterministic smoke artifact');
const isChunked = promptText.includes('Create a chunked deterministic smoke artifact');
const isFollowUp = promptText.includes('Create a follow-up deterministic smoke artifact');
@ -169,6 +179,43 @@ async function emitRun(promptText) {
exitSoon(0);
}
async function emitPluginAuthoringRun() {
const folder = join(process.cwd(), 'generated-plugin');
await mkdir(join(folder, 'examples'), { recursive: true });
await writeFileFs(
join(folder, 'open-design.json'),
JSON.stringify({
specVersion: 1,
name: 'generated-plugin',
version: '0.1.0',
description: 'Fake plugin authoring smoke scaffold.',
mode: 'agent',
taskKind: 'new-generation',
inputs: [{ id: 'prompt', type: 'string', label: 'Prompt' }],
}, null, 2) + '\\n',
'utf8',
);
await writeFileFs(
join(folder, 'SKILL.md'),
'# Generated Plugin\\n\\nThis fake plugin exists for plugin authoring smoke coverage.\\n',
'utf8',
);
await writeFileFs(
join(folder, 'examples', 'demo.md'),
'# Demo\\n\\nGenerated by the fake plugin authoring runtime.\\n',
'utf8',
);
const summary = [
'Created generated-plugin with open-design.json, SKILL.md, and examples/demo.md.',
'od plugin validate: passed',
'od plugin pack: generated-plugin-0.1.0.tgz',
'od plugin install --source: passed',
].join('\\n');
emitSuccess(summary, false, false);
process.exitCode = 0;
exitSoon(0);
}
function writeJson(value) {
process.stdout.write(JSON.stringify(value) + '\\n');
}

View file

@ -94,14 +94,9 @@ describe('dialog artifact consistency', () => {
const page = await context.newPage();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await playwrightExpect(
page.getByRole('heading', { name: 'What do you want to design?' }),
).toBeVisible();
await page.evaluate(({ projectId, conversationId }) => {
const target = `/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`;
window.history.pushState(null, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, { projectId: project.project.id, conversationId: project.conversationId });
await waitForLoadingToClear(page);
const target = `/projects/${encodeURIComponent(project.project.id)}/conversations/${encodeURIComponent(project.conversationId)}`;
await page.goto(target, { waitUntil: 'domcontentloaded' });
await expectWorkspaceReady(page);
const createRunResponse = await sendPrompt(page, PROMPT);

View file

@ -203,6 +203,38 @@ test('manual edit mode keeps deck navigation available for deck-shaped HTML', as
await expect(frame.getByText('Slide Two')).toBeVisible();
});
test('HTML preview stays rendered after switching from Preview to Code and back', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'HTML preview toggle regression');
await seedHtmlArtifact(
page,
projectId,
'toggle-preview.html',
'<!doctype html><html><body><main><h1>Toggle Preview Stable</h1><p>Still visible after tab switches.</p></main></body></html>',
);
await page.goto(`/projects/${projectId}`);
await openDesignFile(page, 'toggle-preview.html');
const previewFrame = page.getByTestId('artifact-preview-frame');
await expect(previewFrame).toBeVisible();
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Toggle Preview Stable' }),
).toBeVisible();
const viewModeTabs = page.getByRole('tablist', { name: 'View mode' });
await viewModeTabs.getByRole('tab', { name: 'Code' }).click();
await expect(page.locator('.viewer-source')).toContainText('Toggle Preview Stable');
await viewModeTabs.getByRole('tab', { name: 'Preview' }).click();
await expect(previewFrame).toBeVisible();
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Toggle Preview Stable' }),
).toBeVisible();
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByText('Still visible after tab switches.'),
).toBeVisible();
});
async function routeMockAgents(page: Page) {
await page.route('**/api/agents', async (route) => {
await route.fulfill({

View file

@ -1993,6 +1993,257 @@ test('a successful retry after a failed send restores the workspace to a fresh a
await expect(page.getByText('retry prompt that succeeds')).toBeVisible();
});
test('retrying a failed run does not duplicate the original user message', async ({ page }) => {
const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic');
if (!entry) throw new Error('prototype-basic scenario missing');
await routeMockAgents(page);
let runCount = 0;
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `retry-run-${runCount}` }),
});
});
let eventCount = 0;
await page.route('**/api/runs/*/events', async (route) => {
eventCount += 1;
const body =
eventCount === 1
? [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: error',
'data: {"message":"connection refused"}',
'',
'',
].join('\n')
: [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
'<artifact identifier="retry-dedup-artifact" type="text/html" title="Retry Dedup Artifact"><!doctype html><html><body><main><h1>Retry Dedup Artifact</h1></main></body></html></artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
const prompt = 'retry dedup prompt';
await sendPrompt(page, prompt);
await expect(page.locator('.msg.error')).toContainText('connection refused');
await expect(page.locator('.chat-error-retry')).toBeVisible();
await expect(page.locator('.msg.user', { hasText: prompt })).toHaveCount(1);
await Promise.all([
page.waitForResponse((resp) => /\/api\/runs$/.test(new URL(resp.url()).pathname) && resp.request().method() === 'POST'),
page.locator('.chat-error-retry').click(),
]);
await expect(page.getByRole('tab', { name: /retry-dedup-artifact\.html/i })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Retry Dedup Artifact' }),
).toBeVisible();
await expect(page.locator('.msg.user', { hasText: prompt })).toHaveCount(1);
});
test('chat file links open project files in the workspace and keep trailing punctuation out of hrefs', async ({ page }) => {
await routeMockAgents(page);
let runCount = 0;
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `link-run-${runCount}` }),
});
});
await page.route('**/api/runs/*/events', async (route) => {
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
'Open [details.html](details.html). Also see https://example.com/release-notes。 for external notes.',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
const projectId = await createEmptyProject(page, 'Chat file links stay in workspace');
await expectWorkspaceReady(page);
await seedHtmlArtifact(
page,
projectId,
'details.html',
'<!doctype html><html><body><main><h1>Linked Details</h1></main></body></html>',
);
await sendPrompt(page, 'send chat links');
const localLink = page.getByRole('link', { name: 'details.html' }).last();
await expect(localLink).toBeVisible();
const externalLink = page.getByRole('link', { name: 'https://example.com/release-notes' }).last();
await expect(externalLink).toHaveAttribute('href', 'https://example.com/release-notes');
await localLink.click();
await expect(page.getByRole('tab', { name: /details\.html/i })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Linked Details' }),
).toBeVisible();
});
test('sending another prompt while a run is active queues it and starts it after the first run finishes', async ({ page }) => {
const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic');
if (!entry) throw new Error('prototype-basic scenario missing');
await routeMockAgents(page);
let runCount = 0;
let releaseFirstRun!: () => void;
const firstRunReleased = new Promise<void>((resolve) => {
releaseFirstRun = resolve;
});
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `queued-run-${runCount}` }),
});
});
let eventCount = 0;
await page.route('**/api/runs/*/events', async (route) => {
eventCount += 1;
if (eventCount === 1) {
await firstRunReleased;
const firstBody = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
'<artifact identifier="first-queued-artifact" type="text/html" title="First Queued Artifact"><!doctype html><html><body><main><h1>First Queued Artifact</h1></main></body></html></artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body: firstBody,
});
return;
}
const secondBody = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
'<artifact identifier="second-queued-artifact" type="text/html" title="Second Queued Artifact"><!doctype html><html><body><main><h1>Second Queued Artifact</h1></main></body></html></artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body: secondBody,
});
});
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
await sendPrompt(page, 'first queued prompt');
await expect.poll(() => runCount).toBe(1);
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible();
const input = page.getByTestId('chat-composer-input');
await input.click();
await input.fill('second queued prompt');
await page.getByTestId('chat-send').click();
const queuedStrip = page.getByTestId('chat-queued-send-strip');
await expect(queuedStrip).toBeVisible();
await expect(queuedStrip).toContainText('second queued prompt');
expect(runCount).toBe(1);
const release: () => void = releaseFirstRun ?? (() => { throw new Error('first run release handle missing'); });
release();
await expect.poll(() => runCount).toBe(2);
await expect(queuedStrip).toHaveCount(0);
await expect(page.getByRole('tab', { name: /second-queued-artifact\.html/i })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Second Queued Artifact' }),
).toBeVisible();
});
async function routeMockAgents(page: Page) {
await page.route('**/api/agents', async (route) => {
await route.fulfill({

View file

@ -379,6 +379,100 @@ for (const entry of automatedUiScenarios().filter(
});
}
test('sending preview comments keeps the preview live and refreshes it with the follow-up artifact', async ({ page }) => {
const entry = automatedUiScenarios().find((scenario) => scenario.id === 'comment-attachment-flow');
if (!entry?.mockArtifact) {
throw new Error('comment-attachment-flow scenario fixture is missing');
}
await routeMockAgents(page);
let requestCount = 0;
await page.route('**/api/runs', async (route) => {
requestCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `mock-run-${requestCount}` }),
});
});
await page.route('**/api/runs/*/events', async (route) => {
const revisedHtml =
'<!doctype html><html><body><main data-od-id="hero-section">' +
'<h1 data-od-id="hero-title" data-screen-label="Hero title">Revised headline</h1>' +
'<p data-od-id="hero-copy">Preview copy refreshed after comment send.</p>' +
'</main></body></html>';
const artifactTitle = requestCount === 1 ? entry.mockArtifact!.title : 'Commentable Artifact Revised';
const artifactHtml = requestCount === 1 ? entry.mockArtifact!.html : revisedHtml;
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
`<artifact identifier="${entry.mockArtifact!.identifier}" type="text/html" title="${artifactTitle}">` +
artifactHtml +
'</artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await gotoEntryHome(page);
await createProject(page, entry);
await expectWorkspaceReady(page);
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
await page.getByTestId('board-mode-toggle').click();
await page.getByTestId('comment-panel-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-popover-save').click();
await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible();
const sidePanel = page.getByTestId('comment-side-panel');
await expect(sidePanel).toBeVisible();
await sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' })
.getByRole('button', { name: 'Select' })
.click();
await expect(page.getByTestId('comment-side-send-claude')).toBeVisible();
const runRequest = page.waitForRequest(isCreateRunRequest);
await page.getByTestId('comment-side-send-claude').click();
const body = (await runRequest).postDataJSON() as {
commentAttachments?: Array<{ elementId?: string; comment?: string; filePath?: string }>;
};
expect(body.commentAttachments).toEqual([
expect.objectContaining({
elementId: 'hero-title',
comment: 'Make the headline more specific.',
filePath: 'commentable-artifact.html',
}),
]);
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
await expect(frame.getByRole('heading', { name: 'Revised headline' })).toBeVisible();
await expect(frame.getByText('Preview copy refreshed after comment send.')).toBeVisible();
});
async function routeMockAgents(page: Page) {
await page.route('**/api/agents', async (route) => {
await route.fulfill({
@ -870,7 +964,7 @@ async function runCommentAttachmentFlow(
await expectArtifactVisible(page, entry);
await page.getByTestId('board-mode-toggle').click();
await page.getByTestId('comment-mode-toggle').click();
await page.getByTestId('comment-panel-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();

View file

@ -296,6 +296,199 @@ test.describe('Automations page', () => {
await expect(page).toHaveURL(/\/projects\/proj-run/);
});
test('places a newly created automation at the top of the list and highlights it', async ({ page }) => {
await seedAutomationsBase(page);
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
let routines: Array<Record<string, unknown>> = [
{
id: 'routine-existing-1',
name: 'Older digest',
prompt: 'Summarize older activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now() - 120_000,
updatedAt: Date.now() - 120_000,
},
];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects }),
});
});
await page.route('**/api/routines', async (route) => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines }),
});
return;
}
if (method === 'POST') {
const payload = route.request().postDataJSON() as Record<string, unknown>;
const now = Date.now();
const routine = {
id: 'routine-newest-1',
name: payload.name,
prompt: payload.prompt,
schedule: payload.schedule,
target: payload.target,
enabled: true,
nextRunAt: now + 3600_000,
lastRun: null,
createdAt: now,
updatedAt: now,
};
routines = [...routines, routine];
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ routine }),
});
return;
}
await route.fulfill({ status: 404, body: '{}' });
});
await page.route('**/api/automation-templates', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ templates: [] }),
});
});
await page.route('**/api/automation-proposals?status=pending-review', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ proposals: [] }),
});
});
await page.route('**/api/automation-source-packets?limit=3', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ packets: [] }),
});
});
const view = await gotoAutomations(page);
await expect(view.getByText('Older digest')).toBeVisible();
await view.getByRole('button', { name: 'New automation' }).click();
const modal = page.getByTestId('automation-modal');
await modal.getByLabel('Automation title').fill('Newest digest');
await modal.getByTestId('automation-modal-prompt').fill('Summarize the newest activity.');
await modal.getByRole('button', { name: 'Create' }).click();
const rows = view.locator('.automation-row');
await expect(rows.first()).toContainText('Newest digest');
await expect(view.getByTestId('automation-row-routine-newest-1')).toHaveClass(/is-focused/);
});
test('keeps saved automations ordered by newest createdAt first', async ({ page }) => {
await seedAutomationsBase(page);
const now = Date.now();
const routines = [
{
id: 'routine-oldest-1',
name: 'Oldest digest',
prompt: 'Summarize the oldest activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: now + 3600_000,
lastRun: null,
createdAt: now - 300_000,
updatedAt: now - 300_000,
},
{
id: 'routine-middle-1',
name: 'Middle digest',
prompt: 'Summarize the middle activity.',
schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: now + 7200_000,
lastRun: null,
createdAt: now - 120_000,
updatedAt: now - 120_000,
},
{
id: 'routine-newest-1',
name: 'Newest digest',
prompt: 'Summarize the newest activity.',
schedule: { kind: 'daily', time: '11:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: now + 10_800_000,
lastRun: null,
createdAt: now - 10_000,
updatedAt: now - 10_000,
},
];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects: [] }),
});
});
await page.route('**/api/routines', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines }),
});
});
await page.route('**/api/automation-templates', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ templates: [] }),
});
});
await page.route('**/api/automation-proposals?status=pending-review', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ proposals: [] }),
});
});
await page.route('**/api/automation-source-packets?limit=3', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ packets: [] }),
});
});
const view = await gotoAutomations(page);
const rowTitles = view.locator('.automation-row .automation-row__title');
await expect(rowTitles).toHaveText([
'Newest digest',
'Middle digest',
'Oldest digest',
]);
});
test('keeps the automation modal open with the typed values when creation fails', async ({ page }) => {
await seedAutomationsBase(page);
@ -880,4 +1073,77 @@ test.describe('Automations page', () => {
await expect(view.getByText(/Refresh project memory from recent work\./i)).toBeVisible();
await expect(view.getByRole('status')).toHaveCount(0);
});
test('renders the routine target and last-run status in the row summary', async ({ page }) => {
await seedAutomationsBase(page);
const projects = [{ id: 'proj-shared-1', name: 'Shared Release Project' }];
const routines = [
{
id: 'routine-summary-1',
name: 'Release digest',
prompt: 'Summarize release issues and recent commits.',
schedule: { kind: 'weekly', weekday: 3, time: '14:30', timezone: 'UTC' },
target: { mode: 'reuse', projectId: 'proj-shared-1' },
enabled: true,
nextRunAt: Date.now() + 86_400_000,
lastRun: {
id: 'run-summary-1',
status: 'failed',
trigger: 'manual',
startedAt: Date.now() - 7_200_000,
error: 'Provider request timed out after 30s',
summary: 'Provider request timed out after 30s',
},
createdAt: Date.now() - 300_000,
updatedAt: Date.now() - 60_000,
},
];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects }),
});
});
await page.route('**/api/routines', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines }),
});
});
await page.route('**/api/automation-templates', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ templates: [] }),
});
});
await page.route('**/api/automation-proposals?status=pending-review', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ proposals: [] }),
});
});
await page.route('**/api/automation-source-packets?limit=3', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ packets: [] }),
});
});
const view = await gotoAutomations(page);
const row = view.locator('.automation-row', { hasText: 'Release digest' }).first();
await expect(row).toContainText('Shared Release Project');
await expect(row).toContainText('Failed');
});
});

View file

@ -0,0 +1,349 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
type UserSystem = {
id: string;
title: string;
category: string;
summary: string;
surface: 'web' | 'image' | 'video' | 'audio';
source: 'user';
status: 'draft' | 'published';
updatedAt: string;
};
function requireSystem(system: UserSystem | undefined): UserSystem {
if (!system) throw new Error('design system fixture missing');
return system;
}
function baseConfig(): Record<string, unknown> {
return {
mode: 'daemon',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
}
async function seedEntryBase(page: Page, override?: Record<string, unknown>) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: { ...baseConfig(), ...override } },
);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByTestId('home-hero')).toBeVisible();
}
async function routeDesignSystemsManager(
page: Page,
systems: UserSystem[],
{
initialConfig,
}: {
initialConfig?: Partial<Record<string, unknown>>;
} = {},
) {
const persistedConfigs: Array<{ designSystemId?: string | null }> = [];
let currentConfig = { ...baseConfig(), ...(initialConfig ?? {}) };
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const path = url.pathname;
if (path === '/api/health') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
if (path === '/api/agents') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
agents: [
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.130.0',
models: [{ id: 'default', label: 'Default' }],
},
],
}),
});
return;
}
if (path === '/api/app-config') {
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config: currentConfig }),
});
return;
}
if (method === 'PUT') {
const body = route.request().postDataJSON() as { designSystemId?: string | null };
persistedConfigs.push(body);
currentConfig = { ...currentConfig, ...body };
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
}
if (path === '/api/connectors/composio/config') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '{"configured":false,"apiKeyTail":""}',
});
return;
}
if (path === '/api/media/config' && method === 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"providers":{}}' });
return;
}
if (path === '/api/media/config' && method === 'PUT') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
if (path === '/api/skills') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"skills":[]}' });
return;
}
if (path === '/api/design-systems' && method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ designSystems: systems }),
});
return;
}
if (/^\/api\/design-systems\/[^/]+$/.test(path) && method === 'PATCH') {
const id = decodeURIComponent(path.split('/').at(-1) ?? '');
const body = route.request().postDataJSON() as { status?: 'draft' | 'published' };
const system = systems.find((entry) => entry.id === id);
if (system && body.status) {
system.status = body.status;
}
const responseSystem = requireSystem(system ?? systems[0]);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
designSystem: {
...responseSystem,
body: `# ${responseSystem.title}`,
},
}),
});
return;
}
if (/^\/api\/design-systems\/[^/]+$/.test(path) && method === 'DELETE') {
const id = decodeURIComponent(path.split('/').at(-1) ?? '');
const index = systems.findIndex((entry) => entry.id === id);
if (index >= 0) systems.splice(index, 1);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '{"ok":true}',
});
return;
}
if (/^\/api\/design-systems\/[^/]+$/.test(path) && method === 'GET') {
const id = decodeURIComponent(path.split('/').at(-1) ?? '');
const system = requireSystem(systems.find((entry) => entry.id === id) ?? systems[0]);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
designSystem: {
...system,
body: `# ${system.title}`,
},
}),
});
return;
}
if (path === '/api/projects') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"projects":[]}' });
return;
}
if (path === '/api/plugins') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"plugins":[]}' });
return;
}
if (path === '/api/prompt-templates') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"promptTemplates":[]}' });
return;
}
if (path === '/api/templates') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"templates":[]}' });
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
return { persistedConfigs };
}
test('publishing a user design system promotes it to the default system in the manager', async ({ page }) => {
await seedEntryBase(page);
const systems: UserSystem[] = [
{
id: 'brand-alpha',
title: 'Brand Alpha',
category: 'Productivity & SaaS',
summary: 'Draft internal design system.',
surface: 'web',
source: 'user',
status: 'draft',
updatedAt: '2026-05-28T01:00:00.000Z',
},
{
id: 'brand-beta',
title: 'Brand Beta',
category: 'Productivity & SaaS',
summary: 'Published baseline system.',
surface: 'web',
source: 'user',
status: 'published',
updatedAt: '2026-05-28T00:00:00.000Z',
},
];
const { persistedConfigs } = await routeDesignSystemsManager(page, systems);
await gotoEntryHome(page);
await page.getByTestId('entry-nav-design-systems').click();
await expect(page).toHaveURL(/\/design-systems$/);
await page.getByRole('tab', { name: 'Your systems' }).click();
const manager = page.locator('section[aria-label="Your design systems"]');
const alphaRow = manager.locator('.ds-user-row').filter({ hasText: 'Brand Alpha' });
await expect(alphaRow.getByRole('button', { name: 'Make default' })).toHaveCount(0);
await alphaRow.locator('.ds-status-toggle').click();
await expect(alphaRow.locator('.ds-status-toggle')).toContainText('Published');
await expect(alphaRow.getByText('Default')).toBeVisible();
await expect
.poll(() => persistedConfigs.at(-1)?.designSystemId)
.toBe('brand-alpha');
});
test('filters user design systems by draft and published status in the manager', async ({ page }) => {
await seedEntryBase(page);
const systems: UserSystem[] = [
{
id: 'brand-alpha',
title: 'Brand Alpha',
category: 'Productivity & SaaS',
summary: 'Draft internal design system.',
surface: 'web',
source: 'user',
status: 'draft',
updatedAt: '2026-05-28T01:00:00.000Z',
},
{
id: 'brand-beta',
title: 'Brand Beta',
category: 'Productivity & SaaS',
summary: 'Published baseline system.',
surface: 'web',
source: 'user',
status: 'published',
updatedAt: '2026-05-28T00:00:00.000Z',
},
];
await routeDesignSystemsManager(page, systems);
await gotoEntryHome(page);
await page.getByTestId('entry-nav-design-systems').click();
await expect(page).toHaveURL(/\/design-systems$/);
await page.getByRole('tab', { name: 'Your systems' }).click();
const manager = page.locator('section[aria-label="Your design systems"]');
const filter = manager.getByRole('combobox', { name: 'Filter design systems' });
await filter.selectOption('published');
await expect(manager.getByText('Brand Beta')).toBeVisible();
await expect(manager.getByText('Brand Alpha')).toHaveCount(0);
await filter.selectOption('draft');
await expect(manager.getByText('Brand Alpha')).toBeVisible();
await expect(manager.getByText('Brand Beta')).toHaveCount(0);
});
test('deleting the active design system falls back to another user system', async ({ page }) => {
await seedEntryBase(page, { designSystemId: 'brand-alpha' });
const systems: UserSystem[] = [
{
id: 'brand-alpha',
title: 'Brand Alpha',
category: 'Productivity & SaaS',
summary: 'Primary published system.',
surface: 'web',
source: 'user',
status: 'published',
updatedAt: '2026-05-28T01:00:00.000Z',
},
{
id: 'brand-beta',
title: 'Brand Beta',
category: 'Productivity & SaaS',
summary: 'Fallback published system.',
surface: 'web',
source: 'user',
status: 'published',
updatedAt: '2026-05-28T00:00:00.000Z',
},
];
const { persistedConfigs } = await routeDesignSystemsManager(page, systems, {
initialConfig: { designSystemId: 'brand-alpha' },
});
await gotoEntryHome(page);
await page.getByTestId('entry-nav-design-systems').click();
await expect(page).toHaveURL(/\/design-systems$/);
await page.getByRole('tab', { name: 'Your systems' }).click();
page.once('dialog', (dialog) => dialog.accept());
const manager = page.locator('section[aria-label="Your design systems"]');
const alphaRow = manager.locator('.ds-user-row').filter({ hasText: 'Brand Alpha' });
await alphaRow.getByRole('button', { name: 'Delete Brand Alpha' }).click();
await expect(alphaRow).toHaveCount(0);
await expect
.poll(() => persistedConfigs.at(-1)?.designSystemId)
.toBe('brand-beta');
const betaRow = manager.locator('.ds-user-row').filter({ hasText: 'Brand Beta' });
await expect(betaRow.getByText('Default')).toBeVisible();
});

View file

@ -0,0 +1,103 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const execFileAsync = promisify(execFile);
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: 45_000 });
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: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, STORAGE_KEY);
});
test('diagnostics export zip includes the primary daemon, web, and desktop logs', async ({ page }) => {
await gotoEntryHome(page);
const response = await page.request.get('/api/diagnostics/export');
expect(response.ok(), await response.text()).toBeTruthy();
expect(response.headers()['content-type']).toContain('application/zip');
const tmpRoot = await mkdtemp(path.join(tmpdir(), 'od-diagnostics-e2e-'));
try {
const zipPath = path.join(tmpRoot, 'diagnostics.zip');
await writeFile(zipPath, Buffer.from(await response.body()));
const names = await unzipList(zipPath);
expect(names).toEqual(expect.arrayContaining([
'summary/manifest.json',
'logs/daemon/latest.log',
'logs/web/latest.log',
'logs/desktop/latest.log',
]));
const manifest = JSON.parse(await unzipRead(zipPath, 'summary/manifest.json')) as {
files?: Array<{ name?: string; missing?: boolean }>;
};
const manifestNames = new Set((manifest.files ?? []).map((file) => file.name).filter(Boolean));
expect(manifestNames.has('logs/daemon/latest.log')).toBe(true);
expect(manifestNames.has('logs/web/latest.log')).toBe(true);
expect(manifestNames.has('logs/desktop/latest.log')).toBe(true);
const daemonLog = await unzipRead(zipPath, 'logs/daemon/latest.log');
const webLog = await unzipRead(zipPath, 'logs/web/latest.log');
const desktopLog = await unzipRead(zipPath, 'logs/desktop/latest.log');
expect(daemonLog.length).toBeGreaterThan(0);
expect(webLog.length).toBeGreaterThan(0);
expect(desktopLog.length).toBeGreaterThan(0);
} finally {
await rm(tmpRoot, { recursive: true, force: true });
}
});
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByTestId('home-hero')).toBeVisible();
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function unzipList(zipPath: string): Promise<string[]> {
const { stdout } = await execFileAsync('unzip', ['-Z1', zipPath]);
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
async function unzipRead(zipPath: string, entryName: string): Promise<string> {
const { stdout } = await execFileAsync('unzip', ['-p', zipPath, entryName], {
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
});
return stdout;
}

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Page, Response } from '@playwright/test';
import type { Page, Request, Response } from '@playwright/test';
import {
createFakeAgentRuntimes,
FAKE_AGENT_RUNTIME_IDS,
@ -247,6 +247,61 @@ test('real daemon run previews an artifact from a fake OpenCode runtime', async
await expectProjectFileToContain(page, projectId, fileName, heading);
});
test('plugin authoring produces a generated-plugin scaffold with action cards', async ({ page }) => {
await configureFakeAgent(page, 'codex');
await installBrowserAgentConfig(page, 'codex');
await gotoEntryHome(page);
await setBrowserAgentConfig(page, 'codex');
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
await setBrowserAgentConfig(page, 'codex');
await configureFakeAgent(page, 'codex');
await expectBrowserAgentConfig(page, 'codex');
await dismissPrivacyDialog(page);
await page.getByTestId('home-hero-shortcuts-trigger').click();
await page.getByTestId('home-hero-rail-create-plugin').click();
await expect(page.getByTestId('home-hero-input')).toHaveValue(/Create an Open Design plugin for:/);
const projectRequestPromise = page.waitForRequest(isCreateProjectRequest);
const runRequestPromise = page.waitForRequest(isCreateRunRequest);
await page.getByTestId('home-hero-submit').click();
const projectRequest = await projectRequestPromise;
const projectBody = projectRequest.postDataJSON() as {
pluginId?: string;
pendingPrompt?: string;
};
expect(projectBody.pluginId).toBe('od-plugin-authoring');
expect(projectBody.pendingPrompt).toContain('produce a folder named generated-plugin');
const runRequest = await runRequestPromise;
const runBody = runRequest.postDataJSON() as { message?: string; agentId?: string };
expect(runBody.agentId).toBe('codex');
expect(runBody.message).toContain('produce a folder named generated-plugin');
await expectWorkspaceReady(page);
const { projectId } = await currentProjectContext(page);
await expectProjectFilesToContain(page, projectId, [
'generated-plugin/open-design.json',
'generated-plugin/SKILL.md',
'generated-plugin/examples/demo.md',
]);
await expectProjectFileToContain(page, projectId, 'generated-plugin/open-design.json', '"name": "generated-plugin"');
await expectProjectFileToContain(page, projectId, 'generated-plugin/SKILL.md', '# Generated Plugin');
await expect(page.getByText('Files from this turn')).toBeVisible();
await expect(page.getByTestId('assistant-plugin-actions-generated-plugin')).toBeVisible();
await expect(page.getByTestId('assistant-plugin-install-generated-plugin')).toBeVisible();
await expect(page.getByTestId('assistant-plugin-publish-generated-plugin')).toBeVisible();
await expect(page.getByTestId('assistant-plugin-contribute-generated-plugin')).toBeVisible();
await expect(page.getByTestId('design-plugin-folder-generated-plugin')).toBeVisible();
await expect(page.getByTestId('design-plugin-folder-install-generated-plugin')).toBeVisible();
await expect(page.getByTestId('design-plugin-folder-publish-generated-plugin')).toBeVisible();
await expect(page.getByTestId('design-plugin-folder-contribute-generated-plugin')).toBeVisible();
});
test('real daemon run supports fake non-Codex runtime protocols', async ({ page }) => {
test.setTimeout(180_000);
@ -639,6 +694,16 @@ function isCreateRunResponse(response: Response): boolean {
return url.pathname === '/api/runs' && response.request().method() === 'POST';
}
function isCreateRunRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/runs' && request.method() === 'POST';
}
function isCreateProjectRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/projects' && request.method() === 'POST';
}
function expectCreateRunAgentId(response: Response, agentId: FakeAgentId) {
expect(response.request().postDataJSON()).toMatchObject({ agentId });
}

View file

@ -43,6 +43,11 @@ function baseConfig(): Record<string, unknown> {
skillId: null,
designSystemId: null,
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
@ -109,6 +114,7 @@ async function openConnectorsSettings(
onDisconnect?: () => { status: number; body: Record<string, unknown> };
} = {},
) {
let githubState: Record<string, unknown> = { ...CONNECTORS[0] };
await page.addInitScript(({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
window.open = ((() => ({
@ -159,10 +165,15 @@ async function openConnectorsSettings(
const statuses = Object.fromEntries(
connectors.map((connector) => [
connector.id,
{
status: connector.status,
accountLabel: 'accountLabel' in connector ? connector.accountLabel : undefined,
},
connector.id === 'github'
? {
status: githubState.status,
accountLabel: githubState.accountLabel,
}
: {
status: connector.status,
accountLabel: 'accountLabel' in connector ? connector.accountLabel : undefined,
},
]),
);
await route.fulfill({ json: { statuses } });
@ -171,12 +182,29 @@ async function openConnectorsSettings(
await page.route('**/api/connectors/discovery*', async (route) => {
await route.fulfill({
json: {
connectors,
connectors: connectors.map((connector) => (
connector.id === 'github' ? ({ ...connector, ...githubState }) : connector
)),
meta: { provider: 'composio' },
},
});
});
await page.route('**/api/connectors/github**', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: {
connector: {
...CONNECTORS[0],
...githubState,
},
},
});
});
await page.route('**/api/connectors/composio/config', async (route) => {
await route.fulfill({ json: { configured: true, apiKeyTail: '1234' } });
});
@ -191,6 +219,10 @@ async function openConnectorsSettings(
await page.route('**/api/connectors/github/connect', async (route) => {
const response = onConnect();
githubState = {
...CONNECTORS[0],
...(response.body.connector ?? {}),
};
await route.fulfill({
status: response.status,
contentType: 'application/json',
@ -200,6 +232,10 @@ async function openConnectorsSettings(
await page.route('**/api/connectors/github/connection', async (route) => {
const response = onDisconnect();
githubState = {
...CONNECTORS[0],
...(response.body.connector ?? {}),
};
await route.fulfill({
status: response.status,
contentType: 'application/json',
@ -311,4 +347,61 @@ test.describe('Settings connectors auth happy path', () => {
await expect(githubCard.getByRole('button', { name: 'Connect' })).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Disconnect' })).toHaveCount(0);
});
test('disconnecting and reconnecting keeps the connector usable without stale pending state', async ({ page }) => {
let connectAttempts = 0;
let disconnectRequests = 0;
const dialog = await openConnectorsSettings(page, {
onConnect: () => {
connectAttempts += 1;
const accountLabel = connectAttempts === 1 ? 'octo-user' : 'octo-user-2';
return {
status: 200,
body: {
connector: {
...CONNECTORS[0],
status: 'connected',
accountLabel,
},
auth: { kind: 'connected' },
},
};
},
onDisconnect: () => {
disconnectRequests += 1;
return {
status: 200,
body: {
connector: {
...CONNECTORS[0],
status: 'available',
},
},
};
},
});
const githubCard = connectorCard(dialog, 'github');
await githubCard.getByRole('button', { name: 'Connect' }).click();
await expect(githubCard.getByRole('button', { name: 'Disconnect' })).toBeVisible();
await githubCard.click();
const drawer = page.getByTestId('connector-drawer');
await expect(drawer).toContainText('Connected');
await expect(drawer).not.toContainText('Authorization pending');
await drawer.getByTestId('connector-drawer-close').click();
await githubCard.getByRole('button', { name: 'Disconnect' }).click();
await expect.poll(() => disconnectRequests).toBe(1);
await expect(githubCard.getByRole('button', { name: 'Connect' })).toBeVisible();
await githubCard.getByRole('button', { name: 'Connect' }).click();
await expect.poll(() => connectAttempts).toBe(2);
await expect(githubCard.getByRole('button', { name: 'Disconnect' })).toBeVisible();
await githubCard.click();
await expect(drawer).toContainText('Connected');
await expect(drawer).not.toContainText('Authorization pending');
await expect(drawer).not.toContainText("Couldn't cancel authorization");
});
});

View file

@ -6,6 +6,19 @@ const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
test.describe.configure({ timeout: 30_000 });
type ConnectorFixture = {
id: string;
name: string;
provider: 'composio';
category: string;
description: string;
status: 'available' | 'connected' | 'error';
accountLabel?: string;
lastError?: string;
auth: { provider: 'composio'; configured: true };
tools: readonly unknown[];
};
const CONNECTORS = [
{
id: 'github',
@ -28,7 +41,7 @@ const CONNECTORS = [
auth: { provider: 'composio', configured: true },
tools: [],
},
] as const;
] as const satisfies readonly ConnectorFixture[];
function baseConfig(): Record<string, unknown> {
return {
@ -43,6 +56,11 @@ function baseConfig(): Record<string, unknown> {
skillId: null,
designSystemId: null,
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
@ -110,13 +128,20 @@ async function openConnectorsSettings(
},
},
}),
statusResponse = () => ({
github: pendingAuthorization
? { status: 'error', accountLabel: undefined }
: { status: CONNECTORS[0]?.status ?? 'available', accountLabel: undefined },
slack: { status: 'connected', accountLabel: 'design-team' },
}),
pendingAuthorization = null,
blockPopup = false,
}: {
connectors?: typeof CONNECTORS;
connectors?: readonly ConnectorFixture[];
onPrepare?: () => Record<string, unknown>;
onConnect?: () => { status: number; body: Record<string, unknown> };
onCancel?: () => { status: number; body: Record<string, unknown> };
statusResponse?: () => Record<string, unknown>;
pendingAuthorization?: Record<string, unknown> | null;
blockPopup?: boolean;
} = {},
@ -180,16 +205,7 @@ async function openConnectorsSettings(
});
await page.route('**/api/connectors/status', async (route) => {
const statuses = Object.fromEntries(
connectors.map((connector) => [
connector.id,
{
status: connector.status,
accountLabel: 'accountLabel' in connector ? connector.accountLabel : undefined,
},
]),
);
await route.fulfill({ json: { statuses } });
await route.fulfill({ json: { statuses: statusResponse() } });
});
await page.route('**/api/connectors/discovery*', async (route) => {
@ -260,31 +276,38 @@ test.describe('Settings connectors auth recovery', () => {
.toBe(true);
});
test('settles a pending authorization into Disconnect when status polling reports the connector as connected', async ({ page }) => {
let statusRequests = 0;
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: pendingAuthorizationStorage(),
});
await page.unroute('**/api/connectors/status');
await page.route('**/api/connectors/status', async (route) => {
statusRequests += 1;
const githubStatus =
statusRequests >= 2
? { status: 'connected', accountLabel: 'octo-user' }
: { status: 'available', accountLabel: undefined };
await route.fulfill({
json: {
statuses: {
github: githubStatus,
slack: { status: 'connected', accountLabel: 'design-team' },
},
test('shows a continue-in-browser CTA for pending authorizations that include a redirect URL', async ({ page }) => {
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: {
github: {
expiresAt: '2099-01-01T00:00:00.000Z',
redirectUrl: 'https://example.com/oauth/github',
},
});
},
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Continue in browser' })).toBeVisible();
});
test('settles a pending authorization into Disconnect when status polling reports the connector as connected', async ({ page }) => {
let statusRequests = 0;
const { dialog } = await openConnectorsSettings(page, {
pendingAuthorization: pendingAuthorizationStorage(),
statusResponse: () => {
statusRequests += 1;
return {
github: statusRequests >= 2
? { status: 'connected', accountLabel: 'octo-user' }
: { status: 'error', accountLabel: undefined },
slack: { status: 'connected', accountLabel: 'design-team' },
};
},
});
const githubCard = connectorCard(dialog, 'github');
await expect
.poll(async () => statusRequests, { timeout: 5000 })
@ -313,9 +336,10 @@ test.describe('Settings connectors auth recovery', () => {
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
const cancelButton = githubCard.getByRole('button', { name: 'Cancel' });
await expect(cancelButton).toBeVisible();
await githubCard.getByRole('button', { name: 'Cancel' }).click();
await cancelButton.click();
await expect(githubCard.getByRole('button', { name: 'Connect' })).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
@ -326,4 +350,28 @@ test.describe('Settings connectors auth recovery', () => {
.toBe(null);
});
test('surfaces a connector error state when credentials have degraded', async ({ page }) => {
const githubConnector = CONNECTORS[0];
const slackConnector = CONNECTORS[1];
if (!githubConnector || !slackConnector) throw new Error('missing connector fixtures');
const degradedConnectors: ConnectorFixture[] = [
{
...githubConnector,
status: 'error',
accountLabel: 'octo-user',
lastError: 'GitHub token expired. Reconnect to continue.',
},
slackConnector,
];
const { dialog } = await openConnectorsSettings(page, {
connectors: degradedConnectors,
});
const githubCard = connectorCard(dialog, 'github');
await expect(githubCard).toHaveClass(/status-error/);
await expect(githubCard.locator('.connector-status-pill.status-error')).toBeVisible();
await expect(githubCard.getByRole('button', { name: 'Disconnect' })).toHaveCount(0);
});
});

View file

@ -0,0 +1,303 @@
import { expect, test } from '@playwright/test';
import type { Page, Route } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
const OPEN_SETTINGS_LABEL = /Open settings|打开设置|開啟設定/i;
type DesignSystemFixture = {
id: string;
title: string;
category: string;
summary: string;
surface: 'web' | 'image' | 'video' | 'audio';
swatches?: string[];
source?: 'library' | 'user';
isEditable?: boolean;
};
function baseConfig(): Record<string, unknown> {
return {
mode: 'daemon',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
}
async function seedSettingsBase(page: Page, override?: Record<string, unknown>) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: { ...baseConfig(), ...override } },
);
}
async function waitForLoadingToClear(page: Page) {
await expect(page.getByText('Loading Open Design…')).toHaveCount(0, { timeout: 15_000 });
}
async function gotoEntryHome(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
}
await expect(page.getByTestId('home-hero')).toBeVisible();
}
async function routeBootstrapApis(
page: Page,
systems: DesignSystemFixture[],
options?: {
importLocal?: (route: Route) => Promise<void>;
patchSystem?: (route: Route) => Promise<void>;
},
) {
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const path = url.pathname;
if (path === '/api/health') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
if (path === '/api/agents') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
agents: [
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.130.0',
models: [{ id: 'default', label: 'Default' }],
},
],
}),
});
return;
}
if (path === '/api/app-config') {
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config: baseConfig() }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
if (path === '/api/connectors/composio/config') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '{"configured":false,"apiKeyTail":""}',
});
return;
}
if (path === '/api/media/config') {
if (method === 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"providers":{}}' });
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
return;
}
if (path === '/api/skills') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"skills":[]}' });
return;
}
if (path === '/api/design-systems' && method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ designSystems: systems }),
});
return;
}
if (path === '/api/design-systems/import/local' && method === 'POST') {
if (options?.importLocal) {
await options.importLocal(route);
return;
}
await route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"missing importLocal mock"}' });
return;
}
if (/^\/api\/design-systems\/[^/]+$/.test(path) && method === 'PATCH') {
if (options?.patchSystem) {
await options.patchSystem(route);
return;
}
await route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"missing patchSystem mock"}' });
return;
}
if (/^\/api\/design-systems\/[^/]+$/.test(path) && method === 'GET') {
const id = decodeURIComponent(path.split('/').at(-1) ?? '');
const system = systems.find((entry) => entry.id === id) ?? systems[0];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
designSystem: {
...system,
body: `# ${system?.title ?? id}`,
},
}),
});
return;
}
if (path === '/api/projects') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"projects":[]}' });
return;
}
if (path === '/api/templates') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"templates":[]}' });
return;
}
if (path === '/api/prompt-templates') {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"promptTemplates":[]}' });
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function openDesignSystemsSettings(page: Page) {
await gotoEntryHome(page);
await page.getByRole('button', { name: OPEN_SETTINGS_LABEL }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /Design systems|设计系统|設計系統/i }).click();
await expect(dialog.getByRole('heading', { name: /Design systems|设计系统|設計系統/i })).toBeVisible();
return dialog;
}
test.describe('Settings design systems flows', () => {
test('imports a local design system and makes it visible immediately', async ({ page }) => {
await seedSettingsBase(page);
const systems: DesignSystemFixture[] = [];
const importedSystem: DesignSystemFixture = {
id: 'acme-core',
title: 'Acme Core',
category: 'Productivity & SaaS',
summary: 'Imported Acme product design system.',
surface: 'web',
swatches: ['#111827', '#4f46e5'],
source: 'user',
isEditable: true,
};
await routeBootstrapApis(page, systems, {
importLocal: async (route) => {
systems.push(importedSystem);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ designSystem: importedSystem }),
});
},
});
const dialog = await openDesignSystemsSettings(page);
await dialog.getByRole('button', { name: /Add design system|添加设计系统|新增設計系統/i }).click();
await dialog.getByPlaceholder('/path/to/project').fill('/tmp/acme-design-system');
await dialog.getByRole('button', { name: /Import from project|从项目导入|從專案匯入/i }).click();
await expect(dialog.getByText('Imported Acme Core')).toBeVisible();
await dialog.getByRole('button', { name: /View imported design system|查看导入的设计系统|查看匯入的設計系統/i }).click();
await expect(dialog.locator('.library-ds-title-text', { hasText: 'Acme Core' })).toBeVisible();
await expect(dialog.getByText('Imported Acme product design system.')).toBeVisible();
});
test('renames an editable design system and keeps the new title after reopening settings', async ({ page }) => {
await seedSettingsBase(page);
const systems: DesignSystemFixture[] = [
{
id: 'brand-kit',
title: 'Brand Kit',
category: 'Productivity & SaaS',
summary: 'Editable internal brand rules.',
surface: 'web',
source: 'user',
isEditable: true,
},
];
await routeBootstrapApis(page, systems, {
patchSystem: async (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1) ?? '');
const body = route.request().postDataJSON() as { title?: string };
const system = systems.find((entry) => entry.id === id)!;
if (body.title) system.title = body.title;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ designSystem: { ...system, body: `# ${system.title}` } }),
});
},
});
let dialog = await openDesignSystemsSettings(page);
await dialog.getByRole('button', { name: 'Rename Brand Kit', exact: true }).click();
const renameModal = dialog.locator('.modal.modal-rename');
await expect(renameModal).toBeVisible();
await renameModal.getByRole('textbox', { name: /Rename|重命名/i }).fill('Brand Kit 2026');
await renameModal.getByRole('button', { name: /Save|保存/i }).click();
await expect(dialog.getByText('Brand Kit 2026')).toBeVisible();
await expect(dialog.getByText('Brand Kit', { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
dialog = await openDesignSystemsSettings(page);
await expect(dialog.getByText('Brand Kit 2026')).toBeVisible();
await expect(dialog.getByText('Brand Kit', { exact: true })).toHaveCount(0);
});
test('shows an inline error when importing a broken local design system package', async ({ page }) => {
await seedSettingsBase(page);
const systems: DesignSystemFixture[] = [];
await routeBootstrapApis(page, systems, {
importLocal: async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: {
message: 'Could not read design system package. Expected a valid zip or project directory.',
},
}),
});
},
});
const dialog = await openDesignSystemsSettings(page);
await dialog.getByRole('button', { name: /Add design system|添加设计系统|新增設計系統/i }).click();
await dialog.getByPlaceholder('/path/to/project').fill('/tmp/broken-design-system.zip');
await dialog.getByRole('button', { name: /Import from project|从项目导入|從專案匯入/i }).click();
await expect(
dialog.getByText('Could not read design system package. Expected a valid zip or project directory.'),
).toBeVisible();
await expect(dialog.getByText(/^Imported /)).toHaveCount(0);
});
});

View file

@ -136,6 +136,69 @@ test('keyboard chat panel resize persists after reload', async ({ page }) => {
expect(restoredWidth).toBe(resizedWidth);
});
test('project chat Enter sends while Shift+Enter inserts a newline', async ({ page }) => {
let runCount = 0;
await page.route('**/api/runs', async (route) => {
runCount += 1;
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ runId: `keyboard-run-${runCount}` }),
});
});
await page.route('**/api/runs/*/events', async (route) => {
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({
chunk:
'<artifact identifier="keyboard-artifact" type="text/html" title="Keyboard Artifact"><!doctype html><html><body><main><h1>Keyboard Artifact</h1></main></body></html></artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded"}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
await gotoEntryHome(page);
await createProject(page, 'Project chat keyboard send');
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.click();
await input.fill('first line');
await input.press('Shift+Enter');
await input.pressSequentially('second line');
await expect(input).toHaveValue('first line\nsecond line');
expect(runCount).toBe(0);
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
input.press('Enter'),
]);
expect(runCount).toBe(1);
await expect(input).toHaveValue('');
await expect(page.locator('.msg.user', { hasText: 'first line' })).toHaveCount(1);
await expect(page.locator('.msg.user', { hasText: 'second line' })).toHaveCount(1);
await expect(page.getByRole('tab', { name: /keyboard-artifact\.html/i })).toHaveAttribute(
'aria-selected',
'true',
);
});
test('quick switcher still activates another file after the project reloads', async ({ page }) => {
await gotoEntryHome(page);
await createProject(page, 'Quick switcher after reload');