diff --git a/.changeset/serious-lights-rest.md b/.changeset/serious-lights-rest.md new file mode 100644 index 000000000..bbf3e2270 --- /dev/null +++ b/.changeset/serious-lights-rest.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Refresh the published workspace screenshots in the README and help guides so they match the current desktop and mobile editor shell UI. diff --git a/docs/help/assets/screenshot-desktop-workspace-full.png b/docs/help/assets/screenshot-desktop-workspace-full.png index 5e136f27d..1ea22cf9a 100644 Binary files a/docs/help/assets/screenshot-desktop-workspace-full.png and b/docs/help/assets/screenshot-desktop-workspace-full.png differ diff --git a/docs/help/assets/screenshot-desktop-workspace.png b/docs/help/assets/screenshot-desktop-workspace.png deleted file mode 100644 index 5e136f27d..000000000 Binary files a/docs/help/assets/screenshot-desktop-workspace.png and /dev/null differ diff --git a/docs/help/assets/screenshot-git-view.png b/docs/help/assets/screenshot-git-view.png deleted file mode 100644 index df78e2e8c..000000000 Binary files a/docs/help/assets/screenshot-git-view.png and /dev/null differ diff --git a/docs/help/assets/screenshot-mobile-layout.png b/docs/help/assets/screenshot-mobile-layout.png index c33d4e6dc..26e8ca497 100644 Binary files a/docs/help/assets/screenshot-mobile-layout.png and b/docs/help/assets/screenshot-mobile-layout.png differ diff --git a/docs/help/assets/screenshot-mobile.png b/docs/help/assets/screenshot-mobile.png index e25550e1c..26e8ca497 100644 Binary files a/docs/help/assets/screenshot-mobile.png and b/docs/help/assets/screenshot-mobile.png differ diff --git a/docs/help/assets/screenshot-pc.png b/docs/help/assets/screenshot-pc.png index 69d791bf0..927a682ed 100644 Binary files a/docs/help/assets/screenshot-pc.png and b/docs/help/assets/screenshot-pc.png differ diff --git a/docs/help/assets/screenshot-providers.png b/docs/help/assets/screenshot-providers.png deleted file mode 100644 index 303fcccf2..000000000 Binary files a/docs/help/assets/screenshot-providers.png and /dev/null differ diff --git a/docs/help/assets/screenshot-settings.png b/docs/help/assets/screenshot-settings.png deleted file mode 100644 index bcfeb36f4..000000000 Binary files a/docs/help/assets/screenshot-settings.png and /dev/null differ diff --git a/docs/help/assets/screenshot-workspace-overview.png b/docs/help/assets/screenshot-workspace-overview.png index e0ff5b001..85f681512 100644 Binary files a/docs/help/assets/screenshot-workspace-overview.png and b/docs/help/assets/screenshot-workspace-overview.png differ diff --git a/docs/promotion/article-2026-05-heavy-ai-user-day.md b/docs/promotion/article-2026-05-heavy-ai-user-day.md new file mode 100644 index 000000000..e50476b65 --- /dev/null +++ b/docs/promotion/article-2026-05-heavy-ai-user-day.md @@ -0,0 +1,131 @@ +# 重度 AI 编程用户的一天:我怎么把 Claude Code / Codex 工作流搬进浏览器工作台 + +如果你现在已经是 `Claude Code` 或 `Codex` 的重度用户,大概率会有一种很熟悉的感觉: + +模型越来越能干,但你的工作流还是很碎。 + +Agent 在终端里跑,代码在编辑器里看,Diff 要去 Git 工具里翻,离开工位之后想看进度,还得重新 SSH、远程桌面,或者干脆等回到电脑前再说。很多时候,真正拖慢效率的已经不是模型能力,而是这套工作流根本没有被接住。 + +我最近就在解决这个问题。 + +准确地说,我不是在找一个“更强的 AI 编程工具”,而是在找一个能承载 `Claude Code / Codex` 的工作台。最后,我把自己的日常工作流搬进了浏览器里的 `Coder Studio`。 + +它不是云 IDE,也不是简单把终端塞进网页,而是一个本地优先的 AI 编程工作台:服务跑在你自己的机器上,但你可以通过浏览器在桌面、平板、手机上访问同一个 workspace,把 Agent、文件、Git 和终端放到同一个界面里。 + +![Coder Studio 工作区总览](../help/assets/screenshot-workspace-overview.png) + +## 早上:在桌面端发起真正的 AI 编程任务 + +我现在一天的开始,通常不是先打开一堆终端窗口,而是先打开 `Coder Studio` 的 workspace。 + +比如我要改一个功能时,我会这么分工: + +- 开一个 `Claude Code` 会话,负责重构主逻辑 +- 再开一个 `Codex` 会话,处理测试、边角修复或者辅助验证 +- 同时把文件树、编辑器、Git 变化和终端都放在一个界面里盯着 + +以前这种工作流也能做,但体验很散。 + +你会在多个终端标签之间来回切,在编辑器和 Git 工具之间跳转,脑子里还得一直记住“哪个 Agent 正在改什么、改到了哪里、工作区现在脏成什么样”。Agent 一多,注意力就会被这些上下文切换不断打断。 + +`Coder Studio` 比较对我胃口的一点,就是它把这些东西收拢了。 + +我不需要一边盯终端输出,一边再切到别的应用里看文件改动。Agent 会话、代码、Diff、终端,本来就属于同一条工作流,放回一个工作台里才顺。 + +![Coder Studio 桌面端工作区](../help/assets/screenshot-pc.png) + +## 中午:离开工位之后,工作流不再断掉 + +过去最烦的一种情况是:Agent 还在跑,但人已经离开工位了。 + +这时候你其实很想做三件事: + +- 看它现在跑到哪一步了 +- 看它改了哪些文件 +- 判断这次输出值不值得你回去继续接手 + +传统做法要么是远程桌面,要么是 SSH 进去硬看终端输出。前者在手机上几乎没法用,后者又看不到完整的 workspace 上下文。 + +这也是我开始认真看“浏览器工作台”这条路的原因。 + +在 `Coder Studio` 里,我中午出门之后,手机上直接打开同一个 workspace,就能继续看 Agent 的输出、文件变化和 Git 状态。重点不是“手机上也能编程”这种噱头,而是工作流的连续性没有断。 + +![Coder Studio 移动端工作区](../help/assets/screenshot-mobile.png) + +这点对重度 AI 编程用户很重要。 + +因为我们现在越来越多的时间,不是花在亲手敲每一行代码上,而是花在: + +- 发起任务 +- 观察执行 +- 判断结果 +- 中途纠偏 +- 最后接管收尾 + +如果这些环节只能被绑死在某一台电脑前,其实还是低效。 + +## 晚上:换一台电脑继续,不需要重新接管环境 + +晚上回家继续干活时,很多工具的问题会再次暴露。 + +你需要重新连环境,重新打开项目,重新找刚才那个终端,重新确认 Agent 跑到哪里,重新建立上下文。流程不是不能继续,只是很不丝滑。 + +`Coder Studio` 的思路很简单: + +`Deploy once, code everywhere.` + +服务启动一次,同一个 workspace 可以在不同设备上继续访问。办公室电脑发起的任务,晚上回家在另一台电脑上打开浏览器就能接着看。不是重新开始一份环境,也不是重新接管一台远程机器,而是继续同一个工作区。 + +对 `Claude Code / Codex` 这种本来就偏“异步协作式”的工作方式来说,这种跨设备连续性比我想象中更有价值。 + +## 为什么这类用户会需要一个“工作台” + +我现在越来越觉得,`Claude Code / Codex` 的重度用户,后面一定会遇到一个共同问题: + +不是模型不够强,而是工作流太碎。 + +你会同时遇到这些需求: + +- 想并行跑多个 Agent +- 想一边看输出,一边审代码改动 +- 想把终端、编辑器、Git 放到一起 +- 想离开工位后还能看进度 +- 想换设备时不要重新接手环境 +- 想保留本地运行和代码隐私,而不是把项目整个丢到云 IDE + +这时候你需要的,已经不是又一个聊天窗口,也不是又一个“AI 功能入口”,而是一个真正的工作台。 + +`Coder Studio` 让我比较认可的地方也正在这里: + +- 它是`本地优先`的,代码留在你自己的机器上 +- 它支持 `Claude Code` 和 `Codex` 这类 CLI Agent 工作流 +- 它把 `Agent + 文件 + Git + 终端` 放进一个浏览器界面 +- 它不是只照顾桌面端,手机和平板也能接住查看和续接场景 + +## 这工具适合谁,不适合谁 + +如果你属于下面这类人,`Coder Studio` 会比较有感觉: + +- 每天都在用 `Claude Code / Codex` +- 经常一边跑 Agent,一边盯文件和 Git 变化 +- 会在办公室、家里、移动设备之间切换 +- 希望保留本地环境,不想完全依赖云 IDE + +但如果你只是偶尔让 AI 帮你补几行代码,或者基本只在一台电脑、一个终端里工作,那它未必是刚需。 + +它更适合已经进入“AI 编程工作流阶段”的用户,而不是只想体验一下模型能力的人。 + +## 最后 + +我现在对 AI 编程工具的判断标准,已经慢慢变了。 + +以前我最关心的是“模型强不强”。现在我更关心的是:这套工作流能不能让我在不同设备之间连续工作,能不能让我同时管理多个 Agent,能不能把终端、代码、Git 和上下文放回同一个地方。 + +从这个角度看,`Coder Studio` 更像是在补 AI 编程工作流里的那块缺口。 + +如果你已经是 `Claude Code / Codex` 的重度用户,也许下一步该优化的,不只是提示词,而是你的工作台。 + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` diff --git a/docs/superpowers/plans/2026-05-01-mobile-friendly-phase-1.md b/docs/superpowers/plans/2026-05-01-mobile-friendly-phase-1.md index f9e5e0aeb..882a50a11 100644 --- a/docs/superpowers/plans/2026-05-01-mobile-friendly-phase-1.md +++ b/docs/superpowers/plans/2026-05-01-mobile-friendly-phase-1.md @@ -16,7 +16,7 @@ **New files:** - `packages/web/src/shells/shared/use-workspace-bootstrap.ts` — shared bootstrap/navigation hook extracted from desktop shell -- `packages/web/src/shells/mobile-shell/mobile-topbar.tsx` — workspace pill, connection text, more-actions trigger +- `packages/web/src/shells/mobile-shell/mobile-topbar.tsx` — workspace pill, connection text, direct settings trigger - `packages/web/src/shells/mobile-shell/mobile-dock.tsx` — Files / Terminal dock buttons - `packages/web/src/shells/mobile-shell/mobile-sheet.tsx` — generic fullscreen mobile sheet with backdrop + close affordance - `packages/web/src/shells/mobile-shell/mobile-workspace-drawer.tsx` — workspace list drawer with workspace switching and secondary actions @@ -29,7 +29,7 @@ **No changes in Phase 1:** - `features/agent-panes`, `features/terminal-panel`, `features/workspace/*`, `features/code-editor/*` -- command-palette mobile sheet conversion +- command-palette mobile sheet conversion and workspace-topbar exposure - gesture hooks for swipe/drag interactions beyond tap/backdrop close --- @@ -170,8 +170,7 @@ Render: Responsibilities: - workspace pill showing active workspace name - connection label (`已连接`, `连接中`, `重连中`, `离线`, `另一个标签页已激活`) -- `⋯` button toggling a small action menu -- action menu items for Settings and Quick Actions +- direct settings icon button - [ ] **Step 4: Implement `MobileWorkspaceDrawer`** diff --git a/docs/superpowers/specs/2026-04-30-mobile-friendly-design.md b/docs/superpowers/specs/2026-04-30-mobile-friendly-design.md index 60f3a92ad..c638fdb47 100644 --- a/docs/superpowers/specs/2026-04-30-mobile-friendly-design.md +++ b/docs/superpowers/specs/2026-04-30-mobile-friendly-design.md @@ -59,7 +59,7 @@ | D3 | **断点:`max-width: 899px` OR `pointer: coarse`** | 双条件覆盖外接键鼠 iPad、带触屏小笔记本两种边角 | | D4 | **桌面 split 状态在移动端只读,不回写** | 移动端只是不同的"取景器",不污染桌面布局 | | D5 | **Files / Git 同 sheet 复用桌面 panel-tabs 结构** | 镜像桌面信息架构,零改动复用 | -| D6 | **Dock = Files + Terminal 二项极简** | Editor 是 file 的 consequence、Git 合并进 Files、Supervisor 走 chat 顶徽章、Settings/CP 进 topbar `⋯` | +| D6 | **Dock = Files + Terminal 二项极简** | Editor 是 file 的 consequence、Git 合并进 Files、Supervisor 走 chat 顶徽章、Settings 走 topbar 直达按钮 | | D7 | **Supervisor 改为 chat 上方的状态徽章** | Supervisor 是 agent 执行 objective 的伴生概念,与 chat 强相关,不必占 dock 槽位 | | D8 | **Settings 走整屏路由 + nav stack;其他二级走 sheet** | Settings 有真实层级 + URL 语义,路由优于 sheet;其他无层级用 sheet 一致 | | D9 | **同时只有一个 sheet 存在** | 切换 dock item 时新 sheet 替换旧 sheet,避免多层嵌套引发"我在哪一层"焦虑 | @@ -118,7 +118,7 @@ return viewport === 'mobile' ? : ; ``` ┌──────────────────────────────────────────┐ -│ ☰ my-app ▾ ● 已连接 ⋯ │ topbar 44px +│ ☰ my-app ▾ ● 已连接 ⚙ │ topbar 44px ├──────────────────────────────────────────┤ │ [● Claude] [⏳ Codex²] [✓ Gemini] + │ agent chips 44px │ 📍 3/8 │ supervisor 徽章(条件显示) @@ -140,7 +140,7 @@ return viewport === 'mobile' ? : ; |---|---| | 左 | `☰` + 当前 workspace 名 + `▾`(pill,整体可点 ≥ 44×44px) | | 中 | 连接状态点 + 文字(已连接 / 重连中 / 离线) | -| 右 | `⋯` 溢出菜单:Settings / 主题 / 命令面板 / 帮助 / 登出 | +| 右 | `Settings` 直达按钮(图标) | ### 3.3 Workspace 抽屉(点 pill 触发) @@ -353,7 +353,7 @@ drawer 永远盖过 sheet,sheet 永远盖过 chat。 | Auth / Login | 居中 modal | **整屏路由**,卡片 100% 宽 + 上下 padding | 仅 CSS | | Welcome | 主区居中 | **整屏路由**,按钮纵向 stack、字号放大 | 仅 CSS | | Settings | 居中 modal + 左右栏分类 | **整屏路由 + nav stack**:分类 → 详情,返回箭头回列表 | 加一层 nav stack 包装 | -| Command Palette | 浮层 modal | **整屏 sheet**,topbar `⋯` 触发 | sheet 容器 + 触摸优化 | +| Command Palette | 浮层 modal | **整屏 sheet**,通过独立入口触发,不挂在 workspace topbar 更多菜单下 | sheet 容器 + 触摸优化 | | Branch quick pick | 浮层 | 半屏 sheet(在 Git sheet 内 push) | sheet 容器 | | Workspace launch modal | 居中 modal | **整屏 sheet**(多步表单) | sheet 容器 | | Worktree modal | 居中 modal | 同上 | sheet 容器 | diff --git a/docs/superpowers/specs/2026-05-11-e2e-ui-design.md b/docs/superpowers/specs/2026-05-11-e2e-ui-design.md new file mode 100644 index 000000000..761f85941 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-e2e-ui-design.md @@ -0,0 +1,584 @@ +# E2E UI 图谱方案设计 + +> **版本:** 1.0 +> **日期:** 2026-05-11 +> **状态:** Draft(等待评审) +> **作者:** Spencer + Codex + +--- + +## 0. 文档说明 + +### 0.1 目的 + +为当前 app 新增一套独立于现有 `e2e/` 的 UI 自动化截图系统 `e2e-ui/`,用于: + +- 用 mock 数据和稳定场景批量产出 PC / Mobile 双端页面与弹框截图 +- 支持主题和语言切换 +- 生成统一 `report.html` 供人工查看、样式回归比对和 agent 分析 UI +- 不改变现有 `e2e/` 的职责、目录和运行方式 + +### 0.2 背景 + +当前仓库已经具备以下基础: + +- `packages/web` 已有双端壳切换:桌面走 `DesktopShell`,移动走 `MobileShell` +- UI 偏好已持久化在 `ui.theme` 和 `ui.locale` +- 现有 `e2e/` 已有 Playwright 基础设施与部分视觉类 spec +- 现有视觉类 spec 主要偏结构断言,不适合作为完整 UI 图谱输出系统 + +当前缺口: + +- 没有一套只服务于“截图出图”和“视觉浏览”的独立用例体系 +- 没有统一的截图归档与浏览页面 +- 没有专门用于特殊状态、弹框、空态、错误态的稳定 scene 注册机制 + +### 0.3 设计目标 + +- 新建完全独立的 `e2e-ui/` 目录,与现有 `e2e/` 平级 +- 保持现有 `e2e/` 不变,不迁移、不复用其 spec 结构 +- 执行单一命令 `pnpm e2e-ui` 即完成: + - 启动预览环境 + - 跑全部 UI scene + - 输出全部截图 + - 生成统一 `report.html` +- 支持 PC / Mobile、Dark / Light、ZH / EN 组合截图 +- 允许 scene 同时来源于: + - 真实路由 + - 独立 mock / showcase 场景 +- 结果只用于人工比对和 agent 查看,不接入 CI,不需要 smoke 分层 + +### 0.4 非目标 + +- 不替代现有 `e2e/` +- 不承担真实业务链路验收 +- 不在本期接入 CI +- 不要求每个 scene 都做像素级基线断言 +- 不把所有特殊态都强行走真实页面操作复现 + +--- + +## 1. 核心设计决策 + +| # | 决策 | 取舍理由 | +|---|---|---| +| D1 | UI 套件目录固定为 `e2e-ui/` | 与现有 `e2e/` 职责彻底分离 | +| D2 | 执行入口只保留一个命令 `pnpm e2e-ui` | 降低使用成本,符合“只做特殊用途”的目标 | +| D3 | scene 允许两种来源:`real-route` 与 `showcase` | 兼顾真实性、稳定性和覆盖率 | +| D4 | 截图结果统一写入 `e2e-ui/output/` | 输出目录固定,便于查看与清理 | +| D5 | 自动生成单文件 `report.html` | 无需额外服务即可离线浏览所有结果 | +| D6 | UI 套件默认只启动前端预览,不启动现有 e2e 的完整后端链路 | 保持运行更快、更稳 | +| D7 | 真实页面只覆盖稳定页面与少量易触发弹层 | 避免把图谱系统变成脆弱的流程自动化 | +| D8 | 特殊状态优先走 showcase / mock scene | 可稳定复现空态、错误态、确认态、loading 态 | +| D9 | 每个 scene 跑设备、主题、语言矩阵 | 满足 UI 修改和多皮肤、多语言回归查看需求 | +| D10 | report 按 category / source / device / theme / locale 分组 | 方便人工和 agent 快速定位同类 UI | + +--- + +## 2. 总体架构 + +### 2.1 目录结构 + +建议目录如下: + +```text +e2e-ui/ + package.json + playwright.config.ts + specs/ + capture.spec.ts + fixtures/ + scene-runner.ts + capture.ts + prefs.ts + scenes/ + index.ts + pages.ts + modals.ts + sheets.ts + states.ts + report/ + build-report.ts + output/ + screenshots/ + manifest.json + report.html +``` + +前端侧新增独立预览入口: + +```text +packages/web/ + ui-preview.html + src/ui-preview/ + app.tsx + scenes/ + mocks/ +``` + +### 2.2 职责划分 + +`e2e-ui/` 负责: + +- Playwright 执行 +- scene 编排 +- 截图落盘 +- 结果清单生成 +- `report.html` 产出 + +`packages/web/src/ui-preview/` 负责: + +- 注册 showcase scene +- 注入 mock 数据 +- 稳定渲染特殊状态 + +现有 `e2e/` 继续负责: + +- 真实链路验收 +- 行为与业务流程验证 +- 现有 acceptance / regression 范围 + +### 2.3 系统边界 + +这套系统不是测试“功能是否正确”,而是产出“这个 UI 当前长什么样”的稳定可浏览资产。 + +因此它的成功标准不是断言通过数,而是: + +- scene 能稳定打开 +- 截图能稳定产出 +- report 能按类别浏览 +- 用户和 agent 能快速横向比较同一 scene 的不同主题、语言、设备结果 + +--- + +## 3. Scene 模型 + +### 3.1 Scene 类型 + +每个 scene 必须声明自己的来源: + +- `real-route` + - 直接打开真实页面路由 + - 允许少量 UI 操作进入稳定状态 +- `showcase` + - 进入 `ui-preview.html` + - 通过 mock 数据直接渲染目标 UI + +### 3.2 Scene 元数据 + +建议统一使用以下结构: + +```ts +type UIScene = { + id: string; + title: string; + category: "page" | "modal" | "sheet" | "toast" | "empty" | "error" | "loading"; + source: "real-route" | "showcase"; + route: string; + devices: Array<"desktop" | "mobile">; + themes: Array<"dark" | "light">; + locales: Array<"zh" | "en">; + description?: string; + setup: (page: Page) => Promise; + target: (page: Page) => Promise; +}; +``` + +### 3.3 分类原则 + +- `page`:完整页面主视图 +- `modal`:桌面模态框、确认框、弹层 +- `sheet`:移动端 drawer / sheet / fullscreen sheet +- `toast`:提示信息 +- `empty`:空态 +- `error`:错误态 +- `loading`:加载中状态 + +### 3.4 为什么要 scene 注册层 + +如果没有 scene 注册层,截图逻辑会散落在 Playwright spec 中,结果会出现: + +- 截图命名不一致 +- 同类 UI 无法聚类展示 +- 难以扩展主题 / 语言 / 设备矩阵 +- report 数据源不统一 + +scene 注册层的职责是把“拍什么”从“怎么跑”里分离出来。 + +--- + +## 4. 场景来源策略 + +### 4.1 真实路由场景 + +推荐首批放入 `real-route` 的场景: + +- `welcome` +- `settings-general` +- `settings-appearance` +- `settings-providers` +- `workspace-desktop` +- `workspace-mobile` +- `not-found` +- `auth-preview` + +这些场景的共同特点: + +- 页面本身存在稳定入口 +- 不需要复杂依赖就能打开 +- 更适合直接反映真实产品状态 + +### 4.2 Showcase 场景 + +推荐优先走 `showcase` 的场景: + +- `workspace-launch-modal` +- `command-palette` +- `branch-quick-pick` +- `worktree-modal` +- `supervisor-dialog` +- `confirm-dialog-danger` +- `toast-success` +- `toast-error` +- `empty-state` +- `provider-config-error` +- `mobile-workspace-drawer` +- `mobile-files-sheet` +- `mobile-terminal-sheet` +- `mobile-supervisor-sheet` +- `loading-state` + +这些场景适合 showcase 的原因: + +- 真实流程触发成本高 +- 状态依赖复杂 +- 容易受外部数据波动影响 +- 人工主要关注外观,而非交互链路 + +### 4.3 路由与 Showcase 的混合原则 + +采用以下规则决定 scene 来源: + +1. 能稳定直达、且能代表真实产品页面的,用 `real-route` +2. 需要复杂准备、外部依赖、异常注入、特定空态的,用 `showcase` +3. 同一个组件如果既有真实页面形态,又有多个特殊态,正常态可走真实路由,特殊态走 showcase + +这是本方案的关键:不追求“所有 UI 都走真实流程”,也不追求“所有 UI 都是假场景”,而是用最小成本获得最稳定的 UI 图谱。 + +--- + +## 5. 截图矩阵与文件产物 + +### 5.1 运行矩阵 + +每个 scene 按以下维度展开: + +- Device + - `desktop` + - `mobile` +- Theme + - `dark` + - `light` +- Locale + - `zh` + - `en` + +并不是所有 scene 都必须覆盖全部维度。scene 自己声明支持的矩阵组合。 + +### 5.2 文件命名 + +截图建议命名格式: + +```text +//____.png +``` + +示例: + +```text +pages/settings-appearance/desktop__dark__zh.png +pages/settings-appearance/mobile__light__en.png +modals/workspace-launch-modal/desktop__dark__zh.png +sheets/mobile-files-sheet/mobile__dark__zh.png +``` + +### 5.3 输出目录 + +统一输出到: + +```text +e2e-ui/output/ + screenshots/ + manifest.json + report.html +``` + +其中: + +- `screenshots/` 存放全部 PNG +- `manifest.json` 是 report 的数据源 +- `report.html` 是可直接打开的统一浏览页 + +--- + +## 6. Report 设计 + +### 6.1 目标 + +`report.html` 的目标不是测试报告式的“通过/失败”,而是图片浏览式的“分类查看与横向比较”。 + +### 6.2 页面结构 + +建议页面分为两栏: + +- 左侧筛选栏 + - category + - source + - device + - theme + - locale +- 右侧内容区 + - scene 分组列表 + - 每组内展示同一 scene 的全部截图 + - 支持点击查看大图 + +### 6.3 分组方式 + +report 内的第一层分组建议按 `category`: + +- Pages +- Modals +- Sheets +- Toasts +- Empty +- Error +- Loading + +每个分组下按 `scene id` 聚合。这样用户能在同一块中直接对比: + +- 同一 scene 的 PC / Mobile 差异 +- 同一 scene 的 Dark / Light 差异 +- 同一 scene 的 ZH / EN 差异 + +### 6.4 Manifest 数据 + +建议每张图记录以下字段: + +```json +{ + "id": "settings-appearance", + "title": "Settings / Appearance", + "category": "page", + "source": "real-route", + "device": "desktop", + "theme": "dark", + "locale": "zh", + "path": "screenshots/pages/settings-appearance/desktop__dark__zh.png", + "description": "真实设置页外观设置分区" +} +``` + +report 只依赖 `manifest.json` 和本地图片文件,不依赖额外服务端。 + +--- + +## 7. 执行流程 + +### 7.1 单命令入口 + +根命令固定为: + +```bash +pnpm e2e-ui +``` + +该命令内部完成: + +1. 启动 `packages/web` 预览环境 +2. 执行 `e2e-ui` Playwright scene 采集 +3. 输出截图文件 +4. 生成 `manifest.json` +5. 生成 `report.html` + +### 7.2 为什么只保留一个命令 + +用户明确要求只保留一个入口命令。这样做的好处: + +- 使用成本最低 +- 不需要记住 capture / report / open-report 等多个脚本 +- 适合“有需要时手工跑一下看 UI”这种使用方式 + +### 7.3 运行环境要求 + +- 依赖 Node / pnpm +- 默认本地运行,不接 CI +- 生成结果覆盖 `e2e-ui/output/` 现有产物 + +--- + +## 8. 状态注入与稳定性策略 + +### 8.1 主题与语言注入 + +统一通过 Playwright fixture 在页面加载前写入: + +- `localStorage["ui.theme"]` +- `localStorage["ui.locale"]` + +确保真实路由与 showcase scene 都走同一套偏好入口。 + +### 8.2 稳定截图策略 + +截图前统一做以下处理: + +- 等待主内容渲染完成 +- 等待字体加载完成 +- 关闭或冻结容易扰动截图的动画 +- 规避光标闪烁、时间变化、随机数据变化 + +### 8.3 Mock 数据原则 + +showcase 场景中的 mock 数据必须: + +- 固定 +- 可读 +- 能体现典型 UI 状态 +- 不依赖外部服务 + +例如: + +- 文件列表固定几条有层级的示例路径 +- Git 变更固定 staged / unstaged / conflicted 组合 +- 弹框标题和文案固定,避免长度随机 + +--- + +## 9. 错误处理与可维护性 + +### 9.1 Scene 失败策略 + +单个 scene 失败时,应: + +- 在终端输出失败 scene id +- 尽量不影响其他 scene 继续执行 +- 在 `manifest.json` 中可选记录失败项,便于后续排查 + +### 9.2 目录扩展规则 + +新增 scene 时必须满足: + +- 在 `scenes/` 中注册 +- 明确 `category` +- 明确 `source` +- 明确支持的 `devices / themes / locales` +- 明确截图目标 locator + +这样可以避免后续截图集变成不可管理的脚本拼盘。 + +### 9.3 与现有 e2e 的隔离 + +必须保持以下边界: + +- 不复用现有 `e2e/specs/*` +- 不修改现有 `e2e/playwright.config.ts` +- 不把 `e2e-ui/` 结果混到现有 test report +- 不把 `e2e-ui` 纳入现有 CI 命令 + +--- + +## 10. 首批交付范围 + +### 10.1 第一批 Pages + +- Welcome +- Settings / General +- Settings / Appearance +- Settings / Providers +- Workspace / Desktop +- Workspace / Mobile +- Auth Preview +- Not Found + +### 10.2 第一批 Modals / Sheets / States + +- Workspace Launch Modal +- Command Palette +- Branch Quick Pick +- Worktree Modal +- Supervisor Dialog +- Confirm Dialog +- Mobile Workspace Drawer +- Mobile Files Sheet +- Mobile Terminal Sheet +- Success Toast +- Error Toast +- Empty State +- Loading State +- Provider Error State + +### 10.3 为什么先做这批 + +这批覆盖了: + +- 主页面骨架 +- 双端主要容器 +- 高价值弹层 +- 多数 UI 调整时最常回看的状态 + +已经足够支撑: + +- UI 改版前后人工回看 +- 主题/语言兼容检查 +- agent 查看现有样式结构 + +--- + +## 11. 测试与验证策略 + +这套系统的验证分三层: + +1. Scene 可达性 + - 所有 scene 能稳定打开目标状态 +2. 产物完整性 + - 每个 scene 的截图文件落盘成功 + - `manifest.json` 与图片文件一一对应 +3. 报告可用性 + - `report.html` 可直接打开 + - 筛选和分组可正确浏览图片 + +本期不要求: + +- 把每张图都做断言型视觉基线测试 +- 接入 CI 阻塞合并 +- 输出传统测试通过率报表 + +--- + +## 12. 实施步骤 + +建议按以下顺序实施: + +1. 建立 `e2e-ui/` 基础目录与 Playwright 配置 +2. 建立 scene 注册模型与截图输出规范 +3. 建立 `packages/web/ui-preview.html` 与 showcase 渲染入口 +4. 先接入首批 page scene +5. 再接入 modal / sheet / state scene +6. 生成 `manifest.json` +7. 生成 `report.html` +8. 补充文档和使用说明 + +这样可以先尽快跑通整条链路,再逐步丰富 scene 数量。 + +--- + +## 13. 结论 + +本方案的核心不是“再写一套 Playwright 测试”,而是为仓库建立一套独立的 UI 图谱系统: + +- `e2e-ui/` 和现有 `e2e/` 平级 +- `pnpm e2e-ui` 单命令运行 +- 同时支持真实路由和 mock showcase +- 全量输出截图和统一 `report.html` +- 服务于人工比对、UI 调整和 agent 样式分析 + +这是当前需求下最稳妥的边界:既不污染现有 e2e,也不会把 UI 图谱系统做成脆弱的真实流程自动化。 diff --git a/docs/superpowers/specs/2026-05-11-terminal-copy-on-select-design.md b/docs/superpowers/specs/2026-05-11-terminal-copy-on-select-design.md new file mode 100644 index 000000000..4eb7dce0e --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-terminal-copy-on-select-design.md @@ -0,0 +1,353 @@ +# Terminal Copy On Select — Design + +Date: 2026-05-11 +Status: Draft +Owner: spencer + +## Problem + +当前工作区里的终端区域已经支持选择文本,但没有“选中即复制”的桌面端体验。用户想要的行为是:在 shell terminal 和 agent session terminal 中,用鼠标完成选区后,文本自动进入系统剪贴板,不需要再额外按 `Ctrl/Cmd+C`。 + +仓库里现有终端和 session 都复用同一个 [`XtermHost`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx),因此这个能力应该在终端宿主层统一落地,而不是分别在 terminal panel 和 session card 上做两套逻辑。 + +## Goals + +- 为桌面端终端提供可选的“选中自动复制”能力。 +- 通过设置页全局开关控制,默认关闭。 +- 设置同时作用于 shell terminal 和 agent session terminal。 +- 复制成功静默处理,不打断用户。 +- 复制失败时给出轻量错误反馈,复用现有 toast 系统。 + +## Non-Goals + +- 不实现移动端长按选中或自动复制。 +- 不改变现有终端触摸滚动与惯性滚动逻辑。 +- 不增加新的通知面板或新的 toast 基础设施。 +- 不提供复制成功 toast、悬浮提示或终端内联提示。 +- 不实现 `document.execCommand("copy")` 等旧式剪贴板回退方案。 + +## User Decisions Captured + +- 范围仅限桌面端。 +- 设置入口放在设置页,通过开关控制。 +- 开关默认值为关闭。 +- 开启后,所有常见选区方式都应生效,包括拖拽选中、双击选词、三击选行。 +- 复制成功静默。 +- 复制失败时显示轻量 toast。 + +## Approaches Considered + +### Option A: 只在设置页组件本地保存并下传开关 + +优点: + +- 改动最少。 + +缺点: + +- 终端页如果没进过设置页,可能拿不到最新设置值。 +- 设置状态生命周期绑定到设置页,不适合全局行为。 + +### Option B: 新增全局终端偏好状态,在应用启动时加载一次(推荐) + +优点: + +- 终端无需依赖设置页是否被打开过。 +- shell terminal 和 session terminal 可以直接共享同一份偏好。 +- 与现有 `settings.get/settings.update` 模式兼容,范围小。 + +缺点: + +- 需要补一层轻量 settings 同步逻辑。 + +### Option C: 先建立统一的全量 settings store,再接入该功能 + +优点: + +- 架构最完整。 + +缺点: + +- 明显超出本次范围。 +- 为一个单独终端偏好引入不必要的重构成本。 + +## Final Choice + +采用 Option B。 + +本次只增加一层轻量的终端偏好状态,并复用现有的服务端 settings 命令、设置页保存链路和全局 toast 能力。这样可以在不重构 settings 架构的前提下,保证行为在应用内一致且可预测。 + +## Final Design + +### 1. 设置模型 + +新增设置键: + +- `appearance.terminalCopyOnSelect: boolean` + +默认值: + +- `false` + +放置位置: + +- 设置页 `Appearance` 分组 +- 与 terminal renderer 设置放在同一语义区块 + +文案方向: + +- 中文:`选中自动复制` +- 中文说明:`在桌面端终端中,选中文本后自动复制到系统剪贴板` +- 英文:`Copy on select` +- 英文说明:`Automatically copy selected text to the system clipboard in desktop terminals` + +### 2. 状态同步模型 + +该偏好不能只存在于设置页本地状态,因为终端页可能在用户从未打开过设置页的情况下先行渲染。 + +因此需要增加一层全局偏好状态,职责为: + +- 保存当前 `terminalCopyOnSelect` 值 +- 在应用启动后或连接可用后通过 `settings.get` 同步一次 +- 在设置页切换时立即更新内存状态并调用 `settings.update` + +推荐结构: + +- 新增 `terminalPreferencesAtom`,至少包含: + - `copyOnSelect: boolean` +- 新增一个轻量加载逻辑,放在应用级 provider 层,保证终端首次挂载时能读到最新值 + +该状态不需要替代现有所有 settings,只服务本次终端偏好。 + +### 3. 终端接入点 + +唯一接入点为: + +- [`packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) + +理由: + +- terminal panel 和 session card 都复用它。 +- 复制行为与具体容器无关,只与 xterm 实例、选区和桌面端指针事件有关。 +- 不需要修改 server PTY、session 管理或终端数据链路。 + +### 4. 复制触发时机 + +不在 `onSelectionChange` 中直接写剪贴板。 + +原因: + +- 拖拽选区过程中 `onSelectionChange` 会高频触发。 +- 如果每次变化都 `writeText()`,会产生不必要的权限调用和系统剪贴板抖动。 + +推荐触发模型: + +1. xterm 的 `onSelectionChange` 只负责读取并缓存当前选中文本。 +2. 在桌面端终端容器上监听 `pointerup`(必要时兼容 `mouseup`)。 +3. 当一次选区操作结束时,如果满足以下条件,则执行复制: + - 当前 viewport 不是 mobile + - `copyOnSelect` 开关已开启 + - xterm 当前存在选区 + - 当前缓存文本非空 +4. 复制完成后保留选区,不强制清除。 + +这个模型天然支持: + +- 鼠标拖拽选中 +- 双击选词 +- 三击选行 + +### 5. 剪贴板实现 + +v1 只使用标准异步剪贴板 API: + +- `navigator.clipboard.writeText(selectedText)` + +失败时不做旧式回退: + +- 不用 `document.execCommand("copy")` +- 不做隐藏 textarea hack + +原因: + +- 当前产品运行环境是现代桌面浏览器。 +- 降级方案会显著增加复杂度和维护成本。 +- 失败时 toast 已足够提示用户改用手动复制。 + +### 6. 成功与失败反馈 + +成功: + +- 完全静默 + +失败: + +- 复用现有全局 toast +- tone: `error` +- 文案建议: + - 中文标题:`自动复制失败` + - 中文正文:`请使用 Ctrl/Cmd+C 手动复制` + - 英文标题:`Copy on select failed` + - 英文正文:`Use Ctrl/Cmd+C to copy manually` + +失败 toast 需要节流,避免在不支持剪贴板权限的环境下用户连续拖选时刷屏。 + +推荐规则: + +- 同一终端实例在短时间窗口内只提示一次,例如 3 秒 + +### 7. 桌面端限定 + +该功能只在桌面端启用。 + +具体规则: + +- `useViewport()` 返回 `mobile` 时,不注册复制触发逻辑 +- 不改变现有移动端 `touchstart/touchmove/touchend` 滚动代码 +- 不尝试实现移动端长按选中 + +### 8. 现有 toast 能力复用 + +无需新增组件。 + +直接复用: + +- [`packages/web/src/features/notifications/atoms.ts`](../../../packages/web/src/features/notifications/atoms.ts) 里的 `pushToastAtom` +- [`packages/web/src/features/notifications/toast-container.tsx`](../../../packages/web/src/features/notifications/toast-container.tsx) + +## Architecture + +```text +settings.update / settings.get + | + v +application-level terminal preferences state + | + v +XtermHost + |- xterm.onSelectionChange() -> cache selected text + |- container.pointerup() -> if enabled + hasSelection -> clipboard.writeText() + |- clipboard failure -> pushToast(error) +``` + +## Implementation Notes + +### Server + +修改: + +- [`packages/server/src/commands/settings.ts`](../../../packages/server/src/commands/settings.ts) + +工作内容: + +- 扩展 `appearance` schema,加入 `terminalCopyOnSelect?: boolean` +- 继续复用现有 flatten/unflatten 逻辑,不需要新增命令 + +### Web Settings + +修改: + +- [`packages/web/src/features/settings/components/settings-page.tsx`](../../../packages/web/src/features/settings/components/settings-page.tsx) +- [`packages/web/src/locales/zh.json`](../../../packages/web/src/locales/zh.json) +- [`packages/web/src/locales/en.json`](../../../packages/web/src/locales/en.json) + +工作内容: + +- 加载 `appearance.terminalCopyOnSelect` +- 切换开关时更新本地状态 +- 调用 `settings.update` +- 同步更新全局终端偏好状态 +- 在 `Appearance` 区块渲染新的 `Switch` + +### Global Preference State + +建议新增: + +- `packages/web/src/features/terminal-panel/preferences.ts` 或同等职责文件 + +职责: + +- 暴露 `terminalPreferencesAtom` +- 提供默认值 `copyOnSelect: false` +- 提供应用级初始化入口,避免行为依赖设置页挂载 + +应用级初始化建议放在: + +- [`packages/web/src/app/providers.tsx`](../../../packages/web/src/app/providers.tsx) + +### XtermHost + +修改: + +- [`packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`](../../../packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx) + +工作内容: + +- 读取全局 `copyOnSelect` 偏好 +- 桌面端注册 xterm selection change 回调 +- 在容器上注册 `pointerup` 复制触发 +- 使用 `hasSelection()` / `getSelection()` 判断与读取文本 +- 调用 `navigator.clipboard.writeText()` +- 失败时推送节流 error toast + +不修改: + +- PTY 输入输出协议 +- WebSocket 终端链路 +- session card / terminal panel 的结构层 + +## Testing + +### Server tests + +修改: + +- `packages/server/src/commands/settings.test.ts` + +覆盖: + +- `settings.update` 能持久化 `appearance.terminalCopyOnSelect` +- `settings.get` 能返回该键 + +### Settings page tests + +修改: + +- `packages/web/src/features/settings/components/settings-page.test.tsx` + +覆盖: + +- 加载设置时能读取 `appearance.terminalCopyOnSelect` +- 切换开关时发送正确的 `settings.update` +- stale settings load 不覆盖用户刚切换的本地值 + +### XtermHost tests + +修改: + +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +覆盖: + +- 桌面端且开关开启时,选区结束会调用 `navigator.clipboard.writeText` +- 开关关闭时不会复制 +- 移动端不会复制 +- 复制失败时推送 error toast +- 失败 toast 在节流窗口内不会重复堆积 + +## Risks + +- 浏览器剪贴板 API 需要安全上下文与权限支持,部分桌面环境可能失败。 +- xterm 选区变化与 DOM 指针抬起的时序需要测试确认,避免在双击选词时漏触发。 +- 如果未来引入统一 settings store,本次轻量偏好层需要迁移,但迁移成本可控。 + +## Rollout + +- 一次性合入,无 feature flag +- 默认关闭,因此不会影响现有用户剪贴板习惯 +- 用户显式开启后才生效 + +## Open Questions + +- 是否需要在后续版本增加“复制成功”的更轻局部反馈。 +- 是否需要在未来扩展到移动端长按选中能力。 diff --git a/e2e/fixtures/app-entry.ts b/e2e/fixtures/app-entry.ts new file mode 100644 index 000000000..7b4f7d805 --- /dev/null +++ b/e2e/fixtures/app-entry.ts @@ -0,0 +1,71 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { translatePatternForE2E } from "./i18n.js"; +import { + expectOpenWorkspaceButton, + expectSettingsButton, + expectWelcomeCopy, +} from "./phase1-i18n.js"; + +export const APP_ENTRY_SELECTOR = + ".welcome-container, .workspace-page, .agent-draft-launcher, .session-card.agent-pane[data-session-id]"; + +export async function expectAppEntry(page: Page): Promise { + await expect(page.locator(APP_ENTRY_SELECTOR).first()).toBeVisible(); +} + +export async function isWelcomeVisible(page: Page): Promise { + return await page + .locator(".welcome-container") + .isVisible() + .catch(() => false); +} + +export async function expectWelcomeCardIfVisible(page: Page): Promise { + await expectAppEntry(page); + if (await isWelcomeVisible(page)) { + await expect(page.locator(".welcome-card")).toBeVisible(); + } +} + +export async function expectWelcomeCopyIfVisible(page: Page): Promise { + await expectAppEntry(page); + if (await isWelcomeVisible(page)) { + await expectWelcomeCopy(page); + } +} + +export async function expectPrimaryWorkspaceAction(page: Page): Promise { + await expectAppEntry(page); + + const welcomeButton = page.locator(".welcome-btn").first(); + if (await welcomeButton.isVisible().catch(() => false)) { + await expectOpenWorkspaceButton(welcomeButton); + return welcomeButton; + } + + const newWorkspaceButton = page + .getByRole("button", { + name: translatePatternForE2E("tooltip.new_workspace"), + }) + .first(); + await expect(newWorkspaceButton).toBeVisible(); + return newWorkspaceButton; +} + +export async function expectSettingsEntryPoint(page: Page): Promise { + await expectAppEntry(page); + + const welcomeSettings = page.locator(".welcome-link").first(); + if (await welcomeSettings.isVisible().catch(() => false)) { + await expectSettingsButton(welcomeSettings); + return welcomeSettings; + } + + const settingsButton = page + .getByRole("button", { + name: translatePatternForE2E("action.settings"), + }) + .first(); + await expect(settingsButton).toBeVisible(); + return settingsButton; +} diff --git a/e2e/fixtures/i18n.ts b/e2e/fixtures/i18n.ts index dbeb7d187..5433800a3 100644 --- a/e2e/fixtures/i18n.ts +++ b/e2e/fixtures/i18n.ts @@ -58,3 +58,17 @@ export function translateForE2E( return text; } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function translatePatternForE2E( + key: E2ETranslationKey, + params?: Record +): RegExp { + const en = translateForE2E(key, "en", params); + const zh = translateForE2E(key, "zh", params); + const values = [...new Set([en, zh])].map(escapeRegExp); + return new RegExp(`^(?:${values.join("|")})$`); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 46284c90b..c58123543 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -11,6 +11,11 @@ const sandboxDir = mkdtempSync(join(tmpdir(), "coder-studio-phase1-acceptance-")); const dataDir = process.env.CODER_STUDIO_PHASE1_DATA_DIR ?? join(sandboxDir, "coder-studio.db"); const runtimeDir = process.env.CODER_STUDIO_PHASE1_RUNTIME_DIR ?? join(sandboxDir, "runtime"); +const providerMockDir = + process.env.CODER_STUDIO_E2E_PROVIDER_MOCK_DIR ?? join(sandboxDir, "provider-mock"); +const providerMockBinDir = join(providerMockDir, "bin"); +const providerMockStatePath = join(providerMockDir, "state.json"); +const providerMockDebugLogPath = join(providerMockDir, "debug.log"); async function reservePort(host: string): Promise { return await new Promise((resolve, reject) => { @@ -53,6 +58,7 @@ if (ownsPhase1Sandbox) { process.env.CODER_STUDIO_PHASE1_RUNTIME_DIR = runtimeDir; process.env.CODER_STUDIO_PHASE1_SERVER_PORT = String(SERVER_PORT); process.env.CODER_STUDIO_PHASE1_WEB_PORT = String(WEB_PORT); + process.env.CODER_STUDIO_E2E_PROVIDER_MOCK_DIR = providerMockDir; process.on("exit", () => { rmSync(sandboxDir, { recursive: true, force: true }); @@ -80,6 +86,10 @@ export default defineConfig({ DATA_DIR: dataDir, RUNTIME_DIR: runtimeDir, NO_AUTH: "true", + CODER_STUDIO_E2E_PROVIDER_STATE_PATH: providerMockStatePath, + CODER_STUDIO_E2E_PROVIDER_BIN_DIR: providerMockBinDir, + CODER_STUDIO_E2E_PROVIDER_DEBUG_LOG_PATH: providerMockDebugLogPath, + PATH: `${providerMockBinDir}:${process.env.PATH ?? ""}`, }, }, { diff --git a/e2e/specs/phase1/command-palette.spec.ts b/e2e/specs/app-shell/command-palette.spec.ts similarity index 100% rename from e2e/specs/phase1/command-palette.spec.ts rename to e2e/specs/app-shell/command-palette.spec.ts diff --git a/e2e/specs/app-shell/focus-mode.spec.ts b/e2e/specs/app-shell/focus-mode.spec.ts new file mode 100644 index 000000000..5ba5341c5 --- /dev/null +++ b/e2e/specs/app-shell/focus-mode.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { expectAppEntry, expectWelcomeCopyIfVisible } from "../../fixtures/app-entry"; + +test.describe("@phase1 focus mode acceptance", () => { + test("F1-27 enter focus", async ({ page }) => { + await page.goto("/"); + // Smoke check that the root shell resolves. + await expectAppEntry(page); + }); + + test("F1-28 exit focus", async ({ page }) => { + await page.goto("/"); + // Only assert welcome copy when "/" lands on the welcome shell. + await expectWelcomeCopyIfVisible(page); + }); +}); diff --git a/e2e/specs/phase2/i18n.spec.ts b/e2e/specs/app-shell/i18n.spec.ts similarity index 78% rename from e2e/specs/phase2/i18n.spec.ts rename to e2e/specs/app-shell/i18n.spec.ts index b7570194c..0677b4395 100644 --- a/e2e/specs/phase2/i18n.spec.ts +++ b/e2e/specs/app-shell/i18n.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectAppEntry, isWelcomeVisible } from "../../fixtures/app-entry"; import { translateForE2E } from "../../fixtures/i18n"; import { openSettingsSection, @@ -36,8 +37,8 @@ test.describe("@phase2 i18n acceptance", () => { test("P2I-03 all UI text uses translation", async ({ page }) => { await page.goto("/"); - // Check that welcome screen text is visible (uses translation) - await expect(page.locator(".welcome-container")).toBeVisible(); + // "/" may restore the last workspace instead of showing welcome. + await expectAppEntry(page); // Navigate to settings await page.goto("/settings"); @@ -47,8 +48,10 @@ test.describe("@phase2 i18n acceptance", () => { test("P2I-04 fallback to default language", async ({ page }) => { await page.goto("/"); - // Welcome screen should show content - await expect(page.locator(".welcome-container")).toBeVisible(); - await expect(page.locator(".welcome-title")).toBeVisible(); + // The root shell should render, and welcome title should exist when welcome is active. + await expectAppEntry(page); + if (await isWelcomeVisible(page)) { + await expect(page.locator(".welcome-title")).toBeVisible(); + } }); }); diff --git a/e2e/specs/phase3/multi-tab-visual.spec.ts b/e2e/specs/app-shell/multi-tab-visual.spec.ts similarity index 100% rename from e2e/specs/phase3/multi-tab-visual.spec.ts rename to e2e/specs/app-shell/multi-tab-visual.spec.ts diff --git a/e2e/specs/phase3/multi-tab.spec.ts b/e2e/specs/app-shell/multi-tab.spec.ts similarity index 100% rename from e2e/specs/phase3/multi-tab.spec.ts rename to e2e/specs/app-shell/multi-tab.spec.ts diff --git a/e2e/specs/session-terminal-interaction.spec.ts b/e2e/specs/app-shell/session-terminal-interaction.spec.ts similarity index 95% rename from e2e/specs/session-terminal-interaction.spec.ts rename to e2e/specs/app-shell/session-terminal-interaction.spec.ts index 35a8afbd9..75415c9a5 100644 --- a/e2e/specs/session-terminal-interaction.spec.ts +++ b/e2e/specs/app-shell/session-terminal-interaction.spec.ts @@ -1,7 +1,10 @@ +import * as fs from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; -import * as fs from "fs"; -const SCREENSHOTS_DIR = "/home/spencer/workspace/coder-studio/e2e-screenshots"; +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const SCREENSHOTS_DIR = join(REPO_ROOT, "e2e-screenshots"); function ensureDir() { if (!fs.existsSync(SCREENSHOTS_DIR)) { diff --git a/e2e/specs/phase1/websocket.spec.ts b/e2e/specs/app-shell/websocket.spec.ts similarity index 59% rename from e2e/specs/phase1/websocket.spec.ts rename to e2e/specs/app-shell/websocket.spec.ts index 37feac74f..3fbd5084c 100644 --- a/e2e/specs/phase1/websocket.spec.ts +++ b/e2e/specs/app-shell/websocket.spec.ts @@ -1,16 +1,17 @@ import { expect, test } from "@playwright/test"; +import { expectAppEntry, expectWelcomeCardIfVisible } from "../../fixtures/app-entry"; test.describe("@phase1 websocket acceptance", () => { test("F1-29 connect", async ({ page }) => { await page.goto("/"); - // Page loads correctly - await expect(page.locator(".welcome-container")).toBeVisible(); + // Root shell should load regardless of restore path. + await expectAppEntry(page); }); test("F1-30 message flow", async ({ page }) => { await page.goto("/"); - // Check welcome card - await expect(page.locator(".welcome-card")).toBeVisible(); + // Validate welcome chrome only when the welcome shell is active. + await expectWelcomeCardIfVisible(page); }); test("F1-31 reconnect", async ({ page }) => { diff --git a/e2e/specs/phase2/auth.spec.ts b/e2e/specs/auth/basic.spec.ts similarity index 89% rename from e2e/specs/phase2/auth.spec.ts rename to e2e/specs/auth/basic.spec.ts index 11bfdcf3e..3c120fdcc 100644 --- a/e2e/specs/phase2/auth.spec.ts +++ b/e2e/specs/auth/basic.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectAppEntry } from "../../fixtures/app-entry"; import { translateForE2E } from "../../fixtures/i18n"; import { AUTH_PREVIEW_URL } from "../../fixtures/phase2-i18n"; @@ -7,7 +8,7 @@ import { AUTH_PREVIEW_URL } from "../../fixtures/phase2-i18n"; test.describe("@phase2 auth acceptance", () => { test("P2-01 no-auth mode bypasses login", async ({ page }) => { await page.goto("/"); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expectAppEntry(page); }); test("P2-02 auth status endpoint returns correct response", async ({ request }) => { @@ -32,7 +33,7 @@ test.describe("@phase2 auth acceptance", () => { test("P2-04 frontend reaches main app without auth", async ({ page }) => { await page.goto("/"); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expectAppEntry(page); }); test("P2-05 unavailable backend returns auth status failure", async ({ request }) => { @@ -47,6 +48,6 @@ test.describe("@phase2 auth acceptance", () => { test("P2-06 no-auth frontend ultimately reaches main app", async ({ page }) => { await page.goto("/"); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expectAppEntry(page); }); }); diff --git a/e2e/specs/phase2/auth-visual.spec.ts b/e2e/specs/auth/visual.spec.ts similarity index 100% rename from e2e/specs/phase2/auth-visual.spec.ts rename to e2e/specs/auth/visual.spec.ts diff --git a/e2e/specs/complete-session-flow.spec.ts b/e2e/specs/complete-session-flow.spec.ts deleted file mode 100644 index f9b7d7a12..000000000 --- a/e2e/specs/complete-session-flow.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { expect, test } from "@playwright/test"; - -/** - * Complete Session Flow E2E Tests - * - * Tests the full workflow: - * 1. Open workspace via directory browser - * 2. Navigate to workspace page - * 3. Agent provider selection (Claude/Codex) - * 4. Session creation - * 5. Input/output interaction - */ - -test.describe("complete session flow", () => { - test("CSF-01 workspace page shows directory browser", async ({ page }) => { - // Navigate to workspace page directly - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for modal with directory browser - await expect(page.locator(".modal-content")).toBeVisible(); - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - }); - - test("CSF-02 draft launcher shows provider buttons", async ({ page }) => { - await page.goto("/"); - - // The draft launcher is shown when no workspace is open - // Check that we can access the command palette - await page.locator(".welcome-btn").click(); - - const commandPalette = page.locator(".command-palette"); - await expect(commandPalette).toBeVisible(); - - // Check command palette has commands - const commands = page.locator(".command-palette-item"); - const count = await commands.count(); - expect(count).toBeGreaterThan(0); - }); - - test("CSF-03 command palette keyboard navigation", async ({ page }) => { - await page.goto("/"); - - // Open command palette via keyboard - await page.keyboard.press("Control+k"); - - // Wait for command palette - await expect(page.locator(".command-palette")).toBeVisible(); - - // Navigate with arrow keys - await page.keyboard.press("ArrowDown"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.press("ArrowUp"); - - // Selected item should change - const selectedItem = page.locator(".command-palette-item-selected"); - await expect(selectedItem).toBeVisible(); - }); - - test("CSF-04 command palette search filters commands", async ({ page }) => { - await page.goto("/"); - - // Open command palette - await page.locator(".welcome-btn").click(); - - // Wait for command palette - await expect(page.locator(".command-palette")).toBeVisible(); - - // Get initial command count - const commands = page.locator(".command-palette-item"); - const initialCount = await commands.count(); - expect(initialCount).toBeGreaterThan(0); - - // Type to search - use Chinese term since UI is in Chinese - const input = page.locator(".command-palette-input"); - await input.fill("工作区"); // Search for "workspace" in Chinese - await page.waitForTimeout(300); - - // Filtered count should be > 0 (at least workspace commands match) - const filteredCount = await commands.count(); - expect(filteredCount).toBeGreaterThan(0); - expect(filteredCount).toBeLessThanOrEqual(initialCount); - }); - - test("CSF-05 escape closes modals", async ({ page }) => { - await page.goto("/"); - - // Open command palette - await page.locator(".welcome-btn").click(); - await expect(page.locator(".command-palette")).toBeVisible(); - - // Press Escape - await page.keyboard.press("Escape"); - - // Command palette should close - await expect(page.locator(".command-palette")).not.toBeVisible(); - }); - - test("CSF-06 settings navigation", async ({ page }) => { - await page.goto("/"); - - // Click settings link - const settingsLink = page.locator(".welcome-link"); - await settingsLink.click(); - - // Should navigate to settings - await expect(page.locator(".settings-page")).toBeVisible(); - }); - - test("CSF-07 workspace launch modal directory selection works", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for directory list - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // Select a directory if available - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.click(); - - // Check selected path shows - await expect(page.locator(".selected-path")).toBeVisible(); - - // Open button should be enabled - await expect(page.locator(".modal-content .btn-primary")).toBeEnabled(); - } - }); - - test("CSF-08 workspace launch modal parent navigation", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for directory list - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // First, navigate into a subdirectory if possible - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.dblclick(); - await page.waitForTimeout(500); - - // Now check if parent link appears - const parentItem = page.locator(".directory-item--parent"); - if (await parentItem.isVisible()) { - // Click to go back up - await parentItem.click(); - await page.waitForTimeout(500); - } - } - - // Modal should still be open - await expect(page.locator(".modal-content")).toBeVisible(); - }); - - test("CSF-09 connection status visible", async ({ page }) => { - await page.goto("/"); - - // Wait for page to load - await expect(page.locator(".welcome-container")).toBeVisible(); - - // Connection status should be present somewhere - // In dev mode, should show connected - const pageContent = await page.content(); - expect(pageContent.length).toBeGreaterThan(0); - }); - - test("CSF-10 app loads without errors", async ({ page }) => { - const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); - - await page.goto("/"); - - // Wait for page to fully load - await page.waitForSelector(".welcome-container", { timeout: 5000 }); - - // Filter out non-critical errors - const criticalErrors = errors.filter( - (e) => !e.includes("ResizeObserver") && !e.includes("Non-Error promise rejection") - ); - - expect(criticalErrors.length).toBe(0); - }); -}); diff --git a/e2e/specs/full-integration.spec.ts b/e2e/specs/full-integration.spec.ts deleted file mode 100644 index 6b12fff0e..000000000 --- a/e2e/specs/full-integration.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { expect, test } from "@playwright/test"; - -/** - * Full Integration E2E Tests - * - * Complete workflow: Directory Selection -> Open Workspace -> Open Agent - */ - -test.describe("full integration workflow", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - }); - - test("INT-01 complete workflow: select directory -> open workspace -> see agent launcher", async ({ - page, - }) => { - // Step 1: Open command palette - await page.locator(".welcome-btn").click(); - await expect(page.locator(".command-palette")).toBeVisible(); - - // Step 2: Click "Open Workspace" command - await page.locator(".command-palette-item").first().click(); - - // Step 3: Workspace launch modal appears with directory browser - await expect(page.locator(".modal-content")).toBeVisible(); - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // Step 4: Select a directory - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.click(); - - // Verify selection shows - await expect(page.locator(".selected-path")).toBeVisible(); - - // Step 5: Click Open button - const openButton = page.locator(".modal-content .btn-primary"); - await expect(openButton).toBeEnabled(); - await openButton.click(); - - // Step 6: Wait for workspace to open (or error if directory not valid) - await page.waitForTimeout(2000); - - // Either navigated to workspace or modal closed with error - const modalVisible = await page - .locator(".modal-content") - .isVisible() - .catch(() => false); - // If modal closed, either success (navigated) or error shown - if (!modalVisible) { - // Check if we're on a workspace page - const url = page.url(); - expect(url).toMatch(/\/workspace/); - } - } - }); - - test("INT-02 directory navigation works correctly", async ({ page }) => { - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - const breadcrumb = page.locator(".breadcrumb-path"); - - // Navigate into a subdirectory if available - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.dblclick(); - await page.waitForTimeout(500); - - // Path should have changed - const newPath = await breadcrumb.textContent(); - expect(newPath).toBeDefined(); - - // Navigate back using parent link - const parentItem = page.locator(".directory-item--parent"); - if (await parentItem.isVisible()) { - await parentItem.click(); - await page.waitForTimeout(500); - } - } - }); - - test("INT-03 cancel workflow returns to welcome screen", async ({ page }) => { - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - await expect(page.locator(".modal-content")).toBeVisible(); - - // Click cancel - await page.locator(".modal-content .btn-secondary").click(); - - // Modal should close, welcome screen should still be visible - await expect(page.locator(".modal-content")).not.toBeVisible(); - await expect(page.locator(".welcome-container")).toBeVisible(); - }); - - test("INT-04 escape key closes modal at any point", async ({ page }) => { - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - await expect(page.locator(".modal-content")).toBeVisible(); - - // Navigate into a directory - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.dblclick(); - await page.waitForTimeout(300); - } - - // Press Escape - await page.keyboard.press("Escape"); - - // Modal should close - await expect(page.locator(".modal-content")).not.toBeVisible(); - }); - - test("INT-05 modal shows loading state initially", async ({ page }) => { - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Loading might be very brief, so just check modal appears - await expect(page.locator(".modal-content")).toBeVisible(); - - // Wait for content to load - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/e2e/specs/git-auto-fetch.spec.ts b/e2e/specs/git/auto-fetch.spec.ts similarity index 90% rename from e2e/specs/git-auto-fetch.spec.ts rename to e2e/specs/git/auto-fetch.spec.ts index 87277ca50..416017b9c 100644 --- a/e2e/specs/git-auto-fetch.spec.ts +++ b/e2e/specs/git/auto-fetch.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n"; const HOST = "127.0.0.1"; async function reservePort(host: string): Promise { @@ -39,7 +40,7 @@ const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`; const BASE_URL = `http://${HOST}:${WEB_PORT}`; const REMOTE_BRANCH_NAME = "feature/auto-fetch-remote"; const REMOTE_BRANCH_REF = `origin/${REMOTE_BRANCH_NAME}`; -const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const WEB_ROOT = join(REPO_ROOT, "packages", "web"); let sandboxDir: string; @@ -232,10 +233,14 @@ test.describe("git auto-fetch acceptance", () => { .locator(".workspace-status-bar") .getByRole("button", { name: /^(Current Branch|当前分支): .+$/ }); await expect(branchButton).toBeVisible({ timeout: 20000 }); - await expect(branchButton).toHaveAttribute("title", "main", { timeout: 20000 }); + await expect(branchButton.locator(".git-panel-status-strip__branch-text")).toHaveText("main", { + timeout: 20000, + }); + await branchButton.hover(); + await expect(page.getByRole("tooltip")).toHaveText("main"); await branchButton.click(); - await expect(page.locator(".branch-quick-pick-overlay")).toBeVisible(); + await expect(page.locator(".branch-quick-pick")).toBeVisible(); await expect(page.locator(".branch-quick-pick-name").filter({ hasText: /^main$/ })).toHaveCount( 1, { timeout: 15000 } @@ -251,19 +256,28 @@ test.describe("git auto-fetch acceptance", () => { await expect(page.getByText(REMOTE_BRANCH_REF)).toBeVisible({ timeout: 15000 }); await page.keyboard.press("Escape"); - await expect(page.locator(".branch-quick-pick-overlay")).toHaveCount(0); + await expect(page.locator(".branch-quick-pick")).toHaveCount(0); const fetchButton = page.locator(".git-status-bar").getByRole("button", { name: /^(Fetch|获取)$/, }); - await expect(fetchButton).toHaveAttribute("title", /^(Never fetched|尚未获取)$/); + await fetchButton.hover(); + await expect(page.getByRole("tooltip")).toHaveText( + translatePatternForE2E("git.fetch_last_never") + ); await fetchButton.click(); await expect - .poll(async () => await fetchButton.getAttribute("title"), { - timeout: 15000, - }) + .poll( + async () => { + await fetchButton.hover(); + return await page.getByRole("tooltip").textContent(); + }, + { + timeout: 15000, + } + ) .toMatch(/^(Last fetched |上次获取于 )/); }); }); diff --git a/e2e/specs/git/basic.spec.ts b/e2e/specs/git/basic.spec.ts new file mode 100644 index 000000000..7ab5a0bc8 --- /dev/null +++ b/e2e/specs/git/basic.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { + expectAppEntry, + expectPrimaryWorkspaceAction, + expectSettingsEntryPoint, + expectWelcomeCopyIfVisible, +} from "../../fixtures/app-entry"; + +test.describe("@phase1 git acceptance", () => { + test("F1-16 view status", async ({ page }) => { + await page.goto("/"); + // "/" may land on welcome or a restored workspace. + await expectAppEntry(page); + }); + + test("F1-17 view diff", async ({ page }) => { + await page.goto("/"); + // Only assert welcome copy when the welcome shell is active. + await expectWelcomeCopyIfVisible(page); + }); + + test("F1-18 commit", async ({ page }) => { + await page.goto("/"); + // Root shell should render. + await expect(page.locator("main")).toBeVisible(); + }); + + test("F1-19 branch list", async ({ page }) => { + await page.goto("/"); + // Root shell should render. + await expect(page.locator("main")).toBeVisible(); + }); + + test("F1-20 switch branch", async ({ page }) => { + await page.goto("/"); + // Primary workspace and settings actions should be reachable from either landing state. + await expectPrimaryWorkspaceAction(page); + await expectSettingsEntryPoint(page); + }); +}); diff --git a/e2e/specs/git-branch-switching.spec.ts b/e2e/specs/git/branch-switching.spec.ts similarity index 82% rename from e2e/specs/git-branch-switching.spec.ts rename to e2e/specs/git/branch-switching.spec.ts index 9c1c6f62c..e711a8c51 100644 --- a/e2e/specs/git-branch-switching.spec.ts +++ b/e2e/specs/git/branch-switching.spec.ts @@ -1,7 +1,8 @@ import { type ChildProcess, spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; const HOST = "127.0.0.1"; @@ -10,6 +11,8 @@ const WEB_PORT = 53175; const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`; const BASE_URL = `http://${HOST}:${WEB_PORT}`; const NEW_BRANCH_NAME = "feature/e2e-create-branch"; +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const WEB_ROOT = join(REPO_ROOT, "packages", "web"); let sandboxDir: string; let dbPath: string; @@ -77,7 +80,7 @@ test.describe("git branch switching acceptance", () => { "pnpm", ["exec", "tsx", "e2e/fixtures/seed-git-branch-switching-db.ts", dbPath, workspacesRoot], { - cwd: "/home/spencer/workspace/coder-studio", + cwd: REPO_ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"], } @@ -99,7 +102,7 @@ test.describe("git branch switching acceptance", () => { }); backendProcess = startProcess("pnpm", ["exec", "tsx", "packages/server/src/server.ts"], { - cwd: "/home/spencer/workspace/coder-studio", + cwd: REPO_ROOT, env: { HOST, PORT: String(SERVER_PORT), @@ -115,8 +118,9 @@ test.describe("git branch switching acceptance", () => { "pnpm", ["exec", "vite", "--host", HOST, "--port", String(WEB_PORT)], { - cwd: "/home/spencer/workspace/coder-studio/packages/web", + cwd: WEB_ROOT, env: { + NODE_ENV: "development", VITE_BACKEND_HTTP_URL: BACKEND_HTTP_URL, VITE_BACKEND_WS_URL: `ws://${HOST}:${SERVER_PORT}/ws`, }, @@ -145,21 +149,25 @@ test.describe("git branch switching acceptance", () => { baseURL: BASE_URL, }); + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("ui.locale", JSON.stringify("en")); + }); + }); + test("creates a new branch only after explicit confirmation from the branch quick pick", async ({ page, }) => { await page.goto("/workspace"); await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); - const branchButton = page.getByRole("button", { - name: "Open branch switcher for main", - }); + const branchButton = page.locator(".workspace-status-bar .git-panel-status-strip__branch"); await expect(branchButton).toBeVisible({ timeout: 20000 }); + await expect(branchButton.locator(".git-panel-status-strip__branch-text")).toHaveText("main"); await branchButton.click(); - await expect(page.getByRole("button", { name: "Git Diff" })).toHaveClass(/active/); - await expect(page.locator(".branch-quick-pick-overlay")).toBeVisible(); + await expect(page.locator(".branch-quick-pick")).toBeVisible(); await expect(page.locator(".branch-quick-pick-item").filter({ hasText: "main" })).toBeVisible(); const input = page.getByPlaceholder("Search branches or create new branch..."); @@ -173,11 +181,12 @@ test.describe("git branch switching acceptance", () => { await input.press("Enter"); - await expect(page.locator(".branch-quick-pick-overlay")).toHaveCount(0); - await expect( - page.getByRole("button", { - name: `Open branch switcher for ${NEW_BRANCH_NAME}`, - }) - ).toBeVisible({ timeout: 20000 }); + await expect(page.locator(".branch-quick-pick")).toHaveCount(0); + await expect(branchButton.locator(".git-panel-status-strip__branch-text")).toHaveText( + NEW_BRANCH_NAME, + { + timeout: 20000, + } + ); }); }); diff --git a/e2e/specs/phase3/supervisor.helpers.ts b/e2e/specs/helpers/workspace-session.ts similarity index 53% rename from e2e/specs/phase3/supervisor.helpers.ts rename to e2e/specs/helpers/workspace-session.ts index ad83341a5..be8129fad 100644 --- a/e2e/specs/phase3/supervisor.helpers.ts +++ b/e2e/specs/helpers/workspace-session.ts @@ -1,4 +1,5 @@ import { expect, type Locator, type Page } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n.js"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -46,14 +47,107 @@ async function ensureWorkspaceLaunchModal(page: Page): Promise { return; } - const welcomeButton = page.getByRole("button", { name: "Open Workspace" }); + const welcomeButton = page.getByRole("button", { + name: translatePatternForE2E("action.open_workspace"), + }); if (await welcomeButton.isVisible().catch(() => false)) { await welcomeButton.click(); } else { - await page.getByRole("button", { name: "New workspace" }).click(); + await page + .getByRole("button", { name: translatePatternForE2E("tooltip.new_workspace") }) + .click(); + } + + await expect(page.locator(".launch-modal")).toBeVisible({ timeout: 10000 }); +} + +export async function openWorkspaceLaunchModal(page: Page): Promise { + await waitForWorkspaceEntry(page); + + const welcomeButton = page.getByRole("button", { + name: translatePatternForE2E("action.open_workspace"), + }); + const newWorkspaceButton = page.getByRole("button", { + name: translatePatternForE2E("tooltip.new_workspace"), + }); + + if ( + await newWorkspaceButton + .first() + .isVisible() + .catch(() => false) + ) { + await newWorkspaceButton.first().click(); + } else { + await expect(welcomeButton).toBeVisible({ timeout: 15000 }); + await welcomeButton.click(); } + await waitForWorkspaceLaunchModal(page); +} + +export async function waitForWorkspaceLaunchModal(page: Page): Promise { await expect(page.locator(".launch-modal")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".fp-dir-list")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0); +} + +export async function openWelcomeWorkspaceLaunchModal(page: Page): Promise { + await page.goto("/"); + + await page.waitForFunction( + () => { + const loading = document.querySelector( + '.app-loading-shell, [data-testid="workspace-resolving-shell"]' + ); + if (loading) { + return false; + } + + const buttons = Array.from(document.querySelectorAll("button")); + return buttons.some((button) => { + const label = (button.getAttribute("aria-label") || button.textContent || "").trim(); + return /^(Open Workspace|打开工作区|New workspace|新建工作区)$/.test(label); + }); + }, + { timeout: 20000 } + ); + + const openWorkspaceButton = page.getByRole("button", { + name: translatePatternForE2E("action.open_workspace"), + }); + const newWorkspaceButton = page.getByRole("button", { + name: translatePatternForE2E("tooltip.new_workspace"), + }); + + if ( + await newWorkspaceButton + .first() + .isVisible() + .catch(() => false) + ) { + await newWorkspaceButton.first().click(); + await waitForWorkspaceLaunchModal(page); + return; + } + + await expect(openWorkspaceButton).toBeVisible({ timeout: 15000 }); + try { + await openWorkspaceButton.click(); + await waitForWorkspaceLaunchModal(page); + } catch (error) { + if ( + await newWorkspaceButton + .first() + .isVisible() + .catch(() => false) + ) { + await newWorkspaceButton.first().click(); + await waitForWorkspaceLaunchModal(page); + return; + } + throw error; + } } async function openRepoDirectory(page: Page): Promise { @@ -73,14 +167,16 @@ async function openRepoDirectory(page: Page): Promise { export async function enterDirectory(page: Page, name: string): Promise { const row = directoryRow(page, name); await expect(row).toBeVisible({ timeout: 10000 }); - - const pathDisplay = page.locator(".launch-path-display"); - const currentPath = (await pathDisplay.textContent())?.trim() ?? ""; + const activePathChip = page.locator(".fp-chip.active").last(); + const currentPath = (await activePathChip.textContent().catch(() => ""))?.trim() ?? ""; await row.dblclick(); - await expect(pathDisplay).not.toHaveText(currentPath, { timeout: 10000 }); await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0); + await expect(page.locator(".fp-dir-list .fp-dir").first()).toBeVisible({ timeout: 10000 }); + if (currentPath) { + await expect(activePathChip).not.toHaveText(currentPath, { timeout: 10000 }); + } } export async function openWorkspace(page: Page): Promise { @@ -99,7 +195,9 @@ export async function openWorkspace(page: Page): Promise { await expect(page.locator(".fp-dir-list .fp-dir").first()).toBeVisible({ timeout: 10000 }); await openRepoDirectory(page); - const startButton = page.getByRole("button", { name: "Start Workspace" }); + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); await expect(startButton).toBeEnabled(); await startButton.click(); @@ -161,15 +259,35 @@ export async function enableSupervisor( evaluatorProviderId: "claude" | "codex" ): Promise { const supervisorCard = page.locator(".supervisor-card").first(); - const editButton = page.getByRole("button", { name: "编辑目标" }); + const editButton = page.getByRole("button", { + name: translatePatternForE2E("supervisor.action.edit_objective"), + }); + const objectiveName = translatePatternForE2E("supervisor.field.objective"); + const evaluatorName = translatePatternForE2E("supervisor.field.evaluator"); + const saveName = translatePatternForE2E("supervisor.dialog.edit.confirm"); + const enableName = translatePatternForE2E("supervisor.dialog.enable.confirm"); + const triggerLabel = evaluatorProviderId === "claude" ? "Claude" : "Codex"; + + const fillSupervisorDialog = async (dialog: Locator) => { + await expect(dialog.getByRole("textbox", { name: objectiveName })).toBeVisible({ + timeout: 10000, + }); + await dialog.getByRole("textbox", { name: objectiveName }).fill(objective); + + const evaluatorTrigger = dialog.locator("#evaluator-provider"); + await expect(evaluatorTrigger).toBeVisible({ timeout: 10000 }); + await evaluatorTrigger.click(); + const listbox = dialog.getByRole("listbox", { name: evaluatorName }); + await expect(listbox).toBeVisible({ timeout: 10000 }); + await listbox.getByRole("option", { name: triggerLabel, exact: true }).click(); + await expect(listbox).not.toBeVisible({ timeout: 10000 }); + }; if (await editButton.isVisible().catch(() => false)) { await editButton.click(); const dialog = page.locator(".modal-card"); - await expect(dialog.getByLabel("目标描述")).toBeVisible({ timeout: 10000 }); - await dialog.getByLabel("目标描述").fill(objective); - await dialog.getByLabel("评估方 (Evaluator)").selectOption(evaluatorProviderId); - await dialog.getByRole("button", { name: "保存", exact: true }).click(); + await fillSupervisorDialog(dialog); + await dialog.getByRole("button", { name: saveName, exact: true }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); await expect(supervisorCard).toBeVisible({ timeout: 10000 }); @@ -179,15 +297,18 @@ export async function enableSupervisor( return supervisorCard; } - await expect(page.getByRole("button", { name: "启用 Supervisor" })).toBeVisible({ + await expect( + page.getByRole("button", { name: translatePatternForE2E("supervisor.action.enable") }) + ).toBeVisible({ timeout: 15000, }); - await page.getByRole("button", { name: "启用 Supervisor" }).click(); + await page + .getByRole("button", { name: translatePatternForE2E("supervisor.action.enable") }) + .click(); const dialog = page.locator(".modal-card"); - await dialog.getByLabel("目标描述").fill(objective); - await dialog.getByLabel("评估方 (Evaluator)").selectOption(evaluatorProviderId); - await dialog.getByRole("button", { name: "启用", exact: true }).click(); + await fillSupervisorDialog(dialog); + await dialog.getByRole("button", { name: enableName, exact: true }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); await expect(supervisorCard).toBeVisible({ timeout: 10000 }); diff --git a/e2e/specs/phase1/fixtures.spec.ts b/e2e/specs/infra/fixtures.spec.ts similarity index 100% rename from e2e/specs/phase1/fixtures.spec.ts rename to e2e/specs/infra/fixtures.spec.ts diff --git a/e2e/specs/phase1/reporting.spec.ts b/e2e/specs/infra/reporting.spec.ts similarity index 100% rename from e2e/specs/phase1/reporting.spec.ts rename to e2e/specs/infra/reporting.spec.ts diff --git a/e2e/specs/journeys/complete-session-flow.spec.ts b/e2e/specs/journeys/complete-session-flow.spec.ts new file mode 100644 index 000000000..96a56222e --- /dev/null +++ b/e2e/specs/journeys/complete-session-flow.spec.ts @@ -0,0 +1,134 @@ +import { expect, type Page, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n"; +import { + enterDirectory, + openWelcomeWorkspaceLaunchModal, + waitForWorkspaceLaunchModal, +} from "../helpers/workspace-session"; + +async function openCommandPalette(page: Page) { + await page.goto("/"); + await page.locator("body").press("Control+k"); + await expect(page.locator(".command-palette-overlay")).toBeVisible(); + await expect(page.locator(".command-palette")).toBeVisible(); +} + +test.describe("complete session flow", () => { + test("CSF-01 workspace page shows directory browser", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await expect(page.locator(".launch-modal")).toBeVisible(); + await expect(page.locator(".fp-dir-list")).toBeVisible(); + }); + + test("CSF-02 draft launcher shows provider buttons", async ({ page }) => { + await openCommandPalette(page); + + const commands = page.locator(".command-palette-item"); + await expect(commands.first()).toBeVisible(); + expect(await commands.count()).toBeGreaterThan(0); + }); + + test("CSF-03 command palette keyboard navigation", async ({ page }) => { + await openCommandPalette(page); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowUp"); + + await expect(page.locator(".command-palette-item-selected")).toBeVisible(); + }); + + test("CSF-04 command palette search filters commands", async ({ page }) => { + await openCommandPalette(page); + + const commands = page.locator(".command-palette-item"); + const initialCount = await commands.count(); + expect(initialCount).toBeGreaterThan(0); + + const input = page.locator(".command-palette-input"); + await input.fill("工作区"); + await page.waitForTimeout(300); + + const filteredCount = await commands.count(); + expect(filteredCount).toBeGreaterThan(0); + expect(filteredCount).toBeLessThanOrEqual(initialCount); + }); + + test("CSF-05 escape closes modals", async ({ page }) => { + await openCommandPalette(page); + + await page.keyboard.press("Escape"); + + await expect(page.locator(".command-palette")).toHaveCount(0); + }); + + test("CSF-06 settings navigation", async ({ page }) => { + await page.goto("/"); + + const settingsLink = page.getByRole("button", { + name: translatePatternForE2E("action.settings"), + }); + await settingsLink.click(); + + await expect(page.locator(".settings-page")).toBeVisible(); + }); + + test("CSF-07 workspace launch modal directory selection works", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const directoryItem = page.locator(".fp-dir").first(); + await expect(directoryItem).toBeVisible(); + await directoryItem.click(); + + await expect(page.locator(".fp-dir.selected")).toHaveCount(1); + await expect(page.locator(".fp-dir-action")).toBeVisible(); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeEnabled(); + }); + + test("CSF-08 workspace launch modal parent navigation", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const activePathChip = page.locator(".fp-chip.active").last(); + const beforePath = ((await activePathChip.textContent()) ?? "").trim(); + + await enterDirectory(page, "workspace"); + + const goUpButton = page + .locator(".fp-btn") + .filter({ hasText: translatePatternForE2E("workspace.launch.go_up") }); + await expect(goUpButton).toBeVisible(); + await goUpButton.click(); + + await waitForWorkspaceLaunchModal(page); + await expect(activePathChip).toHaveText(beforePath, { timeout: 10000 }); + }); + + test("CSF-09 connection status visible", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".welcome-container")).toBeVisible(); + + const pageContent = await page.content(); + expect(pageContent.length).toBeGreaterThan(0); + }); + + test("CSF-10 app loads without errors", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + + await page.goto("/"); + + await page.waitForSelector(".welcome-container", { timeout: 5000 }); + + const criticalErrors = errors.filter( + (e) => !e.includes("ResizeObserver") && !e.includes("Non-Error promise rejection") + ); + + expect(criticalErrors.length).toBe(0); + }); +}); diff --git a/e2e/specs/journeys/full-integration.spec.ts b/e2e/specs/journeys/full-integration.spec.ts new file mode 100644 index 000000000..f1155e920 --- /dev/null +++ b/e2e/specs/journeys/full-integration.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n"; +import { + enterDirectory, + openWelcomeWorkspaceLaunchModal, + openWorkspaceLaunchModal, + waitForWorkspaceLaunchModal, +} from "../helpers/workspace-session"; + +test.describe("full integration workflow", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("INT-01 complete workflow: select directory -> open workspace -> see agent launcher", async ({ + page, + }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await enterDirectory(page, "workspace"); + + const repoRow = page + .locator(".fp-dir") + .filter({ has: page.locator(".fp-dir-name").filter({ hasText: /^coder-studio$/ }) }) + .first(); + await expect(repoRow).toBeVisible(); + await repoRow.click(); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeEnabled(); + await startButton.click(); + + await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 }); + await expect( + page.locator(".agent-draft-launcher, .session-card.agent-pane[data-session-id]").first() + ).toBeVisible({ timeout: 15000 }); + }); + + test("INT-02 directory navigation works correctly", async ({ page }) => { + await openWorkspaceLaunchModal(page); + + const activePathChip = page.locator(".fp-chip.active").last(); + const beforePath = ((await activePathChip.textContent()) ?? "").trim(); + + await enterDirectory(page, "workspace"); + + await expect(activePathChip).not.toHaveText(beforePath, { timeout: 10000 }); + + const goUpButton = page + .locator(".fp-btn") + .filter({ hasText: translatePatternForE2E("workspace.launch.go_up") }); + await expect(goUpButton).toBeVisible(); + await goUpButton.click(); + + await waitForWorkspaceLaunchModal(page); + await expect(activePathChip).toHaveText(beforePath, { timeout: 10000 }); + }); + + test("INT-03 cancel workflow returns to welcome screen", async ({ page }) => { + await openWorkspaceLaunchModal(page); + + await page + .locator(".launch-modal") + .getByRole("button", { name: /^(Close|关闭)$/ }) + .click(); + + await expect(page.locator(".launch-modal")).toHaveCount(0); + await expect( + page.locator(".welcome-container, .workspace-page, .agent-draft-launcher").first() + ).toBeVisible(); + }); + + test("INT-04 escape key closes modal at any point", async ({ page }) => { + await openWorkspaceLaunchModal(page); + + const firstDirectory = page.locator(".fp-dir").first(); + if (await firstDirectory.isVisible().catch(() => false)) { + await firstDirectory.click(); + } + + await page.keyboard.press("Escape"); + + await expect(page.locator(".launch-modal")).toHaveCount(0); + }); + + test("INT-05 modal shows loading state initially", async ({ page }) => { + await openWorkspaceLaunchModal(page); + }); +}); diff --git a/e2e/specs/minimal-title-test.spec.ts b/e2e/specs/minimal-title-test.spec.ts deleted file mode 100644 index d264e5271..000000000 --- a/e2e/specs/minimal-title-test.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("Minimal title test", async ({ page }) => { - await page.goto("http://127.0.0.1:5173"); - await page.waitForTimeout(3000); - - // Check console for debug logs - page.on("console", (msg) => { - if (msg.text().includes("[DEBUG]")) { - console.log("Browser DEBUG:", msg.text()); - } - }); - - // Check if on welcome page - const welcomeHeading = page.getByRole("heading", { name: "Welcome to Coder Studio" }); - if (await welcomeHeading.isVisible()) { - const openButton = page.getByRole("button", { name: "Open Workspace" }); - await openButton.click(); - await page.waitForTimeout(1000); - - const workspaceDir = page.locator("text=coder-studio-workspaces").first(); - await workspaceDir.click(); - await page.waitForTimeout(500); - - const startButton = page.getByRole("button", { name: "Start Workspace" }); - await startButton.click(); - await page.waitForTimeout(5000); - } - - // Wait for page to load completely - await page.waitForTimeout(2000); - - // Click Claude analysis button to create a new session - console.log("Looking for Claude analysis button..."); - const claudeButton = page.getByRole("button", { name: "Claude analysis" }); - await claudeButton.waitFor({ state: "visible", timeout: 10000 }); - console.log("✓ Claude button found, clicking..."); - await claudeButton.click(); - - // Wait for session to be created (SessionStart hook should fire) - console.log("Waiting for session creation..."); - await page.waitForTimeout(10000); - - // Now look for the session card with an actual session (not draft launcher) - // The session card should have a session-state element (Idle/Running/Interrupted) - const sessionCard = page - .locator(".session-card") - .filter({ - has: page.locator(".session-state"), - }) - .first(); - - await sessionCard.waitFor({ state: "visible", timeout: 15000 }); - console.log("✓ Session card found"); - - const titleElement = sessionCard.locator(".session-title"); - const initialTitle = await titleElement.textContent(); - console.log("Initial session title:", initialTitle); - - // Click the terminal input textbox directly - const terminalInput = sessionCard.getByRole("textbox", { name: "Terminal input" }); - await terminalInput.click(); - await page.waitForTimeout(500); - - await terminalInput.fill("test input for title"); - await page.keyboard.press("Enter"); - - await page.waitForTimeout(5000); - - const newTitle = await titleElement.textContent(); - console.log("New session title:", newTitle); - - // Verify title changed from SESSION-XX format - expect(newTitle).not.toMatch(/^SESSION-\d+$/); - expect(newTitle).toContain("test"); -}); diff --git a/e2e/specs/phase1/agent-session.spec.ts b/e2e/specs/phase1/agent-session.spec.ts deleted file mode 100644 index 65ed68949..000000000 --- a/e2e/specs/phase1/agent-session.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { expectOpenWorkspaceButton, expectWelcomeCopy } from "../../fixtures/phase1-i18n"; - -test.describe("@phase1 agent session acceptance", () => { - test("F1-06 start session", async ({ page }) => { - await page.goto("/"); - // Welcome page should render correctly - await expect(page.locator(".welcome-container")).toBeVisible(); - await expectWelcomeCopy(page); - }); - - test("F1-07 send prompt", async ({ page }) => { - await page.goto("/"); - // Check welcome page elements - const openBtn = page.locator(".welcome-btn"); - await expectOpenWorkspaceButton(openBtn); - }); - - test("F1-08 receive response", async ({ page }) => { - await page.goto("/"); - // Settings link should be visible - const settingsLink = page.locator(".welcome-link"); - await expect(settingsLink).toBeVisible(); - }); - - test("F1-09 stop session", async ({ page }) => { - await page.goto("/"); - // Page title should be correct - await expect(page).toHaveTitle(/Coder Studio/); - }); - - test("F1-10 resume session", async ({ page }) => { - await page.goto("/"); - // Body should have proper styling - const body = page.locator("body"); - await expect(body).toBeVisible(); - }); -}); diff --git a/e2e/specs/phase1/data-integrity.spec.ts b/e2e/specs/phase1/data-integrity.spec.ts deleted file mode 100644 index d8a343a9d..000000000 --- a/e2e/specs/phase1/data-integrity.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; - -test.describe("@phase1 data integrity acceptance", () => { - test("F1-37 file persistence", async ({ page }) => { - await page.goto("/"); - // Welcome page renders correctly - await expect(page.locator(".welcome-container")).toBeVisible(); - }); - - test("F1-38 session persistence", async ({ page }) => { - await page.goto("/"); - // Check translated welcome copy - await expectWelcomeCopy(page); - }); - - test("F1-39 terminal replay", async ({ page }) => { - await page.goto("/"); - // Check title - await expect(page.locator(".welcome-title")).toContainText("Coder Studio"); - }); - - test("F1-40 git history", async ({ page }) => { - await page.goto("/"); - // Check all welcome elements are present - await expect(page.locator(".welcome-btn")).toBeVisible(); - await expect(page.locator(".welcome-link")).toBeVisible(); - }); -}); diff --git a/e2e/specs/phase1/edge-cases.spec.ts b/e2e/specs/phase1/edge-cases.spec.ts deleted file mode 100644 index b596164cb..000000000 --- a/e2e/specs/phase1/edge-cases.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("@phase1 edge cases acceptance", () => { - test("F1-32 empty workspace", async ({ page }) => { - await page.goto("/"); - // Welcome page is the empty workspace state - await expect(page.locator(".welcome-container")).toBeVisible(); - }); - - test("F1-33 large file", async ({ page }) => { - await page.goto("/"); - // Check page loads without issues - await expect(page.locator(".welcome-card")).toBeVisible(); - }); - - test("F1-34 binary file", async ({ page }) => { - await page.goto("/"); - // Check welcome elements - await expect(page.locator(".welcome-kicker")).toBeVisible(); - }); - - test("F1-35 permission error", async ({ page }) => { - await page.goto("/"); - // Check body text - await expect(page.locator(".welcome-body")).toBeVisible(); - }); - - test("F1-36 network disconnect", async ({ page }) => { - await page.goto("/"); - // Check buttons - await expect(page.locator(".welcome-btn")).toBeVisible(); - }); -}); diff --git a/e2e/specs/phase1/focus-mode.spec.ts b/e2e/specs/phase1/focus-mode.spec.ts deleted file mode 100644 index d56e8ba38..000000000 --- a/e2e/specs/phase1/focus-mode.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; - -test.describe("@phase1 focus mode acceptance", () => { - test("F1-27 enter focus", async ({ page }) => { - await page.goto("/"); - // Welcome page renders - await expect(page.locator(".welcome-container")).toBeVisible(); - }); - - test("F1-28 exit focus", async ({ page }) => { - await page.goto("/"); - // Check translated welcome copy - await expectWelcomeCopy(page); - }); -}); diff --git a/e2e/specs/phase1/git.spec.ts b/e2e/specs/phase1/git.spec.ts deleted file mode 100644 index 94ba2738a..000000000 --- a/e2e/specs/phase1/git.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; - -test.describe("@phase1 git acceptance", () => { - test("F1-16 view status", async ({ page }) => { - await page.goto("/"); - // Welcome page loads - await expect(page.locator(".welcome-container")).toBeVisible(); - }); - - test("F1-17 view diff", async ({ page }) => { - await page.goto("/"); - // Check translated welcome copy - await expectWelcomeCopy(page); - }); - - test("F1-18 commit", async ({ page }) => { - await page.goto("/"); - // Check title - await expect(page.locator(".welcome-title")).toBeVisible(); - }); - - test("F1-19 branch list", async ({ page }) => { - await page.goto("/"); - // Check body - await expect(page.locator(".welcome-body")).toBeVisible(); - }); - - test("F1-20 switch branch", async ({ page }) => { - await page.goto("/"); - // Check buttons - await expect(page.locator(".welcome-btn")).toBeVisible(); - await expect(page.locator(".welcome-link")).toBeVisible(); - }); -}); diff --git a/e2e/specs/provider-install-flow.spec.ts b/e2e/specs/provider-install-flow.spec.ts deleted file mode 100644 index c84c7ea01..000000000 --- a/e2e/specs/provider-install-flow.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import fs from "node:fs"; -import { expect, type Locator, type Page, test } from "@playwright/test"; - -function resetMockProviderBinaries(): void { - fs.rmSync("/tmp/cs-provider-mock/bin/claude", { force: true }); - fs.rmSync("/tmp/cs-provider-mock/bin/codex", { force: true }); -} - -async function waitForWorkspaceEntry(page: Page): Promise { - await page.goto("/workspace"); - await page.waitForFunction( - () => { - const loading = document.querySelector( - '.app-loading-shell, [data-testid="workspace-resolving-shell"]' - ); - const welcome = document.querySelector(".welcome-btn"); - const workspace = document.querySelector( - ".workspace-page, .agent-draft-launcher, .session-card.agent-pane" - ); - - return !loading && Boolean(welcome || workspace); - }, - { timeout: 20000 } - ); -} - -async function ensureWorkspaceOpen(page: Page): Promise { - await waitForWorkspaceEntry(page); - - const draftLauncher = page.locator(".agent-draft-launcher").first(); - const sessionPane = page.locator(".session-card.agent-pane").first(); - - if ( - page.url().includes("/workspace") || - (await draftLauncher.isVisible().catch(() => false)) || - (await sessionPane.isVisible().catch(() => false)) - ) { - await expect( - page.locator(".agent-draft-launcher, .session-card.agent-pane").first() - ).toBeVisible({ - timeout: 15000, - }); - return; - } - - await expect(page.locator(".welcome-btn")).toBeVisible({ timeout: 15000 }); - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - await expect(page.locator(".launch-modal")).toBeVisible({ timeout: 10000 }); - await expect(page.locator(".fp-dir-list .fp-dir").first()).toBeVisible({ timeout: 10000 }); - - await page - .locator(".fp-dir") - .filter({ hasText: /^workspace$/ }) - .first() - .dblclick(); - await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0); - await page - .locator(".fp-dir") - .filter({ hasText: /^coder-studio$/ }) - .first() - .click(); - - const startButton = page.getByRole("button", { name: "Start Workspace" }); - await expect(startButton).toBeEnabled(); - await startButton.click(); - - await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 }); -} - -async function ensureDraftLauncher(page: Page): Promise { - await ensureWorkspaceOpen(page); - - const draftLauncher = page.locator(".agent-draft-launcher").first(); - if (await draftLauncher.isVisible().catch(() => false)) { - return draftLauncher; - } - - const closeButtons = page.locator(".session-card.agent-pane .session-action-btn-close"); - for (let attempt = 0; attempt < 10; attempt += 1) { - if (await draftLauncher.isVisible().catch(() => false)) { - return draftLauncher; - } - - if ((await closeButtons.count()) === 0) { - break; - } - - await closeButtons.first().click(); - await page.waitForTimeout(300); - } - - await expect(draftLauncher).toBeVisible({ timeout: 15000 }); - return draftLauncher; -} - -test.describe("provider install launcher flow", () => { - test.beforeEach(() => { - resetMockProviderBinaries(); - }); - - test("PIF-01 Claude shows install action, installs, and creates a session", async ({ page }) => { - const draftLauncher = await ensureDraftLauncher(page); - const claudeCard = draftLauncher.locator(".agent-provider-card-claude").first(); - - await expect(claudeCard.locator(".agent-provider-card-cta")).toBeVisible({ timeout: 15000 }); - - await claudeCard.click(); - - await expect(claudeCard).toBeDisabled({ timeout: 15000 }); - await expect(claudeCard.locator(".agent-provider-card-status")).toBeVisible({ timeout: 15000 }); - - const sessionCard = page.locator(".session-card.agent-pane[data-session-id]").first(); - await expect(sessionCard).toBeVisible({ timeout: 20000 }); - }); - - test("PIF-02 Codex install failure shows error guidance and docs link", async ({ page }) => { - const draftLauncher = await ensureDraftLauncher(page); - const codexCard = draftLauncher.locator(".agent-provider-card-codex").first(); - - await expect(codexCard.locator(".agent-provider-card-cta")).toBeVisible({ timeout: 15000 }); - - await codexCard.click(); - - await expect(codexCard).toContainText("permission denied", { timeout: 20000 }); - await expect(codexCard.locator(".agent-provider-card-guide a")).toHaveAttribute( - "href", - /openai\.com|github\.com|platform\.openai\.com/i, - { timeout: 10000 } - ); - }); -}); diff --git a/e2e/specs/quality/data-integrity.spec.ts b/e2e/specs/quality/data-integrity.spec.ts new file mode 100644 index 000000000..25ecfbc7f --- /dev/null +++ b/e2e/specs/quality/data-integrity.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; + +test.describe("@phase1 data integrity acceptance", () => { + const appEntrySelector = + ".welcome-container, .workspace-page, .agent-draft-launcher, .session-card.agent-pane[data-session-id]"; + + test("F1-37 file persistence", async ({ page }) => { + await page.goto("/"); + // Root app entry should render whether the app lands on welcome or restores a workspace. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-38 session persistence", async ({ page }) => { + await page.goto("/"); + // Wait for the app root first, then validate the welcome copy only when "/" actually lands there. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + + const welcome = page.locator(".welcome-container"); + if (await welcome.isVisible().catch(() => false)) { + await expectWelcomeCopy(page); + } + }); + + test("F1-39 terminal replay", async ({ page }) => { + await page.goto("/"); + // The root entry should be visible even when "/" restores an existing workspace. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-40 git history", async ({ page }) => { + await page.goto("/"); + // The root app shell should be interactive regardless of whether we hit welcome or restore. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); +}); diff --git a/e2e/specs/quality/edge-cases.spec.ts b/e2e/specs/quality/edge-cases.spec.ts new file mode 100644 index 000000000..c0b971952 --- /dev/null +++ b/e2e/specs/quality/edge-cases.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test.describe("@phase1 edge cases acceptance", () => { + const appEntrySelector = + ".welcome-container, .workspace-page, .agent-draft-launcher, .session-card.agent-pane[data-session-id]"; + + test("F1-32 empty workspace", async ({ page }) => { + await page.goto("/"); + // "/" may show welcome or restore an existing workspace; either root entry is valid. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-33 large file", async ({ page }) => { + await page.goto("/"); + // Smoke check that the root entry loads. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-34 binary file", async ({ page }) => { + await page.goto("/"); + // Smoke check that the root entry loads. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-35 permission error", async ({ page }) => { + await page.goto("/"); + // Smoke check that the root entry loads. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); + + test("F1-36 network disconnect", async ({ page }) => { + await page.goto("/"); + // Smoke check that the root entry loads. + await expect(page.locator(appEntrySelector).first()).toBeVisible(); + }); +}); diff --git a/e2e/specs/phase4/quality.spec.ts b/e2e/specs/quality/general.spec.ts similarity index 100% rename from e2e/specs/phase4/quality.spec.ts rename to e2e/specs/quality/general.spec.ts diff --git a/e2e/specs/phase1/visual-animations.spec.ts b/e2e/specs/quality/visual-animations.spec.ts similarity index 78% rename from e2e/specs/phase1/visual-animations.spec.ts rename to e2e/specs/quality/visual-animations.spec.ts index a0552878d..931f58ad1 100644 --- a/e2e/specs/phase1/visual-animations.spec.ts +++ b/e2e/specs/quality/visual-animations.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectAppEntry } from "../../fixtures/app-entry"; /** * Phase 1 Visual Acceptance Tests: Animations & Transitions @@ -9,8 +10,8 @@ test.describe("@phase1 visual acceptance", () => { test("V1-16 panel collapse animation baseline", async ({ page }) => { await page.goto("/"); - // Page should render with animations enabled - await expect(page.locator(".welcome-container")).toBeVisible(); + // The initial shell should render with motion enabled regardless of restore path. + await expectAppEntry(page); }); test("V1-17 tab switch animation baseline", async ({ page }) => { diff --git a/e2e/specs/phase1/visual-components.spec.ts b/e2e/specs/quality/visual-components.spec.ts similarity index 55% rename from e2e/specs/phase1/visual-components.spec.ts rename to e2e/specs/quality/visual-components.spec.ts index b75ca7721..1282f51cf 100644 --- a/e2e/specs/phase1/visual-components.spec.ts +++ b/e2e/specs/quality/visual-components.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "@playwright/test"; -import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; +import { + expectAppEntry, + expectPrimaryWorkspaceAction, + expectSettingsEntryPoint, + expectWelcomeCardIfVisible, + expectWelcomeCopyIfVisible, +} from "../../fixtures/app-entry"; /** * Phase 1 Visual Acceptance Tests: Core Components @@ -10,33 +16,32 @@ test.describe("@phase1 visual acceptance", () => { test("V1-04 welcome page baseline", async ({ page }) => { await page.goto("/"); - // Welcome container should be visible - await expect(page.locator(".welcome-container")).toBeVisible(); - await expect(page.locator(".welcome-card")).toBeVisible(); + // "/" may render welcome or restore a workspace; validate the welcome shell when it is active. + await expectWelcomeCardIfVisible(page); }); test("V1-05 workspace panel baseline", async ({ page }) => { await page.goto("/"); - // Welcome copy should be present in the active locale - await expectWelcomeCopy(page); + // Restored workspaces bypass welcome; only assert localized welcome copy when it is active. + await expectWelcomeCopyIfVisible(page); }); test("V1-06 agent pane baseline", async ({ page }) => { await page.goto("/"); - // Title should be visible - await expect(page.locator(".welcome-title")).toContainText("Coder Studio"); + // Main shell should render whether "/" lands on welcome or a restored workspace. + await expect(page.locator("main")).toBeVisible(); }); test("V1-07 editor baseline", async ({ page }) => { await page.goto("/"); - // Body text should be visible - await expect(page.locator(".welcome-body")).toBeVisible(); + // Smoke check that the root shell is present. + await expect(page.locator("main")).toBeVisible(); }); test("V1-08 terminal baseline", async ({ page }) => { await page.goto("/"); - // Open workspace button should exist - await expect(page.locator(".welcome-btn")).toBeVisible(); + // Either the welcome CTA or the restored-workspace CTA should be available. + await expectPrimaryWorkspaceAction(page); }); test("V1-09 command palette baseline", async ({ page }) => { @@ -48,14 +53,15 @@ test.describe("@phase1 visual acceptance", () => { test("V1-10 settings baseline", async ({ page }) => { await page.goto("/"); - // Settings link should be visible - await expect(page.locator(".welcome-link")).toBeVisible(); + // Settings is reachable from welcome and restored workspace shells. + await expectSettingsEntryPoint(page); }); test("V1-11 buttons baseline", async ({ page }) => { await page.goto("/"); - // Button should have correct styling - const btn = page.locator(".welcome-btn"); + // The shell should expose at least one interactive button in either landing state. + await expectAppEntry(page); + const btn = page.getByRole("button").first(); await expect(btn).toBeVisible(); }); diff --git a/e2e/specs/phase1/visual-global.spec.ts b/e2e/specs/quality/visual-global.spec.ts similarity index 100% rename from e2e/specs/phase1/visual-global.spec.ts rename to e2e/specs/quality/visual-global.spec.ts diff --git a/e2e/specs/phase1/visual-states.spec.ts b/e2e/specs/quality/visual-states.spec.ts similarity index 57% rename from e2e/specs/phase1/visual-states.spec.ts rename to e2e/specs/quality/visual-states.spec.ts index 8697bb2cf..faf4f2eb8 100644 --- a/e2e/specs/phase1/visual-states.spec.ts +++ b/e2e/specs/quality/visual-states.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectAppEntry, expectPrimaryWorkspaceAction } from "../../fixtures/app-entry"; /** * Phase 1 Visual Acceptance Tests: Interactive States @@ -9,22 +10,22 @@ test.describe("@phase1 visual acceptance", () => { test("V1-13 hover states baseline", async ({ page }) => { await page.goto("/"); - // Button should have hover effect (check it exists) - const btn = page.locator(".welcome-btn"); + // Use whichever primary workspace action is available in the current shell. + const btn = await expectPrimaryWorkspaceAction(page); await expect(btn).toBeVisible(); }); test("V1-14 focus states baseline", async ({ page }) => { await page.goto("/"); - // Focus on button (use first to avoid disabled button in confirm dialog) - const btn = page.locator(".welcome-btn").first(); + // Focus whichever primary workspace action is currently rendered. + const btn = await expectPrimaryWorkspaceAction(page); await btn.focus(); await expect(btn).toBeFocused(); }); test("V1-15 loading states baseline", async ({ page }) => { await page.goto("/"); - // Page should load without loading indicators after ready - await expect(page.locator(".welcome-container")).toBeVisible(); + // The app should finish bootstrapping into either welcome or a restored workspace shell. + await expectAppEntry(page); }); }); diff --git a/e2e/specs/session-flow.spec.ts b/e2e/specs/session-flow.spec.ts deleted file mode 100644 index 1e6814b7c..000000000 --- a/e2e/specs/session-flow.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { expect, test } from "@playwright/test"; - -/** - * Session Flow E2E Tests - * - * Complete workflow tests with directory browser: - * 1. Open workspace via directory selection - * 2. Start agent session (Claude/Codex) - * 3. Wait for agent startup - * 4. Input conversation - * 5. Agent response output - */ - -test.describe("session flow", () => { - test("SF-01 open workspace via launch modal", async ({ page }) => { - await page.goto("/"); - - // Click open workspace button - const openBtn = page.locator(".welcome-btn"); - await openBtn.click(); - - // Command palette should open - await expect(page.locator(".command-palette")).toBeVisible(); - - // Click the first command (Open Workspace) - const firstCommand = page.locator(".command-palette-item").first(); - await firstCommand.click(); - - // Workspace launch modal should appear - await expect(page.locator(".workspace-launch-modal, .modal-content")).toBeVisible(); - }); - - test("SF-02 workspace launch modal has directory browser", async ({ page }) => { - await page.goto("/"); - - // Open command palette and trigger workspace launch - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for modal to appear - await expect(page.locator(".modal-content")).toBeVisible(); - - // Check modal has directory listing (may need to wait for load) - const directoryList = page.locator(".directory-list"); - await expect(directoryList).toBeVisible({ timeout: 5000 }); - - // Check modal has breadcrumb showing current path - const breadcrumb = page.locator(".directory-breadcrumb"); - await expect(breadcrumb).toBeVisible(); - - // Check modal has open button (disabled until selection) - const openButton = page.locator(".modal-content .btn-primary"); - await expect(openButton).toBeVisible(); - await expect(openButton).toBeDisabled(); - }); - - test("SF-03 workspace launch modal open button disabled without selection", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for directory list to load - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // Open button should be disabled when nothing selected - const openButton = page.locator(".modal-content .btn-primary"); - await expect(openButton).toBeDisabled(); - }); - - test("SF-04 workspace launch modal cancel works", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for modal - await expect(page.locator(".modal-content")).toBeVisible(); - - // Click cancel button - const cancelButton = page.locator(".modal-content .btn-secondary"); - await cancelButton.click(); - - // Modal should close - await expect(page.locator(".modal-content")).not.toBeVisible(); - }); - - test("SF-05 workspace launch modal can select directory", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for directory list to load - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // Click on a directory item to select it (exclude parent navigation item) - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - await directoryItem.click(); - - // Selected path should appear - const selectedPath = page.locator(".selected-path"); - await expect(selectedPath).toBeVisible(); - - // Open button should now be enabled - const openButton = page.locator(".modal-content .btn-primary"); - await expect(openButton).toBeEnabled(); - } - }); - - test("SF-06 workspace launch modal can navigate directories", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for directory list to load - await expect(page.locator(".directory-list")).toBeVisible({ timeout: 5000 }); - - // If there are directories, try to navigate into one - const directoryItem = page.locator(".directory-item:not(.directory-item--parent)").first(); - if (await directoryItem.isVisible()) { - // Double-click to navigate - await directoryItem.dblclick(); - - // Wait for new directory list - await page.waitForTimeout(500); - - // Breadcrumb should have changed - const breadcrumbAfter = await page.locator(".breadcrumb-path").textContent(); - // Either path changed or still loading - both are acceptable - expect(breadcrumbAfter).toBeDefined(); - } - }); - - test("SF-07 keyboard shortcuts work in modal", async ({ page }) => { - await page.goto("/"); - - // Open workspace launch modal - await page.locator(".welcome-btn").click(); - await page.locator(".command-palette-item").first().click(); - - // Wait for modal - await expect(page.locator(".modal-content")).toBeVisible(); - - // Press Escape to close - await page.keyboard.press("Escape"); - - // Modal should close - await expect(page.locator(".modal-content")).not.toBeVisible(); - }); -}); diff --git a/e2e/specs/session-title-extraction.spec.ts b/e2e/specs/session-title-extraction.spec.ts deleted file mode 100644 index e7971ec8e..000000000 --- a/e2e/specs/session-title-extraction.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Session Title Extraction", () => { - test("TITLE-01: Extract and truncate title from first input", async ({ page }) => { - // Navigate to app - await page.goto("/"); - await page.waitForTimeout(3000); - - // Check if on welcome page and open workspace if needed - const welcomeHeading = page.getByRole("heading", { name: "Welcome to Coder Studio" }); - if (await welcomeHeading.isVisible()) { - console.log("✓ On welcome page, opening workspace..."); - const openWorkspaceButton = page.getByRole("button", { name: "Open Workspace" }); - await openWorkspaceButton.click(); - await page.waitForTimeout(1000); - - // Select workspace directory - const workspaceDir = page.locator("text=coder-studio-workspaces").first(); - await workspaceDir.click(); - await page.waitForTimeout(500); - - // Click Start Workspace button - const startWorkspaceButton = page.getByRole("button", { name: "Start Workspace" }); - await expect(startWorkspaceButton).toBeEnabled({ timeout: 5000 }); - await startWorkspaceButton.click(); - await page.waitForTimeout(3000); - } - - // Close all existing sessions to ensure we get a fresh draft launcher - const existingCloseButtons = await page.locator('.session-card [class*="close"]').all(); - console.log(`Found ${existingCloseButtons.length} existing sessions to close`); - - for (const closeButton of existingCloseButtons) { - try { - await closeButton.click(); - await page.waitForTimeout(500); - } catch { - // Ignore errors if button is not clickable - } - } - - // Wait for sessions to be closed - await page.waitForTimeout(2000); - - // Now should see draft launcher - const draftLauncher = page.locator(".draft-launcher").first(); - await expect(draftLauncher).toBeVisible({ timeout: 10000 }); - console.log("✓ Draft launcher visible, creating new session..."); - - // Click Claude provider to create session - const claudeButton = draftLauncher.locator(".agent-provider-card-claude"); - await expect(claudeButton).toBeVisible({ timeout: 5000 }); - await claudeButton.click(); - await page.waitForTimeout(5000); - - // Wait for session to transition from draft to active state - const sessionCard = page.locator(".session-card").first(); - await expect(sessionCard).toBeVisible({ timeout: 10000 }); - - const stateBadge = sessionCard.locator(".session-state-badge"); - await expect(stateBadge).not.toHaveText("DRAFT", { timeout: 10000 }); - console.log("✓ Session created, state:", await stateBadge.textContent()); - - // Now we have a fresh session - test title extraction - const titleElement = sessionCard.locator(".session-title"); - const beforeTitle = await titleElement.textContent(); - console.log("Title before input:", beforeTitle); - - // Take screenshot before input - await page.screenshot({ path: "/tmp/title-test-before.png", fullPage: true }); - - // Find terminal area and focus it - const terminalArea = sessionCard.locator('[class*="terminal"], .xterm').first(); - await expect(terminalArea).toBeVisible({ timeout: 5000 }); - - // Click to focus terminal - await terminalArea.click(); - await page.waitForTimeout(1000); - - // Type test message (longer than 10 chars) - const testMessage = "hello world this is a test"; - console.log("Typing message:", testMessage); - - await page.keyboard.type(testMessage); - await page.waitForTimeout(500); - - // Submit by pressing Enter - await page.keyboard.press("Enter"); - console.log("Message submitted"); - - // Wait for processing (title extraction happens on submit) - await page.waitForTimeout(3000); - - // Take screenshot after input - await page.screenshot({ path: "/tmp/title-test-after.png", fullPage: true }); - - // Check title was extracted and truncated - const afterTitle = await titleElement.textContent(); - console.log("Title after input:", afterTitle); - - // According to SESSION_TITLE_MAX_LENGTH = 10: - // "hello world this is a test" → normalized → "hello wor…" - const expectedTitle = "hello wor…"; - - console.log("Expected truncated title:", expectedTitle); - console.log("Actual title:", afterTitle); - - // Title should be extracted and truncated - expect(afterTitle).toBeTruthy(); - expect(afterTitle).not.toContain("SESSION-"); - expect(afterTitle).toBe(expectedTitle); - console.log("✓ Title successfully extracted and truncated"); - - // Verify in database via API (optional, if accessible) - // This would require backend API endpoint to query session state - }); - - test("TITLE-02: Title idempotent - not overwritten on second input", async ({ page }) => { - await page.goto("/"); - await page.waitForTimeout(3000); - - // Check if on welcome page and open workspace if needed - const welcomeHeading = page.getByRole("heading", { name: "Welcome to Coder Studio" }); - if (await welcomeHeading.isVisible()) { - console.log("✓ On welcome page, opening workspace..."); - const openWorkspaceButton = page.getByRole("button", { name: "Open Workspace" }); - await openWorkspaceButton.click(); - await page.waitForTimeout(1000); - - // Select workspace directory - const workspaceDir = page.locator("text=coder-studio-workspaces").first(); - await workspaceDir.click(); - await page.waitForTimeout(500); - - // Click Start Workspace button - const startWorkspaceButton = page.getByRole("button", { name: "Start Workspace" }); - await expect(startWorkspaceButton).toBeEnabled({ timeout: 5000 }); - await startWorkspaceButton.click(); - await page.waitForTimeout(3000); - } - - // Check for draft launcher and create session if needed - const sessionCard = page.locator(".session-card").first(); - const stateBadge = sessionCard.locator(".session-state-badge"); - const stateText = await stateBadge.textContent(); - - if (stateText === "DRAFT") { - const claudeButton = sessionCard.locator(".agent-provider-card-claude"); - await claudeButton.click(); - await page.waitForTimeout(5000); - - // Wait for session to be active - const newStateBadge = page.locator(".session-card .session-state-badge").first(); - await expect(newStateBadge).not.toHaveText("DRAFT", { timeout: 10000 }); - } - - const activeCard = page.locator(".session-card").first(); - await expect(activeCard).toBeVisible(); - - const titleElement = activeCard.locator(".session-title"); - const terminalArea = activeCard.locator('[class*="terminal"]').first(); - await expect(terminalArea).toBeVisible({ timeout: 5000 }); - - // First input - await terminalArea.click(); - await page.keyboard.type("first message"); - await page.keyboard.press("Enter"); - await page.waitForTimeout(3000); - - const firstTitle = await titleElement.textContent(); - console.log("First title:", firstTitle); - - // Second input with different text - await terminalArea.click(); - await page.keyboard.type("second different message"); - await page.keyboard.press("Enter"); - await page.waitForTimeout(3000); - - const secondTitle = await titleElement.textContent(); - console.log("Second title:", secondTitle); - - // Title should NOT change (idempotent) - expect(secondTitle).toBe(firstTitle); - console.log("✓ Title idempotent - preserved after second input"); - }); -}); diff --git a/e2e/specs/sessions/agent-session.spec.ts b/e2e/specs/sessions/agent-session.spec.ts new file mode 100644 index 000000000..683b70bb7 --- /dev/null +++ b/e2e/specs/sessions/agent-session.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; +import { + expectAppEntry, + expectPrimaryWorkspaceAction, + expectSettingsEntryPoint, + expectWelcomeCopyIfVisible, +} from "../../fixtures/app-entry"; + +test.describe("@phase1 agent session acceptance", () => { + test("F1-06 start session", async ({ page }) => { + await page.goto("/"); + // Smoke check that "/" resolves to a usable shell and keeps localized welcome copy when present. + await expectWelcomeCopyIfVisible(page); + }); + + test("F1-07 send prompt", async ({ page }) => { + await page.goto("/"); + // The primary workspace CTA should be available from either landing state. + await expectPrimaryWorkspaceAction(page); + }); + + test("F1-08 receive response", async ({ page }) => { + await page.goto("/"); + // Settings remains reachable even when "/" restores the last workspace. + await expectSettingsEntryPoint(page); + }); + + test("F1-09 stop session", async ({ page }) => { + await page.goto("/"); + // Page title should be correct + await expect(page).toHaveTitle(/Coder Studio/); + }); + + test("F1-10 resume session", async ({ page }) => { + await page.goto("/"); + // Root shell should be interactive. + await expectAppEntry(page); + }); +}); diff --git a/e2e/specs/agent-conversation.spec.ts b/e2e/specs/sessions/conversation.spec.ts similarity index 99% rename from e2e/specs/agent-conversation.spec.ts rename to e2e/specs/sessions/conversation.spec.ts index 3e939e1ae..d3c3392af 100644 --- a/e2e/specs/agent-conversation.spec.ts +++ b/e2e/specs/sessions/conversation.spec.ts @@ -1,5 +1,5 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; -import { openWorkspace } from "./phase3/supervisor.helpers"; +import { openWorkspace } from "../helpers/workspace-session"; /** * Agent Conversation E2E Tests diff --git a/e2e/specs/session-hydrate-refresh.spec.ts b/e2e/specs/sessions/hydrate-refresh.spec.ts similarity index 89% rename from e2e/specs/session-hydrate-refresh.spec.ts rename to e2e/specs/sessions/hydrate-refresh.spec.ts index bc5a2fc9d..c2f31e4af 100644 --- a/e2e/specs/session-hydrate-refresh.spec.ts +++ b/e2e/specs/sessions/hydrate-refresh.spec.ts @@ -12,6 +12,7 @@ const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`; const BASE_URL = `http://${HOST}:${WEB_PORT}`; const INTERRUPTED_SESSION_ID = "sess-hydrate-interrupted"; const UNAVAILABLE_SESSION_ID = "sess-hydrate-unavailable"; +const ENDED_STATE_LABEL = "Ended"; type TerminalTraceEntry = { terminalId?: string; event?: string; @@ -22,7 +23,7 @@ let dbPath: string; let runtimeDir: string; let backendProcess: ChildProcess | undefined; let webProcess: ChildProcess | undefined; -const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const WEB_ROOT = join(REPO_ROOT, "packages", "web"); function startProcess( @@ -173,11 +174,10 @@ test.describe("session hydrate refresh acceptance", () => { await expect(interruptedCard).toBeVisible(); await expect(unavailableCard).toBeVisible(); - await expect(interruptedCard.locator(".session-state-badge")).toHaveText("Interrupted"); + await expect(interruptedCard.locator(".session-title")).toHaveText("Resume me"); + await expect(interruptedCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); await expect(unavailableCard.locator(".session-title")).toHaveText("Unavailable"); - await expect(unavailableCard.locator(".session-state-badge")).toHaveText("Unavailable"); - await expect(interruptedCard.getByRole("button", { name: "Start" })).toBeVisible(); - await expect(unavailableCard.getByRole("button", { name: "Start" })).toHaveCount(0); + await expect(unavailableCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); const interruptedTextarea = interruptedCard.locator(".xterm textarea"); const unavailableTextarea = unavailableCard.locator(".xterm textarea"); @@ -190,8 +190,10 @@ test.describe("session hydrate refresh acceptance", () => { await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); await expect(interruptedCard).toBeVisible(); await expect(unavailableCard).toBeVisible(); - await expect(interruptedCard.getByRole("button", { name: "Start" })).toBeVisible(); - await expect(unavailableCard.getByRole("button", { name: "Start" })).toHaveCount(0); + await expect(interruptedCard.locator(".session-title")).toHaveText("Resume me"); + await expect(interruptedCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); + await expect(unavailableCard.locator(".session-title")).toHaveText("Unavailable"); + await expect(unavailableCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); await expect(interruptedTextarea).toHaveAttribute("readonly", ""); await expect(unavailableTextarea).toHaveAttribute("readonly", ""); }); @@ -276,7 +278,7 @@ test.describe("session hydrate refresh acceptance", () => { await expect(visibleCard).toBeVisible(); await expect(visibleCard).toHaveAttribute("data-session-id", UNAVAILABLE_SESSION_ID); await expect(visibleCard.locator(".session-title")).toHaveText("Unavailable"); - await expect(visibleCard.locator(".session-state-badge")).toHaveText("Unavailable"); + await expect(visibleCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); await expect(visibleCard.getByRole("button", { name: "Expand terminal keys" })).toHaveCount( 0 ); @@ -289,19 +291,20 @@ test.describe("session hydrate refresh acceptance", () => { await expect(page.getByTestId("mobile-shell")).toBeVisible({ timeout: 20000 }); await expect(visibleCard).toBeVisible(); await expect(visibleCard).toHaveAttribute("data-session-id", UNAVAILABLE_SESSION_ID); + await expect(visibleCard.locator(".session-title")).toHaveText("Unavailable"); + await expect(visibleCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL); await expect(visibleCard.getByRole("button", { name: "Expand terminal keys" })).toHaveCount( 0 ); await page.getByRole("button", { name: "Open Agent sheet" }).click(); - const agentSheet = page.getByRole("dialog", { name: "Agent Sessions" }); + const agentSheet = page.getByRole("region", { name: "Agent Sessions sheet" }); await expect(agentSheet).toBeVisible(); - await expect( - agentSheet.getByRole("button", { name: "Switch to agent Resume me" }) - ).toBeVisible(); - await expect( - agentSheet.getByRole("button", { name: "Switch to agent Unavailable" }) - ).toHaveClass(/mobile-inline-sheet__option--active/); + await expect(agentSheet.getByRole("button", { name: "Resume me" })).toBeVisible(); + await expect(agentSheet.getByRole("button", { name: "Unavailable" })).toHaveAttribute( + "aria-pressed", + "true" + ); } finally { await context.close(); } diff --git a/e2e/specs/sessions/provider-install-flow.spec.ts b/e2e/specs/sessions/provider-install-flow.spec.ts new file mode 100644 index 000000000..878797a27 --- /dev/null +++ b/e2e/specs/sessions/provider-install-flow.spec.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import { join } from "node:path"; +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n.js"; +import { openWorkspace } from "../helpers/workspace-session"; + +type MockProviderId = "claude" | "codex"; + +interface ProviderMockState { + commands: Record; + installBehavior: Partial< + Record< + MockProviderId, + { + result: "success" | "permission_denied" | "command_not_found"; + message?: string; + } + > + >; +} + +const sandboxDir = process.env.CODER_STUDIO_PHASE1_SANDBOX_DIR; + +if (!sandboxDir) { + throw new Error("CODER_STUDIO_PHASE1_SANDBOX_DIR must be set for provider install e2e"); +} + +const providerMockDir = + process.env.CODER_STUDIO_E2E_PROVIDER_MOCK_DIR ?? join(sandboxDir, "provider-mock"); +const providerMockBinDir = join(providerMockDir, "bin"); +const providerMockStatePath = join(providerMockDir, "state.json"); + +function resetMockProviderEnvironment(): void { + fs.rmSync(providerMockDir, { recursive: true, force: true }); + fs.mkdirSync(providerMockBinDir, { recursive: true }); +} + +function setMockProviderState(state: ProviderMockState): void { + fs.mkdirSync(providerMockDir, { recursive: true }); + fs.writeFileSync(providerMockStatePath, JSON.stringify(state, null, 2)); +} + +async function ensureWorkspaceOpen(page: Page): Promise { + await openWorkspace(page); + await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 }); +} + +async function ensureDraftLauncher(page: Page): Promise { + await ensureWorkspaceOpen(page); + + const draftLauncher = page.locator(".agent-draft-launcher").first(); + if (await draftLauncher.isVisible().catch(() => false)) { + return draftLauncher; + } + + const closeButtons = page.locator(".session-card.agent-pane .session-action-btn-close"); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (await draftLauncher.isVisible().catch(() => false)) { + return draftLauncher; + } + + if ((await closeButtons.count()) === 0) { + break; + } + + await closeButtons.first().click(); + await page.waitForTimeout(300); + } + + await expect(draftLauncher).toBeVisible({ timeout: 15000 }); + return draftLauncher; +} + +async function waitForProviderCta( + card: Locator, + key: "provider.install.cta.start" | "provider.install.cta.install_and_start" +): Promise { + await expect(card.locator(".agent-provider-card-cta")).toHaveText(translatePatternForE2E(key), { + timeout: 15000, + }); +} + +test.describe("provider install launcher flow", () => { + test.beforeEach(() => { + resetMockProviderEnvironment(); + }); + + test("PIF-01 Claude shows install action, installs, and creates a session", async ({ page }) => { + setMockProviderState({ + commands: { + npm: true, + claude: false, + codex: true, + }, + installBehavior: { + claude: { result: "success" }, + }, + }); + + const draftLauncher = await ensureDraftLauncher(page); + const claudeCard = draftLauncher.locator(".agent-provider-card-claude").first(); + const codexCard = draftLauncher.locator(".agent-provider-card-codex").first(); + + await waitForProviderCta(claudeCard, "provider.install.cta.install_and_start"); + await waitForProviderCta(codexCard, "provider.install.cta.start"); + + await claudeCard.click(); + + await expect(claudeCard).toBeDisabled({ timeout: 15000 }); + await expect(claudeCard.locator(".agent-provider-card-status")).toBeVisible({ timeout: 15000 }); + + const sessionCard = page.locator(".session-card.agent-pane[data-session-id]").first(); + await expect(sessionCard).toBeVisible({ timeout: 20000 }); + }); + + test("PIF-02 Codex install failure shows error guidance and docs link", async ({ page }) => { + setMockProviderState({ + commands: { + npm: true, + claude: true, + codex: false, + }, + installBehavior: { + codex: { + result: "permission_denied", + message: "permission denied", + }, + }, + }); + + const draftLauncher = await ensureDraftLauncher(page); + const codexCard = draftLauncher.locator(".agent-provider-card-codex").first(); + const claudeCard = draftLauncher.locator(".agent-provider-card-claude").first(); + + await waitForProviderCta(claudeCard, "provider.install.cta.start"); + await waitForProviderCta(codexCard, "provider.install.cta.install_and_start"); + + await codexCard.click(); + + await expect(codexCard).toContainText("permission denied", { timeout: 20000 }); + await expect(codexCard.locator(".agent-provider-card-guide a")).toHaveAttribute( + "href", + /openai\.com|github\.com|platform\.openai\.com/i, + { timeout: 10000 } + ); + }); +}); diff --git a/e2e/specs/sessions/title-extraction.spec.ts b/e2e/specs/sessions/title-extraction.spec.ts new file mode 100644 index 000000000..040e4ff96 --- /dev/null +++ b/e2e/specs/sessions/title-extraction.spec.ts @@ -0,0 +1,83 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { openWorkspace } from "../helpers/workspace-session"; + +async function ensureDraftLauncher(page: Page): Promise { + await openWorkspace(page); + + const draftLauncher = page.locator(".agent-draft-launcher").first(); + if (await draftLauncher.isVisible().catch(() => false)) { + return draftLauncher; + } + + const closeButtons = page.locator(".session-card.agent-pane .session-action-btn-close"); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (await draftLauncher.isVisible().catch(() => false)) { + return draftLauncher; + } + + if ((await closeButtons.count()) === 0) { + break; + } + + await closeButtons.first().click(); + await page.waitForTimeout(300); + } + + await expect(draftLauncher).toBeVisible({ timeout: 15000 }); + return draftLauncher; +} + +async function ensureFreshClaudeSession(page: Page): Promise { + const draftLauncher = await ensureDraftLauncher(page); + const claudeButton = draftLauncher.locator(".agent-provider-card-claude").first(); + + await expect(claudeButton).toBeVisible({ timeout: 5000 }); + await claudeButton.click(); + + const sessionCard = page.locator(".session-card.agent-pane[data-session-id]").first(); + await expect(sessionCard).toBeVisible({ timeout: 15000 }); + await expect(sessionCard.locator(".session-state-badge")).not.toHaveText("DRAFT", { + timeout: 15000, + }); + return sessionCard; +} + +async function submitPrompt(page: Page, sessionCard: Locator, prompt: string): Promise { + const terminalArea = sessionCard.locator(".session-terminal, .xterm").first(); + await expect(terminalArea).toBeVisible({ timeout: 5000 }); + + await terminalArea.click(); + await page.waitForTimeout(500); + await page.keyboard.type(prompt); + await page.keyboard.press("Enter"); + await page.waitForTimeout(3000); +} + +test.describe("Session Title Extraction", () => { + test("TITLE-01: Extract and truncate title from first input", async ({ page }) => { + const sessionCard = await ensureFreshClaudeSession(page); + const titleElement = sessionCard.locator(".session-title"); + + const beforeTitle = await titleElement.textContent(); + expect(beforeTitle).toBeTruthy(); + expect(beforeTitle).toContain("SESSION-"); + + await submitPrompt(page, sessionCard, "hello world this is a test"); + + await expect(titleElement).toHaveText("hello wor…", { timeout: 10000 }); + }); + + test("TITLE-02: Title idempotent - not overwritten on second input", async ({ page }) => { + const sessionCard = await ensureFreshClaudeSession(page); + const titleElement = sessionCard.locator(".session-title"); + + await submitPrompt(page, sessionCard, "first message"); + + await expect(titleElement).toHaveText("first mes…", { timeout: 10000 }); + const firstTitle = await titleElement.textContent(); + + await submitPrompt(page, sessionCard, "second different message"); + + await expect(titleElement).toHaveText(firstTitle ?? "", { timeout: 10000 }); + }); +}); diff --git a/e2e/specs/phase2/settings.spec.ts b/e2e/specs/settings/general.spec.ts similarity index 95% rename from e2e/specs/phase2/settings.spec.ts rename to e2e/specs/settings/general.spec.ts index dc1523c23..7410673f9 100644 --- a/e2e/specs/phase2/settings.spec.ts +++ b/e2e/specs/settings/general.spec.ts @@ -27,14 +27,14 @@ test.describe("@phase2 settings acceptance", () => { test("P2S-03 inject hooks updates provider status UI", async ({ page }) => { await page.goto("/settings"); await openSettingsSection(page, "providers"); - await page.getByRole("button", { name: providerSettingPattern("config_file") }).click(); + await page.getByRole("tab", { name: providerSettingPattern("config_file") }).click(); await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); }); test("P2S-04 codex provider shows cwd override field", async ({ page }) => { await page.goto("/settings"); await openSettingsSection(page, "providers"); - await page.getByRole("button", { name: "Codex" }).click(); + await page.getByRole("tab", { name: "Codex" }).click(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); await expect(page.locator(".settings-provider-content textarea.input")).toBeVisible(); }); diff --git a/e2e/specs/phase2/provider.spec.ts b/e2e/specs/settings/provider.spec.ts similarity index 73% rename from e2e/specs/phase2/provider.spec.ts rename to e2e/specs/settings/provider.spec.ts index d876d35be..894970fa8 100644 --- a/e2e/specs/phase2/provider.spec.ts +++ b/e2e/specs/settings/provider.spec.ts @@ -12,28 +12,33 @@ test.describe("@phase2 provider acceptance", () => { await page.goto("/settings"); await openSettingsSection(page, "providers"); - await expect(page.getByRole("button", { name: "Claude" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Codex" })).toBeVisible(); - await expect( - page.getByRole("button", { name: providerSettingPattern("base") }) - ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("tablist", { name: "Providers" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Claude" })).toHaveAttribute( + "aria-selected", + "true" + ); + await expect(page.getByRole("tab", { name: "Codex" })).toBeVisible(); + await expect(page.getByRole("tab", { name: providerSettingPattern("base") })).toHaveAttribute( + "aria-selected", + "true" + ); await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); - await page.getByRole("button", { name: providerSettingPattern("config_file") }).click(); + await page.getByRole("tab", { name: providerSettingPattern("config_file") }).click(); await expect( - page.getByRole("button", { name: providerSettingPattern("config_file") }) - ).toHaveAttribute("aria-pressed", "true"); + page.getByRole("tab", { name: providerSettingPattern("config_file") }) + ).toHaveAttribute("aria-selected", "true"); await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible(); - await page.getByRole("button", { name: "Codex" }).click(); + await page.getByRole("tab", { name: "Codex" }).click(); await expect( - page.getByRole("button", { name: providerSettingPattern("config_file") }) - ).toHaveAttribute("aria-pressed", "true"); + page.getByRole("tab", { name: providerSettingPattern("config_file") }) + ).toHaveAttribute("aria-selected", "true"); await expect(page.getByText(configFilePattern("codex"))).toBeVisible(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible(); - await page.getByRole("button", { name: providerSettingPattern("base") }).click(); + await page.getByRole("tab", { name: providerSettingPattern("base") }).click(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); }); @@ -49,7 +54,7 @@ test.describe("@phase2 provider acceptance", () => { await argsInput.fill("--verbose\n--print"); await expect(page.locator(".settings-command-preview")).toContainText("--print"); - await page.getByRole("button", { name: "Codex" }).click(); + await page.getByRole("tab", { name: "Codex" }).click(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toHaveValue( "--verbose\n--print" ); @@ -79,7 +84,7 @@ test.describe("@phase2 provider acceptance", () => { ).toBeVisible(); await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); - await page.getByRole("button", { name: "Codex" }).click(); + await page.getByRole("tab", { name: "Codex" }).click(); await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); await expect( page.getByRole("button", { name: providerSettingPattern("back_to_base") }) diff --git a/e2e/specs/phase2/settings-visual.spec.ts b/e2e/specs/settings/visual.spec.ts similarity index 100% rename from e2e/specs/phase2/settings-visual.spec.ts rename to e2e/specs/settings/visual.spec.ts diff --git a/e2e/specs/phase3/supervisor.spec.ts b/e2e/specs/supervisor/lifecycle.spec.ts similarity index 92% rename from e2e/specs/phase3/supervisor.spec.ts rename to e2e/specs/supervisor/lifecycle.spec.ts index 8a85a3cdf..2ed0ee14a 100644 --- a/e2e/specs/phase3/supervisor.spec.ts +++ b/e2e/specs/supervisor/lifecycle.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { enableSupervisor, launchClaudeSession, waitForSessionReady } from "./supervisor.helpers"; +import { + enableSupervisor, + launchClaudeSession, + waitForSessionReady, +} from "../helpers/workspace-session"; test.describe("@phase3 supervisor acceptance", () => { test("P3S-01 enables, triggers, pauses, resumes, and disables supervisor from the agent pane", async ({ diff --git a/e2e/specs/phase3/supervisor-visual.spec.ts b/e2e/specs/supervisor/visual.spec.ts similarity index 89% rename from e2e/specs/phase3/supervisor-visual.spec.ts rename to e2e/specs/supervisor/visual.spec.ts index 5a6a45416..5d42351dd 100644 --- a/e2e/specs/phase3/supervisor-visual.spec.ts +++ b/e2e/specs/supervisor/visual.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { enableSupervisor, launchClaudeSession, waitForSessionReady } from "./supervisor.helpers"; +import { + enableSupervisor, + launchClaudeSession, + waitForSessionReady, +} from "../helpers/workspace-session"; test.describe("@phase3 supervisor visual acceptance", () => { test("P3SV-01 supervisor card shows objective row, provider pill, and latest evaluation summary", async ({ diff --git a/e2e/specs/phase1/terminal.spec.ts b/e2e/specs/terminal/basic.spec.ts similarity index 53% rename from e2e/specs/phase1/terminal.spec.ts rename to e2e/specs/terminal/basic.spec.ts index bcc83f005..9cf6e3401 100644 --- a/e2e/specs/phase1/terminal.spec.ts +++ b/e2e/specs/terminal/basic.spec.ts @@ -1,30 +1,33 @@ import { expect, test } from "@playwright/test"; +import { + expectAppEntry, + expectPrimaryWorkspaceAction, + expectSettingsEntryPoint, +} from "../../fixtures/app-entry"; test.describe("@phase1 terminal acceptance", () => { test("F1-21 create terminal", async ({ page }) => { await page.goto("/"); - // Welcome page should render - await expect(page.locator(".welcome-container")).toBeVisible(); + // "/" may restore a workspace; either landing shell is valid. + await expectAppEntry(page); }); test("F1-22 type command", async ({ page }) => { await page.goto("/"); - // Check welcome btn - const btn = page.locator(".welcome-btn"); - await expect(btn).toBeVisible(); + // The shell should expose a primary workspace action. + await expectPrimaryWorkspaceAction(page); }); test("F1-23 resize", async ({ page }) => { await page.goto("/"); // Check page responsiveness await page.setViewportSize({ width: 1024, height: 768 }); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expectAppEntry(page); }); test("F1-24 close", async ({ page }) => { await page.goto("/"); - // Settings link should work - const link = page.locator(".welcome-link"); - await expect(link).toBeVisible(); + // Settings remains accessible in either landing state. + await expectSettingsEntryPoint(page); }); }); diff --git a/e2e/specs/terminal-ws-reconnect.spec.ts b/e2e/specs/terminal/ws-reconnect.spec.ts similarity index 100% rename from e2e/specs/terminal-ws-reconnect.spec.ts rename to e2e/specs/terminal/ws-reconnect.spec.ts diff --git a/e2e/specs/topbar-close-hover.spec.ts b/e2e/specs/topbar/close-hover.spec.ts similarity index 100% rename from e2e/specs/topbar-close-hover.spec.ts rename to e2e/specs/topbar/close-hover.spec.ts diff --git a/e2e/specs/phase1/workspace.spec.ts b/e2e/specs/workspace/basic.spec.ts similarity index 52% rename from e2e/specs/phase1/workspace.spec.ts rename to e2e/specs/workspace/basic.spec.ts index 66b170edf..caabd8f67 100644 --- a/e2e/specs/phase1/workspace.spec.ts +++ b/e2e/specs/workspace/basic.spec.ts @@ -1,15 +1,19 @@ import { expect, test } from "@playwright/test"; +import { + expectAppEntry, + expectPrimaryWorkspaceAction, + expectWelcomeCardIfVisible, + expectWelcomeCopyIfVisible, +} from "../../fixtures/app-entry"; import { translateForE2E } from "../../fixtures/i18n"; -import { expectOpenWorkspaceButton, expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 workspace acceptance", () => { test("F1-01 open workspace", async ({ page }) => { await page.goto("/"); - // Click open workspace button to open the workspace launch modal. - const openBtn = page.locator(".welcome-btn"); - await expectOpenWorkspaceButton(openBtn); + // Open the launch flow from either welcome or a restored workspace. + const openBtn = await expectPrimaryWorkspaceAction(page); await openBtn.click(); - await expect(page.locator(".launch-overlay")).toBeVisible(); + await expect(page.locator(".launch-overlay, .launch-modal").first()).toBeVisible(); await expect(page.locator(".launch-title")).toHaveText( translateForE2E("workspace.launch.title") ); @@ -17,9 +21,8 @@ test.describe("@phase1 workspace acceptance", () => { test("F1-02 browse file tree", async ({ page }) => { await page.goto("/"); - // Welcome page renders correctly - await expect(page.locator(".welcome-container")).toBeVisible(); - await expect(page.locator(".welcome-card")).toBeVisible(); + // Validate welcome chrome when it is active; restored workspaces are also valid. + await expectWelcomeCardIfVisible(page); }); test("F1-03 select file", async ({ page }) => { @@ -30,13 +33,13 @@ test.describe("@phase1 workspace acceptance", () => { test("F1-04 create file", async ({ page }) => { await page.goto("/"); - // Welcome page should have translated copy - await expectWelcomeCopy(page); + // Keep the localized welcome copy assertion only when "/" lands on welcome. + await expectWelcomeCopyIfVisible(page); }); test("F1-05 delete file", async ({ page }) => { await page.goto("/"); - // Check title - await expect(page.locator(".welcome-title")).toContainText("Coder Studio"); + // Root shell should resolve regardless of restore state. + await expectAppEntry(page); }); }); diff --git a/e2e/specs/phase1/editor.spec.ts b/e2e/specs/workspace/editor.spec.ts similarity index 100% rename from e2e/specs/phase1/editor.spec.ts rename to e2e/specs/workspace/editor.spec.ts diff --git a/e2e/specs/workspace/launch-flow.spec.ts b/e2e/specs/workspace/launch-flow.spec.ts new file mode 100644 index 000000000..805eaee56 --- /dev/null +++ b/e2e/specs/workspace/launch-flow.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n"; +import { + enterDirectory, + openWelcomeWorkspaceLaunchModal, + waitForWorkspaceLaunchModal, +} from "../helpers/workspace-session"; + +test.describe("session flow", () => { + test("SF-01 open workspace via launch modal", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await expect(page.locator(".launch-title")).toHaveText( + translatePatternForE2E("workspace.launch.title") + ); + await expect(page.locator(".launch-hint")).toHaveText( + translatePatternForE2E("workspace.launch.hint") + ); + }); + + test("SF-02 workspace launch modal has directory browser", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await expect(page.locator(".fp-root-chips")).toBeVisible(); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeVisible(); + await expect(startButton).toBeDisabled(); + }); + + test("SF-03 workspace launch modal open button disabled without selection", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeDisabled(); + }); + + test("SF-04 workspace launch modal cancel works", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await page.getByRole("button", { name: /^(Close|关闭)$/ }).click(); + + await expect(page.locator(".launch-modal")).toHaveCount(0); + await expect(page.locator(".welcome-container")).toBeVisible(); + }); + + test("SF-05 workspace launch modal can select directory", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const directoryItem = page.locator(".fp-dir").first(); + await expect(directoryItem).toBeVisible(); + await directoryItem.click(); + + await expect(page.locator(".fp-dir.selected")).toHaveCount(1); + await expect(page.locator(".fp-dir-action")).toBeVisible(); + + const startButton = page.getByRole("button", { + name: translatePatternForE2E("workspace.launch.start"), + }); + await expect(startButton).toBeEnabled(); + }); + + test("SF-06 workspace launch modal can navigate directories", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + const activePathChip = page.locator(".fp-chip.active").last(); + const beforePath = ((await activePathChip.textContent()) ?? "").trim(); + + await enterDirectory(page, "workspace"); + + await expect(activePathChip).not.toHaveText(beforePath, { timeout: 10000 }); + await expect( + page.locator(".fp-btn").filter({ hasText: translatePatternForE2E("workspace.launch.go_up") }) + ).toBeVisible(); + }); + + test("SF-07 keyboard shortcuts work in modal", async ({ page }) => { + await openWelcomeWorkspaceLaunchModal(page); + + await page.keyboard.press("Escape"); + + await expect(page.locator(".launch-modal")).toHaveCount(0); + }); +}); diff --git a/e2e/specs/workspace-route-history.spec.ts b/e2e/specs/workspace/route-history.spec.ts similarity index 90% rename from e2e/specs/workspace-route-history.spec.ts rename to e2e/specs/workspace/route-history.spec.ts index cc2fd3f5c..82c846f34 100644 --- a/e2e/specs/workspace-route-history.spec.ts +++ b/e2e/specs/workspace/route-history.spec.ts @@ -1,7 +1,8 @@ import { type ChildProcess, spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; const HOST = "127.0.0.1"; @@ -9,6 +10,8 @@ const SERVER_PORT = 43174; const WEB_PORT = 53174; const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`; const BASE_URL = `http://${HOST}:${WEB_PORT}`; +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const WEB_ROOT = join(REPO_ROOT, "packages", "web"); let sandboxDir: string; let dbPath: string; @@ -76,7 +79,7 @@ test.describe("workspace route history acceptance", () => { "pnpm", ["exec", "tsx", "e2e/fixtures/seed-workspace-route-history-db.ts", dbPath, workspacesRoot], { - cwd: "/home/spencer/workspace/coder-studio", + cwd: REPO_ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"], } @@ -98,7 +101,7 @@ test.describe("workspace route history acceptance", () => { }); backendProcess = startProcess("pnpm", ["exec", "tsx", "packages/server/src/server.ts"], { - cwd: "/home/spencer/workspace/coder-studio", + cwd: REPO_ROOT, env: { HOST, PORT: String(SERVER_PORT), @@ -114,8 +117,9 @@ test.describe("workspace route history acceptance", () => { "pnpm", ["exec", "vite", "--host", HOST, "--port", String(WEB_PORT)], { - cwd: "/home/spencer/workspace/coder-studio/packages/web", + cwd: WEB_ROOT, env: { + NODE_ENV: "development", VITE_BACKEND_HTTP_URL: BACKEND_HTTP_URL, VITE_BACKEND_WS_URL: `ws://${HOST}:${SERVER_PORT}/ws`, }, @@ -145,7 +149,7 @@ test.describe("workspace route history acceptance", () => { page, }) => { await page.goto("/"); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expect(page.locator(".topbar-tab")).toHaveCount(2, { timeout: 20000 }); await page.goto("/workspace"); await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); @@ -165,7 +169,6 @@ test.describe("workspace route history acceptance", () => { await page.goBack(); - await expect(page).toHaveURL(`${BASE_URL}/`); - await expect(page.locator(".welcome-container")).toBeVisible(); + await expect(page).toHaveURL("about:blank"); }); }); diff --git a/e2e/specs/phase3/worktree.spec.ts b/e2e/specs/worktree/management.spec.ts similarity index 100% rename from e2e/specs/phase3/worktree.spec.ts rename to e2e/specs/worktree/management.spec.ts diff --git a/e2e/specs/phase3/worktree-visual.spec.ts b/e2e/specs/worktree/visual.spec.ts similarity index 100% rename from e2e/specs/phase3/worktree-visual.spec.ts rename to e2e/specs/worktree/visual.spec.ts diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index 2807f0918..9d27f7df1 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -124,6 +124,7 @@ export interface GitBranch { isRemote: boolean; // Whether it's a remote branch isCurrent: boolean; // Whether it's the current branch remote?: string; // Remote name (e.g., "origin") + linkedWorktreePath?: string; // Path of another worktree using this branch, if any } export interface WorktreeInfo { diff --git a/packages/server/src/__tests__/git/cli.test.ts b/packages/server/src/__tests__/git/cli.test.ts index 023cabb20..74ef30395 100644 --- a/packages/server/src/__tests__/git/cli.test.ts +++ b/packages/server/src/__tests__/git/cli.test.ts @@ -14,6 +14,7 @@ import { getGitHistory, getGitStatus, runGit, + runGitCheckout, runGitFetch, runGitPull, runGitPush, @@ -432,10 +433,12 @@ describe("GitError", () => { describe("runGitListBranches", () => { let testDir: string; let remoteDir: string; + let linkedWorktreeDir: string; beforeEach(async () => { testDir = join(tmpdir(), `git-test-${Date.now()}`); remoteDir = join(tmpdir(), `git-remote-${Date.now()}`); + linkedWorktreeDir = join(tmpdir(), `git-linked-worktree-${Date.now()}`); await mkdir(testDir); // Initialize git repo @@ -455,6 +458,11 @@ describe("runGitListBranches", () => { } catch { // Ignore cleanup errors } + try { + await rmdir(linkedWorktreeDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } }); it("returns structured branch data with remote branches", async () => { @@ -601,6 +609,33 @@ describe("runGitListBranches", () => { await rm(testDir, { recursive: true }); }); + + it("omits local branches that are checked out in another worktree", async () => { + await writeFile(join(testDir, "README.md"), "test"); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "initial"], { cwd: testDir }); + + const defaultBranch = await getCurrentBranch(testDir); + await execFileAsync("git", ["branch", "feature/worktree-branch"], { cwd: testDir }); + await execFileAsync("git", ["worktree", "add", linkedWorktreeDir, "feature/worktree-branch"], { + cwd: testDir, + }); + + const { runGitListBranches } = await import("../../git/cli.js"); + const result = await runGitListBranches(testDir); + + expect(result.current).toBe(defaultBranch); + expect(result.branches).not.toContainEqual( + expect.objectContaining({ + name: "feature/worktree-branch", + }) + ); + expect(result.branches).not.toContainEqual( + expect.objectContaining({ + name: expect.stringMatching(/^\+\s/), + }) + ); + }); }); describe("getGitStatus", () => { @@ -682,6 +717,42 @@ describe("getGitStatus", () => { }); }); +describe("runGitCheckout", () => { + it("switches to an existing local branch when selecting a remote branch with the same short name", async () => { + const testDir = await mkdtemp(join(tmpdir(), "git-checkout-remote-existing-local-")); + const remoteDir = await mkdtemp(join(tmpdir(), "git-checkout-remote-existing-local-remote-")); + + await execFileAsync("git", ["init"], { cwd: testDir }); + await execFileAsync("git", ["config", "user.name", "Test"], { cwd: testDir }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: testDir }); + await writeFile(join(testDir, "README.md"), "test"); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "initial"], { cwd: testDir }); + + await execFileAsync("git", ["init", "--bare"], { cwd: remoteDir }); + await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: testDir }); + + await execFileAsync("git", ["checkout", "-b", "feature/login"], { cwd: testDir }); + await execFileAsync("git", ["push", "-u", "origin", "feature/login"], { cwd: testDir }); + await execFileAsync("git", ["checkout", "master"], { cwd: testDir }); + + const result = await runGitCheckout(testDir, "origin/feature/login"); + + expect(result).toMatchObject({ + success: true, + branch: "feature/login", + }); + await expect( + execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: testDir }) + ).resolves.toMatchObject({ + stdout: "feature/login\n", + }); + + await rm(testDir, { recursive: true }); + await rm(remoteDir, { recursive: true }); + }); +}); + describe("getGitHistory", () => { it("returns recent commits in reverse chronological order with author and timestamp", async () => { const testDir = await mkdtemp(join(tmpdir(), "git-history-")); @@ -882,6 +953,32 @@ describe("runGitCheckout", () => { const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { cwd: testDir }); expect(stdout.trim()).toBe("feature/login-page"); }); + + it("returns the underlying git error when checkout is blocked by another worktree", async () => { + await writeFile(join(testDir, "README.md"), "test"); + await execFileAsync("git", ["add", "."], { cwd: testDir }); + await execFileAsync("git", ["commit", "-m", "initial"], { cwd: testDir }); + + const linkedWorktreeDir = await mkdtemp(join(tmpdir(), "git-linked-worktree-")); + try { + await execFileAsync("git", ["branch", "feature/worktree-branch"], { cwd: testDir }); + await execFileAsync( + "git", + ["worktree", "add", linkedWorktreeDir, "feature/worktree-branch"], + { + cwd: testDir, + } + ); + + const result = await runGitCheckout(testDir, "feature/worktree-branch"); + + expect(result.success).toBe(false); + expect(result.message).toContain("already used by worktree"); + expect(result.message).toContain(linkedWorktreeDir); + } finally { + await rm(linkedWorktreeDir, { recursive: true, force: true }); + } + }); }); describe("runGitPush", () => { diff --git a/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts b/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts new file mode 100644 index 000000000..f337a8727 --- /dev/null +++ b/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import chokidar, { type FSWatcher } from "chokidar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "../server.js"; +import { dispatch } from "../ws/dispatch.js"; + +import "../commands/workspace.js"; + +describe("workspace watcher hydrate restart", () => { + let server: Server | undefined; + let dataDir: string; + let dbPath: string; + let workspaceDir: string; + let watchSpy: ReturnType>; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "coder-studio-data-")); + dbPath = join(dataDir, "coder-studio.db"); + workspaceDir = mkdtempSync(join(tmpdir(), "coder-studio-workspace-")); + mkdirSync(join(workspaceDir, ".git"), { recursive: true }); + writeFileSync(join(workspaceDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + + watchSpy = vi.spyOn(chokidar, "watch").mockReturnValue({ + on() { + return this; + }, + close: vi.fn().mockResolvedValue(undefined), + } as unknown as FSWatcher); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = undefined; + } + watchSpy.mockRestore(); + rmSync(dataDir, { recursive: true, force: true }); + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + it("restores persisted workspace watchers after server restart", async () => { + server = await createServer({ + dataDir: dbPath, + host: "127.0.0.1", + port: 0, + }); + + const firstCtx = server.__test__!.commandContext; + + const openResult = await dispatch( + { + kind: "command", + id: "workspace-open", + op: "workspace.open", + args: { path: workspaceDir }, + }, + firstCtx + ); + + expect(openResult.ok).toBe(true); + expect(watchSpy).toHaveBeenCalledTimes(1); + + await server.stop(); + server = undefined; + watchSpy.mockClear(); + + server = await createServer({ + dataDir: dbPath, + host: "127.0.0.1", + port: 0, + }); + + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledWith( + workspaceDir, + expect.objectContaining({ + ignoreInitial: true, + persistent: true, + }) + ); + }); +}); diff --git a/packages/server/src/__tests__/workspace/manager.test.ts b/packages/server/src/__tests__/workspace/manager.test.ts index 3a0a1e6db..70bdd87cd 100644 --- a/packages/server/src/__tests__/workspace/manager.test.ts +++ b/packages/server/src/__tests__/workspace/manager.test.ts @@ -4,6 +4,7 @@ import { DatabaseSync } from "node:sqlite"; import type { DomainEvent } from "@coder-studio/core"; +import chokidar, { type FSWatcher } from "chokidar"; import { mkdir, rmdir } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -20,6 +21,7 @@ describe("WorkspaceManager", () => { emit: (event: DomainEvent) => void; on: () => () => void; }; + let watchSpy: ReturnType>; beforeEach(async () => { // Create test directory @@ -53,10 +55,18 @@ describe("WorkspaceManager", () => { on: () => () => {}, }; + watchSpy = vi.spyOn(chokidar, "watch").mockReturnValue({ + on() { + return this; + }, + close: vi.fn().mockResolvedValue(undefined), + } as unknown as FSWatcher); + manager = new WorkspaceManager({ db, eventBus }); }); afterEach(async () => { + watchSpy.mockRestore(); try { db.close(); await rmdir(testDir); @@ -224,6 +234,46 @@ describe("WorkspaceManager", () => { }); }); + describe("hydrateWatchers", () => { + it("starts file watchers for persisted workspaces", async () => { + const persisted = await manager.open({ path: testDir }); + const broadcaster = { broadcast: vi.fn() }; + const restoredManager = new WorkspaceManager({ db, eventBus, broadcaster }); + + restoredManager.hydrateWatchers(); + + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledWith( + testDir, + expect.objectContaining({ + ignoreInitial: true, + persistent: true, + }) + ); + expect( + (restoredManager as unknown as { watchers: Map }).watchers.has( + persisted.id + ) + ).toBe(true); + }); + + it("does not create duplicate watchers when called multiple times", async () => { + const persisted = await manager.open({ path: testDir }); + const broadcaster = { broadcast: vi.fn() }; + const restoredManager = new WorkspaceManager({ db, eventBus, broadcaster }); + + restoredManager.hydrateWatchers(); + restoredManager.hydrateWatchers(); + + expect(watchSpy).toHaveBeenCalledTimes(1); + expect( + (restoredManager as unknown as { watchers: Map }).watchers.has( + persisted.id + ) + ).toBe(true); + }); + }); + describe("updateUiState", () => { it("updates workspace pane layout and emits workspace meta changed", async () => { const workspace = await manager.open({ path: testDir }); diff --git a/packages/server/src/git/cli.ts b/packages/server/src/git/cli.ts index 9f0d99ab1..afb668748 100644 --- a/packages/server/src/git/cli.ts +++ b/packages/server/src/git/cli.ts @@ -551,6 +551,10 @@ export async function runGitCheckout( } ): Promise<{ success: boolean; message: string; branch?: string }> { const args = ["checkout"]; + const formatCheckoutError = (error: unknown, fallbackMessage: string) => + error instanceof GitError + ? error.stderr.trim() || error.message || fallbackMessage + : fallbackMessage; // Detect remote branch refs by querying actual configured remotes let isRemoteRef = false; @@ -568,7 +572,15 @@ export async function runGitCheckout( if (isRemoteRef && !options?.createBranch) { const remoteSeparatorIndex = ref.indexOf("/"); const branchName = remoteSeparatorIndex >= 0 ? ref.slice(remoteSeparatorIndex + 1) : ref; - args.push("-b", branchName, ref); + + try { + await runGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]); + const { stdout, stderr } = await runGit(cwd, ["checkout", branchName]); + const message = stdout || stderr || `Checkout to ${branchName} completed`; + return { success: true, message, branch: branchName }; + } catch { + args.push("-b", branchName, ref); + } try { const { stdout, stderr } = await runGit(cwd, args); @@ -576,10 +588,10 @@ export async function runGitCheckout( // For remote branch checkout, we know the branch name from the ref return { success: true, message, branch: branchName }; - } catch { + } catch (error) { return { success: false, - message: `Failed to checkout remote branch '${ref}'`, + message: formatCheckoutError(error, `Failed to checkout remote branch '${ref}'`), }; } } else { @@ -599,10 +611,10 @@ export async function runGitCheckout( const message = stdout || stderr || `Checkout to ${ref} completed`; return { success: true, message, branch }; - } catch { + } catch (error) { return { success: false, - message: `Failed to checkout '${ref}'`, + message: formatCheckoutError(error, `Failed to checkout '${ref}'`), }; } } @@ -644,18 +656,35 @@ export async function runGitListBranches(cwd: string): Promise<{ }> { // Get local branches const { stdout: localOutput } = await runGit(cwd, ["branch", "--list"]); + const { stdout: localVerboseOutput } = await runGit(cwd, ["branch", "--list", "-vv"]); // Get remote branches const { stdout: remoteOutput } = await runGit(cwd, ["branch", "-r"]); const branches: GitBranch[] = []; let current = ""; + const linkedWorktreePathsByBranch = new Map(); + + const localVerboseLines = localVerboseOutput.split("\n").filter((line) => line.trim()); + for (const line of localVerboseLines) { + const normalizedLine = line.replace(/^[*+ ]\s+/, ""); + const branchMatch = normalizedLine.match(/^([^\s]+)\s+/); + const worktreeMatch = line.match(/\((.+?)\)\s/); + if (!branchMatch?.[1] || !worktreeMatch?.[1]) { + continue; + } + + const worktreePath = worktreeMatch[1]; + if (worktreePath.startsWith("/") || worktreePath.startsWith("~")) { + linkedWorktreePathsByBranch.set(branchMatch[1], worktreePath); + } + } // Parse local branches const localLines = localOutput.split("\n").filter((line) => line.trim()); for (const line of localLines) { const isCurrent = line.startsWith("*"); - const name = line.replace(/^\*?\s+/, "").trim(); + const name = line.replace(/^[*+ ]\s+/, "").trim(); // Skip detached HEAD indicator if (name.startsWith("(HEAD detached")) { @@ -665,10 +694,15 @@ export async function runGitListBranches(cwd: string): Promise<{ continue; // Don't add to branches array } + if (linkedWorktreePathsByBranch.has(name) && !isCurrent) { + continue; + } + branches.push({ name, isRemote: false, isCurrent, + linkedWorktreePath: linkedWorktreePathsByBranch.get(name), }); if (isCurrent) { current = name; diff --git a/packages/server/src/provider-runtime/e2e-provider-mock.ts b/packages/server/src/provider-runtime/e2e-provider-mock.ts new file mode 100644 index 000000000..86a1fa238 --- /dev/null +++ b/packages/server/src/provider-runtime/e2e-provider-mock.ts @@ -0,0 +1,197 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type CommandAvailabilityCheck, checkCommandAvailable } from "./command-check.js"; +import { type CommandRunner, runCommandAsString } from "./command-runner.js"; + +type MockProviderId = "claude" | "codex"; +type MockInstallResult = "success" | "permission_denied" | "command_not_found"; + +interface MockInstallBehavior { + result: MockInstallResult; + message?: string; +} + +interface ProviderMockState { + commands?: Partial>; + installBehavior?: Partial>; +} + +interface ProviderMockOverrides { + commandExists: CommandAvailabilityCheck; + runCommand: CommandRunner; +} + +const PROVIDER_INSTALL_PACKAGES: Record = { + claude: "@anthropic-ai/claude-code", + codex: "@openai/codex", +}; + +const PROVIDER_COMMAND_SCRIPTS: Record = { + claude: `#!/usr/bin/env bash +set -euo pipefail +trap 'exit 0' TERM INT +printf 'Mock Claude ready\\n' +while true; do + sleep 1 +done +`, + codex: `#!/usr/bin/env bash +set -euo pipefail +trap 'exit 0' TERM INT +printf 'Session ID: abcdef-123456\\n> ' +while true; do + sleep 1 +done +`, +}; + +export function createE2EProviderMockOverrides( + env: NodeJS.ProcessEnv = process.env +): ProviderMockOverrides | null { + const statePath = env.CODER_STUDIO_E2E_PROVIDER_STATE_PATH; + if (!statePath) { + return null; + } + + const binDir = env.CODER_STUDIO_E2E_PROVIDER_BIN_DIR; + const debugLogPath = env.CODER_STUDIO_E2E_PROVIDER_DEBUG_LOG_PATH; + + appendDebugLog(debugLogPath, `init statePath=${statePath} binDir=${binDir ?? ""}`); + + const commandExists: CommandAvailabilityCheck = async (command: string) => { + const state = readMockState(statePath); + const override = state.commands?.[command]; + appendDebugLog( + debugLogPath, + `commandExists ${command} override=${String(override)} state=${JSON.stringify(state.commands ?? {})}` + ); + + if (typeof override === "boolean") { + return override; + } + + return checkCommandAvailable(command); + }; + + const runCommand: CommandRunner = async (file, args, options) => { + const providerId = getInstallProviderId(file, args); + appendDebugLog( + debugLogPath, + `runCommand ${file} ${args.join(" ")} provider=${providerId ?? "none"}` + ); + if (!providerId) { + return runCommandAsString(file, args, options); + } + + const state = readMockState(statePath); + const behavior = state.installBehavior?.[providerId]; + appendDebugLog( + debugLogPath, + `behavior ${providerId} ${JSON.stringify(behavior)} state=${JSON.stringify(state)}` + ); + if (!behavior) { + return runCommandAsString(file, args, options); + } + + if (behavior.result === "success") { + writeMockState(statePath, (draft) => { + draft.commands ??= {}; + draft.commands[providerId] = true; + }); + + if (binDir) { + ensureProviderCommand(binDir, providerId); + } + + appendDebugLog(debugLogPath, `install success ${providerId}`); + + return { + stdout: `installed ${providerId}`, + stderr: "", + }; + } + + const message = + behavior.message ?? + (behavior.result === "permission_denied" ? "permission denied" : "command not found"); + + throw Object.assign(new Error(message), { + exitCode: 1, + stdout: "", + stderr: message, + }); + }; + + return { + commandExists, + runCommand, + }; +} + +function getInstallProviderId(file: string, args: string[]): MockProviderId | null { + if (file !== "npm" || args.length !== 3) { + return null; + } + + if (args[0] !== "install" || args[1] !== "-g") { + return null; + } + + const packageName = args[2]; + if (packageName === PROVIDER_INSTALL_PACKAGES.claude) { + return "claude"; + } + if (packageName === PROVIDER_INSTALL_PACKAGES.codex) { + return "codex"; + } + + return null; +} + +function readMockState(statePath: string): ProviderMockState { + if (!existsSync(statePath)) { + return {}; + } + + const raw = readFileSync(statePath, "utf8"); + if (!raw.trim()) { + return {}; + } + + try { + return JSON.parse(raw) as ProviderMockState; + } catch (error) { + throw new Error( + `Invalid provider mock state at ${statePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function writeMockState( + statePath: string, + updater: (state: ProviderMockState) => void +): ProviderMockState { + const nextState = readMockState(statePath); + updater(nextState); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, JSON.stringify(nextState, null, 2)); + return nextState; +} + +function ensureProviderCommand(binDir: string, providerId: MockProviderId): void { + mkdirSync(binDir, { recursive: true }); + const scriptPath = join(binDir, providerId); + writeFileSync(scriptPath, PROVIDER_COMMAND_SCRIPTS[providerId], "utf8"); + chmodSync(scriptPath, 0o755); +} + +function appendDebugLog(path: string | undefined, line: string): void { + if (!path) { + return; + } + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${line}\n`, { flag: "a" }); +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 436717afe..81b98c929 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -17,6 +17,7 @@ import { EventBus } from "./bus/event-bus.js"; import { ensureDataDir, parseServerConfig, type ServerConfig } from "./config.js"; import { AutoFetchScheduler } from "./git/auto-fetch.js"; import { runCommandAsString } from "./provider-runtime/command-runner.js"; +import { createE2EProviderMockOverrides } from "./provider-runtime/e2e-provider-mock.js"; import { ProviderInstallManager } from "./provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "./provider-runtime/runtime-status.js"; import { SessionManager } from "./session/manager.js"; @@ -136,6 +137,7 @@ export async function createServer( console.warn("[uploads] cascade cleanup failed", { wsId: workspaceId, err }) ), }); + workspaceMgr.hydrateWatchers(); const authSessionRepo = new AuthSessionRepo(db); const authLoginBlockRepo = new AuthLoginBlockRepo(db); @@ -180,10 +182,15 @@ export async function createServer( await sessionMgr.hydrate(); await supervisorMgr.hydrate(); - const providerRuntimeDeps: RuntimeStatusDeps = {}; + const providerMockOverrides = createE2EProviderMockOverrides(); + const providerRuntimeDeps: RuntimeStatusDeps = providerMockOverrides + ? { + commandExists: providerMockOverrides.commandExists, + } + : {}; const providerInstallMgr = new ProviderInstallManager(providerRegistry, { ...providerRuntimeDeps, - runCommand: runCommandAsString, + runCommand: providerMockOverrides?.runCommand ?? runCommandAsString, }); commandContext = { diff --git a/packages/server/src/workspace/manager.ts b/packages/server/src/workspace/manager.ts index b4c622ca1..a97bda859 100644 --- a/packages/server/src/workspace/manager.ts +++ b/packages/server/src/workspace/manager.ts @@ -60,6 +60,12 @@ export class WorkspaceManager { ); } + hydrateWatchers(): void { + for (const workspace of this.list()) { + this.startWatcher(workspace.id, workspace.path); + } + } + updateUiState(workspaceId: string, uiState: Workspace["uiState"]): void { const workspace = this.get(workspaceId); if (!workspace) { diff --git a/packages/web/src/components/ui/MIGRATION.md b/packages/web/src/components/ui/MIGRATION.md index 1f12685ca..116ae6445 100644 --- a/packages/web/src/components/ui/MIGRATION.md +++ b/packages/web/src/components/ui/MIGRATION.md @@ -43,7 +43,7 @@ `Popover` now completes the bounded feature inventory for desktop custom-content dropdowns: the desktop terminal selector and workspace branch quick pick both use the shared primitive from the public UI barrel for click-to-toggle or ArrowDown-to-open trigger semantics, portaled non-modal dialog content, and outside-click / `Escape` dismissal while preserving legacy hooks such as `terminal-selector-btn`, `terminal-selector-dropdown`, `terminal-selector-item*`, `git-panel-status-strip__branch`, and `branch-quick-pick*`. No additional bounded feature callers remain in the current `features/components` inventory. The mobile fullscreen terminal switcher and mobile branch quick pick intentionally stay on their existing `Select` + `MobileSelectSheet` and global `MobileSelectSheet` paths by design, while menu-list composition now moves into the shared `ActionMenu` wrapper. -`ActionMenu` now completes the bounded feature inventory for shared menu-list triggers: the mobile workspace topbar replaces its direct settings icon with a shared more-actions trigger that opens a mobile `Sheet`-backed action list containing `Settings` and `Quick Actions`. The primitive owns the bounded action-list chrome and desktop menu/mobile sheet presentation, while the feature layer still owns trigger styling and command wiring such as `commandPaletteOpenAtom` and the existing settings navigation callback. No additional bounded feature callers remain in the current inventory. +`ActionMenu` now completes the bounded feature inventory for shared menu-list triggers used by bounded desktop/mobile overflow menus. The primitive owns the shared action-list chrome and desktop menu/mobile sheet presentation where those menus remain intentionally part of the product. The mobile workspace topbar no longer uses this pattern and instead exposes a direct settings icon button to keep the shell chrome aligned with the current mobile IDE navigation model. `Notice` now completes the bounded legacy `.settings-page__notice*` migration inventory: the settings-page load-error shell uses the shared primitive from the public UI barrel while preserving the legacy `settings-page__notice*` compatibility classes and the caller-owned `settings-link` refresh action styling. diff --git a/packages/web/src/components/ui/kbd/index.module.css b/packages/web/src/components/ui/kbd/index.module.css index 56e6c5f9e..c0f0b6432 100644 --- a/packages/web/src/components/ui/kbd/index.module.css +++ b/packages/web/src/components/ui/kbd/index.module.css @@ -1,11 +1,14 @@ .kbd, :global(.shortcuts-key) { + display: inline-flex; + align-items: center; + justify-content: center; min-width: 80px; padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-hover); - color: var(--text-secondary); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-input) 82%, var(--bg-surface) 18%); + color: var(--text-primary); font-family: var(--font-mono); font-size: var(--text-sm); text-align: center; @@ -13,13 +16,14 @@ .interactive:hover, :global(.shortcuts-key):hover { - border-color: var(--accent-blue); + border-color: var(--border-focus); + background: var(--bg-active); } .kbd:focus-visible, :global(.shortcuts-key):focus-visible { outline: none; - border-color: var(--accent-blue); + border-color: var(--border-focus); box-shadow: 0 0 0 1px rgba(108, 182, 255, 0.4); } diff --git a/packages/web/src/components/ui/notice/index.module.css b/packages/web/src/components/ui/notice/index.module.css index ccb142407..606855f0b 100644 --- a/packages/web/src/components/ui/notice/index.module.css +++ b/packages/web/src/components/ui/notice/index.module.css @@ -2,32 +2,32 @@ display: flex; align-items: flex-start; justify-content: space-between; - gap: var(--sp-4); - margin: 0 0 var(--sp-5); - padding: var(--sp-3) var(--sp-4); + gap: var(--sp-3); + margin: 0 0 var(--sp-4); + padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-surface); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-page) 42%, var(--bg-surface) 58%); } .info { border-color: color-mix(in srgb, var(--color-info) 24%, transparent); - background: color-mix(in srgb, var(--color-info) 6%, var(--bg-surface)); + background: color-mix(in srgb, var(--color-info) 5%, var(--bg-surface)); } .success { border-color: color-mix(in srgb, var(--color-success) 24%, transparent); - background: color-mix(in srgb, var(--color-success) 6%, var(--bg-surface)); + background: color-mix(in srgb, var(--color-success) 5%, var(--bg-surface)); } .warning { - border-color: color-mix(in srgb, var(--accent-amber) 35%, transparent); - background: color-mix(in srgb, var(--accent-amber) 8%, var(--bg-surface)); + border-color: color-mix(in srgb, var(--accent-amber) 28%, transparent); + background: color-mix(in srgb, var(--accent-amber) 6%, var(--bg-surface)); } .error { - border-color: color-mix(in srgb, var(--color-error) 36%, transparent); - background: color-mix(in srgb, var(--color-error) 10%, var(--bg-surface)); + border-color: color-mix(in srgb, var(--color-error) 28%, transparent); + background: color-mix(in srgb, var(--color-error) 7%, var(--bg-surface)); } .copy { @@ -35,22 +35,26 @@ min-width: 0; flex: 1; flex-direction: column; - gap: var(--sp-1); + gap: 2px; } .title { - font-size: var(--text-sm); + font-size: var(--text-xs); font-weight: var(--font-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; color: var(--text-primary); } .message { - font-size: var(--text-xs); + font-size: var(--text-sm); + line-height: 1.45; color: var(--text-secondary); word-break: break-word; } .action { flex-shrink: 0; - align-self: center; + display: inline-flex; + align-self: flex-start; } diff --git a/packages/web/src/components/ui/pill/index.module.css b/packages/web/src/components/ui/pill/index.module.css index 40a29b33a..bad3817f3 100644 --- a/packages/web/src/components/ui/pill/index.module.css +++ b/packages/web/src/components/ui/pill/index.module.css @@ -1,14 +1,18 @@ .pill { display: inline-flex; align-items: center; + justify-content: center; + min-height: 36px; + min-width: 0; gap: var(--sp-2); - padding: var(--sp-2) var(--sp-4); - border: 1px solid var(--border); - border-radius: var(--radius-full); - background: var(--bg-surface); + padding: var(--sp-2) var(--sp-3); + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; color: var(--text-secondary); font-family: var(--font-sans); - font-size: var(--text-base); + font-size: var(--text-sm); + line-height: 1.4; transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), @@ -17,24 +21,26 @@ .pill:hover:not(:disabled) { background: var(--bg-hover); - border-color: var(--border-light); + border-color: color-mix(in srgb, var(--border-light) 72%, transparent); + color: var(--text-primary); } .pill:focus-visible { outline: none; - border-color: var(--accent-blue); - box-shadow: 0 0 0 1px rgba(108, 182, 255, 0.4); + border-color: var(--border-focus); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-blue) 35%, transparent); } .active { - background: var(--accent-blue); - border-color: var(--accent-blue); - color: var(--text-inverse); + background: var(--bg-active); + border-color: var(--border-focus); + color: var(--text-primary); } .icon { display: inline-flex; align-items: center; + color: inherit; } .pill:disabled { diff --git a/packages/web/src/components/ui/segmented-control/index.module.css b/packages/web/src/components/ui/segmented-control/index.module.css index 820a1de21..cf1448162 100644 --- a/packages/web/src/components/ui/segmented-control/index.module.css +++ b/packages/web/src/components/ui/segmented-control/index.module.css @@ -60,6 +60,10 @@ :global(.settings-provider-tabs) { margin-bottom: var(--sp-6); + padding: 0; + border: none; + border-radius: 0; + background: transparent; } :global(.settings-provider-subnav) { @@ -76,15 +80,17 @@ padding-left: 0; padding-right: 0; padding-top: 0; + background: transparent; } :global(.settings-provider-tab) { - background: var(--bg-surface); - border-color: var(--border); + background: transparent; + border-color: transparent; } :global(.settings-provider-tab.active) { - border-color: var(--accent-blue); + background: var(--bg-active); + border-color: var(--border-focus); } :global(.settings-provider-subnav-button) { @@ -92,19 +98,24 @@ } :global(.settings-provider-subnav-button.active) { - background: var(--bg-hover); - border-color: var(--border-light); + background: var(--bg-active); + border-color: var(--border-focus); } :global(.shortcuts-category-tabs) { margin-bottom: var(--sp-4); + padding: 0; + border: none; + border-radius: 0; + background: transparent; } :global(.shortcuts-category-tab) { - background: var(--bg-hover); + background: transparent; + border-color: transparent; } :global(.shortcuts-category-tab.active) { - background: color-mix(in srgb, var(--accent-blue) 18%, var(--bg-surface)); - border-color: transparent; + background: var(--bg-active); + border-color: var(--border-focus); } diff --git a/packages/web/src/components/ui/sheet/index.test.tsx b/packages/web/src/components/ui/sheet/index.test.tsx index d5d4b9921..5134d8d22 100644 --- a/packages/web/src/components/ui/sheet/index.test.tsx +++ b/packages/web/src/components/ui/sheet/index.test.tsx @@ -30,6 +30,8 @@ describe("Sheet", () => { expect(document.querySelector(".mobile-sheet__body")).toBeTruthy(); expect(document.querySelector(".mobile-sheet__footer")).toBeTruthy(); expect(screen.getByText("Body")).toBeInTheDocument(); + expect(screen.getByText("Quick Actions")).toBeInTheDocument(); + expect(screen.queryByText("COMMAND PALETTE")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument(); }); @@ -89,6 +91,7 @@ describe("Sheet", () => { expect(document.querySelector(".mobile-sheet--fullscreen")).toBeTruthy(); expect(document.querySelector(".mobile-sheet--launch")).toBeTruthy(); + expect(document.querySelector(".mobile-sheet__header .mobile-page-header")).toBeTruthy(); expect(document.querySelector(".mobile-sheet__body--flush")).toBeTruthy(); expect(document.querySelector(".mobile-sheet__body--fullscreen")).toBeTruthy(); expect(document.querySelector(".mobile-launch-sheet")).toBeTruthy(); diff --git a/packages/web/src/components/ui/sheet/index.tsx b/packages/web/src/components/ui/sheet/index.tsx index bbaafd31c..c9c207d65 100644 --- a/packages/web/src/components/ui/sheet/index.tsx +++ b/packages/web/src/components/ui/sheet/index.tsx @@ -1,16 +1,8 @@ import clsx from "clsx"; -import { ArrowLeft } from "lucide-react"; import type { ReactNode } from "react"; +import { MobilePageHeader } from "../../../features/shared/components/mobile-page-header"; import { useTranslation } from "../../../lib/i18n"; -interface SheetHeaderProps { - readonly title: string; - readonly kicker?: string; - readonly onBack: () => void; - readonly backLabel: string; - readonly headerAction?: ReactNode; -} - export interface SheetProps { readonly title: string; readonly body: ReactNode; @@ -25,24 +17,6 @@ export interface SheetProps { readonly backLabel?: string; } -function SheetHeader({ title, kicker, onBack, backLabel, headerAction }: SheetHeaderProps) { - return ( -
-
- -
- {kicker ?
{kicker}
: null} -

{title}

-
-
- {headerAction ?
{headerAction}
: null} -
- ); -} - export function Sheet({ title, body, @@ -79,12 +53,13 @@ export function Sheet({ > {fullscreen ? null :