From 205bfe3e7f0e3e73cb2076661806691a6b38d17b Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 10 May 2026 14:28:32 +0800 Subject: [PATCH 01/12] docs: add heavy ai user promotion article --- .../article-2026-05-heavy-ai-user-day.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/promotion/article-2026-05-heavy-ai-user-day.md 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 +``` From 9fce5af17ba847a2abd87e1d5440fbad06734992 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 10 May 2026 14:36:42 +0800 Subject: [PATCH 02/12] Restore workspace watchers on server startup --- .../workspace-watcher-hydrate-restart.test.ts | 84 +++++++++++++++++++ .../src/__tests__/workspace/manager.test.ts | 50 +++++++++++ packages/server/src/server.ts | 1 + packages/server/src/workspace/manager.ts | 6 ++ 4 files changed, 141 insertions(+) create mode 100644 packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts 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/server.ts b/packages/server/src/server.ts index 436717afe..d6bbec1c6 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -136,6 +136,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); 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) { From 01770d2753ad7c2331069cf8d1bfc378166ea1b3 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 10 May 2026 17:26:57 +0800 Subject: [PATCH 03/12] chore(e2e): reorganize specs by feature --- e2e/fixtures/app-entry.ts | 71 +++++++ e2e/fixtures/i18n.ts | 14 ++ e2e/playwright.config.ts | 10 + .../command-palette.spec.ts | 0 e2e/specs/app-shell/focus-mode.spec.ts | 16 ++ e2e/specs/{phase2 => app-shell}/i18n.spec.ts | 13 +- .../multi-tab-visual.spec.ts | 0 .../{phase3 => app-shell}/multi-tab.spec.ts | 0 .../session-terminal-interaction.spec.ts | 7 +- .../{phase1 => app-shell}/websocket.spec.ts | 9 +- .../auth.spec.ts => auth/basic.spec.ts} | 7 +- .../visual.spec.ts} | 0 e2e/specs/complete-session-flow.spec.ts | 193 ----------------- e2e/specs/full-integration.spec.ts | 130 ------------ .../auto-fetch.spec.ts} | 30 ++- e2e/specs/git/basic.spec.ts | 40 ++++ .../branch-switching.spec.ts} | 39 ++-- .../workspace-session.ts} | 155 ++++++++++++-- e2e/specs/{phase1 => infra}/fixtures.spec.ts | 0 e2e/specs/{phase1 => infra}/reporting.spec.ts | 0 .../journeys/complete-session-flow.spec.ts | 134 ++++++++++++ e2e/specs/journeys/full-integration.spec.ts | 91 ++++++++ e2e/specs/minimal-title-test.spec.ts | 76 ------- e2e/specs/phase1/agent-session.spec.ts | 38 ---- e2e/specs/phase1/data-integrity.spec.ts | 29 --- e2e/specs/phase1/edge-cases.spec.ts | 33 --- e2e/specs/phase1/focus-mode.spec.ts | 16 -- e2e/specs/phase1/git.spec.ts | 35 ---- e2e/specs/provider-install-flow.spec.ts | 132 ------------ e2e/specs/quality/data-integrity.spec.ts | 36 ++++ e2e/specs/quality/edge-cases.spec.ts | 36 ++++ .../general.spec.ts} | 0 .../visual-animations.spec.ts | 5 +- .../visual-components.spec.ts | 38 ++-- .../{phase1 => quality}/visual-global.spec.ts | 0 .../{phase1 => quality}/visual-states.spec.ts | 13 +- e2e/specs/session-flow.spec.ts | 157 -------------- e2e/specs/session-title-extraction.spec.ts | 186 ----------------- e2e/specs/sessions/agent-session.spec.ts | 39 ++++ .../conversation.spec.ts} | 2 +- .../hydrate-refresh.spec.ts} | 33 +-- .../sessions/provider-install-flow.spec.ts | 147 +++++++++++++ e2e/specs/sessions/title-extraction.spec.ts | 83 ++++++++ .../general.spec.ts} | 4 +- .../{phase2 => settings}/provider.spec.ts | 33 +-- .../visual.spec.ts} | 0 .../lifecycle.spec.ts} | 6 +- .../visual.spec.ts} | 6 +- .../basic.spec.ts} | 21 +- .../ws-reconnect.spec.ts} | 0 .../close-hover.spec.ts} | 0 .../basic.spec.ts} | 27 +-- .../{phase1 => workspace}/editor.spec.ts | 0 e2e/specs/workspace/launch-flow.spec.ts | 88 ++++++++ .../route-history.spec.ts} | 17 +- .../management.spec.ts} | 0 .../visual.spec.ts} | 0 .../src/provider-runtime/e2e-provider-mock.ts | 197 ++++++++++++++++++ packages/server/src/server.ts | 10 +- .../actions/use-workspace-sessions.ts | 22 +- 60 files changed, 1354 insertions(+), 1170 deletions(-) create mode 100644 e2e/fixtures/app-entry.ts rename e2e/specs/{phase1 => app-shell}/command-palette.spec.ts (100%) create mode 100644 e2e/specs/app-shell/focus-mode.spec.ts rename e2e/specs/{phase2 => app-shell}/i18n.spec.ts (78%) rename e2e/specs/{phase3 => app-shell}/multi-tab-visual.spec.ts (100%) rename e2e/specs/{phase3 => app-shell}/multi-tab.spec.ts (100%) rename e2e/specs/{ => app-shell}/session-terminal-interaction.spec.ts (95%) rename e2e/specs/{phase1 => app-shell}/websocket.spec.ts (59%) rename e2e/specs/{phase2/auth.spec.ts => auth/basic.spec.ts} (89%) rename e2e/specs/{phase2/auth-visual.spec.ts => auth/visual.spec.ts} (100%) delete mode 100644 e2e/specs/complete-session-flow.spec.ts delete mode 100644 e2e/specs/full-integration.spec.ts rename e2e/specs/{git-auto-fetch.spec.ts => git/auto-fetch.spec.ts} (90%) create mode 100644 e2e/specs/git/basic.spec.ts rename e2e/specs/{git-branch-switching.spec.ts => git/branch-switching.spec.ts} (82%) rename e2e/specs/{phase3/supervisor.helpers.ts => helpers/workspace-session.ts} (53%) rename e2e/specs/{phase1 => infra}/fixtures.spec.ts (100%) rename e2e/specs/{phase1 => infra}/reporting.spec.ts (100%) create mode 100644 e2e/specs/journeys/complete-session-flow.spec.ts create mode 100644 e2e/specs/journeys/full-integration.spec.ts delete mode 100644 e2e/specs/minimal-title-test.spec.ts delete mode 100644 e2e/specs/phase1/agent-session.spec.ts delete mode 100644 e2e/specs/phase1/data-integrity.spec.ts delete mode 100644 e2e/specs/phase1/edge-cases.spec.ts delete mode 100644 e2e/specs/phase1/focus-mode.spec.ts delete mode 100644 e2e/specs/phase1/git.spec.ts delete mode 100644 e2e/specs/provider-install-flow.spec.ts create mode 100644 e2e/specs/quality/data-integrity.spec.ts create mode 100644 e2e/specs/quality/edge-cases.spec.ts rename e2e/specs/{phase4/quality.spec.ts => quality/general.spec.ts} (100%) rename e2e/specs/{phase1 => quality}/visual-animations.spec.ts (78%) rename e2e/specs/{phase1 => quality}/visual-components.spec.ts (55%) rename e2e/specs/{phase1 => quality}/visual-global.spec.ts (100%) rename e2e/specs/{phase1 => quality}/visual-states.spec.ts (57%) delete mode 100644 e2e/specs/session-flow.spec.ts delete mode 100644 e2e/specs/session-title-extraction.spec.ts create mode 100644 e2e/specs/sessions/agent-session.spec.ts rename e2e/specs/{agent-conversation.spec.ts => sessions/conversation.spec.ts} (99%) rename e2e/specs/{session-hydrate-refresh.spec.ts => sessions/hydrate-refresh.spec.ts} (89%) create mode 100644 e2e/specs/sessions/provider-install-flow.spec.ts create mode 100644 e2e/specs/sessions/title-extraction.spec.ts rename e2e/specs/{phase2/settings.spec.ts => settings/general.spec.ts} (95%) rename e2e/specs/{phase2 => settings}/provider.spec.ts (73%) rename e2e/specs/{phase2/settings-visual.spec.ts => settings/visual.spec.ts} (100%) rename e2e/specs/{phase3/supervisor.spec.ts => supervisor/lifecycle.spec.ts} (92%) rename e2e/specs/{phase3/supervisor-visual.spec.ts => supervisor/visual.spec.ts} (89%) rename e2e/specs/{phase1/terminal.spec.ts => terminal/basic.spec.ts} (53%) rename e2e/specs/{terminal-ws-reconnect.spec.ts => terminal/ws-reconnect.spec.ts} (100%) rename e2e/specs/{topbar-close-hover.spec.ts => topbar/close-hover.spec.ts} (100%) rename e2e/specs/{phase1/workspace.spec.ts => workspace/basic.spec.ts} (52%) rename e2e/specs/{phase1 => workspace}/editor.spec.ts (100%) create mode 100644 e2e/specs/workspace/launch-flow.spec.ts rename e2e/specs/{workspace-route-history.spec.ts => workspace/route-history.spec.ts} (90%) rename e2e/specs/{phase3/worktree.spec.ts => worktree/management.spec.ts} (100%) rename e2e/specs/{phase3/worktree-visual.spec.ts => worktree/visual.spec.ts} (100%) create mode 100644 packages/server/src/provider-runtime/e2e-provider-mock.ts 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/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 d6bbec1c6..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"; @@ -181,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/web/src/features/agent-panes/actions/use-workspace-sessions.ts b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts index 39802898d..6e64a419f 100644 --- a/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts +++ b/packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts @@ -53,6 +53,11 @@ export function useWorkspaceSessions( } let cancelled = false; + const workspaceSessionIdsAtRequestStart = new Set( + Object.values(store.get(sessionsAtom)) + .filter((session) => session.workspaceId === workspace.id) + .map((session) => session.id) + ); dispatch("session.list", { workspaceId: workspace.id }) .then((result) => { @@ -62,13 +67,24 @@ export function useWorkspaceSessions( } const nextSessions = result.data; + const currentWorkspaceSessions = Object.values(store.get(sessionsAtom)).filter( + (session) => session.workspaceId === workspace.id + ); + const preservedLateSessions = currentWorkspaceSessions.filter( + (session) => + !workspaceSessionIdsAtRequestStart.has(session.id) && + !nextSessions.some((nextSession) => nextSession.id === session.id) + ); + // Preserve sessions that were added after this fetch started so an older bootstrap + // response cannot revert a freshly launched session back into the draft launcher. + const mergedSessions = [...nextSessions, ...preservedLateSessions]; setSessions((prev) => { const next = Object.fromEntries( Object.entries(prev).filter(([, session]) => session.workspaceId !== workspace.id) ); - for (const session of nextSessions) { + for (const session of mergedSessions) { next[session.id] = session; } @@ -81,10 +97,10 @@ export function useWorkspaceSessions( const baseLayout = workspacePaneLayout ?? legacyPaneLayout ?? currentLayout ?? defaultPaneLayout; const displayableSessionIds = new Set( - nextSessions.filter((session) => session.state !== "draft").map((session) => session.id) + mergedSessions.filter((session) => session.state !== "draft").map((session) => session.id) ); - const displayableSessions = nextSessions.filter((session) => session.state !== "draft"); + const displayableSessions = mergedSessions.filter((session) => session.state !== "draft"); const sanitized = sanitizePaneLayout(baseLayout, displayableSessionIds); let nextLayout = sanitized; if (sanitized !== currentLayout) { From 335bf1e1cf24f94408f7a4d10ff030fe13970bf0 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 10 May 2026 23:44:30 +0800 Subject: [PATCH 04/12] feat(web): polish mobile editor shell and branch picker --- packages/web/src/components/ui/MIGRATION.md | 2 +- .../src/components/ui/kbd/index.module.css | 14 +- .../src/components/ui/notice/index.module.css | 34 +- .../src/components/ui/pill/index.module.css | 28 +- .../ui/segmented-control/index.module.css | 27 +- .../src/components/ui/sheet/index.test.tsx | 3 + .../web/src/components/ui/sheet/index.tsx | 33 +- .../components/mobile-select-sheet.test.tsx | 16 +- .../components/mobile-select-sheet.tsx | 41 +- .../settings/components/config-editor.tsx | 3 +- .../settings/components/provider-settings.tsx | 6 +- .../components/settings-page.test.tsx | 16 +- .../settings/components/settings-page.tsx | 6 +- .../settings/components/settings-sections.tsx | 4 +- .../components/mobile-page-header.test.tsx | 24 + .../shared/components/mobile-page-header.tsx | 21 + .../shared/components/page-header.tsx | 7 +- .../views/shared/terminal-panel.tsx | 1 + .../workspace/actions/use-git-actions.ts | 33 +- .../views/mobile/mobile-agent-sheet.tsx | 1 + .../workspace/views/mobile/mobile-topbar.tsx | 50 +- .../mobile/mobile-workspace-drawer.test.tsx | 41 + .../views/mobile/mobile-workspace-drawer.tsx | 22 +- .../views/mobile/workspace-mobile-view.tsx | 26 +- .../views/shared/branch-quick-pick.test.tsx | 39 +- .../views/shared/branch-quick-pick.tsx | 9 +- packages/web/src/locales/en.json | 10 +- packages/web/src/locales/zh.json | 10 +- .../web/src/shells/desktop-shell.test.tsx | 14 + packages/web/src/shells/desktop-shell.tsx | 10 +- .../src/shells/mobile-shell/index.test.tsx | 57 +- .../web/src/shells/mobile-shell/index.tsx | 6 +- packages/web/src/styles/components.css | 1429 ++++++++++++----- .../web/src/styles/components.theme.test.ts | 653 +++++++- packages/web/src/styles/tokens.css | 6 + 35 files changed, 2040 insertions(+), 662 deletions(-) create mode 100644 packages/web/src/features/shared/components/mobile-page-header.test.tsx create mode 100644 packages/web/src/features/shared/components/mobile-page-header.tsx 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 :