ci: faster e2e via image split, shard rebalance, and flake fixes (#1126)

This commit is contained in:
Carlos Florêncio
2026-05-28 19:16:30 +01:00
committed by GitHub
parent 8f065f1673
commit f7eff241b9
21 changed files with 315 additions and 104 deletions
+48 -37
View File
@@ -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'
+1 -1
View File
@@ -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`
+42 -11
View File
@@ -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') }}
+18 -9
View File
@@ -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:
+1 -1
View File
@@ -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
+63 -1
View File
@@ -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<FileEditorState>) => 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<string, unknown>;
};
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),
+5
View File
@@ -34,8 +34,13 @@ async function focusPanel(page: Page, kind: PanelKind): Promise<void> {
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
+11 -5
View File
@@ -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,
@@ -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();
@@ -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 };
}
+12 -1
View File
@@ -75,8 +75,19 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
// 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;
}
+4 -4
View File
@@ -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();
@@ -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
@@ -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({
@@ -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");
@@ -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();
@@ -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 ({
@@ -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;
}
@@ -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 };
}
+14 -3
View File
@@ -286,11 +286,21 @@ function applyLayoutAndSet(
state: LayoutState,
pinnedWidths: Map<string, number>,
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);
+42 -7
View File
@@ -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<string, number>,
): 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<string, number>,
): 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"));
}
}