fix(test): stabilize flaky e2e tests and increase CI parallelism (#384)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
Reference in New Issue
Block a user