fix(test): stabilize flaky e2e tests and increase CI parallelism (#384)

This commit is contained in:
Carlos Florêncio
2026-03-07 18:55:03 +00:00
committed by GitHub
parent 0da44478d1
commit 0cd891d27a
19 changed files with 421 additions and 94 deletions
+59 -8
View File
@@ -29,9 +29,13 @@ permissions:
jobs:
e2e:
name: Run E2E Tests
name: E2E Shard ${{ matrix.shard }}/4
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout code
@@ -61,21 +65,68 @@ jobs:
working-directory: apps/web
run: npx playwright install chromium --with-deps
- name: Run E2E tests
run: make test-e2e
- name: Build backend and web
run: make build-backend build-web
- name: Upload test report
- name: Run E2E tests (shard ${{ matrix.shard }}/4)
working-directory: apps/web
run: npx playwright test --config e2e/playwright.config.ts --shard=${{ matrix.shard }}/4
- name: Upload blob report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/web/e2e/playwright-report/
retention-days: 14
name: blob-report-${{ matrix.shard }}
path: apps/web/e2e/blob-report/
retention-days: 3
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-results
name: test-results-${{ matrix.shard }}
path: apps/web/e2e/test-results/
retention-days: 7
e2e-report:
name: Merge E2E Reports
needs: e2e
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: "9"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install dependencies
working-directory: apps
run: pnpm install --frozen-lockfile
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: apps/web/e2e/blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge reports
working-directory: apps/web
run: npx playwright merge-reports --reporter=html ./e2e/blob-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 14
+19
View File
@@ -81,6 +81,7 @@ help:
@echo " test-e2e Run E2E tests (headless, parallel)"
@echo " test-e2e-headed Run E2E tests with visible browser"
@echo " test-e2e-ui Run E2E tests in Playwright UI mode"
@echo " test-e2e-ci Run E2E tests in Docker with CI-like Linux + resource limits"
@echo " test-e2e-report Open Playwright HTML report"
@echo ""
@echo "Code Quality:"
@@ -242,6 +243,24 @@ test-e2e-ui: build-backend build-web
test-e2e-report:
@cd $(WEB_DIR) && npx playwright show-report e2e/playwright-report
# Run E2E tests inside Docker to simulate CI conditions (Linux + resource limits).
# Configurable via env vars; defaults match GitHub Actions ubuntu-latest runners.
E2E_CI_CPUS ?= 4
E2E_CI_MEMORY ?= 16g
E2E_CI_SHM_SIZE ?= 1g
E2E_CI_IMAGE ?= kandev-e2e
.PHONY: test-e2e-ci
test-e2e-ci:
@printf "$(CYAN)Building E2E Docker image...$(RESET)\n"
@docker build -f e2e.Dockerfile -t $(E2E_CI_IMAGE) .
@printf "$(CYAN)Running E2E tests in Docker (cpus=$(E2E_CI_CPUS), memory=$(E2E_CI_MEMORY))...$(RESET)\n"
@docker run --rm \
--cpus=$(E2E_CI_CPUS) \
--memory=$(E2E_CI_MEMORY) \
--shm-size=$(E2E_CI_SHM_SIZE) \
$(E2E_CI_IMAGE) $(E2E_ARGS)
#
# Code Quality
#
@@ -5,25 +5,6 @@ import { useTheme } from "next-themes";
import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
import { PIERRE_THEME } from "@/lib/theme/colors";
const LANGS = [
"javascript",
"typescript",
"jsx",
"tsx",
"json",
"css",
"html",
"markdown",
"python",
"go",
"rust",
"java",
"yaml",
"toml",
"bash",
"sql",
] as const;
const workerFactory = () =>
new Worker(new URL("@pierre/diffs/worker/worker.js", import.meta.url), { type: "module" });
@@ -41,9 +22,13 @@ function ThemeSync() {
export function DiffWorkerPoolProvider({ children }: { children: ReactNode }) {
return (
<WorkerPoolContextProvider
poolOptions={{ workerFactory, poolSize: 4 }}
poolOptions={{ workerFactory, poolSize: 1 }}
highlighterOptions={{
langs: [...LANGS],
// Don't pre-load language grammars at startup — they resolve on-demand per file type
// via resolveLanguagesAndExecuteTask. Pre-loading 15 grammars blocks worker pool
// initialization on cold CI/Docker starts (can take >60s), causing diff views to
// remain empty until all imports complete.
langs: [],
theme: PIERRE_THEME.dark,
lineDiffType: "word",
}}
@@ -45,6 +45,7 @@ export const RepositorySelector = memo(function RepositorySelector({
dropdownLabel="Repository"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
testId="repository-selector"
/>
);
});
@@ -85,6 +86,7 @@ export const BranchSelector = memo(function BranchSelector({
disabled={disabled}
dropdownLabel="Base Branch"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
testId="branch-selector"
/>
);
});
@@ -118,6 +120,7 @@ export const AgentSelector = memo(function AgentSelector({
dropdownLabel="Agent profile"
className={`min-w-[380px]${disabled ? "" : ` ${CURSOR_POINTER_CLASS}`}`}
triggerClassName={triggerClassName}
testId="agent-profile-selector"
/>
);
});
+32 -12
View File
@@ -25,18 +25,37 @@ export type BackendContext = {
restart: () => Promise<void>;
};
async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
async function waitForHealth(url: string, timeoutMs: number, proc?: ChildProcess): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
// not ready yet
// Track process exit so we can fail fast instead of polling for the full timeout.
let processExited = false;
let processExitCode: number | null = null;
const onExit = (code: number | null) => {
processExited = true;
processExitCode = code;
};
proc?.once("exit", onExit);
try {
while (Date.now() < deadline) {
if (processExited) {
throw new Error(
`Backend process exited with code ${processExitCode} while waiting for health at ${url}`,
);
}
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
}
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`);
} finally {
proc?.off("exit", onExit);
}
throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`);
}
function killProcess(proc: ChildProcess): Promise<void> {
@@ -229,10 +248,11 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
*/
const restart = async () => {
await killProcessGroup(backendProc);
// Brief delay to ensure ports are fully released by the OS
await new Promise((r) => setTimeout(r, 500));
// Wait for OS to release the TCP port — Linux TIME_WAIT can exceed 500ms under load
await new Promise((r) => setTimeout(r, 2_000));
backendProc = spawnBackendProcess(backendEnv, debug, backendPort);
await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS);
// Pass the process so waitForHealth fails fast if it exits (e.g. port still in use)
await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS, backendProc);
};
try {
+21 -4
View File
@@ -95,7 +95,7 @@ export const test = backendFixture.extend<
baseURL: backend.frontendUrl,
});
const page = await context.newPage();
await setupPage(page, backend);
await setupPage(page, backend, seedData);
await use(page);
await context.close();
},
@@ -103,13 +103,30 @@ export const test = backendFixture.extend<
export { expect } from "@playwright/test";
async function setupPage(page: Page, backend: BackendContext): Promise<void> {
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
await page.addInitScript(
({ backendUrl }: { backendUrl: string }) => {
({
backendUrl,
repositoryId,
agentProfileId,
}: {
backendUrl: string;
repositoryId: string;
agentProfileId: string;
}) => {
localStorage.setItem("kandev.onboarding.completed", "true");
// Pre-seed dialog selections so auto-select effects resolve on their
// first render cycle instead of waiting for async API chains.
localStorage.setItem("kandev.dialog.lastRepositoryId", JSON.stringify(repositoryId));
localStorage.setItem("kandev.dialog.lastAgentProfileId", JSON.stringify(agentProfileId));
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
// Set the window global that getBackendConfig() reads for API/WS connections
window.__KANDEV_API_BASE_URL = backendUrl;
},
{ backendUrl: backend.baseUrl },
{
backendUrl: backend.baseUrl,
repositoryId: seedData.repositoryId,
agentProfileId: seedData.agentProfileId,
},
);
}
+8 -3
View File
@@ -4,12 +4,17 @@ const CI = !!process.env.CI;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
// 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: false,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 2 : 1,
workers: 1,
timeout: 60_000,
reporter: CI ? [["html", { outputFolder: "./playwright-report" }]] : "list",
// CI uses blob reporter for cross-shard merge-reports; local uses list.
reporter: CI ? [["blob", { outputDir: "./blob-report" }]] : "list",
outputDir: "./test-results",
use: {
+71 -6
View File
@@ -2,7 +2,72 @@ import { test, expect } from "../fixtures/test-base";
import { KanbanPage } from "../pages/kanban-page";
import { SessionPage } from "../pages/session-page";
const START_AGENT_TEST_ID = "submit-start-agent";
const START_ENABLED_TIMEOUT = 30_000;
test.describe("Task creation", () => {
test("dialog pre-selects repository, branch, and agent profile from seed data", async ({
testPage,
}) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
// Fill title + description to trigger the "Start task" split button (showStartTask)
await testPage.getByTestId("task-title-input").fill("Pre-select Test");
await testPage.getByTestId("task-description-input").fill("testing pre-selections");
// Wait for the submit button to be enabled — confirms all selections resolved
const startBtn = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
// Verify the pre-seeded selections are displayed in the dialog selectors
await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo");
await expect(testPage.getByTestId("branch-selector")).toContainText("main");
// Agent profile name varies — just verify it's not the empty placeholder
await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText(
"Select agent...",
);
});
test("dialog remembers selections after creating a task", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
// First: create a task so localStorage selections get persisted
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await testPage.getByTestId("task-title-input").fill("First Task");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
const startBtn = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Second: open the dialog again and verify selections persist
await kanban.createTaskButton.first().click();
await expect(dialog).toBeVisible();
await testPage.getByTestId("task-title-input").fill("Second Task");
await testPage.getByTestId("task-description-input").fill("checking persistence");
const startBtn2 = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn2).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
// The same repo, branch, and agent profile should still be selected
await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo");
await expect(testPage.getByTestId("branch-selector")).toContainText("main");
await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText(
"Select agent...",
);
});
test("opens create task dialog from kanban header", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
@@ -43,8 +108,8 @@ test.describe("Task creation", () => {
// The dialog auto-selects the E2E Repo (first available repository) and the main branch.
// Wait for the button to become enabled (repo + branch + agent profile all resolved).
// Under load the branch/profile resolution can take a moment.
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
const startBtn = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
// Click "Start task" — the agent starts, the dialog closes, we stay on kanban
await startBtn.click();
@@ -115,8 +180,8 @@ test.describe("Task creation", () => {
await testPage.getByTestId("task-title-input").fill("Plan MCP Task");
await testPage.getByTestId("task-description-input").fill(script);
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
const startBtn = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
await testPage.getByTestId("submit-start-agent-chevron").click();
await expect(testPage.getByTestId("submit-plan-mode")).toBeVisible({ timeout: 5_000 });
@@ -177,8 +242,8 @@ test.describe("Task creation", () => {
// Wait for the main submit button to be enabled (repo + agent profile resolved),
// then open the dropdown chevron to reveal the "Start task in plan mode" option.
const startBtn = testPage.getByTestId("submit-start-agent");
await expect(startBtn).toBeEnabled({ timeout: 15_000 });
const startBtn = testPage.getByTestId(START_AGENT_TEST_ID);
await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT });
// The split-button group wraps "Start task" + a chevron-only dropdown trigger.
// Click the chevron to open the dropdown.
+7 -4
View File
@@ -61,7 +61,7 @@ async function openExpansionFileDiff(testPage: Page) {
}
test.describe("Diff expansion — Pierre Diffs provider", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
test.describe.configure({ retries: 2, timeout: 120_000 });
test("renders Pierre Diffs viewer and shows both hunks", async ({
testPage,
@@ -72,10 +72,13 @@ test.describe("Diff expansion — Pierre Diffs provider", () => {
await openChangesTab(testPage);
await openExpansionFileDiff(testPage);
// Pierre Diffs renders a diffs-container custom element
// Pierre Diffs renders a diffs-container custom element with an open shadow DOM.
// Playwright's getByText auto-pierces shadow DOM and auto-retries.
await expect(testPage.locator("diffs-container")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ timeout: 15_000 });
// On cold CI runners (first test in shard, no V8 code cache), resolveLanguagesAndExecuteTask
// triggers createJavaScriptRegexEngine() which can take 30-40s to JIT-compile.
// diffs-container mounts immediately but content appears only after the engine is ready.
await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ timeout: 60_000 });
await expect(testPage.getByText("HUNK_BOTTOM", { exact: false })).toBeVisible({
timeout: 5_000,
});
+3 -1
View File
@@ -99,7 +99,9 @@ test.describe("Diff update on file change", () => {
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
// The Pierre Diffs viewer should show the initial modification
// The Pierre Diffs viewer should show the initial modification.
// Playwright's getByText auto-pierces shadow DOM and auto-retries, so we use it
// directly with a generous timeout to handle async web worker initialization.
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
+51 -16
View File
@@ -18,7 +18,24 @@ class GitHelper {
) {}
exec(cmd: string): string {
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
const lockPath = path.join(this.repoDir, ".git", "index.lock");
// Retry up to 3 times on index.lock conflicts. The backend's git status
// polling briefly holds the lock; waiting a short time and retrying is
// safer than deleting an actively-held lock.
for (let attempt = 0; attempt < 3; attempt++) {
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
try {
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
} catch (err) {
const msg = (err as Error).message ?? "";
if (msg.includes("index.lock") && attempt < 2) {
execSync("sleep 0.2");
continue;
}
throw err;
}
}
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
}
createFile(name: string, content: string) {
@@ -187,9 +204,10 @@ test.describe("Git Changes Panel", () => {
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
// The new file should appear in unstaged
// The new file should appear in unstaged (scope to changes panel to avoid
// matching the Files panel which also shows the filename)
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByText("new-feature.ts")).toBeVisible({ timeout: 15_000 });
await expect(session.changes.getByText("new-feature.ts")).toBeVisible({ timeout: 15_000 });
// Clean up
git.deleteFile("new-feature.ts");
@@ -293,8 +311,20 @@ test.describe("Git Changes Panel", () => {
// Look for the commit message (which uniquely identifies this diff view)
await expect(session.changes.getByText("Add diff test file")).toBeVisible({ timeout: 10_000 });
// Additionally verify the diff shows the actual file content (lines added)
// This confirms the diff is working and showing the commit changes
// Additionally verify the diff shows the actual file content (lines added).
// Pierre Diffs renders in a shadow DOM — check all diffs-container elements
// since multiple may exist (inline chat diffs + Changes panel).
await testPage.waitForFunction(
(searchText: string) => {
for (const container of document.querySelectorAll("diffs-container")) {
const shadow = container.shadowRoot;
if (shadow?.textContent?.includes(searchText)) return true;
}
return false;
},
"line 1",
{ timeout: 60_000 },
);
await expect(testPage.getByText("line 1")).toBeVisible({ timeout: 5_000 });
});
@@ -690,17 +720,18 @@ test.describe("Git Changes Panel", () => {
};
const git = new GitHelper(repoDir, gitEnv);
// Create multiple commits
// Create multiple commits — use stageFile() instead of stageAll() to avoid
// picking up leftover files from prior tests in the shared repo.
git.createFile("file1.txt", "content 1");
git.stageAll();
git.stageFile("file1.txt");
const sha1 = git.commit("First persistent commit");
git.createFile("file2.txt", "content 2");
git.stageAll();
git.stageFile("file2.txt");
const sha2 = git.commit("Second persistent commit");
git.createFile("file3.txt", "content 3");
git.stageAll();
git.stageFile("file3.txt");
const sha3 = git.commit("Third persistent commit");
// Click the Changes tab
@@ -781,7 +812,8 @@ test.describe("Git Changes Panel", () => {
// Helper to clean up branch - ensures cleanup runs even if test fails
const cleanupBranch = () => {
try {
git.exec("git checkout main");
git.exec("git checkout -f main");
git.exec("git clean -fd");
git.exec("git branch -D feature-rebase");
} catch {
// Branch may not exist if test failed before creation
@@ -789,16 +821,19 @@ test.describe("Git Changes Panel", () => {
};
try {
// Clean any leftover state from prior tests
git.exec("git clean -fd");
// Create a commit on a feature branch
git.exec("git checkout -b feature-rebase");
git.createFile("feature-file.txt", "feature content");
git.stageAll();
git.stageFile("feature-file.txt");
git.commit("Feature commit before rebase");
// Go back to main and create a new commit
git.exec("git checkout main");
git.createFile("main-file.txt", "main content");
git.stageAll();
git.stageFile("main-file.txt");
git.commit("Main commit after branch");
// Go back to feature branch and rebase onto main
@@ -859,11 +894,11 @@ test.describe("Git Changes Panel", () => {
// Create two commits to squash
git.createFile("squash1.txt", "first");
git.stageAll();
git.stageFile("squash1.txt");
git.commit("First commit to squash");
git.createFile("squash2.txt", "second");
git.stageAll();
git.stageFile("squash2.txt");
git.commit("Second commit to squash");
// Click the Changes tab
@@ -935,11 +970,11 @@ test.describe("Git Changes Panel", () => {
// Create commits with distinct content
git.createFile("cumulative-file.txt", "line 1: first commit\n");
git.stageAll();
git.stageFile("cumulative-file.txt");
git.commit("Add first line");
git.modifyFile("cumulative-file.txt", "line 1: first commit\nline 2: second commit\n");
git.stageAll();
git.stageFile("cumulative-file.txt");
git.commit("Add second line");
// Click the Changes tab
@@ -141,14 +141,14 @@ test.describe("Git commit pre-hooks", () => {
// Verify the error output contains the pre-commit hook message
await expect(
session.gitOperationErrorMessage().getByText("lint check failed: missing semicolons"),
).toBeVisible({ timeout: 5_000 });
).toBeVisible({ timeout: 15_000 });
// Click the Fix button
await session.gitFixButton().click();
// The fix prompt should appear in the chat as a user message
await expect(
session.chat.getByText("git commit command failed", { exact: false }),
session.chat.getByText("git commit command failed", { exact: false }).first(),
).toBeVisible({ timeout: 10_000 });
// The agent should process the fix prompt and respond
@@ -62,14 +62,17 @@ test.describe("PR external detection", () => {
repository_ids: [seedData.repositoryId],
});
// Navigate to kanban BEFORE moving tasks so the WebSocket is already
// subscribed when task.updated events fire. The mock agent completes in
// <100ms; if we navigate after moveTask the events are emitted before
// the browser subscribes and the kanban never receives them.
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Move tasks to Working → auto_start → mock agent completes → Done
await apiClient.moveTask(featureTask.id, workflow.id, workingStep.id);
await apiClient.moveTask(helperTask.id, workflow.id, workingStep.id);
// --- Navigate to kanban and wait for tasks to complete ---
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardInColumn("Feature Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
@@ -127,6 +127,13 @@ test.describe("PR switcher changes panel", () => {
repository_ids: [seedData.repositoryId],
});
// Navigate to kanban BEFORE moving tasks so the WebSocket is already
// subscribed when task.updated events fire. The mock agent completes in
// <100ms; if we navigate after moveTask the events are emitted before
// the browser subscribes and the kanban never receives them.
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Move all tasks to Working → auto_start → mock agent completes → Done
await apiClient.moveTask(taskA.id, workflow.id, workingStep.id);
await apiClient.moveTask(taskB.id, workflow.id, workingStep.id);
@@ -160,10 +167,6 @@ test.describe("PR switcher changes panel", () => {
deletions: 5,
});
// --- Navigate to kanban and wait for tasks to complete ---
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Wait for all three tasks to reach Done
await expect(kanban.taskCardInColumn("Auth Fix Task", doneStep.id)).toBeVisible({
timeout: 45_000,
+6 -4
View File
@@ -47,7 +47,7 @@ test.describe("Session resume (ACP mode)", () => {
seedData,
backend,
}) => {
test.setTimeout(90_000);
test.setTimeout(120_000);
// 1. Create task and start agent with a simple scenario
await apiClient.createTaskWithAgent(
@@ -88,8 +88,10 @@ test.describe("Session resume (ACP mode)", () => {
await expect(session.chat.getByText("/e2e:simple-message")).toBeVisible({ timeout: 15_000 });
// 7. Wait for auto-resume to complete — useSessionResumption hook detects
// needs_resume=true and relaunches the agent via session.launch
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// needs_resume=true and relaunches the agent via session.launch.
// The full cycle (backend restart → health check → page reload → SSR →
// WS reconnect → auto-resume → agent turn) can be slow under CI load.
await expect(session.idleInput()).toBeVisible({ timeout: 60_000 });
// 8. Verify the "Resumed agent Mock" boot message appeared after resume
await expect(session.chat.getByText("Resumed agent Mock", { exact: false })).toBeVisible({
@@ -119,7 +121,7 @@ test.describe("Session resume (TUI passthrough mode)", () => {
seedData,
backend,
}) => {
test.setTimeout(90_000);
test.setTimeout(120_000);
// 1. Create a TUI agent profile
const tuiProfile = await createTUIProfile(apiClient, "TUI Resume");
@@ -112,11 +112,13 @@ test.describe("Task switcher sidebar status", () => {
await expect(session.taskInSection("Beta Task", "Review")).toBeVisible({ timeout: 30_000 });
// --- Move Gamma to Working (background task) ---
// Small delay to let the backend settle after Beta's completion
await testPage.waitForTimeout(2_000);
await apiClient.moveTask(taskGamma.id, workflow.id, workingStep.id);
// Gamma should appear in "In Progress" while running
await expect(session.taskInSection("Gamma Task", "In Progress")).toBeVisible({
timeout: 30_000,
timeout: 45_000,
});
// Gamma transitions to "Review"
@@ -298,9 +298,6 @@ test.describe("Terminal agent (TUI passthrough)", () => {
await taskB.click();
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
// Loading overlay reappears during the session switch
await session.waitForPassthroughLoading();
// Wait for task B's passthrough terminal to load
await session.waitForPassthroughLoad();
await session.waitForPassthroughLoaded();
@@ -209,8 +209,10 @@ test.describe("Workflow start step placement", () => {
});
// --- Toggle off plan mode ---
// Wait for the editor to be fully interactive before sending the shortcut
await testPage.waitForTimeout(1_000);
await session.togglePlanMode();
await expect(session.planPanel).not.toBeVisible({ timeout: 10_000 });
await expect(session.planPanel).not.toBeVisible({ timeout: 15_000 });
// --- Send message to start the agent ---
// Use script mode with a delay so we can observe the In Progress intermediate state.
+113
View File
@@ -0,0 +1,113 @@
# e2e.Dockerfile — Run E2E tests in CI-like Linux environment with resource limits
#
# Usage:
# make test-e2e-ci # All tests
# make test-e2e-ci E2E_ARGS="--shard=1/4" # Single shard
# make test-e2e-ci E2E_ARGS="tests/diff-update.spec.ts" # Specific test
# make test-e2e-ci E2E_CI_CPUS=2 E2E_CI_MEMORY=4g # Custom limits
#
# Note: Builds for native architecture (ARM64 on Apple Silicon). To match CI
# exactly (x86_64), add --platform=linux/amd64, but expect ~10x slower builds
# due to QEMU emulation. The default is fine for reproducing timing/resource issues.
# ---------------------------------------------------------------------------
# Stage 1: Go builder — compile kandev + mock-agent binaries
# ---------------------------------------------------------------------------
FROM golang:1.26-bookworm AS go-builder
WORKDIR /build
COPY apps/backend/go.mod apps/backend/go.sum ./
RUN go mod download
COPY apps/backend/ ./
RUN go build -ldflags "-s -w" -o /out/kandev ./cmd/kandev && \
go build -ldflags "-s -w" -o /out/agentctl ./cmd/agentctl && \
go build -ldflags "-s -w" -o /out/mock-agent ./cmd/mock-agent
# ---------------------------------------------------------------------------
# Stage 2: Node builder — install deps + build web app
# ---------------------------------------------------------------------------
FROM node:24-bookworm AS node-builder
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /build/apps
# Copy workspace config and package.jsons for dependency caching
COPY apps/package.json apps/pnpm-workspace.yaml apps/pnpm-lock.yaml ./
COPY apps/web/package.json ./web/package.json
COPY apps/cli/package.json ./cli/package.json
COPY apps/packages/ui/package.json ./packages/ui/package.json
COPY apps/packages/theme/package.json ./packages/theme/package.json
COPY apps/packages/types/package.json ./packages/types/package.json
RUN pnpm install --frozen-lockfile
# Copy full source for build
COPY apps/ ./
# Build web app (produces .next/standalone/ needed by E2E fixture)
RUN pnpm --filter @kandev/web build
# ---------------------------------------------------------------------------
# Stage 3: Test runner — everything needed to run E2E tests
# ---------------------------------------------------------------------------
FROM node:24-bookworm AS runner
RUN corepack enable && corepack prepare pnpm@9 --activate
# Install git (backend fixture creates test repos) and CA certs
RUN apt-get update && \
apt-get install -y --no-install-recommends git ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Prefer IPv4 over IPv6 for localhost — matches GitHub Actions ubuntu-latest behavior.
# Debian Bookworm's gai.conf defaults to IPv6-first, so Node's server.listen('localhost')
# binds to ::1 while undici's fetch('http://localhost:port') tries 127.0.0.1 → ECONNREFUSED.
RUN echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
WORKDIR /app
# Copy node_modules + source + built output from node-builder
COPY --from=node-builder /build/apps/ ./apps/
# Copy Go binaries into the location global-setup.ts expects
COPY --from=go-builder /out/kandev ./apps/backend/bin/kandev
COPY --from=go-builder /out/mock-agent ./apps/backend/bin/mock-agent
# agentctl must be in PATH — kandev spawns it as a subprocess
COPY --from=go-builder /out/agentctl /usr/local/bin/agentctl
# Create static/public symlinks for standalone server (mirrors global-setup.ts)
RUN mkdir -p /app/apps/web/.next/standalone/web/.next && \
ln -sf /app/apps/web/.next/static /app/apps/web/.next/standalone/web/.next/static && \
ln -sf /app/apps/web/public /app/apps/web/.next/standalone/web/public
# Store Playwright browsers in a shared location accessible by all users
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
# Install Playwright chromium with all system dependencies
RUN cd /app/apps/web && npx playwright install --with-deps chromium
# Configure git system-wide so backend fixture's temp dirs inherit it
RUN git config --system user.name "E2E Test" && \
git config --system user.email "e2e@test.local" && \
git config --system commit.gpgsign false && \
git config --system tag.gpgsign false && \
git config --system init.defaultBranch main
# Use the built-in node user (uid 1000) as non-root user
RUN chown -R node:node /app
USER node
# CI=true enables retries=2 in playwright.config.ts
ENV CI=true
WORKDIR /app/apps/web
# --reporter=list overrides blob reporter for real-time terminal output.
# Extra args (shard, test file, --grep) can be appended via CMD.
ENTRYPOINT ["npx", "playwright", "test", "--config", "e2e/playwright.config.ts", "--reporter=list"]
CMD []