mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add UI e2e automation suite and reporting (#64)
* test: add e2e ui automation suite * fix review feedback for ui e2e suite Resolved the FileWorkspace.tsx merge-marker issue and kept the intended combination of multiple, accept="image/*", and data-testid. Updated the e2e port handling so the test config no longer relies on a single hardcoded app port. It now resolves an available port first and passes the same port selection through the dev server and Playwright base URL. Since main has moved to the Next.js dev stack, this was also adapted from the old Vite-based flow to NEXT_PORT. Kept test:ui serialized so cleanup completes before Playwright starts. Updated reset-e2e-artifacts.mjs so cleanup failures are surfaced with a warning instead of being silently swallowed, except for the expected ENOENT case.
This commit is contained in:
parent
88b6ea30b3
commit
751c9de56d
27 changed files with 2354 additions and 40 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -10,6 +10,17 @@ out/
|
||||||
# Holds app.sqlite (project metadata), projects/<id>/ (per-project artifacts,
|
# Holds app.sqlite (project metadata), projects/<id>/ (per-project artifacts,
|
||||||
# the agent's CWD), and artifacts/ (one-off renders). Never commit.
|
# the agent's CWD), and artifacts/ (one-off renders). Never commit.
|
||||||
.od
|
.od
|
||||||
|
.od-e2e
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
e2e/.od-data
|
||||||
|
e2e/playwright-report
|
||||||
|
e2e/reports/html
|
||||||
|
e2e/reports/playwright-html-report
|
||||||
|
e2e/reports/test-results
|
||||||
|
e2e/reports/results.json
|
||||||
|
e2e/reports/junit.xml
|
||||||
|
e2e/reports/latest.md
|
||||||
|
|
||||||
# Legacy folder name from before the rename; keep ignored so existing
|
# Legacy folder name from before the rename; keep ignored so existing
|
||||||
# clones don't accidentally stage stale runtime data.
|
# clones don't accidentally stage stale runtime data.
|
||||||
|
|
|
||||||
115
e2e/cases/README.zh-CN.md
Normal file
115
e2e/cases/README.zh-CN.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# 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`:当前是否会被 `npm 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
|
||||||
|
npm run 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
|
||||||
|
npm run test:ui:headed
|
||||||
|
```
|
||||||
380
e2e/cases/index.ts
Normal file
380
e2e/cases/index.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
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: '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
e2e/cases/modules/conversations.md
Normal file
67
e2e/cases/modules/conversations.md
Normal 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
e2e/cases/modules/files.md
Normal file
121
e2e/cases/modules/files.md
Normal 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 清理策略
|
||||||
88
e2e/cases/modules/project-and-generation.md
Normal file
88
e2e/cases/modules/project-and-generation.md
Normal file
|
|
@ -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 项目创建
|
||||||
|
- 创建项目后的刷新恢复
|
||||||
|
- 创建失败或必填校验
|
||||||
127
e2e/cases/report-metadata.cjs
Normal file
127
e2e/cases/report-metadata.cjs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
module.exports = {
|
||||||
|
'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 仍然保持选中',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
44
e2e/cases/types.ts
Normal file
44
e2e/cases/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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'
|
||||||
|
| '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[];
|
||||||
|
}
|
||||||
58
e2e/playwright.config.ts
Normal file
58
e2e/playwright.config.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { resolveDevPorts } from '../scripts/resolve-dev-ports.mjs';
|
||||||
|
|
||||||
|
const desiredDaemonPort = Number(process.env.OD_PORT) || 17_456;
|
||||||
|
const desiredNextPort = Number(process.env.NEXT_PORT) || 17_573;
|
||||||
|
const { daemonPort, appPort: nextPort } = await resolveDevPorts({
|
||||||
|
daemonStart: desiredDaemonPort,
|
||||||
|
appStart: desiredNextPort,
|
||||||
|
appLabel: 'next',
|
||||||
|
searchRange: 200,
|
||||||
|
});
|
||||||
|
const baseURL = `http://127.0.0.1:${nextPort}`;
|
||||||
|
|
||||||
|
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.cjs', { outputFile: 'e2e/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.cjs', { outputFile: 'e2e/reports/latest.md' }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command:
|
||||||
|
`OD_DATA_DIR=e2e/.od-data ` +
|
||||||
|
`OD_PORT=${daemonPort} OD_PORT_STRICT=1 ` +
|
||||||
|
`NEXT_PORT=${nextPort} NEXT_PORT_STRICT=1 npm run dev:all`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
244
e2e/reporters/markdown-reporter.cjs
Normal file
244
e2e/reporters/markdown-reporter.cjs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const caseMetadata = require('../cases/report-metadata.cjs');
|
||||||
|
|
||||||
|
class MarkdownReporter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.rootSuite = null;
|
||||||
|
this.startedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBegin(_config, suite) {
|
||||||
|
this.rootSuite = suite;
|
||||||
|
this.startedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onEnd() {
|
||||||
|
if (!this.rootSuite) return;
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
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, rows) {
|
||||||
|
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;
|
||||||
|
const parsed = parseCaseTitle(test.title);
|
||||||
|
rows.push({
|
||||||
|
caseId: parsed.caseId,
|
||||||
|
title: parsed.title,
|
||||||
|
module: caseMetadata[parsed.caseId]?.module || '未分组',
|
||||||
|
assertions: caseMetadata[parsed.caseId]?.assertions || [],
|
||||||
|
status: normalizeStatus(finalResult.status, test.outcome && 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) : null,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.path),
|
||||||
|
error: compactError(finalResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaseTitle(title) {
|
||||||
|
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, outcome) {
|
||||||
|
if (outcome === 'flaky') return 'flaky';
|
||||||
|
return status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactError(error) {
|
||||||
|
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) {
|
||||||
|
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 }) {
|
||||||
|
const lines = [];
|
||||||
|
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) {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
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) {
|
||||||
|
if (!filePath) return '';
|
||||||
|
return path.relative(process.cwd(), filePath) || filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCell(value) {
|
||||||
|
return String(value).replace(/\|/g, '\\|');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MarkdownReporter;
|
||||||
49
e2e/reports/README.zh-CN.md
Normal file
49
e2e/reports/README.zh-CN.md
Normal 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 等原始附件
|
||||||
|
|
||||||
|
每次执行 `npm run 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
e2e/reports/ui-test-report.html
Normal file
52
e2e/reports/ui-test-report.html
Normal 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>
|
||||||
759
e2e/specs/app.spec.ts
Normal file
759
e2e/specs/app.spec.ts
Normal file
|
|
@ -0,0 +1,759 @@
|
||||||
|
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/chat', 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/chat', 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/chat', 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}',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
].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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
|
||||||
|
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 });
|
||||||
|
await sendButton.evaluate((button: HTMLButtonElement) => button.click());
|
||||||
|
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 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,
|
||||||
|
) {
|
||||||
|
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(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.getByTestId('design-file-row-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', { exact: true })).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();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId('design-files-upload-input').setInputFiles({
|
||||||
|
name: 'trash-me.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: /trash-me\.png/i })).toBeVisible();
|
||||||
|
await page.getByTestId('design-files-tab').click();
|
||||||
|
|
||||||
|
const fileRow = page.getByTestId('design-file-row-trash-me.png');
|
||||||
|
await expect(fileRow).toBeVisible();
|
||||||
|
await fileRow.hover();
|
||||||
|
await page.getByTestId('design-file-menu-trash-me.png').click();
|
||||||
|
await expect(page.getByTestId('design-file-menu-popover')).toBeVisible();
|
||||||
|
await page.getByTestId('design-file-delete-trash-me.png').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('design-file-row-trash-me.png')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('tab', { name: /trash-me\.png/i })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
"preview": "pnpm build && node daemon/cli.js --no-open",
|
"preview": "pnpm build && node daemon/cli.js --no-open",
|
||||||
"test:e2e:live": "node --test scripts/runtime-adapter.e2e.live.test.mjs",
|
"test:e2e:live": "node --test scripts/runtime-adapter.e2e.live.test.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:ui:clean": "node scripts/reset-e2e-artifacts.mjs",
|
||||||
|
"test:ui": "npm run test:ui:clean && playwright test -c e2e/playwright.config.ts",
|
||||||
|
"test:ui:headed": "npm run test:ui:clean && playwright test -c e2e/playwright.config.ts --headed",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"start": "npm run build && node daemon/cli.js",
|
"start": "npm run build && node daemon/cli.js",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
|
|
@ -32,6 +35,7 @@
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ importers:
|
||||||
version: 1.4.5-lts.2
|
version: 1.4.5-lts.2
|
||||||
next:
|
next:
|
||||||
specifier: ^16.2.4
|
specifier: ^16.2.4
|
||||||
version: 16.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -33,6 +33,9 @@ importers:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.59.1
|
||||||
|
version: 1.59.1
|
||||||
'@testing-library/jest-dom':
|
'@testing-library/jest-dom':
|
||||||
specifier: ^6.9.1
|
specifier: ^6.9.1
|
||||||
version: 6.9.1
|
version: 6.9.1
|
||||||
|
|
@ -316,89 +319,105 @@ packages:
|
||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
|
@ -446,24 +465,28 @@ packages:
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.4':
|
'@next/swc-linux-arm64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.4':
|
'@next/swc-linux-x64-gnu@16.2.4':
|
||||||
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.4':
|
'@next/swc-linux-x64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.4':
|
'@next/swc-win32-arm64-msvc@16.2.4':
|
||||||
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
||||||
|
|
@ -477,6 +500,11 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||||
resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
|
resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -511,66 +539,79 @@ packages:
|
||||||
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
|
||||||
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.60.2':
|
'@rollup/rollup-linux-arm64-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.60.2':
|
'@rollup/rollup-linux-arm64-musl@4.60.2':
|
||||||
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.60.2':
|
'@rollup/rollup-linux-loong64-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.60.2':
|
'@rollup/rollup-linux-loong64-musl@4.60.2':
|
||||||
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
|
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.60.2':
|
'@rollup/rollup-linux-ppc64-musl@4.60.2':
|
||||||
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.60.2':
|
'@rollup/rollup-linux-riscv64-musl@4.60.2':
|
||||||
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.60.2':
|
'@rollup/rollup-linux-s390x-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.60.2':
|
'@rollup/rollup-linux-x64-gnu@4.60.2':
|
||||||
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.60.2':
|
'@rollup/rollup-linux-x64-musl@4.60.2':
|
||||||
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.60.2':
|
'@rollup/rollup-openbsd-x64@4.60.2':
|
||||||
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
||||||
|
|
@ -1016,6 +1057,11 @@ packages:
|
||||||
fs-constants@1.0.0:
|
fs-constants@1.0.0:
|
||||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -1289,6 +1335,16 @@ packages:
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
playwright-core@1.59.1:
|
||||||
|
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
@ -2003,6 +2059,10 @@ snapshots:
|
||||||
'@next/swc-win32-x64-msvc@16.2.4':
|
'@next/swc-win32-x64-msvc@16.2.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.59.1
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2541,6 +2601,9 @@ snapshots:
|
||||||
|
|
||||||
fs-constants@1.0.0: {}
|
fs-constants@1.0.0: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2725,7 +2788,7 @@ snapshots:
|
||||||
|
|
||||||
negotiator@0.6.3: {}
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
next@16.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
next@16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.4
|
'@next/env': 16.2.4
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
|
|
@ -2744,6 +2807,7 @@ snapshots:
|
||||||
'@next/swc-linux-x64-musl': 16.2.4
|
'@next/swc-linux-x64-musl': 16.2.4
|
||||||
'@next/swc-win32-arm64-msvc': 16.2.4
|
'@next/swc-win32-arm64-msvc': 16.2.4
|
||||||
'@next/swc-win32-x64-msvc': 16.2.4
|
'@next/swc-win32-x64-msvc': 16.2.4
|
||||||
|
'@playwright/test': 1.59.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|
@ -2787,6 +2851,14 @@ snapshots:
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
playwright-core@1.59.1: {}
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.59.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
|
|
|
||||||
|
|
@ -17,36 +17,19 @@
|
||||||
// the switch so the user notices.
|
// the switch so the user notices.
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import net from 'node:net';
|
import { findFreePort } from './resolve-dev-ports.mjs';
|
||||||
|
|
||||||
const HOST = '127.0.0.1';
|
|
||||||
const PORT_SEARCH_RANGE = 50;
|
|
||||||
|
|
||||||
function isPortFree(port, host = HOST) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.once('error', () => resolve(false));
|
|
||||||
server.listen({ port, host, exclusive: true }, () => {
|
|
||||||
server.close(() => resolve(true));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFreePort(start, label) {
|
|
||||||
for (let port = start; port < start + PORT_SEARCH_RANGE; port++) {
|
|
||||||
if (await isPortFree(port)) return port;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`[dev:all] could not find a free ${label} port near ${start} (tried ${PORT_SEARCH_RANGE})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredDaemon = Number(process.env.OD_PORT) || 7456;
|
const desiredDaemon = Number(process.env.OD_PORT) || 7456;
|
||||||
const desiredNext = Number(process.env.NEXT_PORT) || 3000;
|
const desiredNext = Number(process.env.NEXT_PORT) || 3000;
|
||||||
|
const strictDaemonPort = process.env.OD_PORT_STRICT === '1';
|
||||||
|
const strictNextPort = process.env.NEXT_PORT_STRICT === '1';
|
||||||
|
|
||||||
const daemonPort = await findFreePort(desiredDaemon, 'daemon');
|
const daemonPort = strictDaemonPort
|
||||||
const nextPort = await findFreePort(desiredNext, 'next');
|
? desiredDaemon
|
||||||
|
: await findFreePort(desiredDaemon, 'daemon');
|
||||||
|
const nextPort = strictNextPort
|
||||||
|
? desiredNext
|
||||||
|
: await findFreePort(desiredNext, 'next');
|
||||||
|
|
||||||
if (daemonPort !== desiredDaemon) {
|
if (daemonPort !== desiredDaemon) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
55
scripts/reset-e2e-artifacts.mjs
Normal file
55
scripts/reset-e2e-artifacts.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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 rootDir = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
path.join(rootDir, 'e2e', '.od-data'),
|
||||||
|
path.join(rootDir, 'e2e', 'test-results'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'test-results'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'html'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'playwright-html-report'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'results.json'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'junit.xml'),
|
||||||
|
path.join(rootDir, 'e2e', 'reports', 'latest.md'),
|
||||||
|
path.join(rootDir, 'e2e', '.DS_Store'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
await rm(target, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.join(rootDir, 'e2e', 'reports'), { recursive: true });
|
||||||
|
|
||||||
|
// Recreate runtime roots so local inspection stays predictable even before
|
||||||
|
// Playwright or the daemon materializes them.
|
||||||
|
await mkdir(path.join(rootDir, 'e2e', '.od-data'), { recursive: true });
|
||||||
|
await mkdir(path.join(rootDir, 'e2e', '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(rootDir, 'e2e', '.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) {
|
||||||
|
// It's fine if the daemon hasn't created the projects root yet.
|
||||||
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
// Missing roots are expected before the first daemon boot.
|
||||||
|
} else {
|
||||||
|
console.warn('Failed to clean stale e2e project dirs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
scripts/resolve-dev-ports.mjs
Normal file
47
scripts/resolve-dev-ports.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const HOST = '127.0.0.1';
|
||||||
|
const DEFAULT_PORT_SEARCH_RANGE = 50;
|
||||||
|
|
||||||
|
export function isPortFree(port, host = HOST) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once('error', () => resolve(false));
|
||||||
|
server.listen({ port, host, exclusive: true }, () => {
|
||||||
|
server.close(() => resolve(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFreePort(
|
||||||
|
start,
|
||||||
|
label,
|
||||||
|
{ host = HOST, searchRange = DEFAULT_PORT_SEARCH_RANGE } = {},
|
||||||
|
) {
|
||||||
|
for (let port = start; port < start + searchRange; port++) {
|
||||||
|
if (await isPortFree(port, host)) return port;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`[dev:all] could not find a free ${label} port near ${start} (tried ${searchRange})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDevPorts({
|
||||||
|
daemonStart = 7456,
|
||||||
|
appStart = 5173,
|
||||||
|
appLabel = 'app',
|
||||||
|
host = HOST,
|
||||||
|
searchRange = DEFAULT_PORT_SEARCH_RANGE,
|
||||||
|
} = {}) {
|
||||||
|
const daemonPort = await findFreePort(daemonStart, 'daemon', {
|
||||||
|
host,
|
||||||
|
searchRange,
|
||||||
|
});
|
||||||
|
const appPort = await findFreePort(appStart, appLabel, {
|
||||||
|
host,
|
||||||
|
searchRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { daemonPort, appPort };
|
||||||
|
}
|
||||||
|
|
@ -263,6 +263,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`composer${dragActive ? " drag-active" : ""}`}
|
className={`composer${dragActive ? " drag-active" : ""}`}
|
||||||
|
data-testid="chat-composer"
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragActive(true);
|
setDragActive(true);
|
||||||
|
|
@ -282,6 +283,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
<div className="composer-input-wrap">
|
<div className="composer-input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
data-testid="chat-composer-input"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={t('chat.composerPlaceholder')}
|
placeholder={t('chat.composerPlaceholder')}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -304,6 +306,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
<div className="composer-row">
|
<div className="composer-row">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
data-testid="chat-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
|
|
@ -324,6 +327,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
|
data-testid="chat-attach"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title={t('chat.attachTitle')}
|
title={t('chat.attachTitle')}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
|
|
@ -383,6 +387,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="composer-send"
|
className="composer-send"
|
||||||
|
data-testid="chat-send"
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim()}
|
||||||
>
|
>
|
||||||
|
|
@ -411,7 +416,7 @@ function StagedAttachments({
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="staged-row">
|
<div className="staged-row" data-testid="staged-attachments">
|
||||||
{attachments.map((a) => (
|
{attachments.map((a) => (
|
||||||
<div key={a.path} className={`staged-chip staged-${a.kind}`}>
|
<div key={a.path} className={`staged-chip staged-${a.kind}`}>
|
||||||
{a.kind === "image" && projectId ? (
|
{a.kind === "image" && projectId ? (
|
||||||
|
|
@ -478,7 +483,7 @@ function MentionPopover({
|
||||||
if (ref.current) ref.current.scrollTop = 0;
|
if (ref.current) ref.current.scrollTop = 0;
|
||||||
}, [files]);
|
}, [files]);
|
||||||
return (
|
return (
|
||||||
<div className="mention-popover" ref={ref}>
|
<div className="mention-popover" data-testid="mention-popover" ref={ref}>
|
||||||
{files.map((f) => {
|
{files.map((f) => {
|
||||||
const key = f.path ?? f.name;
|
const key = f.path ?? f.name;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ export function ChatPane({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-only"
|
className="icon-only"
|
||||||
|
data-testid="conversation-history-trigger"
|
||||||
title={
|
title={
|
||||||
activeConversation?.title
|
activeConversation?.title
|
||||||
? `${t('chat.conversationsTitle')} · ${activeConversation.title}`
|
? `${t('chat.conversationsTitle')} · ${activeConversation.title}`
|
||||||
|
|
@ -227,7 +228,7 @@ export function ChatPane({
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
{showConvList ? (
|
{showConvList ? (
|
||||||
<div className="chat-history-menu" role="menu">
|
<div className="chat-history-menu" role="menu" data-testid="conversation-history-menu">
|
||||||
<div className="chat-history-menu-head">
|
<div className="chat-history-menu-head">
|
||||||
<span className="chat-history-menu-title">
|
<span className="chat-history-menu-title">
|
||||||
{t('chat.conversationsHeading')}
|
{t('chat.conversationsHeading')}
|
||||||
|
|
@ -236,6 +237,7 @@ export function ChatPane({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="chat-history-new"
|
className="chat-history-new"
|
||||||
|
data-testid="conversation-history-new"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onNewConversation();
|
onNewConversation();
|
||||||
setShowConvList(false);
|
setShowConvList(false);
|
||||||
|
|
@ -246,7 +248,7 @@ export function ChatPane({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-history-list">
|
<div className="chat-history-list" data-testid="conversation-list">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<div className="chat-history-empty">
|
<div className="chat-history-empty">
|
||||||
{t('chat.emptyConversations')}
|
{t('chat.emptyConversations')}
|
||||||
|
|
@ -274,6 +276,7 @@ export function ChatPane({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-only"
|
className="icon-only"
|
||||||
|
data-testid="new-conversation"
|
||||||
title={t('chat.newConversationsTitle')}
|
title={t('chat.newConversationsTitle')}
|
||||||
aria-label={t('chat.newConversation')}
|
aria-label={t('chat.newConversation')}
|
||||||
onClick={onNewConversation}
|
onClick={onNewConversation}
|
||||||
|
|
@ -411,7 +414,10 @@ function ConversationRow({
|
||||||
const displayTitle =
|
const displayTitle =
|
||||||
conversation.title || t('chat.untitledConversation');
|
conversation.title || t('chat.untitledConversation');
|
||||||
return (
|
return (
|
||||||
<div className={`chat-conv-item${active ? ' active' : ''}`}>
|
<div
|
||||||
|
className={`chat-conv-item${active ? ' active' : ''}`}
|
||||||
|
data-testid={`conversation-item-${conversation.id}`}
|
||||||
|
>
|
||||||
{editing && onRename ? (
|
{editing && onRename ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -436,6 +442,7 @@ function ConversationRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="chat-conv-item-name"
|
className="chat-conv-item-name"
|
||||||
|
data-testid={`conversation-select-${conversation.id}`}
|
||||||
style={{ background: 'transparent', border: 'none', padding: 0, textAlign: 'left' }}
|
style={{ background: 'transparent', border: 'none', padding: 0, textAlign: 'left' }}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
|
|
@ -451,6 +458,7 @@ function ConversationRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="chat-conv-item-del"
|
className="chat-conv-item-del"
|
||||||
|
data-testid={`conversation-delete-${conversation.id}`}
|
||||||
title={t('chat.deleteConversation')}
|
title={t('chat.deleteConversation')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,12 @@ export function DesignFilesPanel({
|
||||||
<Icon name="copy" size={13} />
|
<Icon name="copy" size={13} />
|
||||||
<span>{t('designFiles.paste.label')}</span>
|
<span>{t('designFiles.paste.label')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onUpload} title={t('designFiles.upload.title')}>
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="design-files-upload-trigger"
|
||||||
|
onClick={onUpload}
|
||||||
|
title={t('designFiles.upload.title')}
|
||||||
|
>
|
||||||
<Icon name="upload" size={13} />
|
<Icon name="upload" size={13} />
|
||||||
<span>{t('designFiles.upload.label')}</span>
|
<span>{t('designFiles.upload.label')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -163,6 +168,7 @@ export function DesignFilesPanel({
|
||||||
<button
|
<button
|
||||||
key={f.name}
|
key={f.name}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`design-file-row-${f.name}`}
|
||||||
className={`df-row ${active ? 'active' : ''}`}
|
className={`df-row ${active ? 'active' : ''}`}
|
||||||
onMouseEnter={() => setHover(f.name)}
|
onMouseEnter={() => setHover(f.name)}
|
||||||
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
|
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
|
||||||
|
|
@ -178,6 +184,7 @@ export function DesignFilesPanel({
|
||||||
</span>
|
</span>
|
||||||
<span className="df-row-time">{relativeTime(f.mtime, t)}</span>
|
<span className="df-row-time">{relativeTime(f.mtime, t)}</span>
|
||||||
<span
|
<span
|
||||||
|
data-testid={`design-file-menu-${f.name}`}
|
||||||
className="df-row-menu"
|
className="df-row-menu"
|
||||||
style={isHovered || active ? { opacity: 1 } : undefined}
|
style={isHovered || active ? { opacity: 1 } : undefined}
|
||||||
role="button"
|
role="button"
|
||||||
|
|
@ -239,6 +246,7 @@ export function DesignFilesPanel({
|
||||||
) : null}
|
) : null}
|
||||||
{menuPos ? (
|
{menuPos ? (
|
||||||
<div
|
<div
|
||||||
|
data-testid="design-file-menu-popover"
|
||||||
className="df-row-popover"
|
className="df-row-popover"
|
||||||
style={{ top: menuPos.top, left: menuPos.left }}
|
style={{ top: menuPos.top, left: menuPos.left }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
|
@ -265,6 +273,7 @@ export function DesignFilesPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger"
|
className="danger"
|
||||||
|
data-testid={`design-file-delete-${menuPos.name}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const name = menuPos.name;
|
const name = menuPos.name;
|
||||||
setMenuPos(null);
|
setMenuPos(null);
|
||||||
|
|
@ -315,7 +324,7 @@ function DfPreview({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="df-preview-meta">
|
<div className="df-preview-meta" data-testid="design-file-preview">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost"
|
className="ghost"
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@ function TopTabButton({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
|
data-testid={`entry-tab-${value}`}
|
||||||
aria-selected={current === value}
|
aria-selected={current === value}
|
||||||
className={`entry-tab ${current === value ? 'active' : ''}`}
|
className={`entry-tab ${current === value ? 'active' : ''}`}
|
||||||
onClick={() => onClick(value)}
|
onClick={() => onClick(value)}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@ function ExampleCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="example-card"
|
className="example-card"
|
||||||
|
data-testid={`example-card-${skill.id}`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
onLoad();
|
onLoad();
|
||||||
|
|
@ -355,7 +356,11 @@ function ExampleCard({
|
||||||
: skill.description.replace(/\s+/g, ' ').slice(0, 240)}
|
: skill.description.replace(/\s+/g, ' ').slice(0, 240)}
|
||||||
</div>
|
</div>
|
||||||
<div className="example-card-actions">
|
<div className="example-card-actions">
|
||||||
<button className="primary example-cta" onClick={onUsePrompt}>
|
<button
|
||||||
|
className="primary example-cta"
|
||||||
|
data-testid={`example-use-prompt-${skill.id}`}
|
||||||
|
onClick={onUsePrompt}
|
||||||
|
>
|
||||||
{t('examples.usePrompt')}
|
{t('examples.usePrompt')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -704,6 +704,7 @@ function HtmlViewer({
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
|
data-testid="artifact-preview-frame"
|
||||||
title={file.name}
|
title={file.name}
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
srcDoc={srcDoc}
|
srcDoc={srcDoc}
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ export function FileWorkspace({
|
||||||
const activeSketch = activeFile && isActiveSketch ? sketches[activeFile.name] : null;
|
const activeSketch = activeFile && isActiveSketch ? sketches[activeFile.name] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace">
|
<div className="workspace" data-testid="file-workspace">
|
||||||
<div className="ws-tabs-bar" role="tablist" aria-label={t('workspace.designFiles')}>
|
<div className="ws-tabs-bar" role="tablist" aria-label={t('workspace.designFiles')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -327,6 +327,7 @@ export function FileWorkspace({
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === DESIGN_FILES_TAB}
|
aria-selected={activeTab === DESIGN_FILES_TAB}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
data-testid="design-files-tab"
|
||||||
onClick={() => setActiveTab(DESIGN_FILES_TAB)}
|
onClick={() => setActiveTab(DESIGN_FILES_TAB)}
|
||||||
title={t('workspace.designFiles')}
|
title={t('workspace.designFiles')}
|
||||||
>
|
>
|
||||||
|
|
@ -413,6 +414,8 @@ export function FileWorkspace({
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
|
data-testid="design-files-upload-input"
|
||||||
|
accept="image/*"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -140,12 +140,13 @@ export function NewProjectPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="newproj">
|
<div className="newproj" data-testid="new-project-panel">
|
||||||
<div className="newproj-tabs" role="tablist">
|
<div className="newproj-tabs" role="tablist">
|
||||||
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
|
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
|
||||||
<button
|
<button
|
||||||
key={entry}
|
key={entry}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
data-testid={`new-project-tab-${entry}`}
|
||||||
aria-selected={tab === entry}
|
aria-selected={tab === entry}
|
||||||
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
|
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
|
||||||
onClick={() => setTab(entry)}
|
onClick={() => setTab(entry)}
|
||||||
|
|
@ -159,6 +160,7 @@ export function NewProjectPanel({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="newproj-name"
|
className="newproj-name"
|
||||||
|
data-testid="new-project-name"
|
||||||
placeholder={t('newproj.namePlaceholder')}
|
placeholder={t('newproj.namePlaceholder')}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
|
@ -205,6 +207,7 @@ export function NewProjectPanel({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="primary newproj-create"
|
className="primary newproj-create"
|
||||||
|
data-testid="create-project"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={!canCreate}
|
disabled={!canCreate}
|
||||||
title={
|
title={
|
||||||
|
|
@ -574,10 +577,11 @@ function DesignSystemPicker({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="newproj-section ds-picker" ref={wrapRef}>
|
<div className="newproj-section ds-picker" data-testid="design-system-picker" ref={wrapRef}>
|
||||||
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="design-system-trigger"
|
||||||
className={`ds-picker-trigger${open ? ' open' : ''}${primary ? '' : ' empty'}`}
|
className={`ds-picker-trigger${open ? ' open' : ''}${primary ? '' : ' empty'}`}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
|
|
@ -611,6 +615,7 @@ function DesignSystemPicker({
|
||||||
<div className="ds-picker-head">
|
<div className="ds-picker-head">
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
|
data-testid="design-system-search"
|
||||||
className="ds-picker-search"
|
className="ds-picker-search"
|
||||||
placeholder={t('newproj.dsSearch')}
|
placeholder={t('newproj.dsSearch')}
|
||||||
value={query}
|
value={query}
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,7 @@ export function ProjectView({
|
||||||
<div className="topbar-title">
|
<div className="topbar-title">
|
||||||
<span
|
<span
|
||||||
className="title editable"
|
className="title editable"
|
||||||
|
data-testid="project-title"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
|
|
@ -779,7 +780,7 @@ export function ProjectView({
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="meta">{projectMeta}</span>
|
<span className="meta" data-testid="project-meta">{projectMeta}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue