first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
# UI 用例库
这个目录是 UI 自动化场景的来源库。
## 目的
用例库把这三层拆开:
- 场景设计
- 自动化实现
- 测试素材和运行数据
这样 Playwright spec 不会慢慢变成一堆写死的 prompt 和一次性断言。
## 当前目录结构
- [index.ts](/Users/mac/open-design/open-design/e2e/cases/index.ts):用例定义
- [types.ts](/Users/mac/open-design/open-design/e2e/cases/types.ts):用例 schema
- [modules/project-and-generation.md](/Users/mac/open-design/open-design/e2e/cases/modules/project-and-generation.md):项目创建与生成链路用例
- [modules/conversations.md](/Users/mac/open-design/open-design/e2e/cases/modules/conversations.md):会话生命周期用例
- [modules/files.md](/Users/mac/open-design/open-design/e2e/cases/modules/files.md):文件上传、mention、预览恢复用例
- [../reports/README.zh-CN.md](/Users/mac/open-design/open-design/e2e/reports/README.zh-CN.md):测试结果与报告说明
- [../specs/app.spec.ts](/Users/mac/open-design/open-design/e2e/specs/app.spec.ts):执行已自动化用例的 Playwright 入口
## Schema 说明
每条用例都是一个 `UICase`
- `id`:稳定的用例标识,用于 spec 和测试报告
- `title`:人可读的用例名称
- `kind`:项目类型,比如 `prototype``deck``workspace`
- `flow`Playwright 里对应的自动化流程分支
- `automated`:当前是否会被 `pnpm run test:ui` 执行
- `description`:覆盖目标和场景说明
- `create`:创建项目时要用到的输入
- `prompt`:主输入内容
- `secondaryPrompt`:多步骤流程里的后续输入
- `mockArtifact`mock SSE 时预期生成的 artifact
- `notes`:实现细节或维护备注
## 当前支持的 Flow
- `standard`:创建项目,发送 prompt,校验生成 artifact
- `conversation-persistence`:创建多会话,刷新后恢复,再切换历史
- `file-mention`:预置文件后通过 `@` mention 选中并校验 staged attachment
- `deep-link-preview`:通过文件路由打开预览并校验恢复
- `file-upload-send`:走真实文件选择器,校验上传和发送
- `conversation-delete-recovery`:删除当前活跃会话后校验回退
## 文档拆分规则
- `README.zh-CN.md` 只保留总览、结构和维护规则
- 具体用例清单按模块拆到 `modules/` 目录
- 一个模块一个 Markdown,后面可以继续细分
- 当单个模块内容变长时,再继续按子模块拆分
## 新增用例的方式
1. 在 [index.ts](/Users/mac/open-design/open-design/e2e/cases/index.ts) 里新增一条 `UICase`
2. 先把场景写进对应模块文档,如果只是设计阶段,保持 `automated: false`
3. 能复用已有 `flow` 就优先复用。
4. 只有在确实需要新自动化路径时,才去 [types.ts](/Users/mac/open-design/open-design/e2e/cases/types.ts) 增加新的 `flow` 类型。
5. 在 [app.spec.ts](/Users/mac/open-design/open-design/e2e/specs/app.spec.ts) 里实现这个流程。
6. 用例稳定后,再把 `automated` 改成 `true`
## 推荐工作流
1. 先用产品语言把场景写清楚。
2. 先决定它归哪个模块文档。
3. 判断它能不能归到已有的自动化 flow。
4. 只在确实需要的节点补 `data-testid`
5. 优先 mock `/api/chat` 的 SSE,保证稳定性。
6. 项目创建、路由、持久化、文件 API 尽量走真实链路。
## 适合放进来的范围
适合:
- 项目创建主流程
- 生成与 artifact 预览流程
- 会话生命周期流程
- 文件上传、mention、重新打开流程
- deep link 和刷新恢复流程
不建议优先放:
- 纯视觉、容易抖的检查
- 模型质量评估
- 强依赖真实外部 agent CLI 的测试
## 运行方式
```bash
pnpm run test:ui
```
也可以直接在独立测试包内运行:
```bash
pnpm --filter @open-design/e2e test:ui
```
运行完成后会自动生成:
- `e2e/reports/latest.md`
- `e2e/reports/ui-test-report.html`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
运行开始前会自动清理旧的 e2e 运行时数据和上一次报告,避免:
- `.od-data` 里累积空 project 目录
- `e2e/reports/test-results` 混入旧失败截图
- 报告内容和本次执行结果不一致
如果要带界面调试:
```bash
pnpm run test:ui:headed
```
+405
View File
@@ -0,0 +1,405 @@
import type { UICase } from './types';
export const uiCases: UICase[] = [
{
id: 'prototype-basic',
title: 'Prototype project creates and previews a generated artifact',
kind: 'prototype',
flow: 'standard',
automated: true,
description:
'Validates the primary happy path: create a prototype project, send one prompt, persist the generated HTML, and render it in the preview iframe.',
create: {
projectName: 'UI automation smoke',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'This is the seed smoke test and should stay fast.',
'It uses mocked SSE so the UI path stays deterministic.',
],
},
{
id: 'deck-basic',
title: 'Deck project renders a mocked slide artifact',
kind: 'deck',
flow: 'standard',
automated: true,
description:
'Covers the deck tab in project creation and verifies that a deck artifact lands in the workspace preview.',
create: {
projectName: 'Deck automation smoke',
tab: 'deck',
},
prompt: 'Create a short deck with two slides',
mockArtifact: {
identifier: 'mock-deck',
title: 'Mock Deck',
fileName: 'mock-deck.html',
heading: 'Mock Deck',
html:
'<!doctype html><html><body><section class="slide"><h1>Mock Deck</h1></section></body></html>',
},
notes: [
'Confirms the deck creation tab still routes into the same generation path.',
],
},
{
id: 'comment-attachment-flow',
title: 'Preview comments attach to chat and send as structured context',
kind: 'prototype',
flow: 'comment-attachment-flow',
automated: true,
description:
'Exercises V1 comment mode: save a latest element comment, attach/remove it from the composer, and send it as an empty visible prompt with structured comment context.',
create: {
projectName: 'Comment attachment flow',
tab: 'prototype',
},
prompt: 'Create a commentable preview artifact',
mockArtifact: {
identifier: 'commentable-artifact',
title: 'Commentable Artifact',
fileName: 'commentable-artifact.html',
heading: 'Prototype headline',
html:
'<!doctype html><html><body><main data-od-id="hero-section"><h1 data-od-id="hero-title" data-screen-label="Hero title">Prototype headline</h1><p data-od-id="hero-copy">Preview copy for comment mode.</p></main></body></html>',
},
notes: [
'The composer textarea stays empty; selected preview comments are sent through commentAttachments.',
],
},
{
id: 'design-system-selection',
title: 'Selecting a design system carries through project creation',
kind: 'prototype',
flow: 'design-system-selection',
automated: true,
description:
'Verifies that a chosen design system is selectable in the new-project panel and remains visible in project metadata after creation.',
create: {
projectName: 'Design system selection',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
notes: [
'Uses a mocked design-system list so the picker stays deterministic across environments.',
'Focuses on creation and metadata persistence instead of generation output.',
],
},
{
id: 'example-use-prompt',
title: 'Using an example prompt creates a project with a seeded draft',
kind: 'prototype',
flow: 'example-use-prompt',
automated: true,
description:
'Verifies the Examples tab fast path: click Use this prompt, create a project immediately, and carry the example prompt into the chat composer.',
create: {
projectName: 'Example prompt project',
tab: 'prototype',
},
prompt: 'Draft a warm utility landing page for a productivity app',
notes: [
'Uses a mocked skills list so the examples gallery stays deterministic.',
'Targets the pendingPrompt fast-create path instead of the standard new-project form.',
],
},
{
id: 'conversation-persistence',
title: 'Conversation history survives refresh and switching',
kind: 'workspace',
flow: 'conversation-persistence',
automated: true,
description:
'Exercises conversation creation, persistence, refresh reload, and switching between threads in one project.',
create: {
projectName: 'Conversation persistence',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
secondaryPrompt: 'Create another artifact in a fresh conversation',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Should use the same mock SSE flow as the prototype smoke path.',
'Reload should keep the original conversation content available from the history menu.',
],
},
{
id: 'file-mention',
title: 'Uploaded files can be mentioned and sent back to the agent',
kind: 'workspace',
flow: 'file-mention',
automated: true,
description:
'Validates the upload, staged attachment, and @ mention flow inside the chat composer.',
create: {
projectName: 'File mention flow',
tab: 'prototype',
},
prompt: 'Review @reference.txt and use it as context',
notes: [
'Seeds a tiny text fixture through the project file API, then exercises the composer mention flow.',
],
},
{
id: 'deep-link-preview',
title: 'Deep-linking to a file route opens the expected preview tab',
kind: 'workspace',
flow: 'deep-link-preview',
automated: true,
description:
'Verifies that /projects/:id/files/:name restores the matching open tab and preview frame after navigation or refresh.',
create: {
projectName: 'Deep link preview',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Can reuse the generated HTML from prototype-basic, then revisit with a routed URL.',
],
},
{
id: 'file-upload-send',
title: 'Composer file picker uploads a file and sends it with the prompt',
kind: 'workspace',
flow: 'file-upload-send',
automated: true,
description:
'Exercises the real attach button and hidden file input, then verifies the staged file is sent and shown back on the user message.',
create: {
projectName: 'File upload send flow',
tab: 'prototype',
},
prompt: 'Use the uploaded reference as context',
notes: [
'Uses Playwright setInputFiles on the hidden composer picker instead of seeding through the API.',
],
},
{
id: 'design-files-upload',
title: 'Design Files panel uploads an image and opens it in the workspace',
kind: 'workspace',
flow: 'design-files-upload',
automated: true,
description:
'Exercises the Design Files upload flow in the workspace, then verifies the uploaded image can be previewed and opened as a tab.',
create: {
projectName: 'Design files upload flow',
tab: 'prototype',
},
prompt: 'Upload an image through the design files browser',
notes: [
'Uses the FileWorkspace upload input rather than the chat composer upload path.',
],
},
{
id: 'design-files-delete',
title: 'Design Files panel deletes an uploaded file and clears its tab',
kind: 'workspace',
flow: 'design-files-delete',
automated: true,
description:
'Uploads a file through the Design Files panel, deletes it from the row menu, and verifies it disappears from both the list and open tabs.',
create: {
projectName: 'Design files delete flow',
tab: 'prototype',
},
prompt: 'Delete an uploaded image through the design files browser',
notes: [
'Builds on the same workspace file flow as design-files-upload, then verifies cleanup behavior.',
],
},
{
id: 'design-files-tab-persistence',
title: 'Open file tabs survive refresh with the correct active tab',
kind: 'workspace',
flow: 'design-files-tab-persistence',
automated: true,
description:
'Uploads multiple files through the Design Files flow, switches the active tab, reloads the page, and verifies both the tab set and selected tab are restored.',
create: {
projectName: 'Design files tab persistence',
tab: 'prototype',
},
prompt: 'Restore open file tabs after refresh',
notes: [
'Covers the persisted tabs state stored by ProjectView and restored by FileWorkspace.',
],
},
{
id: 'conversation-delete-recovery',
title: 'Deleting the active conversation falls back cleanly',
kind: 'workspace',
flow: 'conversation-delete-recovery',
automated: true,
description:
'Creates multiple conversations, deletes the active one, and verifies the UI falls back to the remaining thread instead of getting stuck.',
create: {
projectName: 'Conversation delete recovery',
tab: 'prototype',
},
prompt: 'Create a small test artifact',
secondaryPrompt: 'Create another artifact before deleting this thread',
mockArtifact: {
identifier: 'mock-artifact',
title: 'Mock Artifact',
fileName: 'mock-artifact.html',
heading: 'Mock Artifact',
html:
'<!doctype html><html><body><main><h1>Mock Artifact</h1><p>Generated by Playwright.</p></main></body></html>',
},
notes: [
'Confirms the project still has a live conversation after deleting the current thread.',
],
},
{
id: 'question-form-selection-limit',
title: 'Question form checkbox limits block selecting more than the allowed maximum',
kind: 'workspace',
flow: 'question-form-selection-limit',
automated: true,
description:
'Verifies that a discovery-style checkbox question with maxSelections=2 cannot be pushed past two selected options.',
create: {
projectName: 'Question form selection limit',
tab: 'prototype',
},
prompt: 'Help me plan a restaurant homepage',
notes: [
'Mocks a question-form response instead of an artifact so the test can exercise the inline clarifying UI.',
'Confirms both the interaction guard and the rendered checked state stay capped at two options.',
],
},
{
id: 'question-form-submit-persistence',
title: 'Question form answers persist into chat history and reload in a locked state',
kind: 'workspace',
flow: 'question-form-submit-persistence',
automated: true,
description:
'Verifies that answering a question form writes a user follow-up message, then rehydrates the form in an answered and locked state after reload.',
create: {
projectName: 'Question form submit persistence',
tab: 'prototype',
},
prompt: 'Plan a small restaurant homepage',
notes: [
'Mocks an inline question form on the first assistant turn and a plain acknowledgment on the follow-up turn.',
'Confirms the answered state survives a full page reload instead of relying only on local submit state.',
],
},
{
id: 'generation-does-not-create-extra-file',
title: 'Generated artifacts stay stable when no new prompt is sent',
kind: 'workspace',
flow: 'generation-does-not-create-extra-file',
automated: true,
description:
'Generates one HTML artifact, then verifies reload and idle time do not create any additional project files without a new user prompt.',
create: {
projectName: 'No extra generated file',
tab: 'prototype',
},
prompt: 'Create one landing page artifact',
mockArtifact: {
identifier: 'stable-artifact',
title: 'Stable Artifact',
fileName: 'stable-artifact.html',
heading: 'Stable Artifact',
html:
'<!doctype html><html><body><main><h1>Stable Artifact</h1><p>Only one file should exist.</p></main></body></html>',
},
notes: [
'Targets the trust-sensitive bug where a project can appear to generate a fresh file on its own.',
'Uses the files API after reload to assert the project file set is unchanged.',
],
},
{
id: 'deck-pagination-next-prev-correctness',
title: 'Deck preview previous and next controls move in the correct direction',
kind: 'deck',
flow: 'deck-pagination-next-prev-correctness',
automated: false,
description:
'Should verify that deck preview pagination moves to the actual previous and next slide instead of routing both actions to the same page.',
create: {
projectName: 'Deck pagination controls',
tab: 'deck',
},
prompt: 'Review pagination behavior in a multi-slide deck preview',
},
{
id: 'deck-pagination-per-file-isolated',
title: 'Each HTML deck tab preserves its own pagination state',
kind: 'deck',
flow: 'deck-pagination-per-file-isolated',
automated: false,
description:
'Should verify that switching between multiple deck HTML files does not leak page position across tabs or reset both files to page 1.',
create: {
projectName: 'Deck pagination isolation',
tab: 'deck',
},
prompt: 'Keep pagination state isolated per generated deck file',
},
{
id: 'uploaded-image-renders-in-preview',
title: 'Uploaded reference images render correctly in generated deck preview',
kind: 'workspace',
flow: 'uploaded-image-renders-in-preview',
automated: false,
description:
'Should verify that uploaded images resolve to loadable src paths inside generated HTML instead of rendering as broken images.',
create: {
projectName: 'Uploaded image preview render',
tab: 'prototype',
},
prompt: 'Use uploaded brand images inside a generated deck preview',
},
{
id: 'python-source-preview',
title: 'Python files should open with a readable inline source preview',
kind: 'workspace',
flow: 'python-source-preview',
automated: false,
description:
'Should verify that opening a .py file in the main workspace renders a readable source/code preview instead of an unsupported blank state.',
create: {
projectName: 'Python source preview',
tab: 'prototype',
},
prompt: 'Open a generated Python file and inspect its source inline',
notes: [
'Candidate follow-up to the Python preview gap in the file viewer.',
'Likely automation shape: seed a .py file through the project files API, open it, and assert the viewer renders code text.',
],
},
];
export function automatedCases(): UICase[] {
return uiCases.filter((entry) => entry.automated);
}
+67
View File
@@ -0,0 +1,67 @@
# 会话生命周期
这个模块聚焦项目内聊天会话的生命周期:
- 新建会话
- 切换会话
- 刷新恢复
- 删除会话
- 后续可扩展重命名等场景
## 当前用例
### `conversation-persistence`
- 状态:已自动化
- 对应 flow`conversation-persistence`
- 目标:覆盖会话创建、刷新恢复、历史切换
- 核心步骤:
1. 在第一个会话里发送 prompt
2. 新建第二个会话
3. 在第二个会话里发送新的 prompt
4. 刷新页面
5. 校验当前会话内容仍在
6. 打开历史菜单切回第一个会话
### `conversation-delete-recovery`
- 状态:已自动化
- 对应 flow`conversation-delete-recovery`
- 目标:覆盖删除当前活跃会话后的回退逻辑
- 核心步骤:
1. 创建两个会话
2. 删除当前活跃会话
3. 校验界面自动回退到剩余会话
4. 校验项目仍然保有可用会话
### `question-form-selection-limit`
- 状态:已自动化
- 对应 flow`question-form-selection-limit`
- 目标:覆盖快速确认里 checkbox 多选上限约束
- 核心步骤:
1. 创建项目并发送一条 prompt
2. mock 返回带 `maxSelections: 2` 的 question form
3. 连续点击三个视觉风格选项
4. 校验始终只有两个选项处于选中态
5. 校验第三个选项不会被错误选中
### `question-form-submit-persistence`
- 状态:已自动化
- 对应 flow`question-form-submit-persistence`
- 目标:覆盖 question form 提交后的用户回答落盘、锁定态与刷新回填
- 核心步骤:
1. mock 返回一个带必填项的 question form
2. 选择答案并点击提交
3. 校验会话里写入了用户回答消息
4. 校验原表单进入 answered / locked 状态
5. 刷新页面后再次确认锁定态和已选答案仍然正确
## 推荐后续补充
- 会话重命名
- 删除最后一个会话后的自动重建
- 历史菜单关闭/重新打开后的状态一致性
- 长会话列表滚动与选中态
- 多轮对话后的会话标题生成或更新策略
+121
View File
@@ -0,0 +1,121 @@
# 文件链路
这个模块聚焦项目文件相关的主链路:
- 文件上传
- 文件 mention
- staged attachment
- 文件路由打开
- 预览恢复
## 当前用例
### `file-mention`
- 状态:已自动化
- 对应 flow`file-mention`
- 目标:覆盖 `@` mention 选择文件并加入 staged attachment
- 核心步骤:
1. 通过项目文件 API 预置 `reference.txt`
2. 在聊天输入框中输入 `@ref`
3. 选择 mention popover 里的文件
4. 校验输入框中插入 `@reference.txt`
5. 校验 staged attachment 显示正确
### `file-upload-send`
- 状态:已自动化
- 对应 flow`file-upload-send`
- 目标:覆盖聊天区真实上传文件并发送
- 核心步骤:
1. 通过 composer 的隐藏 file input 上传文件
2. 校验 staged attachment 出现
3. 发送 prompt
4. 校验用户消息里带上上传文件
### `deep-link-preview`
- 状态:已自动化
- 对应 flow`deep-link-preview`
- 目标:覆盖文件路由直达和预览恢复
- 核心步骤:
1. 生成 artifact
2. 校验 URL 进入 `/projects/:id/files/:name`
3. 离开项目文件路由
4. 再次通过文件路由进入
5. 校验预览 iframe 正常恢复
### `design-files-upload`
- 状态:已自动化
- 对应 flow`design-files-upload`
- 目标:覆盖 Design Files 面板真实上传、预览与打开
- 核心步骤:
1. 通过 Design Files 面板的上传入口选择图片
2. 校验文件行出现在列表中
3. 校验右侧预览信息出现
4. 双击文件行
5. 校验文件以 tab 形式打开
### `design-files-delete`
- 状态:已自动化
- 对应 flow`design-files-delete`
- 目标:覆盖 Design Files 面板删除文件以及打开 tab 的清理
- 核心步骤:
1. 先上传一张图片
2. 回到 Design Files 面板
3. 打开文件行菜单并执行删除
4. 确认文件行从列表中消失
5. 确认对应文件 tab 也被清理
### `design-files-tab-persistence`
- 状态:已自动化
- 对应 flow`design-files-tab-persistence`
- 目标:覆盖多个打开文件 tab 在刷新后的恢复
- 核心步骤:
1. 先上传两张图片
2. 确认两张图片都打开为 tab
3. 切换当前 active tab
4. 刷新页面
5. 确认两个 tab 都被恢复
6. 确认刷新前的 active tab 仍然是 active
## 推荐后续补充
### `deck-pagination-per-file-isolated`
- 状态:待自动化
- 对应 flow`deck-pagination-per-file-isolated`
- 目标:覆盖多个 deck HTML 之间的分页状态隔离
- 核心步骤:
1. 打开两个多页 deck 文件
2. 分别停留在不同页码
3. 来回切换文件 tab
4. 校验每个文件维持自己的页码
### `uploaded-image-renders-in-preview`
- 状态:待自动化
- 对应 flow`uploaded-image-renders-in-preview`
- 目标:覆盖上传图片参与生成后,预览中的图片真实可加载
- 核心步骤:
1. 上传图片作为参考素材
2. 生成引用该图片的 HTML artifact
3. 进入预览 iframe
4. 校验对应 `img``src` 可解析且不是 broken image
### `python-source-preview`
- 状态:待自动化
- 对应 flow`python-source-preview`
- 目标:覆盖 `.py` 文件在主工作区中的源码预览能力
- 核心步骤:
1. 通过项目文件 API 预置一个 `.py` 文件
2. 在主工作区打开该文件
3. 校验文件查看器进入源码/文本预览模式
4. 校验能看到 Python 源码内容,而不是空白或不支持状态
- 图片文件上传与缩略图展示
- 刷新后 staged attachment 清理策略
@@ -0,0 +1,88 @@
# 项目创建与生成
这个模块聚焦主入口链路:
- 创建项目
- 进入工作区
- 发送 prompt
- 生成 artifact
- 打开预览
## 当前用例
### `prototype-basic`
- 状态:已自动化
- 对应 flow`standard`
- 目标:覆盖 prototype 项目的主 happy path
- 核心步骤:
1. 创建 `prototype` 项目
2. 输入 prompt
3. mock `/api/chat` SSE 返回 HTML artifact
4. 校验生成文件出现在工作区
5. 校验 iframe 预览正常
### `deck-basic`
- 状态:已自动化
- 对应 flow`standard`
- 目标:覆盖 deck 项目创建分支
- 核心步骤:
1. 切换到 `deck` 创建 tab
2. 创建项目
3. 发送 prompt
4. mock 返回 deck artifact
5. 校验预览正常
### `design-system-selection`
- 状态:已自动化
- 对应 flow`design-system-selection`
- 目标:覆盖设计系统选择后创建项目,并确认项目元信息保留了该选择
- 核心步骤:
1. mock 设计系统列表
2. 打开设计系统选择器
3. 搜索并选择指定设计系统
4. 创建项目
5. 校验项目页 meta 中出现设计系统名称
### `example-use-prompt`
- 状态:已自动化
- 对应 flow`example-use-prompt`
- 目标:覆盖 Examples 页的快捷创建链路
- 核心步骤:
1. mock skills 列表,提供一个示例卡片
2. 切到 Examples 页
3. 点击 `Use this prompt`
4. 校验项目被直接创建
5. 校验聊天输入框预填了 example prompt
### `generation-does-not-create-extra-file`
- 状态:已自动化
- 对应 flow`generation-does-not-create-extra-file`
- 目标:覆盖“没有新 prompt 却自己多生成一个 HTML 文件”的回归风险
- 核心步骤:
1. 生成一个 mocked artifact
2. 通过 files API 记录当前项目文件集合
3. 刷新页面但不发送新 prompt
4. 再次读取 files API
5. 校验文件集合没有变化,也没有新增 HTML 文件
## 推荐后续补充
### `deck-pagination-next-prev-correctness`
- 状态:待自动化
- 对应 flow`deck-pagination-next-prev-correctness`
- 目标:覆盖 deck 预览上一页 / 下一页按钮的方向正确性
- 核心步骤:
1. 打开多页 deck HTML
2. 进入中间页
3. 点击上一页并校验页码递减
4. 点击下一页并校验页码递增
- template 项目创建
- 创建项目后的刷新恢复
- 创建失败或必填校验
+134
View File
@@ -0,0 +1,134 @@
export interface ReportCaseMetadata {
module: string;
assertions: string[];
}
const caseMetadata: Record<string, ReportCaseMetadata> = {
'prototype-basic': {
module: '项目创建与生成',
assertions: [
'可以创建 prototype 项目并进入工作区',
'发送 prompt 后会收到 mocked artifact',
'生成文件会出现在工作区',
'预览 iframe 中能看到期望标题',
],
},
'deck-basic': {
module: '项目创建与生成',
assertions: [
'可以通过 deck tab 创建项目',
'发送 prompt 后会收到 deck artifact',
'deck 文件会出现在工作区',
'预览 iframe 中能看到期望标题',
],
},
'design-system-selection': {
module: '项目创建与生成',
assertions: [
'设计系统选择器可以搜索并选中目标设计系统',
'创建项目后项目 meta 会保留设计系统名称',
'项目成功进入工作区而不是停留在创建页',
],
},
'example-use-prompt': {
module: '项目创建与生成',
assertions: [
'Examples 页的 Use this prompt 可以直接创建项目',
'创建后的项目标题与 meta 会带上对应 skill 名称',
'聊天输入框会预填 example prompt',
],
},
'conversation-persistence': {
module: '会话生命周期',
assertions: [
'可以创建第二个会话并发送新的 prompt',
'刷新后当前会话消息仍然存在',
'历史菜单中可以切回旧会话',
'切回后旧会话内容仍然正确显示',
],
},
'conversation-delete-recovery': {
module: '会话生命周期',
assertions: [
'删除当前活跃会话后不会卡死在空状态',
'界面会回退到剩余会话',
'被删除会话的消息不会继续显示',
],
},
'question-form-selection-limit': {
module: '会话生命周期',
assertions: [
'question form 中声明 maxSelections=2 的 checkbox 题目最多只能选中两个选项',
'达到上限后新的未选项不会被选中',
'界面中的已选数量会保持在约束范围内',
],
},
'question-form-submit-persistence': {
module: '会话生命周期',
assertions: [
'提交 question form 后会写入一条用户回答消息',
'表单会立即进入 answered / locked 状态',
'刷新页面后表单仍会根据历史答案正确回填并保持锁定',
],
},
'generation-does-not-create-extra-file': {
module: '项目创建与生成',
assertions: [
'第一次生成后项目中只出现预期的 artifact 文件',
'在没有发送新 prompt 的情况下刷新页面不会新增文件',
'files API 返回的文件集合在前后两次检查中保持一致',
],
},
'file-mention': {
module: '文件链路',
assertions: [
'预置文件后 mention popover 可以搜索并选中文件',
'输入框会插入 @filename',
'staged attachment 会显示对应文件',
],
},
'file-upload-send': {
module: '文件链路',
assertions: [
'聊天区 file input 可以上传文件',
'上传后 staged attachment 会显示文件',
'发送消息后用户消息中会保留该附件',
],
},
'deep-link-preview': {
module: '文件链路',
assertions: [
'生成 artifact 后 URL 会进入文件路由',
'离开项目文件路由后可再次通过文件路由进入',
'重新进入后预览 iframe 仍能恢复到正确文件',
],
},
'design-files-upload': {
module: '文件链路',
assertions: [
'Design Files 面板可以真实上传图片',
'上传后文件行会出现在 Design Files 列表',
'右侧预览面板会显示文件信息',
'双击文件行会把文件打开成 tab',
],
},
'design-files-delete': {
module: '文件链路',
assertions: [
'Design Files 行级菜单可以触发删除',
'删除确认后文件行会从列表消失',
'如果文件已打开,对应 tab 也会被清理',
],
},
'design-files-tab-persistence': {
module: '文件链路',
assertions: [
'多个文件 tab 可以同时打开',
'切换 active tab 后状态会被持久化',
'刷新页面后 tab 集合会恢复',
'刷新前选中的 active tab 仍然保持选中',
],
},
} satisfies Record<string, ReportCaseMetadata>;
export default caseMetadata;
+45
View File
@@ -0,0 +1,45 @@
export type CaseKind = 'prototype' | 'deck' | 'template' | 'workspace';
export interface MockArtifactCase {
identifier: string;
title: string;
html: string;
fileName: string;
heading: string;
}
export interface UICase {
id: string;
title: string;
kind: CaseKind;
flow?:
| 'standard'
| 'design-system-selection'
| 'example-use-prompt'
| 'conversation-persistence'
| 'file-mention'
| 'deep-link-preview'
| 'file-upload-send'
| 'design-files-upload'
| 'design-files-delete'
| 'design-files-tab-persistence'
| 'conversation-delete-recovery'
| 'question-form-selection-limit'
| 'question-form-submit-persistence'
| 'generation-does-not-create-extra-file'
| 'comment-attachment-flow'
| 'deck-pagination-next-prev-correctness'
| 'deck-pagination-per-file-isolated'
| 'uploaded-image-renders-in-preview'
| 'python-source-preview';
automated: boolean;
description: string;
create: {
projectName: string;
tab?: 'prototype' | 'deck' | 'template' | 'other';
};
prompt: string;
secondaryPrompt?: string;
mockArtifact?: MockArtifactCase;
notes?: string[];
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@open-design/e2e",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p ../scripts/tsconfig.json --noEmit",
"test:ui:clean": "node --experimental-strip-types scripts/reset-artifacts.ts",
"test:ui": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts",
"test:ui:headed": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed",
"test:e2e:live": "corepack pnpm --filter @open-design/daemon build && node --experimental-strip-types --test scripts/runtime-adapter.e2e.live.test.ts"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.17.10",
"jsdom": "^29.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
const daemonPort = Number(process.env.OD_PORT) || 17_456;
const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
const baseURL = `http://127.0.0.1:${webPort}`;
export default defineConfig({
testDir: './specs',
outputDir: './reports/test-results',
timeout: 30_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
reporter: process.env.CI
? [
['github'],
['list'],
['html', { open: 'never', outputFolder: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
]
: [
['list'],
['html', { open: 'never', outputFolder: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command:
`OD_DATA_DIR=e2e/.od-data ` +
`pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+289
View File
@@ -0,0 +1,289 @@
import fs from 'node:fs';
import path from 'node:path';
import type { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import caseMetadata from '../cases/report-metadata.ts';
interface MarkdownReporterOptions {
outputFile?: string;
}
interface CaseRow {
caseId: string;
title: string;
module: string;
assertions: string[];
status: string;
durationMs: number;
retries: number;
file: string;
line: number | null;
attachments: Array<{ name: string; contentType: string; path: string }>;
error: string | null;
}
interface Summary {
total: number;
passed: number;
failed: number;
flaky: number;
skipped: number;
timedOut: number;
interrupted: number;
durationMs: number;
}
interface MarkdownInput {
startedAt: Date;
finishedAt: Date;
summary: Summary;
rows: CaseRow[];
outputFile: string;
}
class MarkdownReporter implements Reporter {
private rootSuite: Suite | null = null;
private startedAt: Date | null = null;
private readonly options: MarkdownReporterOptions;
constructor(options: MarkdownReporterOptions = {}) {
this.options = options;
}
onBegin(_config: FullConfig, suite: Suite): void {
this.rootSuite = suite;
this.startedAt = new Date();
}
async onEnd(): Promise<void> {
if (!this.rootSuite) return;
const rows: CaseRow[] = [];
visitSuite(this.rootSuite, rows);
rows.sort((a, b) => a.caseId.localeCompare(b.caseId));
const summary = summarize(rows);
const startedAt = this.startedAt ?? new Date();
const finishedAt = new Date();
const outputFile = this.options.outputFile || './reports/latest.md';
const resolvedOutput = path.resolve(process.cwd(), outputFile);
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
fs.writeFileSync(
resolvedOutput,
buildMarkdown({
startedAt,
finishedAt,
summary,
rows,
outputFile,
}),
'utf8',
);
}
}
function visitSuite(suite: Suite, rows: CaseRow[]): void {
for (const child of suite.suites || []) {
visitSuite(child, rows);
}
for (const test of suite.tests || []) {
const finalResult = test.results[test.results.length - 1];
if (!finalResult) continue;
rows.push(buildCaseRow(test, finalResult));
}
}
function buildCaseRow(test: TestCase, finalResult: TestResult): CaseRow {
const parsed = parseCaseTitle(test.title);
const metadata = caseMetadata[parsed.caseId];
return {
caseId: parsed.caseId,
title: parsed.title,
module: metadata?.module || '未分组',
assertions: metadata?.assertions || [],
status: normalizeStatus(finalResult.status, test.outcome?.()),
durationMs: finalResult.duration ?? 0,
retries: Math.max(0, test.results.length - 1),
file: test.location?.file ?? '',
line: test.location?.line ?? null,
attachments: (finalResult.attachments || [])
.map((entry) => ({
name: entry.name || '',
contentType: entry.contentType || '',
path: entry.path ? toRelative(entry.path) : '',
}))
.filter((entry) => entry.path.length > 0),
error: compactError(finalResult.error),
};
}
function parseCaseTitle(title: string): { caseId: string; title: string } {
const idx = title.indexOf(': ');
if (idx === -1) {
return { caseId: title, title };
}
return {
caseId: title.slice(0, idx).trim(),
title: title.slice(idx + 2).trim(),
};
}
function normalizeStatus(status: string | undefined, outcome: string | undefined): string {
if (outcome === 'flaky') return 'flaky';
return status || 'unknown';
}
function compactError(error: TestResult['error']): string | null {
if (!error) return null;
const raw = [error.message, error.value, error.stack]
.filter(Boolean)
.join('\n')
.trim();
if (!raw) return null;
return raw.split('\n').slice(0, 8).join('\n');
}
function summarize(rows: CaseRow[]): Summary {
const summary = {
total: rows.length,
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
timedOut: 0,
interrupted: 0,
durationMs: rows.reduce((sum, row) => sum + row.durationMs, 0),
};
for (const row of rows) {
if (row.status === 'passed') summary.passed += 1;
else if (row.status === 'failed') summary.failed += 1;
else if (row.status === 'flaky') summary.flaky += 1;
else if (row.status === 'skipped') summary.skipped += 1;
else if (row.status === 'timedOut') summary.timedOut += 1;
else if (row.status === 'interrupted') summary.interrupted += 1;
}
return summary;
}
function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }: MarkdownInput): string {
const lines: string[] = [];
lines.push('# UI 自动化测试报告');
lines.push('');
lines.push(`- 生成时间:${finishedAt.toISOString()}`);
lines.push(`- 开始时间:${startedAt.toISOString()}`);
lines.push(`- 结束时间:${finishedAt.toISOString()}`);
lines.push(`- 报告文件:\`${outputFile}\``);
lines.push(`- 执行结果:${summary.failed === 0 && summary.timedOut === 0 ? '通过' : '失败'}`);
lines.push('');
lines.push('## 汇总');
lines.push('');
lines.push(`- 总用例:${summary.total}`);
lines.push(`- 通过:${summary.passed}`);
lines.push(`- 失败:${summary.failed}`);
lines.push(`- Flaky${summary.flaky}`);
lines.push(`- 跳过:${summary.skipped}`);
lines.push(`- 超时:${summary.timedOut}`);
lines.push(`- 中断:${summary.interrupted}`);
lines.push(`- 总耗时:${formatDuration(summary.durationMs)}`);
lines.push('');
lines.push('## 用例结果');
lines.push('');
lines.push('| Case ID | 模块 | 标题 | 状态 | 耗时 | 重试 |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const row of rows) {
lines.push(
`| \`${escapeCell(row.caseId)}\` | ${escapeCell(row.module)} | ${escapeCell(row.title)} | ${statusLabel(row.status)} | ${formatDuration(row.durationMs)} | ${row.retries} |`,
);
}
lines.push('');
lines.push('## 关键断言');
lines.push('');
for (const row of rows) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 模块:${row.module}`);
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
if (row.assertions.length > 0) {
lines.push('- 本次验证点:');
for (const assertion of row.assertions) {
lines.push(` - ${assertion}`);
}
} else {
lines.push('- 本次验证点:未配置');
}
lines.push('');
}
const problematic = rows.filter((row) => row.status !== 'passed');
if (problematic.length > 0) {
lines.push('');
lines.push('## 异常详情');
lines.push('');
for (const row of problematic) {
lines.push(`### ${row.caseId}`);
lines.push('');
lines.push(`- 标题:${row.title}`);
lines.push(`- 状态:${statusLabel(row.status)}`);
lines.push(`- 位置:\`${toRelative(row.file)}${row.line ? `:${row.line}` : ''}\``);
if (row.error) {
lines.push('- 错误:');
lines.push('```text');
lines.push(row.error);
lines.push('```');
}
if (row.attachments.length > 0) {
lines.push('- 附件:');
for (const attachment of row.attachments) {
lines.push(` - \`${attachment.name}\` · \`${attachment.path}\``);
}
}
lines.push('');
}
}
lines.push('## 原始产物');
lines.push('');
lines.push('- HTML 报告入口:`e2e/reports/ui-test-report.html`');
lines.push('- Playwright HTML 底层目录:`e2e/reports/playwright-html-report/`');
lines.push('- JSON 结果:`e2e/reports/results.json`');
lines.push('- JUnit 结果:`e2e/reports/junit.xml`');
lines.push('- Playwright 附件:`e2e/reports/test-results/`');
lines.push('');
lines.push('## 说明');
lines.push('');
lines.push('- 这份报告记录的是本次实际执行到的 UI 自动化用例。');
lines.push('- 用例设计来源见 `e2e/cases/` 以及各模块文档。');
lines.push('- 如果用例失败,优先查看本报告中的附件路径和 HTML 报告。');
lines.push('');
return `${lines.join('\n')}\n`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusLabel(status: string): string {
if (status === 'passed') return 'passed';
if (status === 'failed') return 'failed';
if (status === 'flaky') return 'flaky';
if (status === 'skipped') return 'skipped';
if (status === 'timedOut') return 'timedOut';
if (status === 'interrupted') return 'interrupted';
return status;
}
function toRelative(filePath: string): string {
if (!filePath) return '';
return path.relative(process.cwd(), filePath) || filePath;
}
function escapeCell(value: string): string {
return String(value).replace(/\|/g, '\\|');
}
export default MarkdownReporter;
+49
View File
@@ -0,0 +1,49 @@
# UI 测试报告
这个目录存放 UI 自动化测试的运行结果和可读报告。
## 目录说明
- `latest.md`:最近一次测试运行的 Markdown 汇总报告
- `ui-test-report.html`:给人直接打开的 HTML 报告入口
- `playwright-html-report/`Playwright 原生 HTML 报告目录,内部入口仍是 `index.html`
- `results.json`Playwright JSON 原始结果
- `junit.xml`JUnit 格式结果,方便接 CI
- `test-results/`:失败用例的截图、trace、error-context 等原始附件
每次执行 `pnpm run test:ui`(或 `pnpm --filter @open-design/e2e test:ui`)前,系统会先自动清理旧的:
- `e2e/.od-data/`
- `e2e/reports/test-results/`
- `e2e/reports/playwright-html-report/`
- `e2e/reports/results.json`
- `e2e/reports/junit.xml`
- `e2e/reports/latest.md`
这样报告和测试数据默认只反映最近一次执行结果,不会把上一次残留混进来。
## 怎么看
如果你想快速判断“这次到底测了什么、有没有过”,先看:
- [latest.md](/Users/mac/open-design/open-design/e2e/reports/latest.md)
- [ui-test-report.html](/Users/mac/open-design/open-design/e2e/reports/ui-test-report.html)
它会包含:
- 本次执行时间
- 总用例数、通过数、失败数
- 每条 case 的结果、耗时、重试次数
- 失败时对应的错误摘要和附件路径
如果你想看更细的失败上下文,再看:
- `e2e/reports/playwright-html-report/`
- `e2e/reports/test-results/`
## 和用例库的关系
- `e2e/cases/`:定义“应该测什么”
- `e2e/reports/`:记录“这次实际测了什么、结果如何”
这两层分开以后,既能看覆盖设计,也能看真实执行结果。
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Open Design UI Test Report</title>
<meta http-equiv="refresh" content="0; url=./playwright-html-report/index.html" />
<style>
:root {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f6f3ee;
color: #201d18;
}
main {
width: min(560px, calc(100vw - 48px));
padding: 28px 32px;
border: 1px solid #ddd4c7;
border-radius: 18px;
background: #fffdfa;
box-shadow: 0 16px 40px rgba(32, 29, 24, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: 22px;
}
p {
margin: 0;
line-height: 1.6;
}
a {
color: #b45b33;
}
</style>
</head>
<body>
<main>
<h1>Open Design UI Test Report</h1>
<p>
正在跳转到 Playwright HTML 报告。
如果没有自动跳转,请打开
<a href="./playwright-html-report/index.html">playwright-html-report/index.html</a>
</p>
</main>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
import { mkdir, readdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const e2eDir = path.resolve(__dirname, '..');
const targets = [
path.join(e2eDir, '.od-data'),
path.join(e2eDir, 'test-results'),
path.join(e2eDir, 'reports', 'test-results'),
path.join(e2eDir, 'reports', 'html'),
path.join(e2eDir, 'reports', 'playwright-html-report'),
path.join(e2eDir, 'reports', 'results.json'),
path.join(e2eDir, 'reports', 'junit.xml'),
path.join(e2eDir, 'reports', 'latest.md'),
path.join(e2eDir, '.DS_Store'),
];
for (const target of targets) {
await rm(target, { recursive: true, force: true });
}
await mkdir(path.join(e2eDir, 'reports'), { recursive: true });
// Recreate runtime roots so local inspection stays predictable even before
// Playwright or the daemon materializes them.
await mkdir(path.join(e2eDir, '.od-data'), { recursive: true });
await mkdir(path.join(e2eDir, 'reports', 'test-results'), {
recursive: true,
});
// Best-effort removal of accidental empty directories directly under the
// test data root. This keeps old project ids from piling up across runs.
const projectsRoot = path.join(e2eDir, '.od-data', 'projects');
try {
const entries = await readdir(projectsRoot, { withFileTypes: true });
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map((entry) =>
rm(path.join(projectsRoot, entry.name), {
recursive: true,
force: true,
}),
),
);
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined;
if (code !== 'ENOENT') {
console.warn('Failed to clean stale e2e project dirs:', error);
}
}
@@ -0,0 +1,319 @@
import type http from 'node:http';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
interface AgentInfo {
id: string;
name: string;
bin: string;
available: boolean;
path?: string;
version?: string | null;
models?: Array<{ id: string; label?: string }>;
streamFormat?: string;
}
interface AgentsResponse {
agents: AgentInfo[];
}
interface ParsedSseEvent {
event: string;
data: Record<string, unknown>;
}
type StartServer = (options: { port: number; returnServer: true }) => Promise<http.Server | undefined>;
type CloseDatabase = () => void;
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES);
const maxRuntimeCount = 8;
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
let baseUrl: string;
let server: http.Server | undefined;
let startServer: StartServer;
let closeDatabase: CloseDatabase | undefined;
let detectedAgents: AgentInfo[] | undefined;
let dataDir: string;
test.before(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-'));
process.env.OD_DATA_DIR = dataDir;
({ startServer } = await import('../../apps/daemon/dist/server.js') as { startServer: StartServer });
({ closeDatabase } = await import('../../apps/daemon/dist/db.js') as { closeDatabase: CloseDatabase });
const started = await startServer({ port: 0, returnServer: true });
if (started == null) {
throw new Error('startServer did not return a server handle');
}
const address = started.address();
if (address == null || typeof address === 'string') {
throw new Error('startServer did not bind to a TCP port');
}
server = started;
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {
if (server) {
await new Promise<void>((resolve, reject) => {
server?.close((err) => (err ? reject(err) : resolve()));
});
}
closeDatabase?.();
if (dataDir) {
await fs.rm(dataDir, { recursive: true, force: true });
}
});
test('runtime adapter live detection flow exposes installed runtimes', async () => {
log('detect', 'starting runtime detection via /api/agents');
const res = await fetch(`${baseUrl}/api/agents`);
assert.equal(res.status, 200);
const body = await readAgentsResponse(res);
assert.ok(Array.isArray(body.agents));
assert.ok(body.agents.length > 0);
detectedAgents = body.agents;
const available = body.agents.filter((agent) => agent.available);
for (const agent of body.agents) {
const status = agent.available ? 'available' : 'unavailable';
const version = agent.version ? ` version=${agent.version}` : '';
const resolvedPath = agent.path ? ` path=${agent.path}` : '';
log(
'detect',
`${agent.id}: ${status}${version}${resolvedPath} models=${agent.models?.length ?? 0} stream=${agent.streamFormat}`,
);
}
assert.ok(
available.length > 0,
'Install at least one supported runtime CLI on PATH: claude, codex, gemini, opencode, hermes, kimi, cursor-agent, or qwen.',
);
for (const agent of body.agents) {
assert.equal(typeof agent.id, 'string');
assert.equal(typeof agent.name, 'string');
assert.equal(typeof agent.bin, 'string');
assert.equal(typeof agent.available, 'boolean');
assert.ok(Array.isArray(agent.models));
assert.ok(agent.models.some((model) => model.id === 'default'));
assert.equal(typeof agent.streamFormat, 'string');
if (agent.available) {
assert.equal(typeof agent.path, 'string');
const resolvedPath = agent.path;
assert.ok(resolvedPath && resolvedPath.length > 0);
}
}
});
test('runtime adapter live run flow streams a successful response for every available runtime', { timeout: liveTimeoutMs * maxRuntimeCount + 30_000 }, async () => {
if (!detectedAgents) {
log('run', 'detection cache empty; fetching /api/agents before run flow');
const res = await fetch(`${baseUrl}/api/agents`);
detectedAgents = (await readAgentsResponse(res)).agents;
}
const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null;
const availableAgents = detectedAgents.filter(
(agent) => agent.available && (!requestedSet || requestedSet.has(agent.id)),
);
if (requestedSet) {
log('run', `runtime filter=${requestedRuntimeIds?.join(',')}`);
for (const id of requestedSet) {
assert.ok(
detectedAgents.some((agent) => agent.id === id),
`Requested runtime ${id} is missing from /api/agents.`,
);
}
}
for (const agent of detectedAgents) {
if (agent.available) {
if (!requestedSet || requestedSet.has(agent.id)) {
log('run', `${agent.id}: queued`);
} else {
log('run', `${agent.id}: skipped by runtime filter`);
}
} else {
log('run', `${agent.id}: skipped because runtime is unavailable`);
}
}
assert.ok(
availableAgents.length > 0,
requestedSet
? `Requested runtimes unavailable: ${requestedRuntimeIds?.join(',')}.`
: 'Available runtime required from /api/agents.',
);
for (const agent of availableAgents) {
await runRuntime(agent);
}
});
async function runRuntime(agent: AgentInfo): Promise<void> {
const startedAt = Date.now();
log('run', `${agent.id}: starting /api/chat live run`);
const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const events: ParsedSseEvent[] = [];
const abort = AbortSignal.timeout(liveTimeoutMs);
try {
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: abort,
body: JSON.stringify({
agentId: agent.id,
projectId,
model: 'default',
message: `Reply with exactly this token and nothing else: ${marker}`,
systemPrompt: [
'You are running a local runtime-adapter live smoke test.',
'Produce a minimal text-only response.',
'Do not create, edit, delete, or inspect files.',
].join('\n'),
}),
});
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/event-stream/);
assert.ok(res.body, 'SSE response should include a readable body.');
await collectSseEvents(res, events, agent.id);
} finally {
await fs.rm(path.join(dataDir, 'projects', projectId), {
recursive: true,
force: true,
});
}
const start = events.find((event) => event.event === 'start');
assert.ok(start, 'SSE stream should include a start event.');
assert.equal(start.data.agentId, agent.id);
assert.equal(start.data.projectId, projectId);
log('run', `${agent.id}: start event cwd=${String(start.data.cwd ?? '')}`);
const end = events.find((event) => event.event === 'end');
assert.ok(end, 'SSE stream should include an end event.');
assert.equal(end.data.code, 0, renderEvents(events));
log('run', `${agent.id}: end event code=${String(end.data.code)} signal=${String(end.data.signal ?? 'none')}`);
const text = events
.map((event) => {
if (event.event === 'stdout') return stringData(event.data.chunk);
if (event.event === 'agent') return stringData(event.data.text) || stringData(event.data.delta);
return '';
})
.join('');
assert.match(text, new RegExp(marker), renderEvents(events));
log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`);
}
async function collectSseEvents(res: Response, events: ParsedSseEvent[], agentId: string): Promise<void> {
const reader = res.body?.getReader();
assert.ok(reader, 'SSE response should include a readable body.');
const decoder = new TextDecoder();
let buffer = '';
const seen = new Set<string>();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() || '';
for (const chunk of chunks) {
const parsed = parseSseEvent(chunk);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const parsed = parseSseEvent(buffer);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
function parseSseEvent(chunk: string): ParsedSseEvent | null {
const lines = chunk.split('\n');
if (lines.every((line) => line === '' || line.startsWith(':'))) return null;
const eventLine = lines.find((line) => line.startsWith('event: '));
const dataLine = lines.find((line) => line.startsWith('data: '));
if (!eventLine || !dataLine) return null;
return {
event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
};
}
function renderEvents(events: ParsedSseEvent[]): string {
return JSON.stringify(events, null, 2).slice(0, 8000);
}
function parseRuntimeIds(value: string | undefined): string[] | null {
if (!value) return null;
const ids = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return ids.length > 0 ? ids : null;
}
function log(stage: string, message: string): void {
console.log(`[runtime-adapter:e2e:${stage}] ${message}`);
}
function logSseProgress(agentId: string, event: ParsedSseEvent, seen: Set<string>): void {
if (event.event === 'start' && !seen.has('start')) {
seen.add('start');
log('run', `${agentId}: received start event`);
return;
}
if (event.event === 'stdout' && !seen.has('stdout')) {
seen.add('stdout');
log('run', `${agentId}: received stdout stream`);
return;
}
const type = stringData(event.data.type) || 'event';
if (event.event === 'agent' && !seen.has(`agent:${type}`)) {
seen.add(`agent:${type}`);
log('run', `${agentId}: received agent event type=${type || 'unknown'}`);
return;
}
if (event.event === 'stderr' && !seen.has('stderr')) {
seen.add('stderr');
log('run', `${agentId}: received stderr stream`);
return;
}
if (event.event === 'error') {
log('run', `${agentId}: received error event ${stringData(event.data.message)}`.trim());
return;
}
if (event.event === 'end' && !seen.has('end')) {
seen.add('end');
log('run', `${agentId}: received end event`);
}
}
async function readAgentsResponse(res: Response): Promise<AgentsResponse> {
const body = await res.json() as Partial<AgentsResponse>;
return { agents: Array.isArray(body.agents) ? body.agents : [] };
}
function stringData(value: unknown): string {
return typeof value === 'string' ? value : '';
}
+883
View File
@@ -0,0 +1,883 @@
import { expect, test } from '@playwright/test';
import { automatedCases } from '../cases';
import type { UICase } from '../cases/types';
const STORAGE_KEY = 'open-design:config';
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
}),
);
}, STORAGE_KEY);
});
for (const entry of automatedCases()) {
test(`${entry.id}: ${entry.title}`, async ({ page }) => {
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
agents: [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
if (entry.flow === 'design-system-selection') {
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({
json: {
designSystems: [
{
id: 'nexu-soft-tech',
title: 'Nexu Soft Tech',
category: 'Product',
summary: 'Warm utility system for product interfaces.',
swatches: ['#F7F4EE', '#D6CBBF', '#1F2937', '#D97757'],
},
],
},
});
});
}
if (entry.flow === 'example-use-prompt') {
await page.route('**/api/skills', async (route) => {
await route.fulfill({
json: {
skills: [
{
id: 'warm-utility-example',
name: 'Warm Utility Example',
description: 'A warm utility prototype example.',
triggers: [],
mode: 'prototype',
platform: 'desktop',
scenario: 'product',
previewType: 'html',
designSystemRequired: false,
defaultFor: ['prototype'],
upstream: null,
featured: 1,
fidelity: 'high-fidelity',
speakerNotes: null,
animations: null,
hasBody: true,
examplePrompt: entry.prompt,
},
],
},
});
});
}
if (entry.mockArtifact) {
await page.route('**/api/runs', async (route) => {
await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' });
});
await page.route('**/api/runs/*/events', async (route) => {
const artifact =
`<artifact identifier="${entry.mockArtifact!.identifier}" type="text/html" title="${entry.mockArtifact!.title}">` +
entry.mockArtifact!.html +
'</artifact>';
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({ chunk: artifact })}`,
'',
'event: end',
'data: {"code":0}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
}
if (entry.flow === 'question-form-selection-limit') {
await page.route('**/api/runs', async (route) => {
await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' });
});
await page.route('**/api/runs/*/events', async (route) => {
const form = [
'<question-form id="discovery" title="Quick brief — 30 seconds">',
JSON.stringify(
{
description: "I'll lock these in before building.",
questions: [
{
id: 'tone',
label: 'Visual tone (pick up to two)',
type: 'checkbox',
maxSelections: 2,
options: ['Editorial / magazine', 'Modern minimal', 'Soft / warm'],
required: true,
},
],
},
null,
2,
),
'</question-form>',
].join('\n');
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({ chunk: form })}`,
'',
'event: end',
'data: {"code":0}',
'',
'',
].join('\n');
await route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
body,
});
});
}
if (entry.flow === 'question-form-submit-persistence') {
let requestCount = 0;
await page.route('**/api/runs', async (route) => {
await route.fulfill({ status: 202, contentType: 'application/json', body: '{"runId":"mock-run"}' });
});
await page.route('**/api/runs/*/events', async (route) => {
requestCount += 1;
const chunk =
requestCount === 1
? [
'<question-form id="discovery" title="Quick brief — 30 seconds">',
JSON.stringify(
{
description: "I'll lock these in before building.",
questions: [
{
id: 'tone',
label: 'Visual tone (pick up to two)',
type: 'checkbox',
maxSelections: 2,
options: ['Editorial / magazine', 'Modern minimal', 'Soft / warm'],
required: true,
},
],
},
null,
2,
),
'</question-form>',
].join('\n')
: 'Thanks — I will use these answers for the next draft.';
const body = [
'event: start',
'data: {"bin":"mock-agent"}',
'',
'event: stdout',
`data: ${JSON.stringify({ chunk })}`,
'',
'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 page.goto('/');
if (entry.flow === 'design-system-selection') {
await runDesignSystemSelectionFlow(page, entry);
return;
}
if (entry.flow === 'example-use-prompt') {
await runExampleUsePromptFlow(page, entry);
return;
}
await createProject(page, entry);
await expectWorkspaceReady(page);
if (entry.flow === 'conversation-persistence') {
await runConversationPersistenceFlow(page, entry);
return;
}
if (entry.flow === 'file-mention') {
await runFileMentionFlow(page, entry);
return;
}
if (entry.flow === 'deep-link-preview') {
await runDeepLinkPreviewFlow(page, entry);
return;
}
if (entry.flow === 'file-upload-send') {
await runFileUploadSendFlow(page, entry);
return;
}
if (entry.flow === 'design-files-upload') {
await runDesignFilesUploadFlow(page);
return;
}
if (entry.flow === 'design-files-delete') {
await runDesignFilesDeleteFlow(page);
return;
}
if (entry.flow === 'design-files-tab-persistence') {
await runDesignFilesTabPersistenceFlow(page);
return;
}
if (entry.flow === 'conversation-delete-recovery') {
await runConversationDeleteRecoveryFlow(page, entry);
return;
}
if (entry.flow === 'question-form-selection-limit') {
await runQuestionFormSelectionLimitFlow(page, entry);
return;
}
if (entry.flow === 'question-form-submit-persistence') {
await runQuestionFormSubmitPersistenceFlow(page, entry);
return;
}
if (entry.flow === 'generation-does-not-create-extra-file') {
await runGenerationDoesNotCreateExtraFileFlow(page, entry);
return;
}
if (entry.flow === 'comment-attachment-flow') {
await runCommentAttachmentFlow(page, entry);
return;
}
await sendPrompt(page, entry.prompt);
if (entry.mockArtifact) {
await expectArtifactVisible(page, entry);
}
});
}
async function createProject(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await createProjectNameOnly(page, entry);
await page.getByTestId('create-project').click();
}
async function expectWorkspaceReady(page: Parameters<typeof test>[0]['page']) {
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('file-workspace')).toBeVisible();
await expect(page.getByText('Start a conversation')).toBeVisible();
}
async function sendPrompt(
page: Parameters<typeof test>[0]['page'],
prompt: string,
) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
for (let attempt = 0; attempt < 3; attempt++) {
await input.click();
await input.fill(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
return;
} catch (error) {
await input.click();
await input.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+A`);
await input.press('Backspace');
await input.pressSequentially(prompt);
try {
await expect(input).toHaveValue(prompt, { timeout: 1500 });
await expect(sendButton).toBeEnabled({ timeout: 1500 });
const chatResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/runs') && resp.request().method() === 'POST',
{ timeout: 2000 },
);
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
await chatResponse;
return;
} catch (retryError) {
if (attempt === 2) throw retryError;
}
}
}
}
async function runDesignSystemSelectionFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await createProjectNameOnly(page, entry);
await page.getByTestId('design-system-trigger').click();
await expect(page.getByTestId('design-system-search')).toBeVisible();
await page.getByTestId('design-system-search').fill('Nexu');
await page.getByRole('option', { name: /Nexu Soft Tech/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('create-project').click();
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('project-meta')).toContainText('Nexu Soft Tech');
await expect(page.getByTestId('chat-composer')).toBeVisible();
}
async function runExampleUsePromptFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await page.getByTestId('entry-tab-examples').click();
await expect(page.getByTestId('example-card-warm-utility-example')).toBeVisible();
await page.getByTestId('example-use-prompt-warm-utility-example').click();
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toHaveValue(entry.prompt);
await expect(page.getByTestId('project-title')).toContainText('Warm Utility Example');
await expect(page.getByTestId('project-meta')).toContainText('Warm Utility Example');
}
async function runQuestionFormSelectionLimitFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
const toneQuestion = page.locator('.qf-field', {
has: page.getByText('Visual tone (pick up to two)'),
});
await expect(toneQuestion).toBeVisible();
const editorialChip = toneQuestion.locator('label.qf-chip', {
has: page.getByText('Editorial / magazine'),
});
const modernChip = toneQuestion.locator('label.qf-chip', {
has: page.getByText('Modern minimal'),
});
const softChip = toneQuestion.locator('label.qf-chip', {
has: page.getByText('Soft / warm'),
});
const editorial = editorialChip.locator('input[type="checkbox"]');
const modern = modernChip.locator('input[type="checkbox"]');
const soft = softChip.locator('input[type="checkbox"]');
await editorialChip.click();
await modernChip.click();
await expect(editorial).toBeChecked();
await expect(modern).toBeChecked();
await expect(soft).toBeDisabled();
const checkedOptions = toneQuestion.locator('input[type="checkbox"]:checked');
await expect(checkedOptions).toHaveCount(2);
await expect(soft).not.toBeChecked();
await expect(checkedOptions).toHaveCount(2);
}
async function runQuestionFormSubmitPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
const form = page.locator('.question-form').first();
await expect(form).toBeVisible();
const toneQuestion = form.locator('.qf-field', {
has: page.getByText('Visual tone (pick up to two)'),
});
await toneQuestion.locator('label.qf-chip', { has: page.getByText('Editorial / magazine') }).click();
await toneQuestion.locator('label.qf-chip', { has: page.getByText('Modern minimal') }).click();
await form.getByRole('button', { name: 'Send answers' }).click();
await expect(page.getByText('[form answers — discovery]', { exact: false })).toBeVisible();
await expect(form.getByText('answered', { exact: true })).toBeVisible();
await expect(form.getByText('Answers sent — agent is using these for the rest of the session.')).toBeVisible();
const { projectId, conversationId } = await getCurrentProjectContext(page);
const messagesResponse = await page.request.get(
`/api/projects/${projectId}/conversations/${conversationId}/messages`,
);
expect(messagesResponse.ok()).toBeTruthy();
const { messages } = (await messagesResponse.json()) as { messages: Array<{ role: string; content: string }> };
const formAnswerMessage = messages.find((message) => message.role === 'user' && message.content.includes('[form answers — discovery]'));
expect(formAnswerMessage).toBeTruthy();
await page.reload();
const restoredForm = page.locator('.question-form').first();
await expect(restoredForm).toBeVisible();
await expect(restoredForm.getByText('answered', { exact: true })).toBeVisible();
await expect(restoredForm.locator('input[type="checkbox"]:checked')).toHaveCount(2);
await expect(restoredForm.getByRole('button', { name: 'Send answers' })).toHaveCount(0);
}
async function runGenerationDoesNotCreateExtraFileFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
const { projectId } = await getCurrentProjectContext(page);
const initialFiles = await listProjectFilesFromApi(page, projectId);
expect(initialFiles.map((file) => file.name)).toContain(entry.mockArtifact!.fileName);
await page.reload();
await expect(page.getByTestId('file-workspace')).toBeVisible();
const reloadedFiles = await listProjectFilesFromApi(page, projectId);
expect(reloadedFiles.map((file) => file.name)).toEqual(initialFiles.map((file) => file.name));
await expect(page.getByText(entry.mockArtifact!.fileName, { exact: true })).toBeVisible();
}
async function runCommentAttachmentFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
await page.getByTestId('comment-mode-toggle').click();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await frame.locator('[data-od-id="hero-title"]').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByTestId('comment-popover-input').fill('Make the headline more specific.');
await page.getByTestId('comment-add-send').click();
await expect(page.getByTestId('staged-comment-attachments')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
await expect(page.getByTestId('staged-comment-attachments')).toContainText('Make the headline more specific.');
await expect(page.getByTestId('chat-composer-input')).toHaveValue('');
await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible();
await frame.locator('[data-od-id="hero-copy"]').hover();
await expect(page.getByTestId('comment-target-overlay')).toBeVisible();
await expect(page.getByTestId('comment-target-overlay')).toContainText('hero-copy');
await page.getByTestId('comment-saved-marker-hero-title').getByRole('button').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await expect(page.getByTestId('comment-popover-input')).toHaveValue('Make the headline more specific.');
await page.getByTestId('comment-popover').getByRole('button', { name: 'Close' }).click();
await page.getByRole('tab', { name: 'Comments' }).click();
await expect(page.getByTestId('comments-panel')).toBeVisible();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Attached to chat' })).toBeVisible();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Saved comments' })).toBeVisible();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Remove' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toHaveCount(0);
await expect(page.getByTestId('chat-send')).toBeDisabled();
await page.getByRole('tab', { name: 'Comments' }).click();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Add' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
const runRequest = page.waitForRequest(
(request) => request.url().includes('/api/runs') && request.method() === 'POST',
);
await page.getByTestId('chat-send').click();
const request = await runRequest;
const body = request.postDataJSON() as {
message?: string;
commentAttachments?: Array<{ elementId?: string; comment?: string; filePath?: string }>;
};
expect(body.message).toMatch(/\n\n## user\n$/);
expect(body.message).not.toContain('Apply selected preview comments');
expect(body.commentAttachments).toEqual([
expect.objectContaining({
elementId: 'hero-title',
comment: 'Make the headline more specific.',
filePath: 'commentable-artifact.html',
}),
]);
}
async function createProjectNameOnly(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
if (entry.create.tab) {
await page.getByTestId(`new-project-tab-${entry.create.tab}`).click();
}
await page.getByTestId('new-project-name').fill(entry.create.projectName);
}
async function getCurrentProjectContext(
page: Parameters<typeof test>[0]['page'],
): Promise<{ projectId: string; conversationId: string }> {
const current = new URL(page.url());
const [, projects, projectId, maybeConversations, conversationId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
if (maybeConversations === 'conversations' && conversationId) {
return { projectId, conversationId };
}
const response = await page.request.get(`/api/projects/${projectId}/conversations`);
expect(response.ok()).toBeTruthy();
const { conversations } = (await response.json()) as {
conversations: Array<{ id: string; updatedAt: number }>;
};
const active = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt)[0];
if (!active) throw new Error(`no conversations found for project ${projectId}`);
return { projectId, conversationId: active.id };
}
async function listProjectFilesFromApi(
page: Parameters<typeof test>[0]['page'],
projectId: string,
): Promise<Array<{ name: string; kind: string }>> {
const response = await page.request.get(`/api/projects/${projectId}/files`);
expect(response.ok()).toBeTruthy();
const { files } = (await response.json()) as { files: Array<{ name: string; kind: string }> };
return files;
}
async function expectArtifactVisible(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
const artifact = entry.mockArtifact!;
await expect(page.getByText(artifact.fileName, { exact: true })).toBeVisible();
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: artifact.heading })).toBeVisible();
}
async function runConversationPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
await expectArtifactVisible(page, entry);
await page.getByTestId('new-conversation').click();
await expect(page.getByText('Start a conversation')).toBeVisible();
const nextPrompt = entry.secondaryPrompt!;
await sendPrompt(page, nextPrompt);
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await page.reload();
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await page.getByTestId('conversation-history-trigger').click();
const historyList = page.getByTestId('conversation-list');
await expect(historyList).toBeVisible();
await expect(historyList.locator('.chat-conv-item')).toHaveCount(2);
await historyList
.locator('.chat-conv-item')
.filter({ hasText: entry.prompt })
.first()
.locator('[data-testid^="conversation-select-"]')
.click();
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
}
async function runFileMentionFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: 'reference.txt',
content: 'Reference content for mention flow.\n',
},
});
expect(resp.ok()).toBeTruthy();
await page.reload();
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByText('reference.txt', { exact: true })).toBeVisible();
await page.getByTestId('chat-composer-input').click();
await page.getByTestId('chat-composer-input').pressSequentially('Review @ref');
await expect(page.getByTestId('mention-popover')).toBeVisible();
await page.getByTestId('mention-popover').getByRole('button', { name: /reference\.txt/i }).click();
await expect(page.getByTestId('chat-composer-input')).toHaveValue('Review @reference.txt ');
await expect(page.getByTestId('staged-attachments')).toBeVisible();
await expect(page.getByTestId('staged-attachments').getByText('reference.txt', { exact: true })).toBeVisible();
await expect(page.getByTestId('chat-send')).toBeEnabled();
}
async function runDeepLinkPreviewFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
await sendPrompt(page, entry.prompt);
await expectArtifactVisible(page, entry);
const fileName = entry.mockArtifact!.fileName;
await expect(page).toHaveURL(new RegExp(`/projects/[^/]+/files/${fileName.replace('.', '\\.')}$`));
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) {
throw new Error(`unexpected project route: ${current.pathname}`);
}
await page.goto(`/projects/${projectId}`);
await expect(page.getByTestId('file-workspace')).toBeVisible();
await page.goto(`/projects/${projectId}/files/${fileName}`);
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: entry.mockArtifact!.heading })).toBeVisible();
}
async function runFileUploadSendFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
const uploadResponse = page.waitForResponse(
(resp) => resp.url().includes('/upload') && resp.request().method() === 'POST',
{ timeout: 5000 },
);
await page.getByTestId('chat-file-input').setInputFiles({
name: 'reference.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Reference content for upload flow.\n', 'utf8'),
});
await expect((await uploadResponse).ok()).toBeTruthy();
await expect(page.getByTestId('staged-attachments')).toBeVisible();
await expect(
page.getByTestId('staged-attachments').getByText('reference.txt', { exact: true }),
).toBeVisible();
await expect(page.getByText('reference.txt', { exact: true })).toBeVisible();
await sendPrompt(page, entry.prompt);
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
await expect(page.locator('.user-attachments').getByText('reference.txt', { exact: true })).toBeVisible();
}
async function runDesignFilesUploadFlow(
page: Parameters<typeof test>[0]['page'],
) {
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'moodboard.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
'base64',
),
});
await expect(page.getByRole('tab', { name: /moodboard\.png/i })).toBeVisible();
await page.getByTestId('design-files-tab').click();
const fileRow = page.locator('[data-testid^="design-file-row-"]', {
hasText: 'moodboard.png',
});
await expect(fileRow).toBeVisible();
await fileRow.click();
const preview = page.getByTestId('design-file-preview');
await expect(preview).toBeVisible();
await expect(preview.getByText(/moodboard\.png/i)).toBeVisible();
await fileRow.dblclick();
await expect(page.getByRole('tab', { name: /moodboard\.png/i })).toBeVisible();
}
async function runDesignFilesDeleteFlow(
page: Parameters<typeof test>[0]['page'],
) {
page.on('dialog', async (dialog) => {
await dialog.accept();
});
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
'base64',
);
// Upload a sibling file first so that, after deleting trash-me.png, there
// is a fallback tab the buggy code would have navigated to. The fix must
// keep the user in the Design Files panel instead.
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'keep-me.png',
mimeType: 'image/png',
buffer: pngBytes,
});
await expect(page.getByRole('tab', { name: /keep-me\.png/i })).toBeVisible();
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'trash-me.png',
mimeType: 'image/png',
buffer: pngBytes,
});
await expect(page.getByRole('tab', { name: /trash-me\.png/i })).toBeVisible();
await page.getByTestId('design-files-tab').click();
const fileRow = page.locator('[data-testid^="design-file-row-"]', {
hasText: 'trash-me.png',
});
await expect(fileRow).toBeVisible();
await fileRow.hover();
await fileRow.locator('[data-testid^="design-file-menu-"]').click();
await expect(page.getByTestId('design-file-menu-popover')).toBeVisible();
await page.locator('[data-testid^="design-file-delete-"]').click();
await expect(fileRow).toHaveCount(0);
await expect(page.getByRole('tab', { name: /trash-me\.png/i })).toHaveCount(0);
// Bug #115: deleting from the Design Files panel must not navigate the
// user into another tab. The Design Files tab should remain the active
// view, and the sibling tab should still exist (just not auto-activated).
await expect(page.getByTestId('design-files-tab')).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByRole('tab', { name: /keep-me\.png/i })).toBeVisible();
}
async function runDesignFilesTabPersistenceFlow(
page: Parameters<typeof test>[0]['page'],
) {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
'base64',
);
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'first-tab.png',
mimeType: 'image/png',
buffer: pngBytes,
});
await expect(page.getByRole('tab', { name: /first-tab\.png/i })).toBeVisible();
await page.getByTestId('design-files-upload-input').setInputFiles({
name: 'second-tab.png',
mimeType: 'image/png',
buffer: pngBytes,
});
const firstTab = page.getByRole('tab', { name: /first-tab\.png/i });
const secondTab = page.getByRole('tab', { name: /second-tab\.png/i });
await expect(firstTab).toBeVisible();
await expect(secondTab).toBeVisible();
await firstTab.click();
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
await expect(secondTab).toHaveAttribute('aria-selected', 'false');
await page.reload();
const restoredFirstTab = page.getByRole('tab', { name: /first-tab\.png/i });
const restoredSecondTab = page.getByRole('tab', { name: /second-tab\.png/i });
await expect(restoredFirstTab).toBeVisible();
await expect(restoredSecondTab).toBeVisible();
await expect(restoredFirstTab).toHaveAttribute('aria-selected', 'true');
await expect(restoredSecondTab).toHaveAttribute('aria-selected', 'false');
}
async function runConversationDeleteRecoveryFlow(
page: Parameters<typeof test>[0]['page'],
entry: UICase,
) {
page.on('dialog', async (dialog) => {
await dialog.accept();
});
await sendPrompt(page, entry.prompt);
await expect(
page.locator('.msg.user .user-text').filter({ hasText: entry.prompt }).first(),
).toBeVisible();
await page.getByTestId('new-conversation').click();
await expect(page.getByText('Start a conversation')).toBeVisible();
const nextPrompt = entry.secondaryPrompt!;
await sendPrompt(page, nextPrompt);
await expect(
page.locator('.msg.user .user-text').filter({ hasText: nextPrompt }).first(),
).toBeVisible();
await page.getByTestId('conversation-history-trigger').click();
await expect(page.getByTestId('conversation-list')).toBeVisible();
const activeRow = page
.getByTestId('conversation-list')
.locator('.chat-conv-item.active')
.first();
await expect(activeRow).toBeVisible();
await activeRow.getByTestId(/conversation-delete-/).click();
await expect(
page.locator('.msg.user .user-text').filter({ hasText: entry.prompt }).first(),
).toBeVisible();
await expect(page.locator('.msg.user .user-text').filter({ hasText: nextPrompt })).toHaveCount(0);
await page.getByTestId('conversation-history-trigger').click();
await expect(page.getByTestId('conversation-list').locator('.chat-conv-item')).toHaveCount(1);
}
+112
View File
@@ -0,0 +1,112 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../apps/web/src/components/AssistantMessage';
import type { AgentEvent, ChatMessage } from '../../apps/web/src/types';
function messageWithEvents(events: AgentEvent[]): ChatMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: '',
events,
startedAt: 1_000,
endedAt: 3_000,
};
}
describe('AssistantMessage unfinished todo state', () => {
afterEach(() => cleanup());
it('keeps Done for a completed latest TodoWrite fixture', () => {
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Ship layout', status: 'completed' }] },
},
])}
streaming={false}
projectId="project-1"
isLast
/>,
);
expect(screen.getByText('Done')).toBeTruthy();
expect(screen.queryByText('Stopped with unfinished work')).toBeNull();
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
});
it('shows unfinished state and passes unfinished todos to the continue callback', () => {
const onContinue = vi.fn();
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{ content: 'Draft layout', status: 'completed' },
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending' },
],
},
},
])}
streaming={false}
projectId="project-1"
isLast
onContinueRemainingTasks={onContinue}
/>,
);
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
expect(screen.getByText('2 task(s) remain')).toBeTruthy();
const remainingList = screen.getByText('2 task(s) remain').closest('.unfinished-todos');
expect(remainingList).not.toBeNull();
expect(within(remainingList as HTMLElement).getByText('Building components')).toBeTruthy();
expect(within(remainingList as HTMLElement).getByText('Run QA')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Continue remaining tasks' }));
expect(onContinue).toHaveBeenCalledWith([
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending', activeForm: undefined },
]);
});
it('hides the continue button on older assistant turns', () => {
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
},
])}
streaming={false}
projectId="project-1"
isLast={false}
onContinueRemainingTasks={vi.fn()}
/>,
);
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
expect(screen.getByText('1 task(s) remain')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
});
});
@@ -0,0 +1,77 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../apps/web/src/components/ChatPane';
import type { ChatMessage } from '../../apps/web/src/types';
function renderChatPane(messages: ChatMessage[]) {
return render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={() => {}}
onStop={() => {}}
conversations={[]}
activeConversationId={null}
onSelectConversation={() => {}}
onDeleteConversation={() => {}}
/>,
);
}
describe('conversation timestamps', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('shows inline relative message times with exact hover text', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
renderChatPane([
{
id: 'user-1',
role: 'user',
content: 'Create a landing page',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
},
{
id: 'assistant-1',
role: 'assistant',
content: 'Done',
createdAt: Date.parse('2025-01-15T12:01:00Z'),
},
]);
const firstTime = screen.getByText('2h ago');
expect(firstTime.tagName).toBe('TIME');
expect(firstTime.getAttribute('title')).toContain('2025');
expect(screen.getByText('1h ago').tagName).toBe('TIME');
});
it('adds day separators when a conversation crosses days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-16T14:00:00Z'));
renderChatPane([
{
id: 'user-1',
role: 'user',
content: 'First request',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
},
{
id: 'user-2',
role: 'user',
content: 'Follow-up',
createdAt: Date.parse('2025-01-16T12:00:00Z'),
},
]);
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../apps/web/src/components/PreviewModal';
// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen
// used to require two presses because the browser exits its native fullscreen
// element on the first press without delivering a keydown to JS, leaving the
// React `fullscreen` state stuck on. The fix listens to fullscreenchange and
// mirrors the native state into React.
const baseProps = {
title: 'Sample',
views: [{ id: 'main', label: 'Main', html: '<p>hi</p>' }],
exportTitleFor: (id: string) => id,
};
function dispatchFullscreenChange() {
act(() => {
document.dispatchEvent(new Event('fullscreenchange'));
});
}
function setNativeFullscreenElement(el: Element | null) {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => el,
});
}
describe('PreviewModal fullscreen exit', () => {
afterEach(() => {
cleanup();
setNativeFullscreenElement(null);
});
it('drops the fullscreen overlay when the browser exits native fullscreen', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
// Click the Fullscreen button. jsdom does not implement requestFullscreen
// on plain elements, so PreviewModal's fallback path runs and just sets
// the React state — exactly matching what happens after a successful
// browser fullscreen request.
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
expect(fsButton).toBeTruthy();
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// Simulate the user pressing Esc in browser fullscreen: the browser
// exits its native fullscreen element and fires fullscreenchange, but
// (in browsers like Firefox) does not deliver the keydown to JS.
setNativeFullscreenElement(null);
dispatchFullscreenChange();
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
it('keeps the modal mounted on Esc while fullscreen, and closes only on a second Esc', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// First Esc — drops fullscreen, must not close the modal.
fireEvent.keyDown(document, { key: 'Escape' });
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
// Second Esc — closes the modal.
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('ignores fullscreenchange when another element is still fullscreen', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// Some other element is the active fullscreen target — our overlay must
// not collapse to non-fullscreen on transitions that leave a different
// element fullscreen.
const other = document.createElement('div');
document.body.appendChild(other);
setNativeFullscreenElement(other);
dispatchFullscreenChange();
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
document.body.removeChild(other);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { createClaudeStreamHandler } from '../../apps/daemon/src/claude-stream.js';
import { createCopilotStreamHandler } from '../../apps/daemon/src/copilot-stream.js';
import { mapPiRpcEvent } from '../../apps/daemon/src/pi-rpc.js';
describe('structured agent stream fixtures', () => {
it('emits TodoWrite tool_use from Claude Code stream JSON', () => {
const events: unknown[] = [];
const handler = createClaudeStreamHandler((event: unknown) => events.push(event));
handler.feed(`${JSON.stringify({
type: 'assistant',
message: {
id: 'msg-1',
content: [
{
type: 'tool_use',
id: 'toolu-1',
name: 'TodoWrite',
input: {
todos: [{ content: 'Run QA', status: 'pending' }],
},
},
],
},
})}\n`);
handler.flush();
expect(events).toContainEqual({
type: 'tool_use',
id: 'toolu-1',
name: 'TodoWrite',
input: {
todos: [{ content: 'Run QA', status: 'pending' }],
},
});
});
it('emits TodoWrite tool_use from Pi RPC tool_execution events', () => {
const events: unknown[] = [];
const send = (_channel: string, payload: unknown) => { events.push(payload); };
const ctx = { runStartedAt: Date.now(), sentFirstToken: { value: false } };
mapPiRpcEvent(
{ type: 'tool_execution_start', toolCallId: 'pi-call-1', toolName: 'TodoWrite', args: { todos: [{ content: 'Run QA', status: 'pending' }] } },
send,
ctx,
);
mapPiRpcEvent(
{ type: 'tool_execution_end', toolCallId: 'pi-call-1', toolName: 'TodoWrite', result: { content: [{ type: 'text', text: 'written' }] }, isError: false },
send,
ctx,
);
expect(events).toContainEqual({
type: 'tool_use',
id: 'pi-call-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
});
expect(events).toContainEqual({
type: 'tool_result',
toolUseId: 'pi-call-1',
content: 'written',
isError: false,
});
});
it('emits TodoWrite tool_use from GitHub Copilot CLI JSON stream', () => {
const events: unknown[] = [];
const handler = createCopilotStreamHandler((event: unknown) => events.push(event));
handler.feed(`${JSON.stringify({
type: 'tool.execution_start',
data: {
toolCallId: 'call-1',
toolName: 'TodoWrite',
arguments: {
todos: [{ content: 'Run QA', status: 'pending' }],
},
},
})}\n`);
handler.flush();
expect(events).toContainEqual({
type: 'tool_use',
id: 'call-1',
name: 'TodoWrite',
input: {
todos: [{ content: 'Run QA', status: 'pending' }],
},
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import {
latestTodosFromEvents,
parseTodoWriteInput,
unfinishedTodosFromEvents,
} from '../../apps/web/src/runtime/todos';
import type { AgentEvent } from '../../apps/web/src/types';
const firstTodoInput = {
todos: [
{ content: 'Draft layout', status: 'completed' },
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
{ content: 'Run QA', status: 'pending' },
{ content: '', status: 'pending' },
{ content: 'Unknown status defaults pending', status: 'blocked' },
null,
],
};
describe('todo event helpers', () => {
it('normalizes TodoWrite input and ignores malformed items', () => {
expect(parseTodoWriteInput(firstTodoInput)).toEqual([
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending', activeForm: undefined },
{
content: 'Unknown status defaults pending',
status: 'pending',
activeForm: undefined,
},
]);
});
it('uses the latest TodoWrite event as the current todo truth', () => {
const events: AgentEvent[] = [
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
{ kind: 'text', text: 'Working...' },
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
{
kind: 'tool_use',
id: 'todo-2',
name: 'TodoWrite',
input: { todos: [{ content: 'Final polish', status: 'pending' }] },
},
];
expect(latestTodosFromEvents(events)).toEqual([
{ content: 'Final polish', status: 'pending', activeForm: undefined },
]);
});
it('treats an empty latest TodoWrite event as authoritative', () => {
const events: AgentEvent[] = [
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
{ kind: 'text', text: 'All done.' },
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
];
expect(latestTodosFromEvents(events)).toEqual([]);
expect(unfinishedTodosFromEvents(events)).toEqual([]);
});
it('returns only pending and in-progress todos as unfinished', () => {
expect(unfinishedTodosFromEvents([
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
])).toEqual([
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending', activeForm: undefined },
{
content: 'Unknown status defaults pending',
status: 'pending',
activeForm: undefined,
},
]);
});
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node", "vitest"]
},
"include": [
"playwright.config.ts",
"vitest.config.ts",
"cases/report-metadata.ts",
"reporters/**/*.ts",
"scripts/**/*.ts"
],
"exclude": ["node_modules", "reports", ".od-data"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
jsx: 'automatic',
jsxImportSource: 'react',
},
test: {
environment: 'jsdom',
include: ['tests/**/*.test.{ts,tsx}'],
},
});