From f7eff241b95113b61f33f50a44c1bb5c14455e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 28 May 2026 19:16:30 +0100 Subject: [PATCH] ci: faster e2e via image split, shard rebalance, and flake fixes (#1126) --- .github/docker/ci-base/Dockerfile | 85 +++++++++++-------- .github/workflows/backend-tests.yml | 2 +- .github/workflows/ci-base-image.yml | 53 +++++++++--- .github/workflows/e2e-tests.yml | 27 ++++-- .github/workflows/frontend-tests.yml | 2 +- .../web/components/task/file-editor-panel.tsx | 64 +++++++++++++- apps/web/e2e/helpers/panel-search.ts | 5 ++ apps/web/e2e/playwright.config.ts | 16 ++-- .../tests/chat/implement-plan-fresh.spec.ts | 4 +- .../e2e/tests/chat/markdown-preview.spec.ts | 2 +- apps/web/e2e/tests/chat/message-queue.spec.ts | 13 ++- apps/web/e2e/tests/chat/monitor.spec.ts | 8 +- .../e2e/tests/git/git-changes-panel.spec.ts | 14 +-- .../kanban/stale-session-navigation.spec.ts | 10 ++- .../tests/layout/plan-panel-indicator.spec.ts | 9 +- .../pr/pr-watcher-dockview-layout.spec.ts | 5 +- .../task/create-task-branch-selector.spec.ts | 30 +++++-- .../e2e/tests/task/file-tree-sorting.spec.ts | 2 +- .../e2e/tests/task/mobile-file-viewer.spec.ts | 2 +- apps/web/lib/state/dockview-store.ts | 17 +++- apps/web/lib/state/layout-manager/applier.ts | 49 +++++++++-- 21 files changed, 315 insertions(+), 104 deletions(-) diff --git a/.github/docker/ci-base/Dockerfile b/.github/docker/ci-base/Dockerfile index 6231e5f231..f1d3b34506 100644 --- a/.github/docker/ci-base/Dockerfile +++ b/.github/docker/ci-base/Dockerfile @@ -1,11 +1,18 @@ -# Kandev CI base image. +# Kandev CI base image — split into two tags. # -# Bundles every tool the CI workflows need so jobs can run via `container:` -# without re-downloading Playwright browsers, Go, Node, pnpm, golangci-lint, -# and go-licenses on every run. +# Two stages published as separate tags on the same GHCR repo: # -# Pinned versions (keep in sync with the matching workflow inputs and -# apps/backend/go.mod, apps/web/package.json): +# - `runtime` (~700 MB): Playwright + Node + pnpm + Docker CLI. Used by the +# `e2e` shards and `e2e-report` — they consume pre-built backend artifacts +# and don't need the Go toolchain. Smaller pull = faster shard startup +# (~20s per shard saved across 10 shards). +# +# - `build` (~1.3 GB): runtime + Go + golangci-lint + go-licenses. Used by +# the `build` job (compiles backend + web), `backend-tests`, and +# `frontend-tests`. These all need the Go toolchain. +# +# Pinned versions (keep in sync with apps/backend/go.mod, apps/web/package.json, +# and the matching workflow inputs): # - Playwright base : v1.58.2-noble (matches @playwright/test ^1.58.2) # - Go : 1.26.0 (matches apps/backend/go.mod) # - Node : 24 (matches actions/setup-node value) @@ -14,12 +21,14 @@ # - go-licenses : v1.6.0 # - Docker CLI : 27.3.1 -FROM mcr.microsoft.com/playwright:v1.58.2-noble +# ============================================================================= +# Stage 1: runtime — Playwright + Node + pnpm + Docker CLI (no Go) +# ============================================================================= +FROM mcr.microsoft.com/playwright:v1.58.2-noble AS runtime ENV DEBIAN_FRONTEND=noninteractive \ PNPM_HOME=/pnpm \ - GOPATH=/root/go \ - PATH=/pnpm:/usr/local/go/bin:/root/go/bin:$PATH + PATH=/pnpm:$PATH # --- System packages ------------------------------------------------------- # Docker CLI is fetched as a static binary below to avoid pulling the daemon. @@ -30,16 +39,12 @@ RUN apt-get update \ gnupg \ git \ make \ - build-essential \ jq \ tar \ xz-utils \ && rm -rf /var/lib/apt/lists/* # --- Node 24 (replaces the base image's Node 22) --------------------------- -# Download the NodeSource setup script to a temp file first rather than piping -# `curl` straight into `bash`, so the script is on disk if anything needs to -# inspect it. RUN curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh \ && bash /tmp/nodesource_setup.sh \ && rm /tmp/nodesource_setup.sh \ @@ -50,6 +55,34 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh RUN corepack enable \ && corepack prepare pnpm@9.15.9 --activate +# --- Docker CLI (no daemon) ------------------------------------------------ +RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + -o /tmp/docker.tgz \ + && tar -xzf /tmp/docker.tgz -C /tmp \ + && mv /tmp/docker/docker /usr/local/bin/docker \ + && rm -rf /tmp/docker /tmp/docker.tgz + +WORKDIR /work + +# --- Runtime smoke checks -------------------------------------------------- +RUN set -eux; \ + node --version | grep -E '^v24\.'; \ + pnpm --version | grep -E '^9\.15\.9$'; \ + docker --version | grep -F '27.3.1' + +# ============================================================================= +# Stage 2: build — runtime + Go + golangci-lint + go-licenses +# ============================================================================= +FROM runtime AS build + +ENV GOPATH=/root/go \ + PATH=/pnpm:/usr/local/go/bin:/root/go/bin:$PATH + +# build-essential is only needed by Go's CGO path (sqlite via fts5 tag). +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + # --- Go 1.26.0 ------------------------------------------------------------- RUN curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz \ -o /tmp/go.tar.gz \ @@ -57,9 +90,6 @@ RUN curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz \ && rm /tmp/go.tar.gz # --- golangci-lint v2.9.0 -------------------------------------------------- -# Fetch the install script from the matching tagged ref (not `master`) so the -# script itself is reproducible across rebuilds, and save it to a temp file -# before executing rather than piping `curl` into `sh`. RUN curl -fsSL \ https://raw.githubusercontent.com/golangci/golangci-lint/v2.9.0/install.sh \ -o /tmp/install-golangci-lint.sh \ @@ -69,27 +99,8 @@ RUN curl -fsSL \ # --- go-licenses v1.6.0 ---------------------------------------------------- RUN go install github.com/google/go-licenses@v1.6.0 -# --- Docker CLI (no daemon) ------------------------------------------------ -# Static binary release lets jobs talk to a mounted host socket without -# dragging containerd/dockerd into the image. -RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ - -o /tmp/docker.tgz \ - && tar -xzf /tmp/docker.tgz -C /tmp \ - && mv /tmp/docker/docker /usr/local/bin/docker \ - && rm -rf /tmp/docker /tmp/docker.tgz - -WORKDIR /work - -# --- Smoke checks ---------------------------------------------------------- -# Fail the build if any pinned tool is missing or mismatched. The Playwright -# version is intentionally not asserted at runtime: `npx playwright --version` -# resolves via npm and fetches the latest release rather than the image's -# bundled binary, and the bundled binary's location is internal to the -# Microsoft image. The FROM pin (`v1.58.2-noble`) is the version guarantee. +# --- Build smoke checks ---------------------------------------------------- RUN set -eux; \ go version | grep -F 'go1.26.0'; \ - node --version | grep -E '^v24\.'; \ - pnpm --version | grep -E '^9\.15\.9$'; \ golangci-lint --version | grep -F '2.9.0'; \ - go version -m "$(which go-licenses)" | grep -F 'v1.6.0'; \ - docker --version | grep -F '27.3.1' + go version -m "$(which go-licenses)" | grep -F 'v1.6.0' diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 003083a04b..2020386b42 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -39,7 +39,7 @@ jobs: # Pre-baked image bundles Go, golangci-lint, go-licenses, Docker CLI, Node, # pnpm, and the Playwright base — see .github/docker/ci-base/Dockerfile. # Skips the per-run setup-go / golangci-lint download steps below. - container: ghcr.io/kdlbs/kandev-ci:latest + container: ghcr.io/kdlbs/kandev-ci:build-latest defaults: run: # Container jobs default to `sh` (dash); pin to bash so `set -o pipefail` diff --git a/.github/workflows/ci-base-image.yml b/.github/workflows/ci-base-image.yml index b768be0d84..33befb83e3 100644 --- a/.github/workflows/ci-base-image.yml +++ b/.github/workflows/ci-base-image.yml @@ -36,8 +36,15 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@v3 + # Push the built images to GHCR on any `push` to main (the default + # publish path) or on manual `workflow_dispatch` (used to seed new tag + # names from a feature branch before the consumer workflows on the same + # branch can pull them — otherwise we hit a chicken-and-egg loop where + # the PR that introduces a new tag can never go green). - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: | + github.event_name == 'workflow_dispatch' + || (github.event_name == 'push' && github.ref == 'refs/heads/main') uses: docker/login-action@v3 with: registry: ghcr.io @@ -60,28 +67,52 @@ jobs: echo "image_tag=${hash}" >> "$GITHUB_OUTPUT" echo "Computed content hash: sha-${hash}" - - name: Build and push + # Stage 1: runtime (Playwright + Node + pnpm + Docker CLI — no Go). + # Consumed by the `e2e` shards and `e2e-report`, where the smaller pull + # is the speedup that pays for the split. + - name: Build and push runtime uses: docker/build-push-action@v6 with: context: . file: .github/docker/ci-base/Dockerfile + target: runtime platforms: linux/amd64 - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - cache-from: type=gha - cache-to: type=gha,mode=max + push: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} + cache-from: type=gha,scope=runtime + cache-to: type=gha,scope=runtime,mode=max tags: | - ${{ env.IMAGE_NAME }}:sha-${{ steps.tag.outputs.image_tag }} - ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:runtime-sha-${{ steps.tag.outputs.image_tag }} + ${{ env.IMAGE_NAME }}:runtime-latest + + # Stage 2: build (runtime + Go + golangci-lint + go-licenses). + # Consumed by the `build` job, `backend-tests`, and `frontend-tests`. + # `cache-from` includes the runtime scope so the shared lower layers + # only build once across both pushes. + - name: Build and push build + uses: docker/build-push-action@v6 + with: + context: . + file: .github/docker/ci-base/Dockerfile + target: build + platforms: linux/amd64 + push: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} + cache-from: | + type=gha,scope=build + type=gha,scope=runtime + cache-to: type=gha,scope=build,mode=max + tags: | + ${{ env.IMAGE_NAME }}:build-sha-${{ steps.tag.outputs.image_tag }} + ${{ env.IMAGE_NAME }}:build-latest - name: Summarize run: | { echo "### Kandev CI base image" echo "" - echo "- image: \`${IMAGE_NAME}:sha-${IMAGE_TAG}\`" - echo "- latest: \`${IMAGE_NAME}:latest\`" - echo "- pushed: \`${PUSHED}\`" + echo "- runtime: \`${IMAGE_NAME}:runtime-sha-${IMAGE_TAG}\` / \`runtime-latest\`" + echo "- build: \`${IMAGE_NAME}:build-sha-${IMAGE_TAG}\` / \`build-latest\`" + echo "- pushed: \`${PUSHED}\`" } >> "$GITHUB_STEP_SUMMARY" env: IMAGE_TAG: ${{ steps.tag.outputs.image_tag }} - PUSHED: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + PUSHED: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8a892a27d4..85ed0e1650 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -38,8 +38,9 @@ jobs: name: Build runs-on: ubuntu-latest # Pre-baked image bundles Go, Node 24, pnpm 9.15.9 — see - # .github/docker/ci-base/Dockerfile. - container: ghcr.io/kdlbs/kandev-ci:latest + # .github/docker/ci-base/Dockerfile. Uses the `build` tag (with Go) + # because this job compiles the backend. + container: ghcr.io/kdlbs/kandev-ci:build-latest timeout-minutes: 10 # Container jobs default to `sh`; pin to bash for the few shell steps below. defaults: @@ -107,10 +108,16 @@ jobs: # `--ipc=host` is required for Chromium: the default container `/dev/shm` # is only ~64MiB, which causes tabs to slow to a crawl and renderers to # crash mid-test. + # Uses the `runtime` tag — no Go toolchain needed; the shards consume the + # pre-built backend artifact from the `build` job. ~600 MB smaller pull + # than the build image, saves ~20s per shard at startup. container: - image: ghcr.io/kdlbs/kandev-ci:latest + image: ghcr.io/kdlbs/kandev-ci:runtime-latest options: --ipc=host - timeout-minutes: 22 + # 25 min (was 22) — leaves room for the worst-case "every shard hits a few + # flake-retries" combination without blowing past the cap. Average shard + # runs ~10 min, so this only kicks in when something is going wrong. + timeout-minutes: 25 # Container jobs default to `sh`; pin to bash. defaults: run: @@ -263,16 +270,17 @@ jobs: # Skip the Playwright CDN download (~7 min and an outage-prone path) by # copying the prebaked chromium out of the kandev-ci image, which is # `FROM mcr.microsoft.com/playwright:...` and already ships the matching - # browser at /ms-playwright. Pinning to :latest matches the other e2e - # jobs that use the same image via `container:`. + # browser at /ms-playwright. Pulling the `runtime` tag (matches the + # `e2e` / `e2e-report` jobs above) — the `build` tag has the same + # browser layer but is ~300 MB larger because of the Go toolchain. - name: Extract Playwright browsers from kandev-ci image run: | set -euo pipefail - docker pull ghcr.io/kdlbs/kandev-ci:latest + docker pull ghcr.io/kdlbs/kandev-ci:runtime-latest mkdir -p /tmp/ms-playwright docker run --rm \ -v /tmp/ms-playwright:/dest \ - ghcr.io/kdlbs/kandev-ci:latest \ + ghcr.io/kdlbs/kandev-ci:runtime-latest \ cp -a /ms-playwright/. /dest/ echo "PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright" >> "$GITHUB_ENV" @@ -337,7 +345,8 @@ jobs: if: always() runs-on: ubuntu-latest # Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright CLI. - container: ghcr.io/kdlbs/kandev-ci:latest + # `runtime` tag — no Go needed for merging blob reports. + container: ghcr.io/kdlbs/kandev-ci:runtime-latest timeout-minutes: 5 # Container jobs default to `sh`; pin to bash. defaults: diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index c00c57b5c7..b0d2bf5c61 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-latest # Pre-baked image bundles Node 24, pnpm 9.15.9, Go, and go-licenses — # see .github/docker/ci-base/Dockerfile. - container: ghcr.io/kdlbs/kandev-ci:latest + container: ghcr.io/kdlbs/kandev-ci:build-latest defaults: run: # Container jobs default to `sh` (dash); pin to bash for `set -euo diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index 6794f6e28c..09544da18c 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -13,6 +13,8 @@ import { getFileCategory } from "@/lib/utils/file-types"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent } from "@/lib/ws/workspace-files"; import { calculateHash } from "@/lib/utils/file-diff"; +import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; +import { syncOpenFileFromWorkspace } from "@/hooks/file-editors-sync"; type FileCategory = "image" | "binary" | "text"; @@ -83,12 +85,71 @@ function useFileLoader( }, [hasFile, activeSessionId, path, setFileState]); } +/** + * Force a workspace sync whenever the panel becomes the active dockview tab. + * + * Background: `useOpenFileWorkspaceSync` (mounted at the parent useFileEditors + * level) refetches file content when gitStatus signatures change. That signal + * arrives via the backend's workspace_tracker poll loop, which can be in + * `PollModeSlow` (30s interval) until the gateway's focus signal upgrades it + * to `PollModeFast` — there are two documented races in + * `manager_subscription.go:FlushSessionMode` where the focus signal can miss + * the mode upgrade for a brief window. When the missed window lines up with a + * file edit, the editor shows stale content until the next slow-poll cycle. + * + * Tab activation is a deterministic, user-driven signal that the editor's + * content is about to be looked at. Forcing a sync on activation closes the + * WS-event-miss gap without depending on git polling cadence. + * + * Safe by construction: syncOpenFileFromWorkspace is dirty-buffer aware — + * clean buffers get their content replaced, dirty buffers surface a Reload + * affordance via `hasRemoteUpdate` rather than clobbering edits. + */ +function useResyncOnTabActivate( + panelId: string, + hasFile: boolean, + activeSessionId: string | null, + path: string, + updateFileState: (path: string, updates: Partial) => void, +) { + useEffect(() => { + if (!hasFile || !activeSessionId) return; + // panelPortalManager.acquire() runs in usePortalSlot's mount effect (the + // dockview-side slot), which fires before child portals' effects, so the + // entry is virtually always present here. There is one acceptable miss: + // a fromJSON layout restore can swap `entry.api` for the same panelId + // without remounting this component, which would silently leave the + // subscription pointing at a disposed api. fromJSON is rare and + // `useOpenFileWorkspaceSync` still covers the common polling gap, so we + // accept that edge case rather than wiring a manager-level subscription. + const entry = panelPortalManager.get(panelId); + if (!entry?.api) return; + const syncNow = () => { + const client = getWebSocketClient(); + if (!client) return; + void syncOpenFileFromWorkspace({ client, sessionId: activeSessionId, path, updateFileState }); + }; + // If the panel is already the active tab when this effect first runs, + // onDidActiveChange won't fire (no transition), but the user is already + // looking at the editor — sync immediately so the initial open path + // benefits from the same WS-event-miss recovery as later activations. + if (entry.api.isActive) syncNow(); + const disposable = entry.api.onDidActiveChange((event) => { + if (event.isActive) syncNow(); + }); + return () => disposable.dispose(); + }, [panelId, hasFile, activeSessionId, path, updateFileState]); +} + type FileEditorPanelProps = { panelId: string; params: Record; }; -export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEditorPanelProps) { +export const FileEditorPanel = memo(function FileEditorPanel({ + panelId, + params, +}: FileEditorPanelProps) { const path = params.path as string; const hasFile = useDockviewStore((s) => s.openFiles.has(path)); @@ -110,6 +171,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ params }: FileEdi const { savingFiles, handleFileChange, saveFile, deleteFile, applyRemoteUpdate } = useFileEditors(); useFileLoader(hasFile, activeSessionId, path, setFileState); + useResyncOnTabActivate(panelId, hasFile, activeSessionId, path, updateFileState); const onChange = useCallback( (newContent: string) => handleFileChange(path, newContent), diff --git a/apps/web/e2e/helpers/panel-search.ts b/apps/web/e2e/helpers/panel-search.ts index 79c424d5ad..f2c224cac8 100644 --- a/apps/web/e2e/helpers/panel-search.ts +++ b/apps/web/e2e/helpers/panel-search.ts @@ -34,8 +34,13 @@ async function focusPanel(page: Page, kind: PanelKind): Promise { return; } if (kind === "plan") { + // ProseMirror's focus dispatch after a click is async on contenteditable — + // pressing Ctrl+F before document.activeElement settles inside the editor + // makes the panel-search hook see body/another panel as the focused node + // and silently no-op. Gate the keypress on the focus actually landing here. const editor = page.getByTestId("plan-panel").locator(".ProseMirror").first(); await editor.click(); + await expect(editor).toBeFocused(); return; } // terminal: click the xterm canvas area so xterm captures keydown diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts index bdaecc6c25..fc5fc12577 100644 --- a/apps/web/e2e/playwright.config.ts +++ b/apps/web/e2e/playwright.config.ts @@ -4,10 +4,16 @@ const CI = !!process.env.CI; export default defineConfig({ testDir: "./tests", - // Single worker per process — CI uses --shard to split tests across matrix - // runners (each gets its own 4 vCPUs). Tests run serially within the worker - // because the testPage fixture does e2eReset on a shared worker-scoped - // backend before each test. + // `fullyParallel: true` is required so `--shard=N/M` splits work at the test + // level (not the file level). With file-level sharding the suite was wildly + // unbalanced: largest shard ran 12 min, smallest 1.5 min, because spec files + // vary from 1 to 30+ tests. Test-level sharding flattens that distribution. + // + // Concurrency is still capped by `workers: 1` below — only one test runs at a + // time per shard process, preserving the worker-scoped backend invariant that + // the testPage fixture relies on (e2eReset before each test on a shared + // backend). `fullyParallel: true` alone does not introduce intra-shard + // parallelism unless workers > 1. // // Isolation strategy: office-routing-* specs are gathered into their own // Playwright project (see below) so the worker-scoped backend env @@ -16,7 +22,7 @@ export default defineConfig({ // topbar agent name. Each routing spec restarts the backend back to // baseline in `afterAll` (see backend.restart() — no args = revert to // the fixture's baseline env snapshot). - fullyParallel: false, + fullyParallel: true, forbidOnly: CI, retries: CI ? 2 : 0, workers: 1, diff --git a/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts b/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts index fb3fc38201..e656b7b6e0 100644 --- a/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts +++ b/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts @@ -40,7 +40,7 @@ async function seedTaskAndEnterPlanMode( const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); await session.togglePlanMode(); await expect(session.planModeInput()).toBeVisible({ timeout: 10_000 }); @@ -134,7 +134,7 @@ test.describe("Implement plan — fresh-agent path", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); // No plan mode → no Implement button and no chevron trigger. await expect(testPage.getByTestId("implement-plan-button")).not.toBeVisible(); diff --git a/apps/web/e2e/tests/chat/markdown-preview.spec.ts b/apps/web/e2e/tests/chat/markdown-preview.spec.ts index 6b3a04c3e2..394bbece65 100644 --- a/apps/web/e2e/tests/chat/markdown-preview.spec.ts +++ b/apps/web/e2e/tests/chat/markdown-preview.spec.ts @@ -39,7 +39,7 @@ async function seedTaskWithSession( await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); return { session, sessionId: task.session_id }; } diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 9d7a4fb9f1..8bcded62f3 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -75,8 +75,19 @@ async function openQuickChatWithAgent(page: Page): Promise { // Wait for chat input to appear AND become editable. Eager init means the // agent starts during the picker → tab transition; the input is briefly // disabled while the FE store catches up to the RUNNING session state. + // + // Race fix: `contenteditable="true"` was observed as a momentary flicker + // before the session settled into STARTING/RUNNING and flipped the input + // back to false. Callers then hit `editor.fill()` against a non-editable + // node and the test failed. Wait for the agent-status indicator to clear + // (STARTING/RUNNING both render a "Agent is …" status; IDLE renders none), + // then assert editability — by that point the input has reached its + // stable, ready state. const editor = dialog.locator(".tiptap.ProseMirror"); await expect(editor).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("status", { name: /Agent is (starting|running)/ })).not.toBeVisible({ + timeout: 30_000, + }); await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); return dialog; } @@ -205,7 +216,7 @@ async function seedTaskAndWaitForIdle( const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); return session; } diff --git a/apps/web/e2e/tests/chat/monitor.spec.ts b/apps/web/e2e/tests/chat/monitor.spec.ts index 40539a310b..12de63b756 100644 --- a/apps/web/e2e/tests/chat/monitor.spec.ts +++ b/apps/web/e2e/tests/chat/monitor.spec.ts @@ -46,7 +46,7 @@ test.describe("Claude-acp Monitor tool", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); // The dedicated Monitor card renders, not the generic tool_call row. const monitorCard = session.chat.locator('[data-testid="monitor-card"]').first(); @@ -104,7 +104,7 @@ test.describe("Claude-acp Monitor tool", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); const card = session.chat.locator('[data-testid="monitor-card"]').first(); await expect(card).toContainText("1 event"); @@ -143,7 +143,7 @@ test.describe("Claude-acp Monitor tool", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); await expect(session.chat.locator('[data-testid="monitor-card"]').first()).toContainText( "2 events", ); @@ -151,7 +151,7 @@ test.describe("Claude-acp Monitor tool", () => { // Reload — SSR + Zustand hydration must reconstitute the card from DB. await testPage.reload(); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); const card = session.chat.locator('[data-testid="monitor-card"]').first(); await expect(card).toBeVisible(); diff --git a/apps/web/e2e/tests/git/git-changes-panel.spec.ts b/apps/web/e2e/tests/git/git-changes-panel.spec.ts index 757fe73b97..3604d16e9e 100644 --- a/apps/web/e2e/tests/git/git-changes-panel.spec.ts +++ b/apps/web/e2e/tests/git/git-changes-panel.spec.ts @@ -517,17 +517,19 @@ test.describe("Git Changes Panel", () => { await session.clickTab("Changes"); await expect(session.changes).toBeVisible({ timeout: 10_000 }); - // Wait for both commits to appear + // Wait for both commits to appear. The commits-section header renders as + // soon as the section mounts (no commits required), so asserting on the + // text alone races the WS git-status push that carries the actual commit + // list. Gate on the row count first — that's the signal that the FE + // received both commits — and only then check text content. await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 }); await session.expandCommitsSection(); - await expect(session.changes.getByText("First commit")).toBeVisible({ timeout: 10_000 }); - await expect(session.changes.getByText("Second commit")).toBeVisible({ timeout: 10_000 }); - - // Verify both commits appear const commitsList = testPage.getByTestId("commits-list"); await expect(commitsList.locator('[data-testid^="commit-row-"]')).toHaveCount(2, { - timeout: 5_000, + timeout: 15_000, }); + await expect(session.changes.getByText("First commit")).toBeVisible({ timeout: 5_000 }); + await expect(session.changes.getByText("Second commit")).toBeVisible({ timeout: 5_000 }); // Find the first commit row (it's the second in the list, older commit) // The list shows newest first, so "Second commit" is at index 0, "First commit" at index 1 diff --git a/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts b/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts index 657fcd888c..bcc4258b01 100644 --- a/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts +++ b/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts @@ -68,8 +68,14 @@ test.describe("Stale session navigation", () => { await cardB.click(); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); - // 6. Verify Task B's page shows the correct title - await expect(testPage.getByText("Sessionless Task")).toBeVisible({ timeout: 10_000 }); + // 6. Verify Task B's page shows the correct title. + // Use the breadcrumb link specifically — `getByText` collides with the + // task card still visible in the sidebar list, triggering a strict-mode + // failure ("resolved to 2 elements") under flaky conditions where both + // surfaces happen to render the title. + await expect(testPage.getByRole("link", { name: "Sessionless Task" })).toBeVisible({ + timeout: 10_000, + }); // 7. Task A's messages must NOT appear on Task B's page await expect(testPage.getByText("simple mock response", { exact: false })).not.toBeVisible({ diff --git a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts index 0942372f1c..970ff40688 100644 --- a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts +++ b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts @@ -52,7 +52,7 @@ async function seedTaskAndWaitForIdle( await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); return { session, taskId: task.id }; } @@ -162,7 +162,12 @@ test.describe("Plan panel auto-open + indicator", () => { CREATE_PLAN_SCRIPT, ); await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); - await expect(planTabIndicator(testPage)).toBeVisible(); + // The plan-update WS event arrives separately from the agent's idle + // signal — the tab mounts as soon as the panel registers, but the + // indicator only flips on once `plan.created_by === "agent"` is in the + // store. Match the tab's 15s budget instead of using the default 5s, + // which raced the WS push under shard load. + await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 }); // Acknowledge await session.clickTab("Plan"); diff --git a/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts index 623335e1e3..ccd3b887c4 100644 --- a/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts +++ b/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts @@ -163,7 +163,10 @@ test.describe("PR watcher dockview layout stability", () => { // Wait for the mock agent to complete and the layout to be stable before toggling // plan mode. Without this, an in-flight layout restore could swallow the panel add. - await session.idleInput().waitFor({ state: "visible", timeout: 30_000 }); + // Use `waitForChatIdle` (vs. raw `idleInput().waitFor`) so the helper's + // reload-and-retry recovery covers the rare case where the WS-driven idle + // signal misses its window under shard pressure. + await session.waitForChatIdle({ timeout: 30_000 }); // --- Toggle plan mode on task 1 --- await session.togglePlanMode(); diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index cac3af8893..dcc45982c7 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -598,14 +598,28 @@ test.describe("Branch refresh + filter", () => { // disabled when we click it. await expect(refreshButton).toBeEnabled({ timeout: 10_000 }); - const refreshRequest = testPage.waitForRequest( - (req) => - req.url().includes(`/repositories/${seedData.repositoryId}/branches`) && - req.url().includes("refresh=true") && - req.method() === "GET", - ); - await refreshButton.click(); - await refreshRequest; + // Also wait for the branch list inside the popover to render at least one + // option. `toBeEnabled` only checks the button itself; there's a small + // window where `refreshing` flips to false while the popover is still + // hydrating its CommandList, and a click during that window is swallowed + // before the refresh handler is wired, so the ?refresh=true request never + // fires and `waitForRequest` hangs the full test timeout. + await expect(testPage.getByRole("option").first()).toBeVisible({ timeout: 10_000 }); + + // Set the listener up before the click — Promise.all guarantees ordering + // and the explicit timeout makes a missed request fail fast instead of + // hanging until the test-level 60s cap. No assertion needed: a missed + // request throws from inside Promise.all. + await Promise.all([ + testPage.waitForRequest( + (req) => + req.url().includes(`/repositories/${seedData.repositoryId}/branches`) && + req.url().includes("refresh=true") && + req.method() === "GET", + { timeout: 15_000 }, + ), + refreshButton.click(), + ]); }); test("branch filter ranks exact match above substring matches", async ({ diff --git a/apps/web/e2e/tests/task/file-tree-sorting.spec.ts b/apps/web/e2e/tests/task/file-tree-sorting.spec.ts index f3669d2f0f..fb4d6a105a 100644 --- a/apps/web/e2e/tests/task/file-tree-sorting.spec.ts +++ b/apps/web/e2e/tests/task/file-tree-sorting.spec.ts @@ -28,7 +28,7 @@ async function seedSimpleTask( const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 30_000 }); return session; } diff --git a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts index a5bed42107..b87a1c3454 100644 --- a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts @@ -61,7 +61,7 @@ async function setupMobileFileViewerTest({ await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(session.idleInput()).toBeVisible({ timeout: 45_000 }); + await session.waitForChatIdle({ timeout: 45_000 }); return { session, filePath }; } diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index b39b7f7da5..ed70eaafd8 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -286,11 +286,21 @@ function applyLayoutAndSet( state: LayoutState, pinnedWidths: Map, set: StoreSet, + preMeasured?: { width: number; height: number }, ): LayoutGroupIds { // Pass measured container dims so fromJSON's grid.width matches the live // container — avoids the proportional rescale that would otherwise grow // pinned columns past their legacy initial caps on the next api.layout. - const measured = measureDockviewContainer(api); + // + // Callers can pre-measure and pass `preMeasured` to avoid re-measuring inside + // the middle of a layout transition. The visibility toggles below take that + // path because `set({ sidebarVisible: ... })` runs before this call and can + // trigger React to repaint the host shell, momentarily shrinking the + // dockview parent's `clientWidth` — re-measuring then would read the + // transient narrow width and clamp pinned columns to it (the + // `pane-resize-sidebar.spec.ts:41` flake mode: cap=301 from a 601px stale + // measurement clamps the 430px sidebar override down to 301). + const measured = preMeasured ?? measureDockviewContainer(api); const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height); set(ids); return ids; @@ -323,13 +333,14 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { const liveWidths = captureLiveWidths(api, set); preserveChatScrollDuringLayout(); const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); + const preMeasured = { width: safeWidth, height: safeHeight }; if (sidebarVisible) { const current = fromDockviewApi(api); const withoutSidebar: LayoutState = { columns: current.columns.filter((c) => c.id !== "sidebar"), }; set({ isRestoringLayout: true, sidebarVisible: false }); - applyLayoutAndSet(api, withoutSidebar, liveWidths, set); + applyLayoutAndSet(api, withoutSidebar, liveWidths, set, preMeasured); requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); enforceFromStore(api, get); @@ -343,7 +354,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { columns: [sidebarCol, ...current.columns], }; set({ isRestoringLayout: true, sidebarVisible: true }); - applyLayoutAndSet(api, withSidebar, liveWidths, set); + applyLayoutAndSet(api, withSidebar, liveWidths, set, preMeasured); requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); enforceFromStore(api, get); diff --git a/apps/web/lib/state/layout-manager/applier.ts b/apps/web/lib/state/layout-manager/applier.ts index 483cecf748..7d98e3c69e 100644 --- a/apps/web/lib/state/layout-manager/applier.ts +++ b/apps/web/lib/state/layout-manager/applier.ts @@ -89,18 +89,53 @@ export function applyLayout( // This avoids the "lock to current" ratchet bug where transient container // shrinks would permanently pin the sidebar at the smaller size. const sv = getRootSplitview(api); - configureSidebarPinned(api, state, sv, w); - configureRightPinned(api, state, sv, w); + configureSidebarPinned(api, state, sv, w, pinnedWidths); + configureRightPinned(api, state, sv, w, pinnedWidths); return resolveGroupIds(api); } +/** Set the pinned target for a column. Prefers an explicit override from + * `pinnedWidths` over what fromJSON happened to render — fromJSON can + * produce a clamped value during a sidebar/right toggle (the panel's NEW + * group is mounted with default constraints and a transient internal + * rebalance can squeeze it below the serialized target before our + * setConstraints lifts the cap), and reading `sv.getViewSize` at that + * moment would silently overwrite the saved user-resized width with the + * clamped value (the `pane-resize-sidebar.spec.ts:41` failure mode). When + * the override is known we also proactively resize dockview to match so + * the user sees their intended width immediately. + */ +function syncPinnedColumnTarget( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sv: any, + idx: number, + columnId: "sidebar" | "right", + override: number | undefined, +): void { + if (override !== undefined && override > 0) { + const live = sv?.getViewSize?.(idx); + if (typeof live === "number" && live > 0 && Math.abs(live - override) > 1) { + try { + sv.resizeView(idx, override); + } catch { + /* dockview rejects unreachable sizes — ignore */ + } + } + setPinnedTarget(columnId, override); + return; + } + const live = sv?.getViewSize?.(idx); + if (typeof live === "number" && live > 0) setPinnedTarget(columnId, live); +} + function configureSidebarPinned( api: DockviewApi, state: LayoutState, // eslint-disable-next-line @typescript-eslint/no-explicit-any sv: any, viewportWidth: number, + pinnedWidths: Map, ): void { const sidebarCol = state.columns.find((c) => c.id === "sidebar"); const sb = api.getPanel("sidebar"); @@ -113,13 +148,13 @@ function configureSidebarPinned( // `innerWidth` of ~601 produces cap=301 (= 601 - VIEWPORT_RESERVE_PX), and // setConstraints({max:301}) then squeezes a 430px sidebar down to 301 — // reproducible as the rare `pane-resize-sidebar.spec.ts:41` failure in CI. + const cap = sidebarCol?.maxWidth ?? computePinnedMaxPxFor("sidebar", viewportWidth); sb.group.api.setConstraints({ - maximumWidth: sidebarCol?.maxWidth ?? computePinnedMaxPxFor("sidebar", viewportWidth), + maximumWidth: cap, minimumWidth: LAYOUT_PINNED_MIN_PX, }); if (!sidebarCol) return; - const live = sv?.getViewSize?.(0); - if (typeof live === "number" && live > 0) setPinnedTarget("sidebar", live); + syncPinnedColumnTarget(sv, 0, "sidebar", pinnedWidths.get("sidebar")); } function configureRightPinned( @@ -128,6 +163,7 @@ function configureRightPinned( // eslint-disable-next-line @typescript-eslint/no-explicit-any sv: any, viewportWidth: number, + pinnedWidths: Map, ): void { for (let i = 0; i < state.columns.length; i++) { const col = state.columns[i]; @@ -137,8 +173,7 @@ function configureRightPinned( const cap = col.maxWidth ?? computePinnedMaxPxFor(col.id, viewportWidth); applyConstraintsToAllPanelGroups(api, col, cap); if (col.id !== "right") continue; - const live = sv?.getViewSize?.(i); - if (typeof live === "number" && live > 0) setPinnedTarget("right", live); + syncPinnedColumnTarget(sv, i, "right", pinnedWidths.get("right")); } }