ci: faster e2e via image split, shard rebalance, and flake fixes (#1126)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user