mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
82203fe4a7
commit
1c2a1c4459
24 changed files with 2382 additions and 77 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
376
docs/testing/launch-review-e2e-regressions.zh-CN.md
Normal file
376
docs/testing/launch-review-e2e-regressions.zh-CN.md
Normal 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 systems:Settings 导入/重命名/坏导入
|
||||
|
||||
文件:
|
||||
- [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"
|
||||
```
|
||||
|
||||
### Connectors:happy 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 systems:settings + 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 关联和队列启动顺序
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
349
e2e/ui/design-systems-manager.test.ts
Normal file
349
e2e/ui/design-systems-manager.test.ts
Normal 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();
|
||||
});
|
||||
103
e2e/ui/diagnostics-export.test.ts
Normal file
103
e2e/ui/diagnostics-export.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
303
e2e/ui/settings-design-systems.test.ts
Normal file
303
e2e/ui/settings-design-systems.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue