diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..9a84d8e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,86 @@ +name: E2E + +# Runs the Playwright UI e2e suite (`bun run e2e:ui`) on every PR and on every +# merge to main. The suite drives the REAL built daemon, so it can't be a bun +# unit test — and until now it had no automated gate, which is how it silently +# rotted across several UI refactors (see DECISIONS.md "UI e2e caps workers…"). +# +# On a POST-MERGE failure (a regression landed on main), the `autofix` job hands +# the failure to Claude Code, which reproduces it, fixes the right side (app or +# stale test), and opens a PR. On a PR, the red check is the gate — you fix it on +# the branch, so the bot does not open a PR there. +# +# Requirements (one-time): the `ANTHROPIC_API_KEY` repo secret, and +# Settings → Actions → General → "Allow GitHub Actions to create and approve +# pull requests" (so the autofix job can open the PR). + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel superseded PR runs; let every merge to main run to completion. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + - name: Run UI e2e suite + run: bun run e2e:ui + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-results + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + + # Post-merge self-heal. Runs only when the suite fails on a push to main; it + # sets up bun + the Playwright browser too, so Claude can actually re-run + # `bun run e2e:ui` to reproduce and verify its fix before opening the PR. + autofix: + needs: [e2e] + if: ${{ failure() && github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + - name: Fix the failing e2e suite and open a PR + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--max-turns 30" + prompt: | + The Playwright UI e2e suite (`bun run e2e:ui`) is failing on `main` after a merge. + Investigate, fix it, and open a pull request with the fix. + + - Reproduce with `bun run e2e:ui` — it builds, boots the real daemon on a throwaway + OTACON_HOME, and runs `test/ui/*.e2e.ts`. + - For each failure, decide whether the bug is in the app/daemon (`src/`) or a stale + expectation in `test/ui/*.e2e.ts`, and fix the correct side. Read DESIGN.md and + DECISIONS.md before changing any product behavior — the suite drives the real + built daemon, and DESIGN.md is the source of truth for intended behavior. + - Re-run `bun run e2e:ui` until the whole suite is green, and keep `bun test` and + `bun run typecheck` passing too. + - Open a PR whose description states the root cause and the fix. diff --git a/DECISIONS.md b/DECISIONS.md index 79076ed..e08322d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -650,6 +650,23 @@ Revisit when**. Every tradeoff made in a change gets its entry here in the same - **Revisit when:** the suites need shared fixtures, or bun can run Playwright specs natively. +## UI e2e caps workers at 2 with 2 retries: one daemon, not a worker fleet + +- **Decision:** `playwright.config.ts` pins `workers: 2` and `retries: 2` (keeping + `fullyParallel`). The whole suite shares ONE `node dist/daemon/main.js`; each + worker holds SSE streams and spawns real `otacon wait` long-polls, so high + concurrency saturates that single process and its keep-alive connections reset + mid-flight (`ECONNRESET` on the API client). The reset count scales with workers + (≈1 → 8 → 24 at 2 → 4 → 9); two workers keeps it rare and the retry budget + absorbs the residual, so the run is deterministically green. +- **Why:** The contention is a test-harness artifact, not a daemon bug — production + otacond serves one human plus their agent, never a fleet of concurrent clients, so + raising the server's keep-alive to chase e2e throughput would change product code + for a test-only reason. Capping the harness is the scoped fix; retries cover the + inherently timing-dependent reuse race a worker cap alone can't fully eliminate. +- **Revisit when:** the suite is sharded across daemons (a port/home per worker), or + the daemon gains real multi-client load worth hardening keep-alive for. + ## M1 scope: CLI surface is `start`/`submit`/`wait`/`status` only - **Decision:** M1 ships sessions, registry, submit + linter (L1/L2/L6), event queues, diff --git a/playwright.config.ts b/playwright.config.ts index 2c72f60..7a1e3a1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,6 +25,17 @@ export default defineConfig({ testDir: "test/ui", testMatch: "**/*.e2e.ts", fullyParallel: true, + // The whole suite drives ONE real daemon (a single Node HTTP server). Each + // worker opens SSE streams and spawns real `otacon wait` long-polls, so a high + // worker count saturates that one process — its keep-alive connections get + // reset mid-flight (`ECONNRESET` on the API client), and the failure count + // scales with concurrency (≈1 → 8 → 24 at 2 → 4 → 9 workers). Cap the workers + // so the shared daemon is never overwhelmed, and keep a small retry budget to + // absorb the rare residual reset; the suite is then deterministically green. + // (The production daemon serves one human + their agent, not a worker fleet, so + // this is a test-harness concern, not a daemon bug to fix in product code.) + workers: 2, + retries: 2, reporter: "list", use: { baseURL }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], diff --git a/test/ui/acceptance.e2e.ts b/test/ui/acceptance.e2e.ts index 7db0105..eee3a1b 100644 --- a/test/ui/acceptance.e2e.ts +++ b/test/ui/acceptance.e2e.ts @@ -2,14 +2,14 @@ // in a real browser against the real built daemon AND the real built CLI. One // session is driven through the entire agent/reviewer loop — grill → draft → review → // revise → approve — and every screen a real reviewer touches is asserted: -// • the index card surfaces the session and its live status chip; +// • the sidebar surfaces the session and its live status glyph; // • a grill question card is answerable, waking a parked `otacon wait`; // • the submitted plan renders as schema'd sections, a real mermaid diagram, // and a collapsed Details block with its size badge; // • a comment composed in the UI reaches a parked CLI wait; // • a new revision raises the changelog banner + diff + gutter markers; -// • a resolved thread collapses, and a thread whose quote a later revision -// deletes lands in the orphan tray; +// • an agent reply lands inline on the open thread, and a thread whose quote a +// later revision deletes detaches inline (muted quote, no orphan tray); // • Approve from the UI flips the screen to its approved read-only state and // surfaces the saved project copy under .otacon/plans (plus the home copy). // Three screenshots (desktop review, phone review, index) are captured to a @@ -18,7 +18,7 @@ // Reuses the shared helpers/CLI harness; runs headless under the same webServer // (node dist/daemon/main.js, temp OTACON_HOME) as every other *.e2e.ts spec. -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -63,11 +63,15 @@ test("UI acceptance: the whole agent/reviewer loop renders and round-trips in th session.id, ); - // ── 1. Index surfaces the new session, drafting ────────────────────────── + // ── 1. Index surfaces the new session ──────────────────────────────────── + // The app-shell sidebar (≥960px) is the live index: the session shows as a + // row the moment it registers. Freshly created over the API with no agent on + // the line yet (no `otacon wait`/`ask` has parked it), its status derivation + // reads stalled — the brighter "drafting" spinner lights once contact lands. await page.goto("/"); - const card = page.locator(".card", { hasText: session.title }); + const card = page.locator(".sl-row", { hasText: session.title }); await expect(card).toBeVisible(); - await expect(card.locator(".chip")).toHaveText("agent working"); + await expect(card.locator(".sl-glyph")).toHaveAttribute("aria-label", "stalled"); // ── 2. Grill: an `otacon ask` card is answerable; it wakes a parked wait ── await page.goto(`/s/${session.id}`); @@ -109,8 +113,11 @@ test("UI acceptance: the whole agent/reviewer loop renders and round-trips in th dir, richBase.replace("- D1: RS256 over HS256 [assumed]", `- D1: RS256 over HS256 ← ${qid}`), ); - // The fresh new-revision banner carries the landed revision (the r0 header pill is gone). - await expect(page.locator(".rev-fresh .rev-label")).toHaveText("r1 received"); + // r1 is a first review, not a re-review: it raises no new-revision banner (the + // banner renders only from r2 — DECISIONS "banner from r2"). The plan itself + // lands live over SSE, replacing the grill placeholder — the schema assertions + // below are the proof it rendered without a reload. + await expect(page.locator(".rev-fresh")).toHaveCount(0); // Schema-aware structure, not markdown soup. await expect(page.locator("#summary .section-title")).toHaveText("Summary"); await expect(page.locator("#phase-1 .phase-name")).toHaveText("Token issuance"); @@ -153,7 +160,7 @@ test("UI acceptance: the whole agent/reviewer loop renders and round-trips in th expect(cev.items[0]!.anchor.exact).toBe("RS256 over HS256"); const threadId = cev.items[0]!.thread; - // ── 5. Revise: r2 raises the banner + gutter marker; the thread resolves ── + // ── 5. Revise: r2 raises the banner; the agent replies on the open thread ── await plantMarker(page); await cliSubmit( session.id, @@ -172,24 +179,29 @@ test("UI acceptance: the whole agent/reviewer loop renders and round-trips in th await expect(banner.locator(".rev-label")).toHaveText("r2 received"); await expect(banner.locator(".rev-changelog")).toContainText("Reworded the summary"); expect(await readMarker(page)).toBe(true); // banner arrived over SSE - // Gutter marker on the changed section, clean view. + // The comment batch in step 4 implicitly marked r1 reviewed (a comment moves + // the diff baseline — app.ts), so r2's gutter markers + diff baseline off r1: + // the reworded summary is flagged changed in the clean view. await expect(page.locator("#summary.unit-changed")).toHaveCount(1); - // The resolved thread collapses to its ✓ line; expanding shows the reply. - const resolved = page.locator(".thread-resolved"); - await expect(resolved).toHaveCount(1); - await expect(resolved.locator(".resolved-rev")).toHaveText("r2"); - await resolved.locator(".resolved-summary").click(); - await expect(resolved.locator(".thread-answer-body")).toContainText("verifiers hold only"); - // The banner's diff shows hunks only where the plan changed. + // A revision reply is a *response*, not a close (DECISIONS "a landed reply is a + // response, not a close" — the reviewer resolves separately): the agent's reply + // lands inline on the still-open comment card, never collapsing it to a ✓. + const thread = page.locator(".thread-comment"); + await expect(thread.locator(".thread-answer-body")).toContainText("verifiers hold only"); + await expect(thread.locator(".thread-answer-label")).toContainText("r2"); + await expect(page.locator(".thread-resolved")).toHaveCount(0); + // The banner's diff shows hunks only where the plan changed (r1 → r2). await banner.locator(".btn-ghost", { hasText: "view diff" }).click(); await expect(page.locator(".plan-diff")).toBeVisible(); await expect(page.locator(".diff-legend")).toContainText("r1 → r2"); await expect(page.locator("#summary .dline-add")).toContainText("Replace legacy session-cookie"); await page.locator(".seg-btn", { hasText: "clean" }).click(); + // Dismiss marks r2 reviewed (lastReviewedRevision → 2), so the NEXT revision's + // diff and gutter markers baseline off r2. await banner.locator(".btn-primary", { hasText: "dismiss" }).click(); await expect(banner).toHaveCount(0); - // ── 6. Orphan tray: r3 deletes the quoted text → the thread orphans live ── + // ── 6. Detach: r3 deletes the quoted text → the thread detaches inline ────── await plantMarker(page); await cliSubmit( session.id, @@ -197,56 +209,85 @@ test("UI acceptance: the whole agent/reviewer loop renders and round-trips in th richBase .replace("revision: 1", "revision: 3") .replace("- D1: RS256 over HS256 [assumed]\n", "") // deletes the anchored quote - .replace("Replace session-cookie auth", "Replace legacy session-cookie auth"), - { changelog: "Dropped the RS256 decision line." }, + .replace("Replace session-cookie auth", "Replace the legacy session-cookie auth flow"), + { changelog: "Dropped the RS256 decision line; retitled the summary." }, ); - const tray = page.locator(".orphan-toggle"); - await expect(tray).toBeVisible(); - await expect(tray.locator(".orphan-count")).toHaveText("1"); - await tray.click(); - const orphan = page.locator(".orphan"); - await expect(orphan).toHaveCount(1); - await expect(orphan.locator(".thread-quote")).toContainText("RS256 over HS256"); - await expect(orphan.locator(".orphan-where")).toHaveText("#decisions"); + const banner3 = page.locator(".rev-banner"); + await expect(banner3.locator(".rev-label")).toHaveText("r3 received"); + await expect(banner3.locator(".rev-changelog")).toContainText("Dropped the RS256 decision line"); + expect(await readMarker(page)).toBe(true); // banner arrived over SSE + // Gutter marker on the reworded summary, clean view — the baseline is r2 now. + await expect(page.locator("#summary.unit-changed")).toHaveCount(1); + // No orphan tray (DECISIONS "no UI tray, inline & muted"): the detached + // thread stays inline in the rail with its quote muted (no live text to jump + // to) beside the ⌀ glyph; the word "orphan" appears nowhere. + await expect(thread.locator(".thread-quote-muted")).toContainText("RS256 over HS256"); + await expect(thread.locator(".thread-quote-detached")).toBeVisible(); + await expect(page.locator(".rail")).not.toContainText("orphan"); + // The banner's diff baselines off r2 now. + await banner3.locator(".btn-ghost", { hasText: "view diff" }).click(); + await expect(page.locator(".diff-legend")).toContainText("r2 → r3"); + await page.locator(".seg-btn", { hasText: "clean" }).click(); + await banner3.locator(".btn-primary", { hasText: "dismiss" }).click(); // ── 7. Approve from the UI → approved read-only state + project-copy path ── await page.locator(".ctrl-approve").click(); const sheet = page.locator(".approve-sheet"); await expect(sheet).toContainText(".otacon/plans"); await sheet.locator(".btn-approve").click(); - // The lone orphaned (resolved) thread is not an open thread, but the orphan - // tray keeps it; if approve warns, force through it — the human's path. - if (await sheet.locator(".btn-force").isVisible().catch(() => false)) { - await sheet.locator(".btn-force").click(); - } - const note = page.locator(".approved-note"); - await expect(note).toBeVisible(); - await expect(page.locator(".chip")).toHaveText("approved"); - const noted = await note.locator(".approved-path").textContent(); - const relPath = /\.otacon\/plans\/\S+\.md/.exec(noted ?? "")?.[0]; - expect(relPath, "approved note surfaces the project-copy path").toBeTruthy(); - // The project copy is on disk in the session's repo with the rewritten frontmatter. - const artifact = readFileSync(join(session.repo, relPath!), "utf8"); + // The replied-but-unresolved comment still counts as unresolved at approve (a + // reply is a response, not a close — app.ts), so the sheet flips to its amber + // warning. Wait for that stage, then force through it ("Save anyway") — the + // human's path. (No "Send to agent": the thread already has a reply, so there + // are no open comments to fold in.) + await expect(sheet).toHaveClass(/approve-warning/); + await sheet.locator(".btn-force").click(); + // A Save-approve of the viewed session redirects it home on the live + // approved crossing (DECISIONS "Approving the viewed session redirects home"): + // the screen leaves /s/:id for the index rather than lingering on a session + // whose switcher chip is gone. + await expect(page).toHaveURL(/\/$/); + // otacon never commits the plan, but it writes a project copy under the repo's + // .otacon/plans. Poll for it (the write completes before the redirect frame), + // then assert the rewritten frontmatter + the folded-in interview. + const plansDir = join(session.repo, ".otacon", "plans"); + let planFiles: string[] = []; + await expect + .poll(() => { + planFiles = existsSync(plansDir) + ? readdirSync(plansDir).filter((f) => f.endsWith(".md")) + : []; + return planFiles.length; + }) + .toBeGreaterThan(0); + const artifact = readFileSync(join(plansDir, planFiles[0]!), "utf8"); expect(artifact).toContain("status: approved"); expect(artifact).toContain("## Interview"); expect(artifact).toContain(`### ${qid}`); - // Read-only: the mutation surfaces are gone, and the daemon refuses mutations. - await expect(page.locator(".ctrl-approve")).toHaveCount(0); + // The daemon refuses mutations on the over (approved) session. const refused = await request.post(`/api/sessions/${session.id}/comments`, { data: { items: [{ anchor: null, body: "too late" }] }, }); expect(refused.status()).toBe(409); // 📸 the index after the loop, and the phone review screen of the same plan. + // Approved (over) sessions fall to the sidebar's collapsed `approved (n)` + // disclosure; expand it and confirm the session now reads approved. await page.goto("/"); - await expect(page.locator(".card", { hasText: session.title }).locator(".chip")).toHaveText( - "approved", - ); + await page.locator(".sl-approved-toggle").click(); + const approvedRow = page.locator(".sl-approved-rows .sl-row", { hasText: session.title }); + await expect(approvedRow).toBeVisible(); + await expect(approvedRow.locator(".sl-glyph")).toHaveAttribute("aria-label", "approved"); await page.screenshot({ path: join(shotsDir, "03-index.png"), fullPage: true }); await page.setViewportSize({ width: 375, height: 720 }); + // Opening an already-approved session does NOT redirect (the crossing guard is + // live-transition only): it renders read-only behind the approved notice, with + // the mutation surfaces (Approve) gone. await page.goto(`/s/${session.id}`); await expect(page.locator("#summary .md")).toBeVisible(); + await expect(page.locator(".approved-note")).toBeVisible(); + await expect(page.locator(".ctrl-approve")).toHaveCount(0); await page.screenshot({ path: join(shotsDir, "02-phone-review.png"), fullPage: true }); // Print the screenshot dir so the orchestrator can surface the images. diff --git a/test/ui/activity.e2e.ts b/test/ui/activity.e2e.ts index 3f06cf8..a00acc2 100644 --- a/test/ui/activity.e2e.ts +++ b/test/ui/activity.e2e.ts @@ -29,9 +29,10 @@ test("a progress note drives the draft chip, the now-playing bar + console, and // The activity-driven draft chip reads the latest note (review UI, D3), // not a fixed "agent drafting". await expect(page.locator(".review-header .chip")).toHaveText("reading the auth module"); - // The always-on now-playing bar shows the latest event's label; in `draft` - // the console auto-expands and the progress note renders as a highlight row. + // The always-on now-playing bar shows the latest event's label. The console + // starts collapsed and never auto-expands now, so open it to see the highlight. await expect(page.locator(".now-playing .np-label")).toContainText("reading the auth module"); + await page.locator(".now-playing").click(); await expect(page.locator(".live-console .lc-highlight-body")).toContainText( "reading the auth module", ); @@ -60,19 +61,25 @@ test("a progress note posted while watching appears live (SSE), no reload", asyn await postProgress(request, session.id, "drafting the plan"); await expect(page.locator(".review-header .chip")).toHaveText("drafting the plan"); await expect(page.locator(".now-playing .np-label")).toContainText("drafting the plan"); + // The console is collapsed by default; open it to see the highlight row. + await page.locator(".now-playing").click(); await expect(page.locator(".live-console .lc-highlight-body")).toContainText( "drafting the plan", ); expect(await readMarker(page)).toBe(true); // SSE updated the screen, no navigation }); -test("the index card shows the latest note and a live agent dot", async ({ page, request }) => { +test("the index sidebar row shows a live agent dot after a progress note", async ({ + page, + request, +}) => { const session = await createSession(request, uniqueTitle("activity-card")); await postProgress(request, session.id, "exploring the daemon"); await page.goto("/"); - const card = page.locator(".card", { hasText: session.title }); - await expect(card.locator(".chip")).toHaveText("exploring the daemon"); - await expect(card.locator(".agent-dot")).toHaveClass(/is-live/); + // The sidebar row carries no note text (that rides the session header's chip), + // but the progress note bumped last-contact, so the row's agent dot reads live. + const row = page.locator(".sl-row", { hasText: session.title }); + await expect(row.locator(".agent-dot")).toHaveClass(/is-live/); }); test("the console keeps multiple notes as distinct highlights; the chip shows the newest", async ({ @@ -85,7 +92,9 @@ test("the console keeps multiple notes as distinct highlights; the chip shows th await page.goto(`/s/${session.id}`); // Each progress note is its own chapter divider (highlights never collapse), - // so two notes render two highlight rows; the draft chip rides the newest. + // so two notes render two highlight rows once the (collapsed) console is opened; + // the draft chip rides the newest. + await page.locator(".now-playing").click(); await expect(page.locator(".live-console .lc-highlight")).toHaveCount(2); await expect(page.locator(".review-header .chip")).toHaveText("second note"); }); diff --git a/test/ui/delete.e2e.ts b/test/ui/delete.e2e.ts index 12fa827..d013a7c 100644 --- a/test/ui/delete.e2e.ts +++ b/test/ui/delete.e2e.ts @@ -1,7 +1,8 @@ // UI e2e for delete-pending-session: a real browser deletes a pending session -// from both surfaces (index card + review header), and confirms an approved -// session offers no delete control. Sessions are seeded through the real HTTP -// API; the daemon hard-removes working state and pushes the `removed` frame. +// from both surfaces (sidebar row + review header), and confirms an approved +// session is deletable too. Sessions are seeded through the real HTTP API; the +// daemon hard-removes working state and pushes the `removed` frame. The index is +// the app-shell sidebar now (rows are `.sl-row`, the per-row ✕ is `.sl-delete`). import type { APIRequestContext, Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; @@ -11,25 +12,27 @@ import { createSession, submitFixturePlan, uniqueTitle } from "./helpers.js"; const submitPlan = (request: APIRequestContext, id: string) => submitFixturePlan(request, id, "valid-plan.md"); -const cardFor = (page: Page, session: Session) => - page.locator(".card", { hasText: session.title }); +const rowFor = (page: Page, session: Session) => + page.locator(".sl-row", { hasText: session.title }); -test("delete a pending session from the index card drops it live", async ({ page, request }) => { +test("delete a pending session from the sidebar row drops it live", async ({ page, request }) => { const session = await createSession(request, uniqueTitle("trash")); await page.goto("/"); - const card = cardFor(page, session); - await expect(card).toBeVisible(); + const row = rowFor(page, session); + await expect(row).toBeVisible(); - // The card is a link; the ✕ must open the sheet, not navigate into it. - await card.locator(".card-delete").click(); + // The row is a link; the ✕ must open the sheet, not navigate into it. The ✕ is + // hover-revealed (pointer-events:none at rest), so hover the row to reach it. + await row.hover(); + await row.locator(".sl-delete").click(); await expect(page).toHaveURL(/\/$/); const sheet = page.locator(".delete-sheet"); await expect(sheet).toBeVisible(); await expect(sheet).toContainText("can't be undone"); await sheet.locator(".btn-delete").click(); - // The terminal `removed` SSE frame drops the card without a reload. - await expect(card).toHaveCount(0); + // The terminal `removed` SSE frame drops the row without a reload. + await expect(row).toHaveCount(0); }); test("delete from the review screen returns to the index", async ({ page, request }) => { @@ -41,9 +44,9 @@ test("delete from the review screen returns to the index", async ({ page, reques await expect(page.locator(".delete-sheet")).toBeVisible(); await page.locator(".btn-delete").click(); - // Deleting from the screen navigates home; the card is gone there too. - await expect(page.locator(".masthead")).toBeVisible(); - await expect(cardFor(page, session)).toHaveCount(0); + // Deleting from the screen navigates home; the row is gone in the sidebar too. + await expect(page).toHaveURL(/\/$/); + await expect(rowFor(page, session)).toHaveCount(0); }); test("a pending delete can be cancelled, leaving the session untouched", async ({ @@ -52,12 +55,14 @@ test("a pending delete can be cancelled, leaving the session untouched", async ( }) => { const session = await createSession(request, uniqueTitle("keep")); await page.goto("/"); - const card = cardFor(page, session); + const row = rowFor(page, session); + await expect(row).toBeVisible(); - await card.locator(".card-delete").click(); + await row.hover(); + await row.locator(".sl-delete").click(); await page.locator(".delete-sheet .btn-ghost").click(); await expect(page.locator(".delete-sheet")).toHaveCount(0); - await expect(card).toBeVisible(); + await expect(row).toBeVisible(); }); test("an approved session is deletable too, and its sheet says removal is permanent", async ({ @@ -71,16 +76,21 @@ test("an approved session is deletable too, and its sheet says removal is perman }); expect(approved.ok()).toBeTruthy(); + // Approved (over) sessions live behind the sidebar's collapsed `approved (n)` + // disclosure; expand it to reach the row, which reads approved. await page.goto("/"); - const card = cardFor(page, session); - await expect(card.locator(".chip")).toHaveText("approved"); + await page.locator(".sl-approved-toggle").click(); + const row = rowFor(page, session); + await expect(row).toBeVisible(); + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "approved"); // Approved sessions also carry the ✕; delete is now a permanent removal of the // home folder (the durable copy survives elsewhere: the saved plan / PR). - await card.locator(".card-delete").click(); + await row.hover(); + await row.locator(".sl-delete").click(); const sheet = page.locator(".delete-sheet"); await expect(sheet).toBeVisible(); await expect(sheet).toContainText("can't be undone"); await sheet.locator(".btn-delete").click(); - await expect(card).toHaveCount(0); + await expect(row).toHaveCount(0); }); diff --git a/test/ui/grill.e2e.ts b/test/ui/grill.e2e.ts index d15c1ed..00d68c6 100644 --- a/test/ui/grill.e2e.ts +++ b/test/ui/grill.e2e.ts @@ -8,7 +8,7 @@ // force Save writes the .otacon/plans/ project copy (plus the home copy) and // locks the session. -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { APIRequestContext, Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; @@ -329,27 +329,42 @@ test("approve warns on unresolved threads, forces on confirm, locks the session, await expect(sheet).toContainText("1 unresolved thread"); await sheet.locator(".btn-force").click(); - // Approved: the quiet notice names the saved project copy; the chip flips - // over SSE. - const note = page.locator(".approved-note"); - await expect(note).toBeVisible(); - await expect(page.locator(".chip")).toHaveText("approved"); - const noted = await note.locator(".approved-path").textContent(); - const relPath = /\.otacon\/plans\/\S+\.md/.exec(noted ?? "")?.[0]; - expect(relPath).toBeTruthy(); - - // The project copy is on disk in the session's repo: daemon-rewritten - // frontmatter plus the appended interview. - const artifact = readFileSync(join(session.repo, relPath!), "utf8"); + // A Save-approve of the viewed session redirects it home on the live approved + // crossing (DECISIONS "Approving the viewed session redirects home"): the + // screen leaves /s/:id for the index. + await expect(page).toHaveURL(/\/$/); + + // The project copy is on disk in the session's repo (otacon never commits it): + // daemon-rewritten frontmatter plus the appended interview. + const plansDir = join(session.repo, ".otacon", "plans"); + let planFiles: string[] = []; + await expect + .poll(() => { + planFiles = existsSync(plansDir) + ? readdirSync(plansDir).filter((f) => f.endsWith(".md")) + : []; + return planFiles.length; + }) + .toBeGreaterThan(0); + const artifact = readFileSync(join(plansDir, planFiles[0]!), "utf8"); expect(artifact).toContain("status: approved"); expect(artifact).toContain("## Interview"); expect(artifact).toContain(`### ${qid}`); expect(artifact).toContain("Ship behind a flag?"); - // Read-only: every mutation surface is gone… + // The daemon refuses mutations outright: the session is over. + const refused = await request.post(`/api/sessions/${session.id}/comments`, { + data: { items: [{ anchor: null, body: "too late" }] }, + }); + expect(refused.status()).toBe(409); + + // Re-open the now-approved session (already over → no redirect): it renders + // read-only behind the approved notice, with every mutation surface gone. + await page.goto(`/s/${session.id}`); + await expect(page.locator(".approved-note")).toBeVisible(); + await expect(page.locator(".chip")).toHaveText("approved"); await expect(page.locator(".ctrl-approve")).toHaveCount(0); - // The Interview panel's answer affordances are gone too: no inline undo on the - // answered card (it opens to the static echo only). + // The Interview panel opens to the static echo only — no inline undo. await page.locator(".interview-toggle").click(); await expect(page.locator(".grill-undo")).toHaveCount(0); await expect(page.locator(`.grill-settled[data-iv="${qid}"] .settled-choice`)).toHaveText("yes"); @@ -358,37 +373,41 @@ test("approve warns on unresolved threads, forces on confirm, locks the session, await expect(page.locator(".sel-bar")).toHaveCount(0); await page.keyboard.press("c"); await expect(page.locator(".composer")).toHaveCount(0); - // …and the daemon refuses mutations outright: the session is over. - const refused = await request.post(`/api/sessions/${session.id}/comments`, { - data: { items: [{ anchor: null, body: "too late" }] }, - }); - expect(refused.status()).toBe(409); }); -test("index chips: questions pending lights live on an open question, approved settles the card", async ({ +test("index glyphs: answer-needed lights live on an open question, approved settles the row", async ({ page, request, }) => { const session = await createSession(request, uniqueTitle("chip-light")); await page.goto("/"); - const card = page.locator(".card", { hasText: session.title }); - await expect(card.locator(".chip")).toHaveText("agent working"); + // The index is the sidebar; status rides the row's glyph (aria-label). A fresh + // draft with no agent on the line yet reads stalled. + const row = page.locator(".session-list > .sl-row", { hasText: session.title }); + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "stalled"); await plantMarker(page); // An open agent question outranks the stored status (derived, review UI)… const qid = await ask(request, session.id, { question: "One thing to verify?" }); - await expect(card.locator(".chip")).toHaveText("questions pending"); - // …and answering hands the turn back. + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "answer needed"); + // …and answering hands the turn back (no longer answer-needed). await answer(request, session.id, { question: qid, text: "the staging deploy" }); - await expect(card.locator(".chip")).toHaveText("agent working"); + await expect(row.locator(".sl-glyph")).not.toHaveAttribute("aria-label", "answer needed"); await submitFixturePlan(request, session.id, "valid-plan.md"); - await expect(card.locator(".chip")).toHaveText("awaiting your review"); + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "review needed"); const approved = await request.post(`/api/sessions/${session.id}/approve`, { data: { force: true }, }); expect(approved.ok()).toBeTruthy(); - await expect(card.locator(".chip")).toHaveText("approved"); + // Approved (over) sessions leave the active list for the collapsed disclosure. + await expect( + page.locator(".session-list > .sl-row", { hasText: session.title }), + ).toHaveCount(0); + await page.locator(".sl-approved-toggle").click(); + await expect( + page.locator(".sl-approved-rows .sl-row", { hasText: session.title }).locator(".sl-glyph"), + ).toHaveAttribute("aria-label", "approved"); expect(await readMarker(page)).toBe(true); // every flip rode SSE }); @@ -406,9 +425,9 @@ test("375px: cards are one-thumb — ≥44px targets, no horizontal scroll, appr }); await submitFixturePlan(request, session.id, "valid-plan.md"); await page.goto(`/s/${session.id}`); - // A submitted plan moves past draft, so the panel starts collapsed; open it to - // reach the open question card (the single grill surface). - await page.locator(".interview-toggle").click(); + // The unanswered question force-opens the Interview panel on load (even past + // the draft grill phase — useInterviewOpen), so the open card is already + // reachable; no toggle click (which would collapse it again). await expect(page.locator(".iv-zone-open .grill-card")).toBeVisible(); // Every chip and the send button meet the 44px thumb target (interview questions); rounded diff --git a/test/ui/index.e2e.ts b/test/ui/index.e2e.ts index 46cebc9..79868d0 100644 --- a/test/ui/index.e2e.ts +++ b/test/ui/index.e2e.ts @@ -18,6 +18,14 @@ async function postComment(request: APIRequestContext, id: string): Promise { + const res = await request.post(`/api/sessions/${id}/progress`, { data: { note } }); + expect(res.status()).toBe(200); +} + /** Mirrors src/ui/accent.ts — the test locks the algorithm in place. */ function expectedHue(sessionId: string): number { let hash = 0x811c9dc5; @@ -28,23 +36,30 @@ function expectedHue(sessionId: string): number { return hash % 360; } -const cardFor = (page: Page, session: Session) => - page.locator(".card", { hasText: session.title }); +// The index is the app-shell sidebar now: one `.sl-row` per session, status +// conveyed by the `.sl-glyph` (aria-label = the status word), not a `.card`/`.chip`. +const rowFor = (page: Page, session: Session) => + page.locator(".sl-row", { hasText: session.title }); +const glyphFor = (page: Page, session: Session) => rowFor(page, session).locator(".sl-glyph"); -test("index renders session cards with the correct status chips", async ({ page, request }) => { +test("the index sidebar renders rows with the correct status glyphs", async ({ page, request }) => { + // The working sessions get a live agent (a progress note bumps last-contact), + // so their glyph reads the working word rather than the offline "stalled". const drafting = await createSession(request, uniqueTitle("drafting")); + await postProgress(request, drafting.id, "reading the auth module"); const awaiting = await createSession(request, uniqueTitle("awaiting")); await submitPlan(request, awaiting.id); const revising = await createSession(request, uniqueTitle("revising")); await submitPlan(request, revising.id); await postComment(request, revising.id); + await postProgress(request, revising.id, "folding in the comment"); await page.goto("/"); - await expect(cardFor(page, drafting).locator(".chip")).toHaveText("agent working"); - await expect(cardFor(page, awaiting).locator(".chip")).toHaveText("awaiting your review"); - await expect(cardFor(page, revising).locator(".chip")).toHaveText("agent revising"); - // repo + branch metadata renders on the card - await expect(cardFor(page, drafting).locator(".card-where")).toContainText("zero/prototype"); + await expect(glyphFor(page, drafting)).toHaveAttribute("aria-label", "drafting"); + await expect(glyphFor(page, awaiting)).toHaveAttribute("aria-label", "review needed"); + await expect(glyphFor(page, revising)).toHaveAttribute("aria-label", "revising"); + // repo + branch metadata renders on the row + await expect(rowFor(page, drafting).locator(".sl-where")).toContainText("zero/prototype"); }); test("a session created via the API appears live, without a reload (SSE)", async ({ @@ -52,27 +67,28 @@ test("a session created via the API appears live, without a reload (SSE)", async request, }) => { await page.goto("/"); - await expect(page.locator(".masthead")).toBeVisible(); + await expect(page.locator(".app-sidebar")).toBeVisible(); await plantMarker(page); const fresh = await createSession(request, uniqueTitle("fresh")); - await expect(cardFor(page, fresh)).toBeVisible(); + await expect(rowFor(page, fresh)).toBeVisible(); expect(await readMarker(page)).toBe(true); // no navigation happened }); -test("a status change flips the chip live and raises the unread badge", async ({ +test("a status change flips the glyph live and raises the unread badge", async ({ page, request, }) => { const session = await createSession(request, uniqueTitle("flip")); await page.goto("/"); - const card = cardFor(page, session); - await expect(card.locator(".chip")).toHaveText("agent working"); + const row = rowFor(page, session); + // Fresh draft, no agent on the line yet → the offline "stalled" glyph. + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "stalled"); await plantMarker(page); await submitPlan(request, session.id); - await expect(card.locator(".chip")).toHaveText("awaiting your review"); - await expect(card.locator(".badge")).toHaveText("r1 unread"); + await expect(row.locator(".sl-glyph")).toHaveAttribute("aria-label", "review needed"); + await expect(row.locator(".sl-unread")).toHaveText("●1"); expect(await readMarker(page)).toBe(true); }); @@ -122,11 +138,11 @@ test("light and dark color schemes both render the index", async ({ page, reques await page.emulateMedia({ colorScheme: "dark" }); await page.goto("/"); - await expect(cardFor(page, session)).toBeVisible(); + await expect(rowFor(page, session)).toBeVisible(); const dark = await backgroundLuminance(page); await page.emulateMedia({ colorScheme: "light" }); - await expect(cardFor(page, session)).toBeVisible(); + await expect(rowFor(page, session)).toBeVisible(); const light = await backgroundLuminance(page); expect(dark).toBeLessThan(64); diff --git a/test/ui/phone.e2e.ts b/test/ui/phone.e2e.ts index 686eeea..da15a6b 100644 --- a/test/ui/phone.e2e.ts +++ b/test/ui/phone.e2e.ts @@ -2,9 +2,10 @@ // real built CLI. Section ⋯ menus mint section-only anchors ({section}, no // exact quote) that round-trip to a parked `otacon wait`; the sticky bar is // the whole one-thumb control surface (❓ queue jump, drawer + send all, -// approve); the header switcher scrolls as chips and navigates; `otacon -// clean` removes a session live everywhere. Desktop keeps its header strip — -// the phone controls never show there. +// approve); the ☰ button opens the mobile session sheet (the app-shell list +// below 960px) that navigates and badges unread; `otacon clean` removes a +// session live everywhere. Desktop keeps its header strip + the persistent +// sidebar — the phone controls never show there. import type { APIRequestContext, Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; @@ -88,8 +89,9 @@ test("375px: ⋯ menu → section comment → sticky-bar send all delivers a sec await expect(thread.locator(".thread-where")).toHaveText("#phase-1"); await expect(thread.locator(".thread-quote")).toHaveCount(0); - // Jumping from the thread still washes the section (anchor sans quote). - await thread.click(); + // Jumping from the thread still washes the section (anchor sans quote). The + // jump lives on the meta row (no quote here), not the card body. + await thread.locator(".thread-meta").click(); await expect(page.locator("#phase-1.anchor-hit")).toHaveCount(1); }); @@ -163,7 +165,7 @@ test("375px: approve from the sticky bar walks the confirm sheet and ends the se request, }) => { await page.setViewportSize(PHONE); - await openReview(page, request, "bar-approve"); + const session = await openReview(page, request, "bar-approve"); const approve = page.locator(".bar-approve"); await expect(approve).toBeVisible(); @@ -174,66 +176,57 @@ test("375px: approve from the sticky bar walks the confirm sheet and ends the se const sheetBox = await page.locator(".approve-sheet").boundingBox(); expect(sheetBox).not.toBeNull(); expect(sheetBox!.y + sheetBox!.height).toBeGreaterThan(PHONE.height * 0.8); // thumb range + // No open threads, so Save Plan finalizes straight away (no warn). A Save-approve + // redirects the viewed session home (DECISIONS "Approving the viewed session + // redirects home"). await page.locator(".btn-approve", { hasText: "Save Plan" }).click(); + await expect(page).toHaveURL(/\/$/); - // The session frame flips the screen read-only: note up, controls gone. + // Re-open it (already over → no redirect): read-only, with the phone control + // surface (sticky bar approve, section ⋯) gone. + await page.goto(`/s/${session.id}`); await expect(page.locator(".approved-note")).toBeVisible(); await expect(page.locator(".chip")).toHaveText("approved"); await expect(page.locator(".drawer")).toHaveCount(0); + await expect(page.locator(".bar-approve")).toHaveCount(0); await expect(page.locator(".sec-menu").first()).toBeHidden(); // no anchors to mint }); -test("375px: switcher chips scroll horizontally, navigate, and badge unread", async ({ +test("375px: the ☰ session sheet lists sessions, navigates, and badges unread", async ({ page, request, }) => { await page.setViewportSize(PHONE); - const a = await createSession(request, uniqueTitle("switch-a chip overflow")); - const b = await createSession(request, uniqueTitle("switch-b chip overflow")); - const c = await createSession(request, uniqueTitle("switch-c chip overflow")); + const a = await createSession(request, uniqueTitle("switch-a")); + const b = await createSession(request, uniqueTitle("switch-b")); + const c = await createSession(request, uniqueTitle("switch-c")); await submitFixturePlan(request, a.id, "valid-plan.md"); await submitFixturePlan(request, b.id, "valid-plan.md"); await page.goto(`/s/${a.id}`); await expect(page.locator("#summary .md")).toBeVisible(); - const chips = page.locator(".switch-chips"); - const chipFor = (s: Session) => chips.locator(".switch-chip", { hasText: s.title.slice(0, 16) }); - await expect(chipFor(a)).toBeVisible(); - await expect(chipFor(a)).toHaveAttribute("aria-current", "page"); - // The current chip leads the strip; the desktop dropdown stays hidden. - await expect(chips.locator(".switch-chip").first()).toHaveAttribute("aria-current", "page"); - await expect(page.locator(".switch-select")).toBeHidden(); - - // Three+ sessions overflow 375px: the strip scrolls, the page does not. - const metrics = (await page.evaluate( - `(() => { - const el = document.querySelector(".switch-chips"); - return { scroll: el.scrollWidth, client: el.clientWidth }; - })()`, - )) as { scroll: number; client: number }; - expect(metrics.scroll).toBeGreaterThan(metrics.client); - expect((await page.evaluate("document.documentElement.scrollWidth")) as number).toBeLessThanOrEqual( - PHONE.width, - ); - - // b has an unseen r1 on this device: its chip wears ●1; c (r0) wears none. - await expect(chipFor(b).locator(".switch-unread")).toHaveText("●1"); - await expect(chipFor(c).locator(".switch-unread")).toHaveCount(0); - - // Tapping b switches screens in place and clears its badge (now reading it). - await chipFor(b).scrollIntoViewIfNeeded(); - await chipFor(b).click(); + // Below 960px the sidebar is hidden; the ☰ button opens the session sheet. + const sheet = page.locator(".ss-sheet"); + const rowFor = (s: Session) => sheet.locator(".sl-row", { hasText: s.title }); + await page.locator(".rh-menu").click(); + await expect(sheet).toBeVisible(); + // The open session is marked current in the list. + await expect(rowFor(a)).toHaveAttribute("aria-current", "page"); + // b has an unseen r1 on this device: its row wears ●1; c (r0) wears none. + await expect(rowFor(b).locator(".sl-unread")).toHaveText("●1"); + await expect(rowFor(c).locator(".sl-unread")).toHaveCount(0); + + // Tapping b switches screens in place (and closes the sheet) and clears unread. + await rowFor(b).click(); await expect(page).toHaveURL(`/s/${b.id}`); await expect(page.locator(".session-title")).toHaveText(b.title); - await expect(chipFor(b)).toHaveAttribute("aria-current", "page"); - await expect(chipFor(b).locator(".switch-unread")).toHaveCount(0); - - // Back on a, b stays cleared: seen is per-device state, not chip position. - await chipFor(a).scrollIntoViewIfNeeded(); - await chipFor(a).click(); - await expect(page).toHaveURL(`/s/${a.id}`); - await expect(chipFor(b).locator(".switch-unread")).toHaveCount(0); + + // Re-open the sheet: b is now current and its badge cleared (reading it). + await page.locator(".rh-menu").click(); + await expect(sheet).toBeVisible(); + await expect(rowFor(b)).toHaveAttribute("aria-current", "page"); + await expect(rowFor(b).locator(".sl-unread")).toHaveCount(0); }); test("clean (real CLI): the session vanishes live from index and switcher; its open screen shows the cleaned state", async ({ @@ -252,14 +245,26 @@ test("clean (real CLI): the session vanishes live from index and switcher; its o const onDoomed = await ctx.newPage(); await onDoomed.goto(`/s/${doomed.id}`); await expect(onDoomed.locator(".approved-note")).toBeVisible(); + + // The index is the inline session list on phone; the approved doomed sits in + // the collapsed `approved` disclosure — expand it to reveal the row. Scope to + // `.app-content`: the (hidden) sidebar list is still in the DOM below 960px, so + // an unscoped `.sl-*` selector would match both lists. const onIndex = await ctx.newPage(); await onIndex.goto("/"); - const card = onIndex.locator(".card", { hasText: doomed.title }); - await expect(card).toBeVisible(); + const indexList = onIndex.locator(".app-content"); + await indexList.locator(".sl-approved-toggle").click(); + const indexRow = indexList.locator(".sl-approved-rows .sl-row", { hasText: doomed.title }); + await expect(indexRow).toBeVisible(); + + // The keeper's nav is the ☰ session sheet; the doomed row lives in its approved + // disclosure too. const onKeeper = await ctx.newPage(); await onKeeper.goto(`/s/${keeper.id}`); - const doomedChip = onKeeper.locator(".switch-chip", { hasText: doomed.title.slice(0, 16) }); - await expect(doomedChip).toBeVisible(); + await onKeeper.locator(".rh-menu").click(); + await onKeeper.locator(".ss-sheet .sl-approved-toggle").click(); + const sheetRow = onKeeper.locator(".ss-sheet .sl-approved-rows .sl-row", { hasText: doomed.title }); + await expect(sheetRow).toBeVisible(); await Promise.all([plantMarker(onDoomed), plantMarker(onIndex), plantMarker(onKeeper)]); // The REAL CLI cleans from the session's repo: daemon DELETE + archive move. @@ -269,10 +274,10 @@ test("clean (real CLI): the session vanishes live from index and switcher; its o expect(out.cleaned.map((entry) => entry.session)).toEqual([doomed.id]); // All three screens react live to the `removed` frame — no navigation. - await expect(card).toHaveCount(0); - await expect(doomedChip).toHaveCount(0); - await expect(onDoomed.locator(".empty-title")).toHaveText("session cleaned"); - await expect(onDoomed.locator(".empty-body")).toContainText("otacon clean"); + await expect(indexRow).toHaveCount(0); + await expect(sheetRow).toHaveCount(0); + await expect(onDoomed.locator(".empty-title")).toHaveText("session closed"); + await expect(onDoomed.locator(".empty-body")).toContainText("left the codec"); expect(await readMarker(onDoomed)).toBe(true); expect(await readMarker(onIndex)).toBe(true); expect(await readMarker(onKeeper)).toBe(true); @@ -289,15 +294,15 @@ test("desktop regression: header strip intact, phone bar controls absent, ⋯ op const session = await openReview(page, request, "desktop-strip"); await ask(request, session.id, "Desktop still routes questions to the queue?"); - // The header instrument strip holds approve; the switcher is a dropdown. + // The header instrument strip holds approve; the desktop nav is the persistent + // sidebar (the mobile ☰ menu is hidden ≥960px). await expect(page.locator(".review-header .ctrl-approve")).toBeVisible(); - await expect(page.locator(".switch-select select")).toBeVisible(); - await expect(page.locator(".switch-chips")).toBeHidden(); + await expect(page.locator(".app-sidebar")).toBeVisible(); + await expect(page.locator(".rh-menu")).toBeHidden(); // The phone-only bar instruments stay dormant even with a question open. The - // plan is past draft, so the Interview panel starts collapsed; open it to reach - // the open question card (the single grill surface). - await page.locator(".interview-toggle").click(); + // pending question force-opens the Interview panel on load, so the open card is + // already reachable (no toggle, which would collapse it again). await expect(page.locator(".iv-zone-open .grill-card")).toBeVisible(); await expect(page.locator(".bar-quest")).toBeHidden(); await expect(page.locator(".bar-approve")).toBeHidden(); @@ -309,9 +314,9 @@ test("desktop regression: header strip intact, phone bar controls absent, ⋯ op await page.keyboard.press("Escape"); await expect(page.locator(".sec-pop")).toHaveCount(0); - // The dropdown navigates like the chips do. + // The sidebar navigates between sessions. const other = await createSession(request, uniqueTitle("desktop-hop")); - await page.locator(".switch-select select").selectOption(other.id); + await page.locator(".app-sidebar .sl-row", { hasText: other.title }).click(); await expect(page).toHaveURL(`/s/${other.id}`); await expect(page.locator(".session-title")).toHaveText(other.title); }); @@ -327,9 +332,9 @@ test("tablet band (600px): the phone face and the menu sheet flip together, not await page.setViewportSize({ width: 600, height: 800 }); await openReview(page, request, "tablet-band"); - // The phone face is on: chips, not the dropdown. - await expect(page.locator(".switch-chips")).toBeVisible(); - await expect(page.locator(".switch-select select")).toBeHidden(); + // The phone face is on: the sidebar is hidden (the ☰ session sheet is the nav). + await expect(page.locator(".app-sidebar")).toBeHidden(); + await expect(page.locator(".rh-menu")).toBeVisible(); // So the ⋯ menu docks as the thumb-range sheet, never a desktop popover. await page.locator("#decisions .sec-menu").click(); @@ -337,7 +342,7 @@ test("tablet band (600px): the phone face and the menu sheet flip together, not await expect(page.locator(".sec-pop")).toHaveCount(0); }); -test("375px dark: menu sheet, sticky bar, and switcher chips render on the dark scheme", async ({ +test("375px dark: menu sheet, sticky bar, and session menu render on the dark scheme", async ({ page, request, }) => { @@ -348,7 +353,7 @@ test("375px dark: menu sheet, sticky bar, and switcher chips render on the dark await expect(page.locator(".bar-quest .bar-count")).toHaveText("1"); await expect(page.locator(".bar-approve")).toBeVisible(); - await expect(page.locator(".switch-chips .switch-chip").first()).toBeVisible(); + await expect(page.locator(".rh-menu")).toBeVisible(); await page.locator("#summary .sec-menu").click(); await expect(page.locator(".sec-sheet")).toBeVisible(); diff --git a/test/ui/review.e2e.ts b/test/ui/review.e2e.ts index 5d2f485..417c0fd 100644 --- a/test/ui/review.e2e.ts +++ b/test/ui/review.e2e.ts @@ -115,16 +115,19 @@ test("a new revision lands live over SSE, without a reload", async ({ page, requ const session = await createSession(request, uniqueTitle("liverev")); await submitRichPlan(request, session.id); await page.goto(`/s/${session.id}`); - // The fresh new-revision banner carries the landed revision (the r0 header - // pill is gone): r1 received on first load, then r2 when the revision lands. - await expect(page.locator(".rev-fresh .rev-label")).toHaveText("r1 received"); + // r1 is a first review, not a re-review: no new-revision banner yet — it + // renders only from r2 (DECISIONS "Re-review chrome ... banner from r2"). + // The plan body itself renders on first load. await expect(page.locator("#summary .md")).toContainText("Replace session-cookie auth"); + await expect(page.locator(".rev-fresh")).toHaveCount(0); await plantMarker(page); await submitRichPlan(request, session.id, 2, (plan) => plan.replace("Replace session-cookie auth", "Revised again: replace session-cookie auth"), ); + // r2 lands live over the session SSE stream: the fresh banner raises with the + // landed revision and the plan body updates, both without a reload. await expect(page.locator(".rev-fresh .rev-label")).toHaveText("r2 received"); await expect(page.locator("#summary .md")).toContainText("Revised again"); expect(await readMarker(page)).toBe(true); // no navigation happened diff --git a/test/ui/revisions.e2e.ts b/test/ui/revisions.e2e.ts index de6a1d7..dcfecda 100644 --- a/test/ui/revisions.e2e.ts +++ b/test/ui/revisions.e2e.ts @@ -3,9 +3,10 @@ // through the UI, r2 resubmitted with resolutions + changelog, and the // re-review layer asserted live over SSE: the changelog banner, dismiss // moving the daemon's last-reviewed baseline, gutter markers, the [clean|diff] -// toggle with its baseline picker, j/k jumps, the resolved thread's reply, -// and a thread orphaned by r4 landing in the tray. Dark + 375px smokes ride -// the HTTP fixtures for speed; the loop itself is CLI-driven end to end. +// toggle with its baseline picker, j/k jumps, the agent's reply landing inline +// on the open thread, and a thread whose quote r4 deletes detaching inline & +// muted (no orphan tray). Dark + 375px smokes ride the HTTP fixtures for speed; +// the loop itself is CLI-driven end to end. import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -104,13 +105,13 @@ test("the revision loop: banner, dismiss, diff + j/k, resolution, orphan tray", await expect(page.locator(".unit-changed")).toHaveCount(1); await expect(page.locator("#summary.unit-changed")).toHaveCount(1); - // The resolved thread collapses to its ✓ line; expanding shows the reply. - const resolved = page.locator(".thread-resolved"); - await expect(resolved).toHaveCount(1); - await expect(resolved.locator(".resolved-rev")).toHaveText("r2"); - await expect(resolved.locator(".thread-answer-body")).not.toBeVisible(); - await resolved.locator(".resolved-summary").click(); - await expect(resolved.locator(".thread-answer-body")).toHaveText( + // The agent's reply lands inline on the still-open comment card (a reply is a + // response, not a close — the reviewer resolves separately; DECISIONS). + const thread = page.locator(".thread-comment"); + await expect(thread).toHaveCount(1); + await expect(page.locator(".thread-resolved")).toHaveCount(0); + await expect(thread.locator(".thread-answer-label")).toContainText("r2"); + await expect(thread.locator(".thread-answer-body")).toHaveText( "Added the rationale — verifiers hold only the public key.", ); @@ -165,22 +166,18 @@ test("the revision loop: banner, dismiss, diff + j/k, resolution, orphan tray", await page.keyboard.press("k"); await expect(page.locator("#summary.anchor-hit")).toHaveCount(1); - // r4 deletes the quoted decision text: the thread orphans into the tray, - // live over the SSE thread frame — never silently dropped (plan structure, lint, and anchoring). + // r4 deletes the quoted decision text: the thread detaches and renders inline + // & muted — never silently dropped, and never in a tray (DECISIONS "no UI + // tray, inline & muted") — live over the SSE thread frame. plan = plan.replace("- D1: RS256 over HS256 [assumed]\n", ""); await cliSubmit(session, dir, plan, { changelog: "Dropped the RS256 decision line." }); - const tray = page.locator(".orphan-toggle"); - await expect(tray).toBeVisible(); - await expect(tray.locator(".orphan-count")).toHaveText("1"); - await expect(page.locator(".thread-resolved")).toHaveCount(0); // moved out of the rail list - await tray.click(); - const orphan = page.locator(".orphan"); - await expect(orphan).toHaveCount(1); - await expect(orphan.locator(".thread-quote")).toContainText("RS256 over HS256"); - await expect(orphan.locator(".orphan-where")).toHaveText("#decisions"); - await orphan.click(); // the full original anchor text + the old resolution - await expect(orphan).toHaveClass(/orphan-open/); - await expect(orphan.locator(".thread-answer-body")).toContainText( + await expect(page.locator(".rev-banner .rev-label")).toHaveText("r4 received"); + const detached = page.locator(".thread-comment"); + await expect(detached.locator(".thread-quote-muted")).toContainText("RS256 over HS256"); + await expect(detached.locator(".thread-quote-detached")).toBeVisible(); + await expect(page.locator(".rail")).not.toContainText("orphan"); + // Its earlier agent reply stays shown inline on the now-detached card. + await expect(detached.locator(".thread-answer-body")).toContainText( "verifiers hold only the public key", ); }); @@ -259,8 +256,10 @@ test("375px: banner, markers, diff, and the orphan tray hold the column", async await expect(page.locator(".rev-banner")).toBeVisible(); await expect(page.locator("#risks.unit-changed")).toHaveCount(1); - await page.locator(".orphan-toggle").click(); - await expect(page.locator(".orphan")).toHaveCount(1); + // The thread whose quote r2 deleted renders inline & muted (no orphan tray). + await expect(page.locator(".thread-comment .thread-quote-muted")).toContainText( + "Key rotation downtime", + ); expect((await page.evaluate("document.documentElement.scrollWidth")) as number).toBeLessThanOrEqual( 375, ); diff --git a/test/ui/settings.e2e.ts b/test/ui/settings.e2e.ts index 1042b5d..083f884 100644 --- a/test/ui/settings.e2e.ts +++ b/test/ui/settings.e2e.ts @@ -130,11 +130,13 @@ test("a project · local override wins over the committed project value", async await expect(page.getByLabel("Summary lines")).toHaveValue("9"); }); -test("sections lead with worktree, then notifications", async ({ page }) => { +test("sections lead with worktree, notifications among them", async ({ page }) => { await page.goto("/settings"); const titles = page.locator(".settings-section-title"); + // The two storage-location knobs (worktree dir + plans dir) lead the form. await expect(titles.first()).toHaveText("worktree"); - await expect(titles.nth(1)).toHaveText("notifications"); + // notifications stays one of the sections (now after update + socratic). + await expect(titles.filter({ hasText: "notifications" })).toHaveCount(1); }); test("worktree section carries both the worktree dir and the plans dir", async ({ page }) => { @@ -159,8 +161,11 @@ test("a text field auto-saves on blur (no Save button)", async ({ page, request await page.goto(`/settings?repo=${encodeURIComponent(session.repo)}`); await page.getByRole("tab", { name: "project", exact: true }).click(); - // Editing IS the commit, with no Save button to click (or forget). - await expect(page.getByRole("button", { name: "save" })).toHaveCount(0); + // Editing IS the commit, with no Save button to click (or forget). Scope to the + // settings pane (.page): the sidebar's per-row "delete session …" buttons can + // carry a session title containing "save" (e.g. "auto-save"), which a global + // button-name match would otherwise pick up. + await expect(page.locator(".page").getByRole("button", { name: "save" })).toHaveCount(0); const input = page.getByLabel("Summary lines"); await input.fill("5"); diff --git a/test/ui/threads.e2e.ts b/test/ui/threads.e2e.ts index 44bf510..3a809ec 100644 --- a/test/ui/threads.e2e.ts +++ b/test/ui/threads.e2e.ts @@ -77,8 +77,9 @@ test("comment flow: selection → bar → drawer → Send all wakes a parked CLI await expect(thread.locator(".thread-quote")).toContainText("short-lived JWTs"); await expect(thread.locator(".thread-where")).toHaveText("#summary"); - // Clicking the anchored thread jumps to and washes the anchored section. - await thread.click(); + // Clicking the thread's quote jumps to and washes the anchored section (the + // jump lives on the meta row + quote, not the card body which holds the reply box). + await thread.locator(".thread-quote").click(); await expect(page.locator("#summary.anchor-hit")).toHaveCount(1); });