From e01efc78f60c85d4f3efd15b65f752d8ff74d837 Mon Sep 17 00:00:00 2001 From: Amy <58060647+AmyShang-alt@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:21:48 +0800 Subject: [PATCH] test(e2e): align UI P0 coverage with the current product (#6405) * test(e2e): preserve useful UI P0 hardening * test(e2e): remove retired Automations coverage * test(e2e): retain Automations smoke coverage Generated-By: looper 0.11.8 (runner=fixer, agent=codex) --- .github/workflows/ui-extended-main.yml | 4 + .../automations-coverage-summary.zh-CN.md | 317 --- .../automations-page-test-plan.zh-CN.md | 149 -- .../launch-review-e2e-regressions.zh-CN.md | 31 +- e2e/lib/playwright/amr.ts | 5 +- e2e/lib/playwright/suites.ts | 17 +- e2e/lib/playwright/visual.ts | 16 - e2e/lib/vitest/routines.ts | 66 - e2e/tests/packaged-smoke-workflow.test.ts | 25 +- .../tools-dev/automations-routines.test.ts | 195 +- e2e/ui/amr-logout-requires-relogin.test.ts | 10 + e2e/ui/amr-onboarding.test.ts | 1 - e2e/ui/automations-page.test.ts | 2132 +---------------- e2e/ui/settings-memory-routines.test.ts | 177 +- e2e/ui/visual-navigation.test.ts | 16 - 15 files changed, 134 insertions(+), 3027 deletions(-) delete mode 100644 docs/testing/automations-coverage-summary.zh-CN.md delete mode 100644 docs/testing/automations-page-test-plan.zh-CN.md delete mode 100644 e2e/lib/vitest/routines.ts diff --git a/.github/workflows/ui-extended-main.yml b/.github/workflows/ui-extended-main.yml index 8b0455204c..92195d37ac 100644 --- a/.github/workflows/ui-extended-main.yml +++ b/.github/workflows/ui-extended-main.yml @@ -52,6 +52,10 @@ jobs: shard: entry-settings - name: project-workspace shard: project-workspace + - name: project-workspace-editor + shard: project-workspace-editor + - name: project-collab + shard: project-collab - name: project-runtime shard: project-runtime - name: workspace-restoration diff --git a/docs/testing/automations-coverage-summary.zh-CN.md b/docs/testing/automations-coverage-summary.zh-CN.md deleted file mode 100644 index 89cdafe5d6..0000000000 --- a/docs/testing/automations-coverage-summary.zh-CN.md +++ /dev/null @@ -1,317 +0,0 @@ -# Automations 当前自动化覆盖清单 - -## 概述 - -这份文档用于说明 `Automations` 页面及其核心运行链路,**当前已经有哪些自动化覆盖**,以及**还剩哪些空缺**。 - -覆盖范围按 3 层拆分: - -- `组件/单测` -- `页面级 E2E` -- `tools-dev / non-UI 回归` - -本文只统计当前仓库 `open-design-amr-runtime-acp` 里已经落地并可执行的自动化,不包含钱包、Stripe 或其他仓库页面。 - -## 当前已覆盖 - -### 1. 组件 / 单测 - -#### [TasksView.page.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/TasksView.page.test.tsx) - -已覆盖: - -- `Automations` 页面骨架渲染 -- hero 标题、副标题、统计区 -- `Active / Paused / Templates` 统计展示 -- 空态展示 -- 从空态打开 `New automation` -- 从顶部按钮打开 `New automation` -- 模板分类切换到空分类时的空态 -- `Run` -- `Pause / Resume` -- `Delete` -- `Open result` -- `History / Hide history` -- `Edit` 预填 -- template filter 的 `aria-selected` 切换 - -#### [TasksView.history.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/TasksView.history.test.tsx) - -已覆盖: - -- 历史运行信息展示 -- `Open conversation` -- 历史视图中的元信息 -- 从列表 `Edit` 打开预填弹窗 - -#### [TasksView.templates.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/TasksView.templates.test.tsx) - -已覆盖: - -- daemon automation templates 渲染 -- 从 template 卡片打开创建弹窗 -- evolution proposals 展示与应用 -- source ingestion 基本链路 - -#### 相关旧测试 - -- [RoutinesSection.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/RoutinesSection.test.tsx) - -说明: - -- 这是旧的 routines surface 测试 -- 仍覆盖部分基础动作,例如创建、暂停、运行、删除 -- 但当前主页面已经是 `TasksView`,所以后续应优先看 `TasksView.*` 测试 - -### 2. 页面级 E2E - -#### [automations-page.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/automations-page.test.ts) - -已覆盖 9 条页面级 smoke / regression: - -- 页面加载:hero、统计区、filter、已保存列表 -- 创建 automation 后从页面运行 -- 创建失败时 modal 保持打开且输入不丢 -- `Run` 失败时页面错误展示与 row 仍可操作 -- `Pause / History / Delete` 主链路 -- `Pause` 失败态 -- `Delete` 失败态 -- `Edit` 保存后列表更新 -- 模板 filter 切换后页面内容变化 - -#### [settings-memory-routines.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-memory-routines.test.ts) - -已覆盖: - -- 从主 `Automations` surface 创建 automation -- 运行 automation 跳转到项目对话 -- 创建失败时 modal 保持打开 - -说明: - -- 这部分和 `automations-page.test.ts` 有一定重叠 -- 但它更像 Settings / Memory 混合场景下的回归 - -### 3. tools-dev / non-UI 回归 - -#### [routines.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/lib/vitest/routines.ts) - -已提供 helper: - -- `listRoutines` -- `createRoutine` -- `updateRoutine` -- `runRoutine` -- `deleteRoutine` - -#### [automations-routines.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/tests/tools-dev/automations-routines.test.ts) - -已覆盖 2 条核心非 UI 回归: - -- 成功链路 - - `create` - - `list` - - `pause / resume` - - `run succeeded` - - `assistant message` - - `lastRun` - - `delete` - -- 失败链路 - - `run failed` - - `assistant message.runStatus = failed` - - `lastRun.status = failed` - - `lastRun.error` 已持久化 - -## 当前已形成的覆盖矩阵 - -### 页面展示 - -已覆盖: - -- 页面可加载 -- 统计区可见 -- 空态可见 -- filter/tab 状态切换 -- 列表项主按钮可见 - -### 主操作 - -已覆盖: - -- `New automation` -- `Edit` -- `Run` -- `Pause / Resume` -- `Delete` -- `Open result` -- `History` - -### 失败态 - -已覆盖: - -- 创建失败 -- 运行失败 -- Pause 失败 -- Delete 失败 -- run 失败后的 `assistant message` 与 `lastRun` 持久化 - -### 数据层 / 契约层 - -已覆盖: - -- `/api/routines` create/list/update/delete/run 主链路 -- `lastRun` 成功持久化 -- `lastRun` 失败持久化 -- 非 UI 运行结果与消息状态一致 - -## 仍然存在的空缺 - -下面这些还没有形成完整自动化,或者只被部分覆盖。 - -### 1. Edit 失败态 - -当前缺少: - -- 编辑保存失败时 modal 是否保持打开 -- 已输入修改是否保留 -- 错误提示是否明确 - -建议优先级:`P1` - -### 2. History 失败态 - -当前缺少: - -- 拉取 history 接口失败时的页面表现 -- history 空态与失败态是否区分 - -建议优先级:`P1` - -### 3. Source ingestion 的页面级 E2E - -当前组件测试已覆盖部分逻辑,但页面级 E2E 还缺: - -- ingest 成功 -- ingest 失败 -- proposals 在页面上的联动 - -建议优先级:`P1` - -### 4. Evolution proposals 的页面级 E2E - -当前缺少: - -- proposal 可见性 -- `Apply` -- `Reject` -- proposal 数量变化 - -建议优先级:`P1` - -### 5. 更细的错误恢复 - -当前缺少: - -- `Run` 失败后再次成功运行 -- `Pause` 失败后再次点击恢复成功 -- `Delete` 失败后重试删除成功 - -建议优先级:`P2` - -### 6. 并发 / 防重入 - -当前缺少: - -- 连点 `Run` -- 连点 `Pause` -- 连点 `Delete` - -建议优先级:`P2` - -### 7. 多语言 / 文案变体 - -当前覆盖主要以当前默认界面为准,缺少: - -- 中文 / 英文切换下关键入口是否仍稳定 - -建议优先级:`P2` - -## 推荐优先级 - -### P0 - -已覆盖完成: - -- 页面加载 -- 新建 -- 运行 -- 暂停 / 恢复 -- 删除 -- 编辑 -- 运行失败 -- 暂停失败 -- 删除失败 -- tools-dev 成功 / 失败主链路 - -### P1 - -建议下一步补: - -- Edit 失败态 -- History 失败态 -- proposals 页面级 E2E -- source ingestion 页面级 E2E - -### P2 - -可后续补: - -- 防重入 -- 重试恢复 -- 多语言稳定性 - -## 推荐执行命令 - -### 组件测试 - -```bash -cd /Users/mac/open-design/open-design-amr-runtime-acp -pnpm --dir apps/web test -- \ - tests/components/TasksView.page.test.tsx \ - tests/components/TasksView.history.test.tsx \ - tests/components/TasksView.templates.test.tsx -``` - -### 页面级 E2E - -```bash -cd /Users/mac/open-design/open-design-amr-runtime-acp/e2e -pnpm exec playwright test -c playwright.config.ts ui/automations-page.test.ts -``` - -### tools-dev / non-UI - -```bash -cd /Users/mac/open-design/open-design-amr-runtime-acp/e2e -pnpm test tests/tools-dev/automations-routines.test.ts -``` - -## 结论 - -当前 `Automations` 这一块已经不是“只有页面能点点看”的状态,而是已经形成了: - -- `组件级` -- `页面级 E2E` -- `非 UI 契约级` - -三层覆盖。 - -如果按回归价值看,当前最值得继续补的不是再堆更多 happy path,而是: - -- `Edit` 失败态 -- `History` 失败态 -- `Source ingestion / proposals` 的页面级 E2E - -这三块补完后,`Automations` 页面自动化会更接近完整回归集。 diff --git a/docs/testing/automations-page-test-plan.zh-CN.md b/docs/testing/automations-page-test-plan.zh-CN.md deleted file mode 100644 index d1584bd155..0000000000 --- a/docs/testing/automations-page-test-plan.zh-CN.md +++ /dev/null @@ -1,149 +0,0 @@ -# Automations 页面自动化待补清单 - -## 说明 - -本文档针对 `/automations` 页面整理自动化测试缺口,目标是把当前“有零散组件测试、但缺页面级覆盖”的状态收敛成一个可执行清单。 - -适用范围: - -- `Automations` 页面本身 -- 列表卡片区 -- 页面级筛选与分类 -- `New automation` 页面入口 -- 页面内的 `Run / History / Edit / Pause / Delete / Open result` - -当前边界: - -- 已有部分组件测试覆盖 `RoutinesSection`、`TasksView.history`、`NewAutomationModal`。 -- 当前仓库内**还没有看到这整个页面的完整 E2E 覆盖**。 -- 本文档优先补“页面真实使用路径”,而不是重复已有纯函数或小组件断言。 - -## 现有覆盖概览 - -当前已存在、与这页有关的测试主要有: - -- [RoutinesSection.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/RoutinesSection.test.tsx) - 覆盖部分自动化列表区和 `New automation` 入口行为。 -- [TasksView.history.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/TasksView.history.test.tsx) - 覆盖 automation history 相关行为。 -- [NewAutomationModal.context-picker.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/NewAutomationModal.context-picker.test.tsx) - 覆盖创建弹窗里的 prompt/context picker。 -- [router-marketplace.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/router-marketplace.test.ts) - 覆盖 `/automations` 路由解析。 - -仍明显缺失的,是你截图这个页面本身的: - -- 页面加载 -- 顶部统计 -- 卡片操作 -- 页面级筛选 -- “Open result” 链路 -- 页面入口到弹窗/编辑页的完整串联 - -## P0 - -这些是最值得先补、最容易出真实回归事故的页面级能力。 - -| ID | 模块 | 待补自动化点 | 推荐层级 | 说明 | -| --- | --- | --- | --- | --- | -| AUTO-P0-001 | 页面加载 | `/automations` 页面可正常进入,标题、副标题、`New automation` 按钮可见 | E2E UI | 守住页面可达性和基本骨架 | -| AUTO-P0-002 | 顶部统计 | `Active / Paused / Templates` 三个统计块正常渲染 | 组件测试 + E2E UI | 至少校验数值显示与空态不炸 | -| AUTO-P0-003 | 列表加载 | `Your automations` 列表可渲染多条记录 | E2E UI | 覆盖成功、失败、暂停等不同状态卡片 | -| AUTO-P0-004 | 状态 badge | `FAILED / SUCCEEDED / PAUSED` badge 正确显示 | 组件测试 | 防止状态串文案、串颜色、串按钮 | -| AUTO-P0-005 | `New automation` 入口 | 点击 `New automation` 能打开创建弹窗/流程 | E2E UI | 守住最核心入口 | -| AUTO-P0-006 | `Run` 操作 | 点击 `Run` 后状态刷新,最近运行信息更新 | E2E UI | 用户最直接的主操作之一 | -| AUTO-P0-007 | `Pause` 操作 | 点击 `Pause` 后状态与按钮文案更新 | E2E UI | 防止暂停后还显示 active | -| AUTO-P0-008 | `History` 入口 | 点击 `History` 能打开历史视图/面板 | 组件测试 + E2E UI | 至少验证入口可达 | -| AUTO-P0-009 | `Edit` 入口 | 点击 `Edit` 能进入编辑状态或编辑弹窗 | E2E UI | 防止编辑入口断链 | -| AUTO-P0-010 | `Delete` 操作 | 删除入口可达,确认后列表移除 | E2E UI | 高风险破坏性操作,必须单测到 | - -## P1 - -这些属于第二层回归,建议在页面主链路补齐后继续补。 - -| ID | 模块 | 待补自动化点 | 推荐层级 | 说明 | -| --- | --- | --- | --- | --- | -| AUTO-P1-001 | `Open result` | 成功 run 有结果时可打开结果页 | E2E UI | 守住成功产物可追溯 | -| AUTO-P1-002 | 失败结果 | 失败 run 的 `Open result` 行为正确,不假装有结果 | E2E UI | 防止死链或错误跳转 | -| AUTO-P1-003 | 顶部筛选 | `Active / Paused / Templates` 切换后列表正确过滤 | 组件测试 + E2E UI | 覆盖页面级聚合筛选 | -| AUTO-P1-004 | 底部分类 | `All / Orbit / Live artifacts / Memory / ...` 分类切换正确 | E2E UI | 贴近截图中的模板分类条 | -| AUTO-P1-005 | 列表时间信息 | `Last run / Next run` 时间展示正确 | 组件测试 | 防止时区、格式或排序回归 | -| AUTO-P1-006 | `Run` 防重入 | 连点 `Run` 不应产生并发重复执行 | 组件测试 + E2E UI | 防止重复触发 | -| AUTO-P1-007 | `Pause` 防重入 | 连点 `Pause` 不会出现状态错乱 | 组件测试 | 防止 UI/接口竞态 | -| AUTO-P1-008 | 创建成功回填 | 新建 automation 后统计数字和列表一起刷新 | E2E UI | 覆盖页面级一致性 | -| AUTO-P1-009 | 编辑成功回填 | 编辑后卡片标题、频率、下一次运行时间更新 | E2E UI | 覆盖编辑结果回流 | - -## P2 - -这些更偏兜底与稳定性,适合在主链路稳定后补充。 - -| ID | 模块 | 待补自动化点 | 推荐层级 | 说明 | -| --- | --- | --- | --- | --- | -| AUTO-P2-001 | 空态 | 没有 automations 时的空态渲染正确 | 组件测试 + E2E UI | 防止空页布局坏掉 | -| AUTO-P2-002 | 接口失败 | 列表加载失败时有明确错误或降级 | 组件测试 | 防止白屏 | -| AUTO-P2-003 | 单操作失败 | `Run / Pause / Delete / Edit` 请求失败时提示明确 | 组件测试 + E2E UI | 防止静默失败 | -| AUTO-P2-004 | 本地化 | 中文/英文下按钮、状态、统计布局正常 | E2E UI | 防止文案切换后布局溢出 | -| AUTO-P2-005 | 长列表 | 多条 automation 时滚动与分页/截断正常 | E2E UI | 防止页面退化 | -| AUTO-P2-006 | 模板统计边界 | 模板数量为 0 或很多时顶部统计仍正确 | 组件测试 | 覆盖计数边界 | - -## 建议测试文件落点 - -### 组件测试 - -- [TasksView.history.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/TasksView.history.test.tsx) - 继续扩展 `History` 相关断言。 -- [RoutinesSection.test.tsx](/Users/mac/open-design/open-design-amr-runtime-acp/apps/web/tests/components/RoutinesSection.test.tsx) - 可继续扩展列表加载、空态、状态 badge、`New automation` 按钮。 -- 建议新增: - - `apps/web/tests/components/TasksView.list.test.tsx` - - `apps/web/tests/components/TasksView.filters.test.tsx` - - `apps/web/tests/components/TasksView.actions.test.tsx` - -### E2E UI - -建议新增: - -- `e2e/ui/automations-page.test.ts` - - 页面加载 - - 标题/统计 - - 列表渲染 - - 顶部/底部筛选 -- `e2e/ui/automations-actions.test.ts` - - `Run / History / Edit / Pause / Delete` - - `Open result` -- `e2e/ui/automations-create-flow.test.ts` - - `New automation` - - 创建成功后回填列表 - -## 推荐补测顺序 - -1. 页面基础骨架 - 先补 `AUTO-P0-001`、`AUTO-P0-002`、`AUTO-P0-003` -2. 卡片主操作 - 再补 `AUTO-P0-006`、`AUTO-P0-007`、`AUTO-P0-008`、`AUTO-P0-009`、`AUTO-P0-010` -3. 创建入口 - 再补 `AUTO-P0-005` -4. 筛选与结果页 - 再补 `AUTO-P1-001`、`AUTO-P1-002`、`AUTO-P1-003`、`AUTO-P1-004` -5. 稳定性与边界 - 最后补 `AUTO-P2-*` - -## 最值得先实现的 5 条 - -如果只优先做最有价值的 5 条,我建议是: - -1. `AUTO-P0-001` `/automations` 页面加载成功 -2. `AUTO-P0-005` `New automation` 入口可打开 -3. `AUTO-P0-006` `Run` 操作后状态刷新 -4. `AUTO-P0-007` `Pause` 操作后状态刷新 -5. `AUTO-P1-003` 顶部筛选切换后列表正确变化 - -## 后续建议 - -这页最适合的策略不是一开始就做很重的全链路,而是: - -- 先用组件测试守住状态展示和局部交互 -- 再用 2 到 3 个 E2E 文件守住页面入口和核心动作 -- 最后再补异常态、防重入和本地化 - -这样补起来更快,也更不容易被页面文案或布局小改动拖垮。 diff --git a/docs/testing/launch-review-e2e-regressions.zh-CN.md b/docs/testing/launch-review-e2e-regressions.zh-CN.md index f7cab5e8d8..5e42e5e6c4 100644 --- a/docs/testing/launch-review-e2e-regressions.zh-CN.md +++ b/docs/testing/launch-review-e2e-regressions.zh-CN.md @@ -94,23 +94,7 @@ - `web/latest.log` - `desktop/latest.log` -### 6. Automations 页面顺序与摘要 - -文件: -- [e2e/ui/automations-page.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/automations-page.test.ts) - -新增用例: - -1. `places a newly created automation at the top of the list and highlights it` - - 覆盖新建后置顶与聚焦态 - -2. `keeps saved automations ordered by newest createdAt first` - - 覆盖多条 automation 混排时按 `createdAt` 倒序稳定排序 - -3. `renders the routine target and last-run status in the row summary` - - 覆盖 row summary 的 target / status 信息 - -### 7. Integrations:连接、恢复、退化状态 +### 6. Integrations:连接、恢复、退化状态 文件: - [e2e/ui/settings-connectors-auth-happy-path.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-connectors-auth-happy-path.test.ts) @@ -129,7 +113,7 @@ 4. `returns a pending authorization to Connect and clears session storage after a successful cancel` 5. `surfaces a connector error state when credentials have degraded` -### 8. Design systems:Settings 导入/重命名/坏导入 +### 7. Design systems:Settings 导入/重命名/坏导入 文件: - [e2e/ui/settings-design-systems.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/settings-design-systems.test.ts) @@ -140,7 +124,7 @@ 2. `renames an editable design system and keeps the new title after reopening settings` 3. `shows an inline error when importing a broken local design system package` -### 9. Design systems manager:发布、过滤、删除 fallback +### 8. Design systems manager:发布、过滤、删除 fallback 文件: - [e2e/ui/design-systems-manager.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/design-systems-manager.test.ts) @@ -151,7 +135,7 @@ 2. `filters user design systems by draft and published status in the manager` 3. `deleting the active design system falls back to another user system` -### 10. main 最新功能回归补测 +### 9. main 最新功能回归补测 文件: - [e2e/ui/project-management-flows.test.ts](/Users/mac/open-design/open-design-amr-runtime-acp/e2e/ui/project-management-flows.test.ts) @@ -234,13 +218,6 @@ pnpm exec playwright test -c playwright.config.ts ui/app.test.ts --grep "sending pnpm exec playwright test -c playwright.config.ts ui/diagnostics-export.test.ts ``` -### Automations - -```bash -pnpm exec playwright test -c playwright.config.ts ui/automations-page.test.ts --grep "places a newly created automation at the top of the list and highlights it|keeps saved automations ordered by newest createdAt first|renders the routine target and last-run status in the row summary" -``` - - ### main 最新功能回归补测 ```bash diff --git a/e2e/lib/playwright/amr.ts b/e2e/lib/playwright/amr.ts index cbcd07974b..c09b26e4b1 100644 --- a/e2e/lib/playwright/amr.ts +++ b/e2e/lib/playwright/amr.ts @@ -244,7 +244,10 @@ export async function expectWorkspaceReady(page: Page) { await waitForLoadingToClear(page); await expect(page).toHaveURL(/\/projects\//); await expect(page.getByTestId('chat-composer')).toBeVisible(); - await expect(page.getByTestId('chat-composer-input')).toBeVisible(); + // The composer mounts before Workspace authority has resolved, but remains + // read-only until the current member has writer access. Wait on the actual + // submit gate so callers cannot race into an opaque click timeout. + await expect(page.getByTestId('chat-composer-input')).toBeEditable({ timeout: T.medium }); } /** diff --git a/e2e/lib/playwright/suites.ts b/e2e/lib/playwright/suites.ts index eac7d6dc92..4aafdbe784 100644 --- a/e2e/lib/playwright/suites.ts +++ b/e2e/lib/playwright/suites.ts @@ -137,7 +137,10 @@ export function validatePlaywrightSuiteTopology(): string[] { const errors: string[] = []; const knownGroups = new Set(Object.keys(uiP0Groups)); const coverageFiles = sortedUnique(uiP0CoverageFiles); - const ciFiles = filesForUiP0Groups(uiP0CiMatrix.map((entry) => entry.shard)); + const ciFileAssignments = uiP0CiMatrix.flatMap( + (entry) => uiP0Groups[entry.shard as UiP0GroupName]?.files ?? [], + ); + const ciFiles = sortedUnique(ciFileAssignments); for (const entry of uiP0CiMatrix) { if (!knownGroups.has(entry.shard)) { @@ -153,6 +156,14 @@ export function validatePlaywrightSuiteTopology(): string[] { errors.push(`UI P0 CI matrix unexpectedly covers ${file}`); } + const seenFiles = new Set(); + for (const file of ciFileAssignments) { + if (seenFiles.has(file)) { + errors.push(`UI P0 CI matrix covers ${file} more than once`); + } + seenFiles.add(file); + } + for (const entry of visualCiMatrix) { if (entry.files.trim().length === 0) { errors.push(`Visual CI matrix entry ${entry.name} has no files`); @@ -162,10 +173,6 @@ export function validatePlaywrightSuiteTopology(): string[] { return errors; } -function filesForUiP0Groups(names: readonly string[]): string[] { - return sortedUnique(names.flatMap((name) => uiP0Groups[name as UiP0GroupName]?.files ?? [])); -} - function difference(left: readonly string[], right: readonly string[]): string[] { const rightSet = new Set(right); return left.filter((value) => !rightSet.has(value)); diff --git a/e2e/lib/playwright/visual.ts b/e2e/lib/playwright/visual.ts index c2f9783d0e..141d40192e 100644 --- a/e2e/lib/playwright/visual.ts +++ b/e2e/lib/playwright/visual.ts @@ -497,22 +497,6 @@ export async function configureVisualPage(page: Page, options: VisualPageOptions }); }); - await page.route('**/api/routines', async (route) => { - await fulfillGet(route, { routines: [] }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await fulfillGet(route, { templates: [] }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await fulfillGet(route, { proposals: [] }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await fulfillGet(route, { packets: [] }); - }); - await page.route('**/api/plugins', async (route) => { await fulfillGet(route, { plugins: VISUAL_PLUGINS }); }); diff --git a/e2e/lib/vitest/routines.ts b/e2e/lib/vitest/routines.ts deleted file mode 100644 index cc3ba3b2b1..0000000000 --- a/e2e/lib/vitest/routines.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { CreateRoutineRequest, Routine, RoutineRun, UpdateRoutineRequest } from '@open-design/contracts'; - -import { requestJson } from './http.ts'; - -export type RoutineListResponse = { - routines: Routine[]; -}; - -export type RoutineMutationResponse = { - routine: Routine; -}; - -export type RoutineRunStartResponse = { - agentRunId?: string | null; - conversationId?: string | null; - projectId?: string | null; - run?: RoutineRun | null; - routine?: Routine | null; -}; - -export async function listRoutines(baseUrl: string): Promise { - const response = await requestJson(baseUrl, '/api/routines'); - return response.routines; -} - -export async function createRoutine(baseUrl: string, body: CreateRoutineRequest): Promise { - const response = await requestJson(baseUrl, '/api/routines', { - body, - method: 'POST', - }); - return response.routine; -} - -export async function updateRoutine( - baseUrl: string, - routineId: string, - body: UpdateRoutineRequest, -): Promise { - const response = await requestJson( - baseUrl, - `/api/routines/${encodeURIComponent(routineId)}`, - { - body, - method: 'PATCH', - }, - ); - return response.routine; -} - -export async function runRoutine(baseUrl: string, routineId: string): Promise { - return await requestJson( - baseUrl, - `/api/routines/${encodeURIComponent(routineId)}/run`, - { - body: {}, - method: 'POST', - }, - ); -} - -export async function deleteRoutine(baseUrl: string, routineId: string): Promise { - await requestJson(baseUrl, `/api/routines/${encodeURIComponent(routineId)}`, { - body: {}, - method: 'DELETE', - }); -} diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index 8a2ccd81e2..84ae18bb9a 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -11,7 +11,11 @@ import { describe, expect, it } from "vitest"; import { T } from "@/timeouts"; -import { uiP0CiMatrix, uiP0Groups } from "../lib/playwright/suites.ts"; +import { + uiP0CiMatrix, + uiP0Groups, + validatePlaywrightSuiteTopology, +} from "../lib/playwright/suites.ts"; const execFileAsync = promisify(execFile); const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -1325,6 +1329,11 @@ process.stdin.on("end", () => { it("[P2] keeps visual ownership and generic full UI sharding explicit", async () => { const playwrightConfig = await readFile(playwrightConfigPath, "utf8"); const benchmarkWorkflow = await readFile(uiExtendedMainWorkflowPath, "utf8"); + const extendedP0 = sectionBetween(benchmarkWorkflow, " ui_p0:", " ui_extended:"); + const extendedP0Matrix = sectionBetween(extendedP0, " include:", "\n steps:"); + const extendedP0Names = [...extendedP0Matrix.matchAll(/^\s+- name: (.+)$/gm)].map( + ([, name]) => name, + ); const fullUi = benchmarkWorkflow.slice(benchmarkWorkflow.indexOf(" ui_full:")); const fullUiFiles = [...fullUi.matchAll(/ui\/[a-z0-9-]+\.test\.ts/g)] .map(([file]) => file) @@ -1336,7 +1345,7 @@ process.stdin.on("end", () => { expect(benchmarkWorkflow).toContain("run-ui-group critical-extras"); expect(benchmarkWorkflow).toContain("Preserve project-runtime domain artifact"); expect(benchmarkWorkflow).toContain("--grep-invert '@merge-extra'"); - expect(benchmarkWorkflow).toContain("name: project-workspace"); + expect(extendedP0Names).toEqual(uiP0CiMatrix.map((entry) => entry.name)); expect(benchmarkWorkflow).toContain("fromJSON(needs.p0_runners.outputs.runs_on).ui_p0"); expect(fullUi).toContain("fromJSON(needs.p0_runners.outputs.runs_on).ui_hot"); expect(fullUi).toContain("shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]"); @@ -1350,6 +1359,18 @@ process.stdin.on("end", () => { expect(fullUiFiles).toEqual([]); }); + it("[P2] rejects duplicate file assignments across UI P0 shards", () => { + const files = uiP0Groups["workspace-restoration"].files as unknown as string[]; + files.push("ui/app.test.ts"); + try { + expect(validatePlaywrightSuiteTopology()).toContain( + "UI P0 CI matrix covers ui/app.test.ts more than once", + ); + } finally { + files.pop(); + } + }); + it("[P2] resolves CI runner profiles by mode", async () => { const defaultProfiles = await runRunners(); const defaultRunsOn = runnerRunsOn(defaultProfiles); diff --git a/e2e/tests/tools-dev/automations-routines.test.ts b/e2e/tests/tools-dev/automations-routines.test.ts index 8b2cd8893a..b5d95ff5fc 100644 --- a/e2e/tests/tools-dev/automations-routines.test.ts +++ b/e2e/tests/tools-dev/automations-routines.test.ts @@ -2,38 +2,30 @@ import { join } from 'node:path'; -import type { CreateRoutineRequest } from '@open-design/contracts'; +import type { CreateRoutineRequest, Routine } from '@open-design/contracts'; import { describe, expect, test } from 'vitest'; import { createFakeAgentRuntimes } from '@/fake-agents'; -import { listMessages } from '@/vitest/messages'; -import { createRoutine, deleteRoutine, listRoutines, runRoutine, updateRoutine } from '@/vitest/routines'; -import { createSmokeSuite } from '@/vitest/suite'; import { requestJson } from '@/vitest/http'; -import { readRunEvents, waitForRunStatus, waitForRunTerminal } from '@/vitest/runs'; +import { waitForRunStatus } from '@/vitest/runs'; +import { createSmokeSuite } from '@/vitest/suite'; -type ProjectResponse = { - project: { - id: string; - metadata?: { - kind?: string; - trigger?: string; - }; - name: string; - }; +type RoutineRunStartResponse = { + agentRunId?: string | null; + conversationId?: string | null; + projectId?: string | null; }; describe('tools-dev automations routines', () => { - test('supports create, pause/resume, run, and delete through the routines API', { timeout: 180_000 }, async () => { + test('creates and runs a routine through the API', { timeout: 180_000 }, async () => { const suite = await createSmokeSuite('tools-dev-automations-routines'); - await suite.with.toolsDev(async ({ runtime, status, webUrl }) => { + await suite.with.toolsDev(async ({ webUrl }) => { const fakeAgents = await createFakeAgentRuntimes({ root: join(suite.scratchDir, 'fake-agents'), runtimeIds: ['codex'], }); - - await requestJson<{ config: Record }>(webUrl, '/api/app-config', { + await requestJson(webUrl, '/api/app-config', { body: { agentCliEnv: { codex: fakeAgents.codex.env }, agentId: 'codex', @@ -55,26 +47,20 @@ describe('tools-dev automations routines', () => { skillId: null, context: {}, }; + const { routine } = await requestJson<{ routine: Routine }>(webUrl, '/api/routines', { + body: createBody, + method: 'POST', + }); + expect(routine.name).toBe('E2E daily digest'); - const created = await createRoutine(webUrl, createBody); - expect(created.id).toEqual(expect.any(String)); - expect(created.name).toBe('E2E daily digest'); - expect(created.enabled).toBe(true); + const listed = await requestJson<{ routines: Routine[] }>(webUrl, '/api/routines'); + expect(listed.routines.map((entry) => entry.id)).toContain(routine.id); - const afterCreate = await listRoutines(webUrl); - expect(afterCreate.map((routine) => routine.id)).toContain(created.id); - - const paused = await updateRoutine(webUrl, created.id, { enabled: false }); - expect(paused.enabled).toBe(false); - const listedPaused = await listRoutines(webUrl); - expect(listedPaused.find((routine) => routine.id === created.id)?.enabled).toBe(false); - - const resumed = await updateRoutine(webUrl, created.id, { enabled: true }); - expect(resumed.enabled).toBe(true); - const listedResumed = await listRoutines(webUrl); - expect(listedResumed.find((routine) => routine.id === created.id)?.enabled).toBe(true); - - const started = await runRoutine(webUrl, created.id); + const started = await requestJson( + webUrl, + `/api/routines/${encodeURIComponent(routine.id)}/run`, + { body: {}, method: 'POST' }, + ); expect(started.projectId).toEqual(expect.any(String)); expect(started.conversationId).toEqual(expect.any(String)); expect(started.agentRunId).toEqual(expect.any(String)); @@ -83,143 +69,6 @@ describe('tools-dev automations routines', () => { timeoutMs: 30_000, }); expect(finalRun.projectId).toBe(started.projectId); - expect(finalRun.status).toBe('succeeded'); - - const project = await requestJson( - webUrl, - `/api/projects/${encodeURIComponent(String(started.projectId))}`, - ); - expect(project.project.id).toBe(started.projectId); - expect(project.project.metadata?.trigger).toBe('manual'); - - const messages = await listMessages( - webUrl, - String(started.projectId), - String(started.conversationId), - ); - expect( - messages.some( - (message) => - message.role === 'assistant' && - message.content.includes('Real Daemon Smoke'), - ), - ).toBe(true); - - const afterRun = await listRoutines(webUrl); - const routineAfterRun = afterRun.find((routine) => routine.id === created.id); - expect(routineAfterRun?.lastRun?.status).toBe('succeeded'); - expect(routineAfterRun?.lastRun?.projectId).toBe(started.projectId); - expect(routineAfterRun?.lastRun?.conversationId).toBe(started.conversationId); - - await deleteRoutine(webUrl, created.id); - const afterDelete = await listRoutines(webUrl); - expect(afterDelete.find((routine) => routine.id === created.id)).toBeUndefined(); - - await suite.report.json('summary.json', { - created, - finalRun, - messages: messages.map((message) => ({ - id: message.id, - role: message.role, - runStatus: message.runStatus ?? null, - })), - namespace: suite.namespace, - project: project.project, - routineAfterRun, - runtime: { - daemonPort: runtime.daemonPort, - webPort: runtime.webPort, - webUrl, - }, - status, - }); - }); - }); - - test('persists failed lastRun details when an automation run errors', { timeout: 180_000 }, async () => { - const suite = await createSmokeSuite('tools-dev-automations-routines-failed-run'); - - await suite.with.toolsDev(async ({ runtime, status, webUrl }) => { - const fakeAgents = await createFakeAgentRuntimes({ - root: join(suite.scratchDir, 'fake-agents'), - runtimeIds: ['codex'], - }); - - await requestJson<{ config: Record }>(webUrl, '/api/app-config', { - body: { - agentCliEnv: { codex: fakeAgents.codex.env }, - agentId: 'codex', - agentModels: { codex: { model: 'default', reasoning: 'default' } }, - designSystemId: null, - onboardingCompleted: true, - skillId: null, - telemetry: { artifactManifest: true, content: false, metrics: false }, - }, - method: 'PUT', - }); - - const createBody: CreateRoutineRequest = { - name: 'E2E failing digest', - prompt: 'Return an intentional daemon smoke failure', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - skillId: null, - context: {}, - }; - - const created = await createRoutine(webUrl, createBody); - const started = await runRoutine(webUrl, created.id); - expect(started.projectId).toEqual(expect.any(String)); - expect(started.conversationId).toEqual(expect.any(String)); - expect(started.agentRunId).toEqual(expect.any(String)); - - const terminal = await waitForRunTerminal(webUrl, String(started.agentRunId), { - timeoutMs: 30_000, - }); - expect(terminal.status).toBe('failed'); - - const events = await readRunEvents(webUrl, String(started.agentRunId)); - expect(events).toMatch(/intentional daemon smoke failure|failed/i); - - const messages = await listMessages( - webUrl, - String(started.projectId), - String(started.conversationId), - ); - const assistant = messages.find( - (message) => message.role === 'assistant' && message.runId === started.agentRunId, - ); - expect(assistant?.runStatus).toBe('failed'); - expect(typeof assistant?.endedAt).toBe('number'); - - const afterRun = await listRoutines(webUrl); - const routineAfterRun = afterRun.find((routine) => routine.id === created.id); - expect(routineAfterRun?.lastRun?.status).toBe('failed'); - expect(routineAfterRun?.lastRun?.projectId).toBe(started.projectId); - expect(routineAfterRun?.lastRun?.conversationId).toBe(started.conversationId); - expect(routineAfterRun?.lastRun?.error).toMatch(/intentional fake codex failure|failed/i); - - await suite.report.json('failure-summary.json', { - created, - lastRun: routineAfterRun?.lastRun ?? null, - messages: messages.map((message) => ({ - endedAt: message.endedAt ?? null, - id: message.id, - role: message.role, - runId: message.runId ?? null, - runStatus: message.runStatus ?? null, - })), - namespace: suite.namespace, - runEventsPreview: events.slice(0, 500), - runtime: { - daemonPort: runtime.daemonPort, - webPort: runtime.webPort, - webUrl, - }, - status, - terminal, - }); }); }); }); diff --git a/e2e/ui/amr-logout-requires-relogin.test.ts b/e2e/ui/amr-logout-requires-relogin.test.ts index 32a08120ab..940747d6f9 100644 --- a/e2e/ui/amr-logout-requires-relogin.test.ts +++ b/e2e/ui/amr-logout-requires-relogin.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Locator } from '@playwright/test'; +import type { ProjectWorkspaceScopeResponse } from '@open-design/contracts'; import { expect, test } from '@/playwright/suite'; @@ -156,7 +157,16 @@ test('[P0] after local Sign out, the app returns to onboarding and AMR runs requ await page.evaluate((next) => { window.localStorage.setItem('open-design:config', JSON.stringify(next)); }, reloginConfig); + const projectScopeResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'GET' + && url.pathname === `/api/projects/${projectId}/workspace-scope`; + }); await gotoProject(page, projectId); + const scopeResponse = await projectScopeResponse; + const scopeBody = (await scopeResponse.json()) as ProjectWorkspaceScopeResponse; + expect(scopeResponse.ok(), JSON.stringify(scopeBody)).toBeTruthy(); + expect(scopeBody.scope).toMatchObject({ kind: 'personal', projectId }); await sendPrompt(page, 'AMR logout should require relogin'); const balanceGate = page.getByTestId('amr-balance-dialog'); diff --git a/e2e/ui/amr-onboarding.test.ts b/e2e/ui/amr-onboarding.test.ts index 12c5cf6392..9fd7bcd9e0 100644 --- a/e2e/ui/amr-onboarding.test.ts +++ b/e2e/ui/amr-onboarding.test.ts @@ -481,7 +481,6 @@ test('[P0] signed-out users can open Home directly without completing onboarding for (const destination of [ { name: 'Community', path: '/community', selector: '[data-testid="entry-nav-community"]' }, { name: 'Projects', path: '/projects', selector: '[data-testid="entry-view-projects"][data-active="true"]' }, - { name: 'Automations', path: '/automations', selector: '[data-testid="entry-view-tasks"][data-active="true"]' }, { name: 'Design Systems', path: '/design-systems', selector: '[data-testid="entry-view-design-systems"][data-active="true"]' }, { name: 'Plugins', path: '/plugins', selector: '[data-testid="entry-view-plugins"][data-active="true"]' }, { name: 'Integrations', path: '/integrations', selector: '.integrations-view' }, diff --git a/e2e/ui/automations-page.test.ts b/e2e/ui/automations-page.test.ts index 9d2d256181..72d00017a8 100644 --- a/e2e/ui/automations-page.test.ts +++ b/e2e/ui/automations-page.test.ts @@ -1,51 +1,39 @@ import { expect, test } from '@/playwright/suite'; import { routeAgents } from '@/playwright/mock-factory'; import { T } from '@/timeouts'; -import type { Locator, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; const STORAGE_KEY = 'open-design:config'; const AUTOMATIONS_TITLE = /Automations|自动化/i; -// Multi-navigation cases (create → run → /tasks → reload) need headroom past -// the suite default once boot waits use the long dynamic-import budget. test.describe.configure({ timeout: T.xlong }); -function baseConfig(): Record { - return { - mode: 'daemon', - apiKey: '', - apiProtocol: 'openai', - apiVersion: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4o', - apiProviderBaseUrl: 'https://api.openai.com/v1', - agentId: 'codex', - skillId: null, - designSystemId: null, - onboardingCompleted: true, - mediaProviders: {}, - agentModels: {}, - agentCliEnv: {}, - }; -} - -async function seedAutomationsBase(page: Page, options: { locale?: string } = {}) { - await page.addInitScript(({ key, value, locale }) => { +async function seedAutomationsBase(page: Page) { + await page.addInitScript(({ key, value }) => { window.localStorage.setItem(key, JSON.stringify(value)); - if (locale) { - window.localStorage.setItem('open-design:locale', locale); - window.localStorage.setItem('open-design:locale-source', 'manual'); - } - }, { key: STORAGE_KEY, value: baseConfig(), locale: options.locale ?? null }); - - await page.route('**/api/health', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: '{"ok":true}', - }); + }, { + key: STORAGE_KEY, + value: { + mode: 'daemon', + apiKey: '', + apiProtocol: 'openai', + apiVersion: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + apiProviderBaseUrl: 'https://api.openai.com/v1', + agentId: 'codex', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + agentModels: {}, + agentCliEnv: {}, + }, }); + await page.route('**/api/health', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' }); + }); await routeAgents(page, [ { id: 'codex', @@ -56,61 +44,33 @@ async function seedAutomationsBase(page: Page, options: { locale?: string } = {} models: [{ id: 'default', label: 'Default' }], }, ]); - await page.route('**/api/plugins', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ plugins: [] }), - }); + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"plugins":[]}' }); }); - await page.route('**/api/mcp/servers', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ servers: [], templates: [] }), + body: '{"servers":[],"templates":[]}', }); }); + await page.route('**/api/automation-templates', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"templates":[]}' }); + }); + await page.route('**/api/automation-proposals?status=pending-review', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"proposals":[]}' }); + }); + await page.route('**/api/automation-source-packets?limit=3', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"packets":[]}' }); + }); } -async function waitForLoadingToClear(page: Page) { - // `apps/web` mounts through `dynamic(..., { ssr: false })`, so - // `domcontentloaded` / bare `reload()` resolve while the boot shell still - // owns the page. The default 10s expect budget and a hard 15s count check - // both flake under CI contention; use the suite long budget and soft-wait - // the shell the same way other entry helpers do. +async function gotoAutomations(page: Page) { + await page.goto('/automations', { waitUntil: 'domcontentloaded' }); await page .getByText('Loading Open Design…') .waitFor({ state: 'hidden', timeout: T.long }) .catch(() => {}); -} - -async function openSchedulePopover(modal: Locator, name: RegExp | string) { - await modal.getByRole('button', { name }).click(); - const popover = modal.locator('.automation-popover--schedule'); - await expect(popover).toBeVisible(); - return popover; -} - -async function gotoEntryHome(page: Page) { - await page.goto('/', { waitUntil: 'domcontentloaded' }); - await waitForLoadingToClear(page); - const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve Open Design' }); - if (await privacyDialog.isVisible().catch(() => false)) { - await privacyDialog.getByRole('button', { name: /I get it|not now|got it|don't share/i }).click(); - } - // #5517 moved the settings entry into the collapsed-by-default nav rail, so it - // is not in the accessibility tree on load; the hero is the ready signal now. - await expect(page.getByTestId('home-hero')).toBeVisible({ timeout: T.long }); -} - -async function gotoAutomations(page: Page) { - await gotoEntryHome(page); - // #5517's rail dropped the Automations destination; /automations is still the - // route the view lives on. - await page.goto('/automations', { waitUntil: 'domcontentloaded' }); - await waitForLoadingToClear(page); const view = page.getByTestId('tasks-view'); await expect(view.getByRole('heading', { level: 1, name: AUTOMATIONS_TITLE })).toBeVisible({ timeout: T.long, @@ -118,96 +78,8 @@ async function gotoAutomations(page: Page) { return view; } -test.describe('Automations page', () => { - test('[P1] renders the page hero, summary metrics, filters, and saved rows', async ({ page }) => { - await seedAutomationsBase(page); - - let routines: Array> = [ - { - id: 'routine-active-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - { - id: 'routine-paused-1', - name: 'Weekly release notes', - prompt: 'Draft release notes.', - schedule: { kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: false, - nextRunAt: null, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - - await expect(view.getByText('Plan recurring conversations for project work, Orbit digests, and live artifacts.')).toBeVisible(); - await expect(view.getByLabel('Automation summary')).toContainText('Active'); - await expect(view.getByLabel('Automation summary')).toContainText('Paused'); - await expect(view.getByLabel('Automation summary')).toContainText('Templates'); - await expect(view.getByLabel('Your automations')).toContainText('Daily digest'); - await expect(view.getByLabel('Your automations')).toContainText('Weekly release notes'); - - const templateFilters = view.getByRole('tablist', { name: 'Template filters' }); - const allTab = templateFilters.getByRole('tab', { name: /^All/i }); - const skillsTab = templateFilters.getByRole('tab', { name: /Skills/i }); - await expect(allTab).toHaveAttribute('aria-selected', 'true'); - await skillsTab.click(); - await expect(skillsTab).toHaveAttribute('aria-selected', 'true'); - await expect(view.getByRole('status')).toContainText('No templates in this category yet.'); - }); - - test('[P1] creates an automation from the page and runs it into a project conversation', async ({ page }) => { +test.describe('Automations page smoke', () => { + test('[P1] creates an automation and runs it into a project conversation', async ({ page }) => { await seedAutomationsBase(page); const projects = [ @@ -224,10 +96,8 @@ test.describe('Automations page', () => { body: JSON.stringify({ projects }), }); }); - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { + if (route.request().method() === 'GET') { await route.fulfill({ status: 200, contentType: 'application/json', @@ -235,17 +105,14 @@ test.describe('Automations page', () => { }); return; } - if (method === 'POST') { + if (route.request().method() === 'POST') { const payload = route.request().postDataJSON() as Record; createBodies.push(payload); const routine = { id: 'routine-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, + ...payload, enabled: true, - nextRunAt: Date.now() + 3600_000, + nextRunAt: Date.now() + 3_600_000, lastRun: null, createdAt: Date.now(), updatedAt: Date.now(), @@ -260,38 +127,12 @@ test.describe('Automations page', () => { } await route.fulfill({ status: 404, body: '{}' }); }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - await page.route('**/api/routines/routine-1/run', async (route) => { - const startedAt = Date.now(); const lastRun = { runId: 'run-1', status: 'queued', trigger: 'manual', - startedAt, + startedAt: Date.now(), projectId: 'proj-run', conversationId: 'conv-run', agentRunId: 'agent-run-1', @@ -311,759 +152,47 @@ test.describe('Automations page', () => { }); const view = await gotoAutomations(page); - await view.getByRole('button', { name: 'New automation' }).click(); const modal = page.getByTestId('automation-modal'); await modal.getByTestId('automation-modal-title').fill('Weekly digest'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Weekly digest'); await modal.getByTestId('automation-modal-prompt').fill('Summarize GitHub and design activity.'); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue('Summarize GitHub and design activity.'); await modal.getByRole('button', { name: 'Create' }).click(); await expect(view.getByText('Weekly digest')).toBeVisible(); expect(createBodies[0]).toMatchObject({ name: 'Weekly digest', prompt: 'Summarize GitHub and design activity.', - schedule: { - kind: 'daily', - time: '09:00', - }, - target: { - mode: 'create_each_run', - }, - }); - const schedule = createBodies[0]?.schedule as { timezone?: unknown } | undefined; - expect(typeof schedule?.timezone).toBe('string'); - expect((schedule?.timezone as string).length).toBeGreaterThan(0); - - const row = view.locator('.automation-row', { hasText: 'Weekly digest' }).first(); - await row.getByRole('button', { name: 'Run' }).click(); - await expect(page).toHaveURL(/\/projects\/proj-run/); - - await page.goto('/tasks', { waitUntil: 'domcontentloaded' }); - await waitForLoadingToClear(page); - const refreshedView = page.getByTestId('tasks-view'); - await expect(refreshedView.getByRole('heading', { level: 1, name: AUTOMATIONS_TITLE })).toBeVisible({ - timeout: T.long, - }); - const refreshedRow = refreshedView.locator('.automation-row', { hasText: 'Weekly digest' }).first(); - await expect(refreshedRow).toContainText(/Queued|Running|Manual/i, { timeout: T.long }); - await expect(refreshedRow.getByRole('button', { name: /Open result/i })).toBeVisible({ - timeout: T.long, + schedule: { kind: 'daily', time: '09:00' }, + target: { mode: 'create_each_run' }, }); - await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForLoadingToClear(page); - const reloadedView = page.getByTestId('tasks-view'); - await expect(reloadedView.getByRole('heading', { level: 1, name: AUTOMATIONS_TITLE })).toBeVisible({ - timeout: T.long, - }); - const reloadedRow = reloadedView.locator('.automation-row', { hasText: 'Weekly digest' }).first(); - await expect(reloadedRow).toContainText(/Queued|Running|Manual/i, { timeout: T.long }); - await expect(reloadedRow.getByRole('button', { name: /Open result/i })).toBeVisible({ - timeout: T.long, - }); - }); - - test('[P1] places a newly created automation at the top of the list and highlights it', async ({ page }) => { - await seedAutomationsBase(page); - - const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; - let routines: Array> = [ - { - id: 'routine-existing-1', - name: 'Older digest', - prompt: 'Summarize older activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now() - 120_000, - updatedAt: Date.now() - 120_000, - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - const now = Date.now(); - const routine = { - id: 'routine-newest-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: now + 3600_000, - lastRun: null, - createdAt: now, - updatedAt: now, - }; - routines = [...routines, routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - await expect(view.getByText('Older digest')).toBeVisible(); - - await view.getByRole('button', { name: 'New automation' }).click(); - const modal = page.getByTestId('automation-modal'); - await modal.getByTestId('automation-modal-title').fill('Newest digest'); - await modal.getByTestId('automation-modal-prompt').fill('Summarize the newest activity.'); - await modal.getByRole('button', { name: 'Create' }).click(); - - const rows = view.locator('.automation-row'); - await expect(rows.first()).toContainText('Newest digest'); - await expect(view.getByTestId('automation-row-routine-newest-1')).toHaveClass(/is-focused/); - }); - - test('[P1] creates a weekly automation with edited weekday time and timezone payload', async ({ page }) => { - await seedAutomationsBase(page); - - const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; - const createBodies: Array> = []; - let routines: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - createBodies.push(payload); - const routine = { - id: 'routine-weekly-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - - await view.getByRole('button', { name: 'New automation' }).click(); - const modal = page.getByTestId('automation-modal'); - await modal.getByTestId('automation-modal-title').fill('Friday launch digest'); - await modal.getByTestId('automation-modal-prompt').fill('Summarize launch readiness every Friday.'); - const schedulePopover = await openSchedulePopover(modal, /Daily/i); - await schedulePopover.getByRole('tab', { name: 'Weekly' }).click(); - await schedulePopover.locator('.automation-popover__weekdays').getByRole('button', { name: 'Fri' }).click(); - await schedulePopover.locator('input[type="time"]').fill('16:45'); - await schedulePopover.locator('select').selectOption('UTC'); - await schedulePopover.getByRole('button', { name: 'Done' }).click(); - await expect(modal.getByRole('button', { name: /Friday.*4:45 PM.*UTC/i })).toBeVisible(); - - await modal.getByRole('button', { name: 'Create' }).click(); - await expect.poll(() => createBodies.length).toBe(1); - expect(createBodies[0]).toMatchObject({ - name: 'Friday launch digest', - prompt: 'Summarize launch readiness every Friday.', - schedule: { - kind: 'weekly', - weekday: 5, - time: '16:45', - timezone: 'UTC', - }, - target: { - mode: 'create_each_run', - }, - }); - }); - - test('[P1] renders localized automation schedule summaries in zh-CN', async ({ page }) => { - await seedAutomationsBase(page, { locale: 'zh-CN' }); - - const now = Date.now(); - const routines = [ - { - id: 'routine-zh-weekly-1', - name: '中文周报', - prompt: '总结本周项目进展。', - schedule: { kind: 'weekly', weekday: 5, time: '16:45', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 3600_000, - lastRun: null, - createdAt: now, - updatedAt: now, - }, - { - id: 'routine-zh-weekdays-1', - name: '工作日摘要', - prompt: '总结工作日活动。', - schedule: { kind: 'weekdays', time: '09:30', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 7200_000, - lastRun: null, - createdAt: now - 60_000, - updatedAt: now - 60_000, - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - await expect(view.getByRole('heading', { name: '自动化', exact: true })).toBeVisible(); - await expect(view.getByLabel('你的自动化')).toContainText('中文周报'); - await expect(view.getByTestId('automation-row-routine-zh-weekly-1')).toContainText('每周五 4:45 下午'); - await expect(view.getByTestId('automation-row-routine-zh-weekly-1')).toContainText('UTC'); - await expect(view.getByTestId('automation-row-routine-zh-weekdays-1')).toContainText('周一至周五 9:30 上午'); - }); - - test('[P1] edits an existing automation and persists updated schedule, prompt, and target', async ({ page }) => { - await seedAutomationsBase(page); - - const now = Date.now(); - const projects = [ - { id: 'proj-1', name: 'Routine Test Project' }, - { id: 'proj-2', name: 'Launch Room' }, - ]; - let routines: Array> = [ - { - id: 'routine-edit-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 3600_000, - lastRun: null, - createdAt: now - 60_000, - updatedAt: now - 60_000, - }, - ]; - const patchBodies: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/routines/routine-edit-1', async (route) => { - if (route.request().method() !== 'PATCH') { - await route.fulfill({ status: 404, body: '{}' }); - return; - } - const payload = route.request().postDataJSON() as Record; - patchBodies.push(payload); - const updated = { - ...routines[0], - ...payload, - enabled: true, - nextRunAt: now + 7200_000, - updatedAt: now, - }; - routines = [updated]; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routine: updated }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - const row = view.getByTestId('automation-row-routine-edit-1'); - await row.getByRole('button', { name: 'Edit' }).click(); - - const modal = page.getByTestId('automation-modal'); - await expect(modal).toHaveAttribute('aria-label', 'Edit'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Daily digest'); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue('Summarize GitHub and design activity.'); - - await modal.getByRole('button', { name: /New project each run/i }).click(); - await modal.getByRole('button', { name: 'Launch Room' }).click(); - await expect(modal.getByRole('button', { name: /Launch Room/i })).toBeVisible(); - - const schedulePopover = await openSchedulePopover(modal, /Daily/i); - await schedulePopover.getByRole('tab', { name: 'Weekdays' }).click(); - await schedulePopover.locator('input[type="time"]').fill('10:30'); - await schedulePopover.locator('select').selectOption('UTC'); - await schedulePopover.getByRole('button', { name: 'Done' }).click(); - await expect(modal.getByRole('button', { name: /Runs Mon.*Fri.*10:30 AM.*UTC/i })).toBeVisible(); - - await modal.getByTestId('automation-modal-title').fill('Launch digest'); - await modal.getByTestId('automation-modal-prompt').fill('Summarize launch readiness and open blockers.'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Launch digest'); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue('Summarize launch readiness and open blockers.'); - - await modal.getByRole('button', { name: 'Save' }).click(); - await expect.poll(() => patchBodies.length).toBe(1); - expect(patchBodies[0]).toMatchObject({ - name: 'Launch digest', - prompt: 'Summarize launch readiness and open blockers.', - schedule: { - kind: 'weekdays', - time: '10:30', - timezone: 'UTC', - }, - target: { - mode: 'reuse', - projectId: 'proj-2', - }, - }); - - await expect(row).toContainText('Launch digest'); - await expect(row).toContainText('Runs Mon–Fri at 10:30 AM'); - await expect(row).toContainText('Launch Room'); - await expect(row).toContainText('Summarize launch readiness and open blockers.'); - }); - - test('[P1] creates a reuse-project automation and opens the reused project after Run', async ({ page }) => { - await seedAutomationsBase(page); - - const projects = [ - { id: 'proj-1', name: 'Routine Test Project' }, - { id: 'proj-run', name: 'Existing Launch Project' }, - ]; - let routines: Array> = []; - const createBodies: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - createBodies.push(payload); - const routine = { - id: 'routine-reuse-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-reuse-1/run', async (route) => { - const startedAt = Date.now(); - const lastRun = { - runId: 'run-reuse-1', - status: 'queued', - trigger: 'manual', - startedAt, - projectId: 'proj-run', - conversationId: 'conv-reuse', - agentRunId: 'agent-run-reuse-1', - }; - routines = [{ ...routines[0], lastRun }]; - await route.fulfill({ - status: 202, - contentType: 'application/json', - body: JSON.stringify({ - routine: routines[0], - run: lastRun, - projectId: 'proj-run', - conversationId: 'conv-reuse', - agentRunId: 'agent-run-reuse-1', - }), - }); - }); - - const view = await gotoAutomations(page); - await view.getByRole('button', { name: 'New automation' }).click(); - const modal = page.getByTestId('automation-modal'); - await modal.getByTestId('automation-modal-title').fill('Reuse launch project'); - await modal.getByTestId('automation-modal-prompt').fill('Append launch readiness notes.'); - - await modal.getByRole('button', { name: /New project each run/i }).click(); - await modal.getByRole('button', { name: 'Existing Launch Project' }).click(); - await expect(modal.getByRole('button', { name: /Existing Launch Project/i })).toBeVisible(); - await modal.getByRole('button', { name: 'Create' }).click(); - - await expect.poll(() => createBodies.length).toBe(1); - expect(createBodies[0]).toMatchObject({ - name: 'Reuse launch project', - prompt: 'Append launch readiness notes.', - target: { - mode: 'reuse', - projectId: 'proj-run', - }, - }); - - const row = view.getByTestId('automation-row-routine-reuse-1'); - await expect(row).toContainText('Existing Launch Project'); - await row.getByRole('button', { name: 'Run' }).click(); + await view.locator('.automation-row', { hasText: 'Weekly digest' }).first() + .getByRole('button', { name: 'Run' }).click(); await expect(page).toHaveURL(/\/projects\/proj-run/); }); - test('[P1] keeps saved automations ordered by newest createdAt first', async ({ page }) => { + test('[P1] preserves the automation draft when creation fails', async ({ page }) => { await seedAutomationsBase(page); - - const now = Date.now(); - const routines = [ - { - id: 'routine-oldest-1', - name: 'Oldest digest', - prompt: 'Summarize the oldest activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 3600_000, - lastRun: null, - createdAt: now - 300_000, - updatedAt: now - 300_000, - }, - { - id: 'routine-middle-1', - name: 'Middle digest', - prompt: 'Summarize the middle activity.', - schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 7200_000, - lastRun: null, - createdAt: now - 120_000, - updatedAt: now - 120_000, - }, - { - id: 'routine-newest-1', - name: 'Newest digest', - prompt: 'Summarize the newest activity.', - schedule: { kind: 'daily', time: '11:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 10_800_000, - lastRun: null, - createdAt: now - 10_000, - updatedAt: now - 10_000, - }, - ]; - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"projects":[]}' }); }); - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - const rowTitles = view.locator('.automation-row .automation-row__title'); - await expect(rowTitles).toHaveText([ - 'Newest digest', - 'Middle digest', - 'Oldest digest', - ]); - }); - - test('[P1] keeps the automation modal open with the typed values when creation fails', async ({ page }) => { - await seedAutomationsBase(page); - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines: [] }), - }); + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"routines":[]}' }); return; } - if (method === 'POST') { + if (route.request().method() === 'POST') { await route.fulfill({ status: 500, contentType: 'application/json', - body: JSON.stringify({ error: 'provider unavailable' }), + body: '{"error":"provider unavailable"}', }); return; } await route.fulfill({ status: 404, body: '{}' }); }); - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - const view = await gotoAutomations(page); - await view.getByRole('button', { name: 'New automation' }).click(); const modal = page.getByTestId('automation-modal'); await modal.getByTestId('automation-modal-title').fill('Weekly digest'); @@ -1075,1157 +204,4 @@ test.describe('Automations page', () => { await expect(modal.getByText('provider unavailable')).toBeVisible(); await expect(view.getByText('No automations yet')).toBeVisible(); }); - - test('[P1] shows a page error and keeps the row usable when Run fails', async ({ page }) => { - await seedAutomationsBase(page); - - const routines = [ - { - id: 'routine-run-error-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-run-error-1/run', async (route) => { - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'provider unavailable' }), - }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily digest' }).first(); - - await row.getByRole('button', { name: 'Run' }).click(); - - await expect(view.getByRole('alert')).toContainText('provider unavailable'); - await expect(row.getByRole('button', { name: 'Run' })).toBeVisible(); - await expect(row.getByRole('button', { name: 'Pause' })).toBeVisible(); - }); - - test('[P1] pauses, expands history, and deletes an automation from the saved list', async ({ page }) => { - await seedAutomationsBase(page); - - let routines: Array> = [ - { - id: 'routine-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-1', async (route) => { - const method = route.request().method(); - if (method === 'PATCH') { - const payload = route.request().postDataJSON() as { enabled?: boolean }; - const routine = routines[0]; - if (!routine) throw new Error('missing routine fixture'); - const updated = { ...routine, enabled: Boolean(payload.enabled), updatedAt: Date.now() }; - routines = [updated]; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routine: updated }), - }); - return; - } - if (method === 'DELETE') { - routines = []; - await route.fulfill({ status: 204, body: '' }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/routines/routine-1/runs?limit=10', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - runs: [ - { - id: 'run-1', - routineId: 'routine-1', - trigger: 'manual', - status: 'succeeded', - projectId: 'proj-run', - conversationId: 'conv-run', - agentRunId: 'agent-run-1', - startedAt: Date.now() - 60_000, - completedAt: Date.now() - 15_000, - summary: 'Updated digest', - error: null, - errorCode: null, - }, - ], - }), - }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily digest' }).first(); - - await row.getByRole('button', { name: 'Pause' }).click(); - await expect(row.getByRole('button', { name: 'Resume' })).toBeVisible(); - - await row.getByRole('button', { name: 'History' }).click(); - await expect(page.getByLabel('Automation run history')).toBeVisible(); - await row.getByRole('button', { name: 'Hide history' }).click(); - await expect(page.getByLabel('Automation run history')).toHaveCount(0); - - page.once('dialog', (dialog) => { - void dialog.accept(); - }); - await row.getByRole('button', { name: 'Delete automation' }).click({ force: true }); - - await expect(view.getByText('No automations yet')).toBeVisible(); - }); - - test('[P1] reviews pending automation proposals with apply and reject actions', async ({ page }) => { - await seedAutomationsBase(page); - - let proposals: Array> = [ - { - id: 'proposal-apply-1', - title: 'Promote launch notes into memory', - summary: 'Store the launch checklist as reusable project memory.', - targetKind: 'memory', - action: 'create', - status: 'pending-review', - reviewPolicy: 'manual', - createdAt: new Date(Date.now() - 60_000).toISOString(), - updatedAt: new Date(Date.now() - 60_000).toISOString(), - sourcePacketIds: ['packet-1'], - automationRunId: 'run-apply-1', - targetRef: 'memory/project-launch', - patch: { format: 'markdown', after: '# Launch checklist', diffSummary: 'Adds launch checklist memory.' }, - confidence: 0.88, - }, - { - id: 'proposal-reject-1', - title: 'Create a nightly skill draft', - summary: 'Draft a skill from the latest automation run.', - targetKind: 'skill', - action: 'update', - status: 'pending-review', - reviewPolicy: 'manual', - createdAt: new Date(Date.now() - 30_000).toISOString(), - updatedAt: new Date(Date.now() - 30_000).toISOString(), - sourcePacketIds: ['packet-2'], - automationRunId: 'run-reject-1', - targetRef: 'skills/nightly', - patch: { format: 'markdown', diffSummary: 'Updates the nightly skill draft.' }, - confidence: 0.72, - }, - ]; - const applyBodies: Array> = []; - const rejectBodies: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ projects: [] }) }); - }); - await page.route('**/api/routines', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ routines: [] }) }); - }); - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ templates: [] }) }); - }); - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ packets: [] }) }); - }); - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals }), - }); - }); - await page.route('**/api/automation-proposals/*/apply', async (route) => { - applyBodies.push(route.request().postDataJSON() as Record); - const id = route.request().url().match(/automation-proposals\/([^/]+)\/apply/)?.[1] ?? ''; - const proposal = proposals.find((item) => item.id === id); - proposals = proposals.filter((item) => item.id !== id); - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposal: { ...proposal, status: 'applied' } }), - }); - }); - await page.route('**/api/automation-proposals/*/reject', async (route) => { - rejectBodies.push(route.request().postDataJSON() as Record); - const id = route.request().url().match(/automation-proposals\/([^/]+)\/reject/)?.[1] ?? ''; - const proposal = proposals.find((item) => item.id === id); - proposals = proposals.filter((item) => item.id !== id); - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposal: { ...proposal, status: 'rejected' } }), - }); - }); - - const view = await gotoAutomations(page); - const proposalSection = view.getByLabel('Automation evolution proposals'); - await expect(proposalSection).toContainText('Promote launch notes into memory'); - await expect(proposalSection).toContainText('Create a nightly skill draft'); - - await proposalSection - .locator('.automation-row', { hasText: 'Promote launch notes into memory' }) - .getByRole('button', { name: 'Apply' }) - .click(); - await expect.poll(() => applyBodies.length).toBe(1); - await expect(proposalSection).not.toContainText('Promote launch notes into memory'); - await expect(proposalSection).toContainText('Create a nightly skill draft'); - - await proposalSection - .locator('.automation-row', { hasText: 'Create a nightly skill draft' }) - .getByRole('button', { name: 'Reject' }) - .click(); - await expect.poll(() => rejectBodies.length).toBe(1); - expect(rejectBodies[0]?.reason).toBeTruthy(); - await expect(view.getByLabel('Automation evolution proposals')).toHaveCount(0); - }); - - test('[P1] crystallizes a successful automation run into a pending proposal entry', async ({ page }) => { - await seedAutomationsBase(page); - - const now = Date.now(); - const routines = [ - { - id: 'routine-crystal-1', - name: 'Crystallize launch digest', - prompt: 'Summarize launch readiness.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: now + 3600_000, - lastRun: null, - createdAt: now, - updatedAt: now, - }, - ]; - let proposals: Array> = []; - let crystallizeCalls = 0; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ projects: [] }) }); - }); - await page.route('**/api/routines', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ routines }) }); - }); - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ templates: [] }) }); - }); - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ packets: [] }) }); - }); - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals }), - }); - }); - await page.route('**/api/routines/routine-crystal-1/runs?limit=10', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - runs: [ - { - id: 'run-crystal-1', - templateId: 'routine-crystal-1', - status: 'succeeded', - triggerKind: 'manual', - trigger: 'manual', - projectId: 'proj-crystal', - conversationId: 'conv-crystal', - agentRunId: 'agent-run-crystal-1', - startedAt: new Date(now - 80_000).toISOString(), - completedAt: new Date(now - 30_000).toISOString(), - sourcePacketIds: ['packet-crystal-1'], - proposalIds: [], - summary: 'Launch readiness memory should be updated.', - }, - ], - }), - }); - }); - await page.route('**/api/routines/routine-crystal-1/runs/run-crystal-1/crystallize', async (route) => { - crystallizeCalls += 1; - proposals = [ - { - id: 'proposal-crystal-1', - title: 'Update launch readiness memory', - summary: 'Capture the successful launch digest as reusable memory.', - targetKind: 'memory', - action: 'update', - status: 'pending-review', - reviewPolicy: 'manual', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - sourcePacketIds: ['packet-crystal-1'], - automationRunId: 'run-crystal-1', - targetRef: 'memory/launch-readiness', - patch: { format: 'markdown', diffSummary: 'Adds launch readiness learnings from the run.' }, - confidence: 0.9, - }, - ]; - await route.fulfill({ status: 202, contentType: 'application/json', body: '{}' }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Crystallize launch digest' }).first(); - await row.getByRole('button', { name: 'History' }).click(); - const history = page.getByLabel('Automation run history'); - await expect(history).toContainText('Launch readiness memory should be updated.'); - - await history.getByRole('button', { name: 'Crystallize' }).click(); - - await expect.poll(() => crystallizeCalls).toBe(1); - const proposalSection = view.getByLabel('Automation evolution proposals'); - await expect(proposalSection).toContainText('Update launch readiness memory'); - await expect(proposalSection).toContainText('Adds launch readiness learnings from the run.'); - }); - - test('[P1] shows a page error and keeps the row usable when Pause fails', async ({ page }) => { - await seedAutomationsBase(page); - - const routines = [ - { - id: 'routine-pause-error-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-pause-error-1', async (route) => { - if (route.request().method() === 'PATCH') { - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'pause failed upstream' }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily digest' }).first(); - - await row.getByRole('button', { name: 'Pause' }).click(); - - await expect(view.getByRole('alert')).toContainText('pause failed upstream'); - await expect(row.getByRole('button', { name: 'Pause' })).toBeVisible(); - await expect(row.getByRole('button', { name: 'Run' })).toBeVisible(); - }); - - test('[P1] shows a page error and keeps the row visible when Delete fails', async ({ page }) => { - await seedAutomationsBase(page); - - const routines = [ - { - id: 'routine-delete-error-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-delete-error-1', async (route) => { - if (route.request().method() === 'DELETE') { - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'delete failed upstream' }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily digest' }).first(); - - page.once('dialog', (dialog) => { - void dialog.accept(); - }); - await row.getByRole('button', { name: 'Delete automation' }).click({ force: true }); - - await expect(view.getByRole('alert')).toContainText('delete failed upstream'); - await expect(row).toBeVisible(); - await expect(row.getByRole('button', { name: 'Delete automation' })).toBeVisible(); - }); - - test('[P1] edits an automation title from the saved list and keeps the updated row visible', async ({ page }) => { - await seedAutomationsBase(page); - - let routines = [ - { - id: 'routine-edit-1', - name: 'Daily digest', - prompt: 'Summarize GitHub and design activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-edit-1', async (route) => { - if (route.request().method() === 'PATCH') { - const payload = route.request().postDataJSON() as { name?: string; prompt?: string }; - const routine = routines[0]; - if (!routine) throw new Error('missing routine fixture'); - const updated = { - ...routine, - name: payload.name ?? routine.name, - prompt: payload.prompt ?? routine.prompt, - updatedAt: Date.now(), - }; - routines = [ - updated, - ]; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routine: updated }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily digest' }).first(); - - await row.getByRole('button', { name: 'Edit' }).click(); - const modal = page.getByTestId('automation-modal'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Daily digest'); - await modal.getByTestId('automation-modal-title').fill('Daily digest edited'); - await modal.getByRole('button', { name: /^Save/i }).click(); - - await expect(view.getByText('Daily digest edited')).toBeVisible(); - }); - - test('[P1] editing an automation schedule sends the updated weekly PATCH payload', async ({ page }) => { - await seedAutomationsBase(page); - - const patchBodies: Array> = []; - let routines: Array> = [ - { - id: 'routine-edit-schedule-1', - name: 'Daily launch digest', - prompt: 'Summarize launch activity.', - schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, - target: { mode: 'create_each_run' }, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - await page.route('**/api/routines/routine-edit-schedule-1', async (route) => { - if (route.request().method() === 'PATCH') { - const payload = route.request().postDataJSON() as Record; - patchBodies.push(payload); - const current = routines[0]; - if (!current) throw new Error('missing routine fixture'); - const routine = { - ...current, - name: payload.name ?? current.name, - prompt: payload.prompt ?? current.prompt, - schedule: payload.schedule ?? current.schedule, - target: payload.target ?? current.target, - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Daily launch digest' }).first(); - - await row.getByRole('button', { name: 'Edit' }).click(); - const modal = page.getByTestId('automation-modal'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Daily launch digest'); - const schedulePopover = await openSchedulePopover(modal, /Daily/i); - await schedulePopover.getByRole('tab', { name: 'Weekly' }).click(); - await schedulePopover.locator('.automation-popover__weekdays').getByRole('button', { name: 'Tue' }).click(); - await schedulePopover.locator('input[type="time"]').fill('08:15'); - await schedulePopover.locator('select').selectOption('UTC'); - await schedulePopover.getByRole('button', { name: 'Done' }).click(); - await modal.getByRole('button', { name: /^Save/i }).click(); - - await expect.poll(() => patchBodies.length).toBe(1); - expect(patchBodies[0]).toMatchObject({ - name: 'Daily launch digest', - prompt: 'Summarize launch activity.', - schedule: { - kind: 'weekly', - weekday: 2, - time: '08:15', - timezone: 'UTC', - }, - target: { - mode: 'create_each_run', - }, - }); - }); - - test('[P1] switches template filters and updates the visible template cards', async ({ page }) => { - await seedAutomationsBase(page); - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines: [] }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - const tabs = view.getByRole('tablist', { name: 'Template filters' }); - - await expect(view.getByText(/Refresh project memory from recent work\./i)).toBeVisible(); - - await tabs.getByRole('tab', { name: /Orbit/i }).click(); - await expect(view.getByRole('status')).toHaveCount(0); - await expect(view.getByText(/Refresh project memory from recent work\./i)).toHaveCount(0); - - await tabs.getByRole('tab', { name: /Memory/i }).click(); - await expect(view.getByText(/Refresh project memory from recent work\./i)).toBeVisible(); - await expect(view.getByRole('status')).toHaveCount(0); - }); - - test('[P1] creates an automation from a catalog template with derived prompt context', async ({ page }) => { - await seedAutomationsBase(page); - - const createBodies: Array> = []; - let routines: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects: [] }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - createBodies.push(payload); - const routine = { - id: 'routine-template-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - templates: [ - { - id: 'memory-refresh-template', - title: 'Memory refresh template', - description: 'Refresh project memory from selected source packets.', - purpose: 'Keep durable memory current from recent product and design work.', - triggerKinds: ['manual', 'schedule'], - sourceKinds: ['artifact', 'connector'], - stages: [ - { id: 'ingest', kind: 'ingest', title: 'Collect source packets' }, - { id: 'compress', kind: 'compress', title: 'Compact durable context' }, - { id: 'apply', kind: 'apply', title: 'Update memory' }, - ], - outputSinks: ['memory'], - reviewPolicy: 'optional', - tokenCompression: 'balanced', - tags: ['memory'], - }, - ], - }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - - await view.getByRole('button', { name: /Memory refresh template/i }).click(); - const modal = page.getByTestId('automation-modal'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Memory refresh template'); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue(/Use Automation template "memory-refresh-template"/); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue(/Pipeline: Collect source packets -> Compact durable context -> Update memory/); - - await modal.getByRole('button', { name: 'Create' }).click(); - await expect(view.getByTestId('automation-row-routine-template-1')).toContainText('Memory refresh template'); - expect(createBodies[0]).toMatchObject({ - name: 'Memory refresh template', - schedule: { - kind: 'daily', - time: '09:00', - }, - target: { - mode: 'create_each_run', - }, - }); - expect(createBodies[0]?.prompt).toContain('Use Automation template "memory-refresh-template"'); - expect(createBodies[0]?.prompt).toContain('Outputs: memory.'); - }); - - test('[P1] template-created automation preserves edited schedule and existing project target payload', async ({ page }) => { - await seedAutomationsBase(page); - - const projects = [{ id: 'proj-template-target-1', name: 'Template Target Project' }]; - const createBodies: Array> = []; - let routines: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - createBodies.push(payload); - const routine = { - id: 'routine-template-target-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - templates: [ - { - id: 'design-system-watch-template', - title: 'Design system watch template', - description: 'Watch artifacts and propose durable design-system updates.', - purpose: 'Keep the design system aligned with accepted project artifacts.', - triggerKinds: ['manual', 'schedule'], - sourceKinds: ['artifact'], - stages: [ - { id: 'collect', kind: 'ingest', title: 'Collect accepted artifacts' }, - { id: 'propose', kind: 'propose', title: 'Draft DESIGN.md updates' }, - ], - outputSinks: ['design-system'], - reviewPolicy: 'always', - tokenCompression: 'balanced', - tags: ['design-system'], - }, - ], - }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - - await view.getByRole('button', { name: /Design system watch template/i }).click(); - const modal = page.getByTestId('automation-modal'); - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Design system watch template'); - await modal.getByRole('button', { name: /New project each run/i }).click(); - await page.getByRole('button', { name: 'Template Target Project' }).click(); - const schedulePopover = await openSchedulePopover(modal, /Daily/i); - await schedulePopover.getByRole('tab', { name: 'Weekly' }).click(); - await schedulePopover.locator('.automation-popover__weekdays').getByRole('button', { name: 'Thu' }).click(); - await schedulePopover.locator('input[type="time"]').fill('13:30'); - await schedulePopover.locator('select').selectOption('UTC'); - await schedulePopover.getByRole('button', { name: 'Done' }).click(); - - await modal.getByRole('button', { name: 'Create' }).click(); - await expect.poll(() => createBodies.length).toBe(1); - expect(createBodies[0]).toMatchObject({ - name: 'Design system watch template', - schedule: { - kind: 'weekly', - weekday: 4, - time: '13:30', - timezone: 'UTC', - }, - target: { - mode: 'reuse', - projectId: 'proj-template-target-1', - }, - }); - expect(createBodies[0]?.prompt).toContain('Use Automation template "design-system-watch-template"'); - expect(createBodies[0]?.prompt).toContain('Outputs: design-system.'); - }); - - test('[P1] renders the routine target and last-run status in the row summary', async ({ page }) => { - await seedAutomationsBase(page); - - const projects = [{ id: 'proj-shared-1', name: 'Shared Release Project' }]; - const routines = [ - { - id: 'routine-summary-1', - name: 'Release digest', - prompt: 'Summarize release issues and recent commits.', - schedule: { kind: 'weekly', weekday: 3, time: '14:30', timezone: 'UTC' }, - target: { mode: 'reuse', projectId: 'proj-shared-1' }, - enabled: true, - nextRunAt: Date.now() + 86_400_000, - lastRun: { - id: 'run-summary-1', - status: 'failed', - trigger: 'manual', - startedAt: Date.now() - 7_200_000, - error: 'Provider request timed out after 30s', - summary: 'Provider request timed out after 30s', - }, - createdAt: Date.now() - 300_000, - updatedAt: Date.now() - 60_000, - }, - ]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - }); - - await page.route('**/api/automation-templates', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ templates: [] }), - }); - }); - - await page.route('**/api/automation-proposals?status=pending-review', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ proposals: [] }), - }); - }); - - await page.route('**/api/automation-source-packets?limit=3', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ packets: [] }), - }); - }); - - const view = await gotoAutomations(page); - const row = view.locator('.automation-row', { hasText: 'Release digest' }).first(); - - await expect(row).toContainText('Shared Release Project'); - await expect(row).toContainText('Failed'); - }); }); diff --git a/e2e/ui/settings-memory-routines.test.ts b/e2e/ui/settings-memory-routines.test.ts index b83be6a3c4..b38bd38cc3 100644 --- a/e2e/ui/settings-memory-routines.test.ts +++ b/e2e/ui/settings-memory-routines.test.ts @@ -169,7 +169,7 @@ async function openMemoryAddDialog( return dialog; } -test.describe('Settings Memory and Automations flows', () => { +test.describe('Settings Memory flows', () => { test('[P1] renders the new Memory information architecture with source tabs, saved stats, and tree summaries', async ({ page }) => { await seedSettingsBase(page); @@ -2196,179 +2196,4 @@ test.describe('Settings Memory and Automations flows', () => { await expect(settingsDialog.getByText('No memory yet.')).toBeVisible(); }); - test('[P1] creates an automation from the main Automations surface and runs it now', async ({ page }) => { - await seedSettingsBase(page); - - const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; - let routines: Array> = []; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines }), - }); - return; - } - if (method === 'POST') { - const payload = route.request().postDataJSON() as Record; - const routine = { - id: 'routine-1', - name: payload.name, - prompt: payload.prompt, - schedule: payload.schedule, - target: payload.target, - enabled: true, - nextRunAt: Date.now() + 3600_000, - lastRun: null, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - routines = [routine]; - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ routine }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/plugins', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ plugins: [] }), - }); - }); - - await page.route('**/api/mcp/servers', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ servers: [], templates: [] }), - }); - }); - - await page.route('**/api/routines/routine-1/run', async (route) => { - const startedAt = Date.now(); - const lastRun = { - runId: 'run-1', - status: 'queued', - trigger: 'manual', - startedAt, - projectId: 'proj-run', - conversationId: 'conv-run', - agentRunId: 'agent-run-1', - }; - routines = [{ ...routines[0], lastRun }]; - await route.fulfill({ - status: 202, - contentType: 'application/json', - body: JSON.stringify({ - routine: routines[0], - run: lastRun, - }), - }); - }); - - await gotoEntryHome(page); - // #5517's rail dropped the Automations destination; /automations is still - // the route the view lives on. - await page.goto('/automations', { waitUntil: 'domcontentloaded' }); - const view = page.getByTestId('tasks-view'); - await expect(view.getByRole('heading', { name: 'Automations', exact: true })).toBeVisible(); - - await view.getByRole('button', { name: 'New automation' }).click(); - const modal = page.getByTestId('automation-modal'); - await modal.getByTestId('automation-modal-title').fill('Weekly digest'); - await modal.getByTestId('automation-modal-prompt').fill('Summarize GitHub and design activity.'); - await modal.getByRole('button', { name: 'Create' }).click(); - - await expect(view.getByText('Weekly digest')).toBeVisible(); - - const row = view.locator('.automation-row', { hasText: 'Weekly digest' }).first(); - await expect(row).toBeVisible(); - await row.getByRole('button', { name: 'Run' }).click(); - await expect(row.getByText(/Last run/i)).toBeVisible(); - await expect(row.getByRole('button', { name: 'Open result' })).toBeVisible(); - }); - - test('[P1] keeps the automation modal open when creating an automation fails', async ({ page }) => { - await seedSettingsBase(page); - - const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; - - await page.route('**/api/projects', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ projects }), - }); - }); - - await page.route('**/api/routines', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ routines: [] }), - }); - return; - } - if (method === 'POST') { - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'provider unavailable' }), - }); - return; - } - await route.fulfill({ status: 404, body: '{}' }); - }); - - await page.route('**/api/plugins', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ plugins: [] }), - }); - }); - - await page.route('**/api/mcp/servers', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ servers: [], templates: [] }), - }); - }); - - await gotoEntryHome(page); - // #5517's rail dropped the Automations destination; /automations is still - // the route the view lives on. - await page.goto('/automations', { waitUntil: 'domcontentloaded' }); - const view = page.getByTestId('tasks-view'); - - await view.getByRole('button', { name: 'New automation' }).click(); - const modal = page.getByTestId('automation-modal'); - await modal.getByTestId('automation-modal-title').fill('Weekly digest'); - await modal.getByTestId('automation-modal-prompt').fill('Summarize GitHub and design activity.'); - await modal.getByRole('button', { name: 'Create' }).click(); - - await expect(modal.getByTestId('automation-modal-title')).toHaveValue('Weekly digest'); - await expect(modal.getByTestId('automation-modal-prompt')).toHaveValue('Summarize GitHub and design activity.'); - await expect(modal.getByText('provider unavailable')).toBeVisible(); - await expect(view.getByText('No automations yet')).toBeVisible(); - }); }); diff --git a/e2e/ui/visual-navigation.test.ts b/e2e/ui/visual-navigation.test.ts index 3af3c8014b..7b3fc59cb2 100644 --- a/e2e/ui/visual-navigation.test.ts +++ b/e2e/ui/visual-navigation.test.ts @@ -154,22 +154,6 @@ test.skip('[P2] captures the integrations MCP surface', async ({ page }) => { await captureVisual(page, 'visual-integrations-mcp'); }); -test('[P2] captures the tasks page surface', async ({ page }) => { - await configureVisualPage(page); - await gotoVisualHome(page); - - await ensureRailOpen(page); - const tasksNav = page.getByTestId('entry-nav-tasks'); - test.skip(!(await tasksNav.isVisible().catch(() => false)), 'Tasks navigation is not present in the current entry shell.'); - await tasksNav.click(); - await expect(page).toHaveURL(/\/automations$/); - await expect(page.getByTestId('tasks-view')).toBeVisible(); - await expect(page.getByText('No automations yet')).toBeVisible(); - await waitForVisualFonts(page); - - await captureVisual(page, 'visual-tasks'); -}); - async function openSettingsSection(page: import('@playwright/test').Page, testId: string) { // #5971 deleted the rail-footer settings chip (`entry-settings-button`). // `openSettingsDialog` owns every remaining entry point — the rail's