diff --git a/.env.example b/.env.example index 5441854..1d861ca 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ # Required: Port for the Vite dev server (each AgentDock instance must use a unique port) -FRONTEND_PORT=5173 +FRONTEND_PORT=5200 diff --git a/.flue/agents/user-agent.ts b/.flue/agents/user-agent.ts index 7763bce..56cb27e 100644 --- a/.flue/agents/user-agent.ts +++ b/.flue/agents/user-agent.ts @@ -15,11 +15,11 @@ * can `bash` invoke it without coupling the agent itself to Playwright. * - Each tool is independently testable. */ -import { defineAgent } from '@flue/runtime'; -import { local } from '@flue/runtime/node'; +import { defineAgent } from "@flue/runtime"; +import { local } from "@flue/runtime/node"; export default defineAgent(() => ({ - model: 'anthropic/claude-sonnet-4-6', + model: "anthropic/claude-sonnet-4-6", // `sandbox: local()` runs bash/file tools on the developer's actual machine // (where flue run was invoked), not in an isolated Linux container. This is // essential because AgentDock + the target project live on the dev's @@ -45,4 +45,4 @@ Rules: - Do NOT try to fix bugs — only report them - If the launcher fails with "No main entry in out/main", tell the user to run \`npx electron-vite build\` first `, -})); \ No newline at end of file +})); diff --git a/.flue/app.ts b/.flue/app.ts index 289612b..4386c97 100644 --- a/.flue/app.ts +++ b/.flue/app.ts @@ -5,21 +5,21 @@ * Reads baseUrl/authToken from env so the same code works against real * Anthropic (env unset) or an internal proxy (env set). */ -import { registerProvider } from '@flue/runtime'; -import { flue } from '@flue/runtime/routing'; -import { Hono } from 'hono'; +import { registerProvider } from "@flue/runtime"; +import { flue } from "@flue/runtime/routing"; +import { Hono } from "hono"; if (process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN) { // Internal gateway — treat ANTHROPIC_AUTH_TOKEN as the api key for the // "anthropic" provider. Flue's model specifier (`anthropic/claude-sonnet-4-6`) // routes here unchanged. - registerProvider('anthropic', { + registerProvider("anthropic", { baseUrl: process.env.ANTHROPIC_BASE_URL, apiKey: process.env.ANTHROPIC_AUTH_TOKEN, }); } const app = new Hono(); -app.route('/', flue()); +app.route("/", flue()); -export default app; \ No newline at end of file +export default app; diff --git a/.flue/tools/launch-electron.ts b/.flue/tools/launch-electron.ts index 65b648f..eea3d3c 100644 --- a/.flue/tools/launch-electron.ts +++ b/.flue/tools/launch-electron.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; /** * Electron + Playwright launcher for the user-agent. * @@ -23,10 +26,7 @@ * Usage: * bun run .flue/tools/launch-electron.ts [--out report.json] */ -import { _electron as electron, type ElectronApplication, type Page } from "@playwright/test"; -import { join } from "node:path"; -import { mkdirSync, writeFileSync, existsSync, readdirSync, mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { type ElectronApplication, type Page, _electron as electron } from "@playwright/test"; const ROOT = "F:\\ProgramPlayground\\JavaScript\\AgentDock\\.agentdock\\worktrees\\bed4c452-74d"; const SHOT_DIR = join(ROOT, "test-results", "user-agent-shots"); @@ -68,7 +68,8 @@ async function step(window: Page, label: string, fn: () => Promise): Promi function mainEntry(): string { const dir = join(ROOT, "out", "main"); const files = readdirSync(dir).filter((f) => f.endsWith(".js")); - if (files.length === 0) throw new Error("No main entry in out/main — run `npx electron-vite build` first"); + if (files.length === 0) + throw new Error("No main entry in out/main — run `npx electron-vite build` first"); return join(dir, files[0]!); } @@ -111,63 +112,97 @@ async function run(projectPath: string): Promise { await window.waitForTimeout(3000); // Step 1: cold start — home button visible - steps.push(await step(window, "cold start home button", async () => { - await window.locator('[data-testid="home-open-project"]').waitFor({ state: "visible", timeout: 15_000 }); - })); + steps.push( + await step(window, "cold start home button", async () => { + await window + .locator('[data-testid="home-open-project"]') + .waitFor({ state: "visible", timeout: 15_000 }); + }), + ); // Step 2: click open project → modal appears - steps.push(await step(window, "click open project modal", async () => { - await window.locator('[data-testid="home-open-project"]').click(); - await window.locator('[data-testid="dir-modal"]').waitFor({ state: "visible", timeout: 10_000 }); - })); + steps.push( + await step(window, "click open project modal", async () => { + await window.locator('[data-testid="home-open-project"]').click(); + await window + .locator('[data-testid="dir-modal"]') + .waitFor({ state: "visible", timeout: 10_000 }); + }), + ); // Step 3: navigate dir browser to target project - steps.push(await step(window, "navigate to target project", async () => { - const segments = projectPath.split(/[\\/]/).filter((s) => s.length > 0); - segments[0] = `${segments[0]}\\`; - const waitEntries = async () => { - const entries = window.locator('[data-testid="dir-entry"]'); - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (await entries.count() > 0) return; - await window.waitForTimeout(100); + steps.push( + await step(window, "navigate to target project", async () => { + const segments = projectPath.split(/[\\/]/).filter((s) => s.length > 0); + segments[0] = `${segments[0]}\\`; + const waitEntries = async () => { + const entries = window.locator('[data-testid="dir-entry"]'); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if ((await entries.count()) > 0) return; + await window.waitForTimeout(100); + } + throw new Error("dir-entry never rendered"); + }; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]!; + await window.locator('[data-testid="dir-search-input"]').fill(seg); + await window.waitForTimeout(300); + await window + .locator('[data-testid="dir-entry"]') + .filter({ + has: window.locator(".dir-entry-name", { + hasText: new RegExp(`^${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`), + }), + }) + .first() + .dblclick(); + await waitEntries(); } - throw new Error("dir-entry never rendered"); - }; - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]!; - await window.locator('[data-testid="dir-search-input"]').fill(seg); + const last = segments[segments.length - 1]!; + await window.locator('[data-testid="dir-search-input"]').fill(last); await window.waitForTimeout(300); - await window.locator('[data-testid="dir-entry"]').filter({ - has: window.locator(".dir-entry-name", { hasText: new RegExp(`^${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) }), - }).first().dblclick(); - await waitEntries(); - } - const last = segments[segments.length - 1]!; - await window.locator('[data-testid="dir-search-input"]').fill(last); - await window.waitForTimeout(300); - await window.locator('[data-testid="dir-entry"]').filter({ - has: window.locator(".dir-entry-name", { hasText: new RegExp(`^${last.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) }), - }).first().click(); - await window.locator('[data-testid="dir-confirm"]').click(); - await window.locator('[data-testid="dir-modal"]').waitFor({ state: "hidden", timeout: 10_000 }); - })); + await window + .locator('[data-testid="dir-entry"]') + .filter({ + has: window.locator(".dir-entry-name", { + hasText: new RegExp(`^${last.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`), + }), + }) + .first() + .click(); + await window.locator('[data-testid="dir-confirm"]').click(); + await window + .locator('[data-testid="dir-modal"]') + .waitFor({ state: "hidden", timeout: 10_000 }); + }), + ); // Step 4: project loads (h2 shows project basename) — this is the one // that crashed before the fix; should now pass. const projectName = projectPath.split(/[\\/]/).filter(Boolean).pop()!; - steps.push(await step(window, "project workspace loaded", async () => { - // Wait for the workspace to settle; bail fast if the ErrorBoundary fires. - await window.waitForTimeout(3000); - // Defensive: if the ErrorBoundary "Cannot read properties of undefined - // (reading 'find')" appears, fail with a clear message. - const errorBoundary = await window.locator("text=Cannot read properties of undefined").isVisible().catch(() => false); - if (errorBoundary) { - throw new Error("ErrorBoundary fired: 'Cannot read properties of undefined' — sessions.find crash regressed"); - } - await window.locator("h2").filter({ hasText: projectName }).first().waitFor({ state: "visible", timeout: 20_000 }); - })); - + steps.push( + await step(window, "project workspace loaded", async () => { + // Wait for the workspace to settle; bail fast if the ErrorBoundary fires. + await window.waitForTimeout(3000); + // Defensive: if the ErrorBoundary "Cannot read properties of undefined + // (reading 'find')" appears, fail with a clear message. + const errorBoundary = await window + .locator("text=Cannot read properties of undefined") + .isVisible() + .catch(() => false); + if (errorBoundary) { + throw new Error( + "ErrorBoundary fired: 'Cannot read properties of undefined' — sessions.find crash regressed", + ); + } + await window + .locator("h2") + .filter({ hasText: projectName }) + .first() + .waitFor({ state: "visible", timeout: 20_000 }); + }), + ); } finally { if (app) await app.close(); } @@ -188,14 +223,16 @@ if (!projectPath) { process.exit(2); } -run(projectPath).then((report) => { - const out = process.argv.includes("--out") - ? process.argv[process.argv.indexOf("--out") + 1] - : join(SHOT_DIR, `report-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); - writeFileSync(out, JSON.stringify(report, null, 2)); - console.log(JSON.stringify(report, null, 2)); - process.exit(report.passed ? 0 : 1); -}).catch((err) => { - console.error("FATAL:", err); - process.exit(2); -}); \ No newline at end of file +run(projectPath) + .then((report) => { + const out = process.argv.includes("--out") + ? process.argv[process.argv.indexOf("--out") + 1] + : join(SHOT_DIR, `report-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); + writeFileSync(out, JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + process.exit(report.passed ? 0 : 1); + }) + .catch((err) => { + console.error("FATAL:", err); + process.exit(2); + }); diff --git a/.gitignore b/.gitignore index e737138..bb9a523 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ tsconfig.tsbuildinfo scripts/e2e-* .agentdock/ +.agentdock-dev/ .claude/ .data/ out/ diff --git a/biome.json b/biome.json index e8652a4..4d8904a 100644 --- a/biome.json +++ b/biome.json @@ -30,6 +30,30 @@ "semicolons": "always" } }, + "overrides": [ + { + "include": [ + "e2e/**", + "**/__tests__/**", + "test-utils/**", + ".flue/**", + "test-real-user-flow.ts" + ], + "linter": { + "rules": { + "correctness": { + "noEmptyPattern": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ], "files": { "ignore": [ "node_modules", diff --git a/e2e/agent-e2e-raw.ts b/e2e/agent-e2e-raw.ts index 6e4ca94..d52c1c5 100644 --- a/e2e/agent-e2e-raw.ts +++ b/e2e/agent-e2e-raw.ts @@ -1,3 +1,6 @@ +import { mkdirSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // @ts-nocheck /** * Raw E2E test — no fixture wrapping, direct electron.launch. @@ -7,10 +10,6 @@ * npx tsx e2e/agent-e2e-raw.ts */ import { _electron as electron } from "@playwright/test"; -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; const ROOT = process.cwd(); @@ -53,7 +52,9 @@ function recordStep( if (status === "failed") { errors.push(`[${step}] ${detail}`); } - console.log(`[STEP] ${status.toUpperCase()}: ${step} — ${detail}${screenshot ? ` (screenshot: ${screenshot})` : ""}`); + console.log( + `[STEP] ${status.toUpperCase()}: ${step} — ${detail}${screenshot ? ` (screenshot: ${screenshot})` : ""}`, + ); } async function main() { @@ -152,7 +153,9 @@ async function main() { console.log("\n--- Step 3: Click Open Project button ---"); try { await window.locator('[data-testid="home-open-project"]').click(); - await window.locator('[data-testid="dir-modal"]').waitFor({ state: "visible", timeout: 10_000 }); + await window + .locator('[data-testid="dir-modal"]') + .waitFor({ state: "visible", timeout: 10_000 }); await sleep(1000); const ssModal = "02-modal-open.png"; await window.screenshot({ path: screenshotPath(ssModal), fullPage: false }); @@ -165,9 +168,7 @@ async function main() { // ---------- Step 4: Navigate to Copilot-Switch project ---------- console.log("\n--- Step 4: Navigate to target project ---"); const targetPath = targetProject; - const segments = targetPath - .split(/[\\/]/) - .filter((s) => s.length > 0 && s !== "."); + const segments = targetPath.split(/[\\/]/).filter((s) => s.length > 0 && s !== "."); try { // Wait for initial dir listing @@ -179,23 +180,27 @@ async function main() { } // Debug: print visible entries - const entryNames = await window.locator('[data-testid="dir-entry"] .dir-entry-name').allTextContents(); + const entryNames = await window + .locator('[data-testid="dir-entry"] .dir-entry-name') + .allTextContents(); console.log(` Initial entries (${entryNames.length}):`, entryNames.slice(0, 10)); // Helper: find an entry matching a segment name (flexible matching) async function findAndClickEntry(segName: string, doubleClick: boolean) { + const activeWindow = window; + if (!activeWindow) throw new Error("Electron window is not available"); // Try exact match first, then case-insensitive, then contains const escaped = segName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const patterns = [ - new RegExp(`^${escaped}\\s*$`, "i"), // exact, case-insensitive - new RegExp(`^${escaped}\\s*$`), // exact, case-sensitive - new RegExp(escaped, "i"), // contains, case-insensitive + new RegExp(`^${escaped}\\s*$`, "i"), // exact, case-insensitive + new RegExp(`^${escaped}\\s*$`), // exact, case-sensitive + new RegExp(escaped, "i"), // contains, case-insensitive ]; for (const pattern of patterns) { - const entry = window + const entry = activeWindow .locator('[data-testid="dir-entry"]') - .filter({ has: window.locator(".dir-entry-name") }) + .filter({ has: activeWindow.locator(".dir-entry-name") }) .filter({ hasText: pattern }) .first(); try { @@ -225,7 +230,9 @@ async function main() { if (process.platform === "win32" && /^[A-Za-z]:$/.test(seg)) { segVariants.push(`${seg}\\`, seg.toLowerCase(), seg.toUpperCase()); } - console.log(` Drilling into segment ${i}: "${seg}" (variants: ${JSON.stringify(segVariants)})`); + console.log( + ` Drilling into segment ${i}: "${seg}" (variants: ${JSON.stringify(segVariants)})`, + ); // Try search first const searchInput = window.locator('[data-testid="dir-search-input"]'); @@ -253,8 +260,12 @@ async function main() { } if (!found) { - const currentEntries = await window.locator('[data-testid="dir-entry"] .dir-entry-name').allTextContents(); - throw new Error(`Could not find entry for segment "${seg}". Visible entries: ${JSON.stringify(currentEntries.slice(0, 20))}`); + const currentEntries = await window + .locator('[data-testid="dir-entry"] .dir-entry-name') + .allTextContents(); + throw new Error( + `Could not find entry for segment "${seg}". Visible entries: ${JSON.stringify(currentEntries.slice(0, 20))}`, + ); } // Wait for next directory listing @@ -280,8 +291,12 @@ async function main() { await sleep(300); const lastFound2 = await findAndClickEntry(last, false); if (!lastFound2) { - const currentEntries = await window.locator('[data-testid="dir-entry"] .dir-entry-name').allTextContents(); - throw new Error(`Could not find entry for last segment "${last}". Visible entries: ${JSON.stringify(currentEntries.slice(0, 20))}`); + const currentEntries = await window + .locator('[data-testid="dir-entry"] .dir-entry-name') + .allTextContents(); + throw new Error( + `Could not find entry for last segment "${last}". Visible entries: ${JSON.stringify(currentEntries.slice(0, 20))}`, + ); } } await sleep(300); @@ -289,7 +304,9 @@ async function main() { // Click confirm console.log(" Clicking confirm..."); await window.locator('[data-testid="dir-confirm"]').click(); - await window.locator('[data-testid="dir-modal"]').waitFor({ state: "hidden", timeout: 15_000 }); + await window + .locator('[data-testid="dir-modal"]') + .waitFor({ state: "hidden", timeout: 15_000 }); console.log(" Modal closed."); recordStep("导航到目标项目", "passed", `导航到 ${targetPath}`, ""); @@ -334,12 +351,16 @@ async function main() { // Collect existing sessions via db:projects:list const preInfo = await window.evaluate(async () => { try { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); return { activeProjectId: active?.id ?? null, existingIds: (active?.sessions ?? []).map((s: any) => s.id), - allProjectIds: projects.map((p: any) => ({ id: p.id, name: p.name, sessionCount: p.sessions.length })), + allProjectIds: projects.map((p: any) => ({ + id: p.id, + name: p.name, + sessionCount: p.sessions.length, + })), }; } catch (e: any) { return { error: e.message }; @@ -362,7 +383,7 @@ async function main() { while (Date.now() - startTime < 60_000) { const info = await window.evaluate(async () => { try { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); return { ids: (active?.sessions ?? []).map((s: any) => s.id), @@ -379,14 +400,16 @@ async function main() { const newIds = info.ids.filter((id: string) => !existingIds.includes(id)); if (newIds.length > 0) { console.log(` New session in DB: ${newIds[0]} (total: ${info.count})`); - console.log(` All statuses:`, JSON.stringify(info.statuses)); + console.log(" All statuses:", JSON.stringify(info.statuses)); cardAppeared = true; // Also check if card rendered in DOM - const domIds = await window.locator('[data-testid="session-card"][data-session-id]').evaluateAll( - (els) => els.map((el) => el.getAttribute("data-session-id")).filter(Boolean), - ); - console.log(` DOM card IDs:`, domIds); + const domIds = await window + .locator('[data-testid="session-card"][data-session-id]') + .evaluateAll((els) => + els.map((el) => el.getAttribute("data-session-id")).filter(Boolean), + ); + console.log(" DOM card IDs:", domIds); break; } } @@ -396,12 +419,15 @@ async function main() { if (cardAppeared) { const ssSession = "04-session-created.png"; await window.screenshot({ path: screenshotPath(ssSession), fullPage: false }); - recordStep("创建Session", "passed", `新 session 已出现在 DB 中`, ssSession); + recordStep("创建Session", "passed", "新 session 已出现在 DB 中", ssSession); } else { const finalInfo = await window.evaluate(async () => { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); - return { ids: (active?.sessions ?? []).map((s: any) => s.id), count: active?.sessions.length ?? 0 }; + return { + ids: (active?.sessions ?? []).map((s: any) => s.id), + count: active?.sessions.length ?? 0, + }; }); recordStep("创建Session", "failed", `Session 未创建 (DB count: ${finalInfo.count})`, ""); } @@ -414,9 +440,9 @@ async function main() { await sleep(3000); try { // Get the latest list of session cards - const preDeleteIds = await window.locator('[data-testid="session-card"][data-session-id]').evaluateAll( - (els) => els.map((el) => el.getAttribute("data-session-id")).filter(Boolean), - ); + const preDeleteIds = await window + .locator('[data-testid="session-card"][data-session-id]') + .evaluateAll((els) => els.map((el) => el.getAttribute("data-session-id")).filter(Boolean)); console.log(` Pre-delete sessions (${preDeleteIds.length}):`, preDeleteIds); if (preDeleteIds.length === 0) { @@ -444,7 +470,7 @@ async function main() { // Capture the row's DB status before clicking confirm const beforeConfirm = await window.evaluate(async (sid) => { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); const session = active?.sessions.find((s: any) => s.id === sid); return session ? { id: session.id, status: session.status } : { error: "not found" }; @@ -460,7 +486,7 @@ async function main() { // Check DB after the delete click to see if status changed to "deleting" await sleep(500); const afterClick = await window.evaluate(async (sid) => { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); const session = active?.sessions.find((s: any) => s.id === sid); return session ? { id: session.id, status: session.status } : { deleted: true }; @@ -472,7 +498,7 @@ async function main() { let deleted = false; while (Date.now() - startTime < 90_000) { const dbInfo = await window.evaluate(async (sid) => { - const projects = await ((window as any).api.db.projects.list()); + const projects = await (window as any).api.db.projects.list(); const active = projects.find((p: any) => p.path.includes("Copilot-Switch")); const session = active?.sessions.find((s: any) => s.id === sid); return session ? { found: true, status: session.status } : { found: false }; @@ -484,8 +510,13 @@ async function main() { break; } // Print status every 5s - if (Math.floor((Date.now() - startTime) / 5000) !== Math.floor((Date.now() - startTime - 1000) / 5000)) { - console.log(` ... waiting (${Math.floor((Date.now() - startTime) / 1000)}s elapsed), DB status: ${dbInfo.status}`); + if ( + Math.floor((Date.now() - startTime) / 5000) !== + Math.floor((Date.now() - startTime - 1000) / 5000) + ) { + console.log( + ` ... waiting (${Math.floor((Date.now() - startTime) / 1000)}s elapsed), DB status: ${dbInfo.status}`, + ); } await sleep(1000); } diff --git a/e2e/agent-e2e-result.json b/e2e/agent-e2e-result.json index e5536a3..d270740 100644 --- a/e2e/agent-e2e-result.json +++ b/e2e/agent-e2e-result.json @@ -46,4 +46,4 @@ ], "errors": [], "summary": "所有步骤通过: 冷启动 -> 打开项目对话框 -> 导航到 Copilot-Switch -> 项目加载 -> 创建Session -> 删除Session" -} \ No newline at end of file +} diff --git a/e2e/backend-state-refactor.spec.ts b/e2e/backend-state-refactor.spec.ts index a9b4a1e..7349828 100644 --- a/e2e/backend-state-refactor.spec.ts +++ b/e2e/backend-state-refactor.spec.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; /** * E2E Tests: Backend State Refactor Verification * @@ -17,12 +20,9 @@ * - Most tests FAIL, demonstrating the gaps * - Some tests PASS because the frontend compensation logic works */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; -import { execSync } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; // ============================================================ // Test project setup/teardown @@ -35,11 +35,11 @@ function createTempProject(name: string, slowHook = false): string { } mkdirSync(projectPath, { recursive: true }); execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email \"test@test.com\"", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name \"Test\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: projectPath, stdio: "ignore" }); writeFileSync(join(projectPath, "README.md"), "# Test Project"); execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync("git commit -m \"init\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); // slowHook: add slow hooks to make lifecycle observable // Use powershell Start-Sleep for delay @@ -68,7 +68,9 @@ function cleanupTempProject(projectPath: string): void { if (existsSync(projectPath)) { rmSync(projectPath, { recursive: true, force: true }); } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } } async function cleanupDbProject(window: any, projectPath: string): Promise { @@ -83,7 +85,9 @@ async function cleanupDbProject(window: any, projectPath: string): Promise }, project.id); } } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } } // ============================================================ @@ -103,9 +107,7 @@ test.describe("Backend state refactor — session status", () => { await cleanupDbProject(window, projectPath); }); - test("PASSING: backend returns status='creating' for in-flight session", async ({ - window, - }) => { + test("PASSING: backend returns status='creating' for in-flight session", async ({ window }) => { // Verifies backend persists "creating" status immediately after IPC call. const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -264,9 +266,7 @@ test.describe("Backend state refactor — background hooks", () => { await cleanupDbProject(window, projectPath); }); - test("PASSING: backend pushes background hook status when hook runs", async ({ - window, - }) => { + test("PASSING: backend pushes background hook status when hook runs", async ({ window }) => { // Verifies backend persists backgroundHookStatus when afterCreateSession hook runs. // With slow hook (5s), we can check status while hook is running. const home = new HomePage(window); @@ -314,9 +314,7 @@ test.describe("Backend state refactor — deletion progress", () => { await cleanupDbProject(window, projectPath); }); - test("PASSING: backend returns status='deleting' during deletion", async ({ - window, - }) => { + test("PASSING: backend returns status='deleting' during deletion", async ({ window }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -331,9 +329,13 @@ test.describe("Backend state refactor — deletion progress", () => { // Get session ID const sessions = await window.evaluate(async () => { const projects = await (window as any).api.db.projects.list(); - return projects.flatMap((p: any) => p.sessions.map((s: any) => ({ - id: s.id, name: s.name, status: s.status, - }))); + return projects.flatMap((p: any) => + p.sessions.map((s: any) => ({ + id: s.id, + name: s.name, + status: s.status, + })), + ); }); console.log("Sessions:", sessions); const sessionId = sessions[0]?.id; diff --git a/e2e/bun-start-simulation.spec.ts b/e2e/bun-start-simulation.spec.ts index c0cd887..cb7189c 100644 --- a/e2e/bun-start-simulation.spec.ts +++ b/e2e/bun-start-simulation.spec.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; /** * E2E Test: Full user simulation — bun start → open project → create → delete session * @@ -6,10 +7,12 @@ * * No IPC shortcuts — everything goes through the actual UI. */ -import { _electron as electron, test as base, type ElectronApplication, type Page } from "@playwright/test"; -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { + type ElectronApplication, + type Page, + test as base, + _electron as electron, +} from "@playwright/test"; const ROOT = "F:\\ProgramPlayground\\JavaScript\\AgentDock\\.agentdock\\worktrees\\bed4c452-74d"; const PROJECT_PATH = "F:\\ProgramPlayground\\JavaScript\\Copilot-Switch"; @@ -22,7 +25,8 @@ async function getMainEntry(): Promise { const fs = await import("node:fs"); const dir = join(ROOT, "out", "main"); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".js")); - if (files.length === 0) throw new Error("No main entry in out/main — run electron-vite build first"); + if (files.length === 0) + throw new Error("No main entry in out/main — run electron-vite build first"); builtEntry = join(dir, files[0]!); return builtEntry; } @@ -36,7 +40,10 @@ const test = base.extend<{ const app = await electron.launch({ args: [mainEntry], cwd: ROOT, - env: { ...process.env, NODE_OPTIONS: (process.env.NODE_OPTIONS ?? "") + " --experimental-sqlite" }, + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --experimental-sqlite`, + }, timeout: 30_000, }); await use(app); @@ -49,7 +56,9 @@ const test = base.extend<{ }); test.describe("Real bun start simulation", () => { - test("full user flow: open Copilot-Switch → create session → delete session", async ({ window }) => { + test("full user flow: open Copilot-Switch → create session → delete session", async ({ + window, + }) => { // ── Wait for the app to fully load ── await window.waitForTimeout(5000); @@ -62,7 +71,9 @@ test.describe("Real bun start simulation", () => { const all = await (window as any).api.db.projects.list(); return all.map((p: any) => ({ id: p.id, name: p.name, path: p.path })); }); - console.log(`Backend projects: ${JSON.stringify(backendProjects.map(p => p.name))}`); + console.log( + `Backend projects: ${JSON.stringify(backendProjects.map((p: { name: string }) => p.name))}`, + ); // Pre-seed: open the project via direct IPC BEFORE clicking the UI button const projectExists = backendProjects.some((p: any) => p.path === PROJECT_PATH); @@ -128,7 +139,12 @@ test.describe("Real bun start simulation", () => { // Instead of waiting for h2 (which won't appear if navigate failed), // load the project directly by clicking the expected tab. // But if tabs are empty, fallback to direct data check. - const tabVisible = await window.locator('[data-testid="project-tab"]').filter({ hasText: PROJECT_NAME }).first().isVisible().catch(() => false); + const tabVisible = await window + .locator('[data-testid="project-tab"]') + .filter({ hasText: PROJECT_NAME }) + .first() + .isVisible() + .catch(() => false); console.log(`Copilot-Switch tab exists: ${tabVisible}`); // But if tabs are empty, fallback: the React Query staleTime issue. @@ -150,7 +166,10 @@ test.describe("Real bun start simulation", () => { await window.waitForTimeout(5000); // After reload, the project tab should be visible - const tabEl = window.locator('[data-testid="project-tab"]').filter({ hasText: PROJECT_NAME }).first(); + const tabEl = window + .locator('[data-testid="project-tab"]') + .filter({ hasText: PROJECT_NAME }) + .first(); await tabEl.waitFor({ state: "visible", timeout: 30000 }); console.log("✓ Project tab visible after reload"); await tabEl.click(); @@ -199,10 +218,16 @@ test.describe("Real bun start simulation", () => { await card.click({ button: "right" }); await window.waitForTimeout(500); - const deleteOption = window.locator('[role="menuitem"]').filter({ hasText: /delete|删除/i }).first(); + const deleteOption = window + .locator('[role="menuitem"]') + .filter({ hasText: /delete|删除/i }) + .first(); if (await deleteOption.isVisible({ timeout: 3000 }).catch(() => false)) { await deleteOption.click(); - const confirmBtn = window.locator("button").filter({ hasText: /confirm|确认/i }).first(); + const confirmBtn = window + .locator("button") + .filter({ hasText: /confirm|确认/i }) + .first(); if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await confirmBtn.click(); } @@ -213,7 +238,9 @@ test.describe("Real bun start simulation", () => { // ── STEP 6: Verify deleted ── await window.locator('[data-testid="rescan-disk"]').click(); await window.waitForTimeout(3000); - const remaining = await window.locator(`[data-testid="session-card"][data-session-id="${sessionId}"]`).count(); + const remaining = await window + .locator(`[data-testid="session-card"][data-session-id="${sessionId}"]`) + .count(); console.log(`Session card remaining: ${remaining}`); if (remaining > 0) { diff --git a/e2e/cold-start-race.spec.ts b/e2e/cold-start-race.spec.ts index 90cc9a7..e1dab95 100644 --- a/e2e/cold-start-race.spec.ts +++ b/e2e/cold-start-race.spec.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; /** * E2E Test: Cold-start race condition * @@ -13,29 +16,28 @@ * The test ensures: after cold start + open project, the project shows up * in the workspace (not stuck on "Project not found" or "Loading…"). */ -import { test, expect } from "./fixtures/electron-fixture"; -import { HomePage } from "./pages/home"; -import { SidebarPage } from "./pages/sidebar"; -import { execSync } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { expect, test } from "./fixtures/electron-fixture"; function createTempProject(name: string): string { const projectPath = join("D:\\ProgramTest", name); if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); mkdirSync(projectPath, { recursive: true }); execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email \"test@test.com\"", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name \"Test\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: projectPath, stdio: "ignore" }); writeFileSync(join(projectPath, "README.md"), "# Test"); execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync("git commit -m \"init\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); writeFileSync(join(projectPath, "agentdock.config.yaml"), "hooks:\n afterCreateSession: []\n"); return projectPath; } function cleanupTempProject(projectPath: string): void { - try { if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); } catch { /* */ } + try { + if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); + } catch { + /* */ + } } test.describe("Cold-start race", () => { @@ -78,7 +80,9 @@ test.describe("Cold-start race", () => { // Wait for renderer to come back. The reload navigates to / (home). // The tab for our project should be visible. - const projectTab = window.locator('[data-testid="project-tab"]').filter({ hasText: "e2e-cold-start" }); + const projectTab = window + .locator('[data-testid="project-tab"]') + .filter({ hasText: "e2e-cold-start" }); await expect(projectTab).toBeVisible({ timeout: 10000 }); // Click the tab to navigate to the project diff --git a/e2e/concurrent-sessions.spec.ts b/e2e/concurrent-sessions.spec.ts index d7be4e0..96dd035 100644 --- a/e2e/concurrent-sessions.spec.ts +++ b/e2e/concurrent-sessions.spec.ts @@ -8,20 +8,19 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TerminalPage } from "./pages/terminal"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -37,7 +36,6 @@ test.describe("concurrent sessions", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -49,7 +47,7 @@ test.describe("concurrent sessions", () => { const sidebar = new SidebarPage(window); const terminalPage = new TerminalPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); // 1. Open project. await home.openProject(projectPath); @@ -67,15 +65,15 @@ test.describe("concurrent sessions", () => { await sidebar.clickNewSession(); // 3. Wait for all 3 cards to appear. - await expect - .poll(async () => sidebar.cardCount(), { timeout: 45_000 }) - .toBe(3); + await expect.poll(async () => sidebar.cardCount(), { timeout: 45_000 }).toBe(3); // 4. Wait for ports to appear on all cards (best-effort). const allCards = window.locator(`[data-testid="${TID.sessionCard}"]`); for (let i = 0; i < 3; i++) { const card = allCards.nth(i); - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 30_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 30_000 }) + .catch(() => {}); } // 5. Collect session IDs and verify uniqueness. @@ -107,9 +105,7 @@ test.describe("concurrent sessions", () => { } // 8. All cards should be gone. - await expect - .poll(() => sidebar.cardCount(), { timeout: 45_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 45_000 }).toBe(0); // 9. No renderer errors. expect(pageErrors).toHaveLength(0); @@ -117,10 +113,11 @@ test.describe("concurrent sessions", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/daemon-client-lifecycle.spec.ts b/e2e/daemon-client-lifecycle.spec.ts deleted file mode 100644 index dbaf8d9..0000000 --- a/e2e/daemon-client-lifecycle.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -// @ts-nocheck -/** - * Daemon client-lifecycle E2E. - * - * Verifies that Electron now: - * 1. Calls `POST /client/register` on boot. - * 2. Sends periodic heartbeats (confirmed by /debug/clients heartbeatAge). - * 3. Calls `POST /client/unregister` on quit. - * - * Uses a real daemon process (not a mock) to ensure the full stack works. - */ -import { - test as base, - expect, - _electron as electron, - type ElectronApplication, -} from "@playwright/test"; -import { execSync } from "node:child_process"; -import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; - -const ROOT = process.cwd(); -const test = base.extend({}); - -function mainEntry(): string { - const dir = join(ROOT, "out", "main"); - const files = readdirSync(dir).filter((f) => f.endsWith(".js")); - return join(dir, files[0]!); -} - -function tmp(label: string): string { - const d = join(tmpdir(), `agentdock-e2e-cl-${label}-${process.hrtime.bigint().toString(36)}`); - mkdirSync(d, { recursive: true }); - return d; -} - -test("client/register on boot, heartbeat ~30s, unregister on quit", async () => { - const dataDir = tmp("data"); - const userData = tmp("user"); - let app: ElectronApplication | null = null; - - try { - app = await electron.launch({ - args: [mainEntry(), `--user-data-dir=${userData}`], - cwd: dataDir, - env: { - ...process.env, - AGENTDOCK_DATA_DIR: dataDir, - FRONTEND_PORT: "5173", - AGENTDOCK_USE_BUN: "1", - ELECTRON_DISABLE_GPU: "1", - ELECTRON_ENABLE_LOGGING: "1", - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --experimental-sqlite`.trim(), - }, - timeout: 30_000, - }); - - const win = await app.firstWindow({ timeout: 20_000 }); - await win.waitForLoadState("domcontentloaded"); - await win.waitForFunction( - () => typeof (window as unknown as { api?: unknown }).api === "object", - null, - { timeout: 10_000 }, - ); - - // Daemon is at ~/.agentdock/ now — read daemon.json to get the port. - const daemonJsonPath = join(homedir(), ".agentdock", "daemon.json"); - const daemonJson = JSON.parse(readFileSync(daemonJsonPath, "utf-8")) as { pid: number; port: number }; - const baseUrl = `http://127.0.0.1:${daemonJson.port}`; - - // 1. Verify client registered — match by dataDir in projectPaths. - const matchesMe = (c: { projectPaths?: string[] }) => - c.projectPaths?.some((p) => p === dataDir) ?? false; - - await expect - .poll( - async () => { - const r = await fetch(`${baseUrl}/debug/clients`); - const j = (await r.json()) as { clients: Array<{ projectPaths: string[] }> }; - return j.clients.some(matchesMe); - }, - { timeout: 10_000, message: "client never registered" }, - ) - .toBe(true); - - // 2. Verify heartbeat is actually ticking: wait 35s and confirm age < 10s. - if (process.env.AGENTDOCK_SKIP_SLOW_E2E !== "1") { - await new Promise((r) => setTimeout(r, 35_000)); - const after = (await fetch(`${baseUrl}/debug/clients`).then((r) => r.json())) as { - clients: Array<{ projectPaths: string[]; heartbeatAge: number }>; - }; - const me = after.clients.find(matchesMe); - expect(me, "client disappeared").toBeDefined(); - expect(me!.heartbeatAge, "heartbeat not ticking").toBeLessThan(10_000); - } - - // 3. Verify unregister on quit. - await app.close(); - app = null; - await new Promise((r) => setTimeout(r, 1_500)); - - let daemonReachable = false; - try { - const probe = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) }); - daemonReachable = probe.ok; - } catch { daemonReachable = false; } - - if (daemonReachable) { - const stillThere = (await fetch(`${baseUrl}/debug/clients`).then((r) => r.json())) as { - clients: Array<{ projectPaths: string[] }>; - }; - expect(stillThere.clients.find(matchesMe)).toBeUndefined(); - } - } finally { - if (app) await app.close().catch(() => {}); - await new Promise((r) => setTimeout(r, 750)); - for (const d of [dataDir, userData]) { - try { rmSync(d, { recursive: true, force: true }); } catch {} - } - } -}); diff --git a/e2e/daemon-multi-instance.spec.ts b/e2e/daemon-multi-instance.spec.ts deleted file mode 100644 index c17b01a..0000000 --- a/e2e/daemon-multi-instance.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -// @ts-nocheck -/** - * Multi-Electron daemon-reuse E2E. - * - * Since daemon.json is now fixed at ~/.agentdock/, multiple Electrons - * from the same machine automatically share the same daemon. This spec - * verifies the two-Electron scenario by launching two instances with - * different data dirs — both discover the same daemon via ~/.agentdock/ - * and register as distinct clients. - */ -import { - test as base, - expect, - _electron as electron, - type ElectronApplication, -} from "@playwright/test"; -import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const ROOT = process.cwd(); -const test = base.extend({}); - -function mainEntry(): string { - const dir = join(ROOT, "out", "main"); - const files = readdirSync(dir).filter((f) => f.endsWith(".js")); - return join(dir, files[0]!); -} - -function tmp(label: string): string { - const d = join(tmpdir(), `agentdock-e2e-multi-${label}-${process.hrtime.bigint().toString(36)}`); - mkdirSync(d, { recursive: true }); - return d; -} - -async function launch(args: { dataDir: string; userDataDir: string }): Promise { - return electron.launch({ - args: [mainEntry(), `--user-data-dir=${args.userDataDir}`], - cwd: args.dataDir, - env: { - ...process.env, - AGENTDOCK_DATA_DIR: args.dataDir, - FRONTEND_PORT: "5173", - AGENTDOCK_USE_BUN: "1", - ELECTRON_DISABLE_GPU: "1", - ELECTRON_ENABLE_LOGGING: "1", - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --experimental-sqlite`.trim(), - }, - timeout: 30_000, - }); -} - -async function readyWindow(app: ElectronApplication) { - const w = await app.firstWindow({ timeout: 20_000 }); - await w.waitForLoadState("domcontentloaded"); - await w.waitForFunction( - () => typeof (window as unknown as { api?: unknown }).api === "object", - null, - { timeout: 10_000 }, - ); - return w; -} - -test("two Electrons share one daemon via fixed ~/.agentdock/", async () => { - const dataA = tmp("a-data"); - const dataB = tmp("b-data"); - const userA = tmp("a-user"); - const userB = tmp("b-user"); - - let appA: ElectronApplication | null = null; - let appB: ElectronApplication | null = null; - - try { - // 1. Boot A → daemon is spawned at ~/.agentdock/ (fixed path). - appA = await launch({ dataDir: dataA, userDataDir: userA }); - const winA = await readyWindow(appA); - const healthA = await winA.evaluate(() => - (window as unknown as { api: { bootstrap: { health: () => Promise<{ daemon: string }> } } }) - .api.bootstrap.health(), - ); - expect(healthA.daemon).toBe("ok"); - - const daemonJsonPath = join(process.env.HOME ?? process.env.USERPROFILE ?? tmpdir(), ".agentdock", "daemon.json"); - const daemonJson = JSON.parse(readFileSync(daemonJsonPath, "utf-8")) as { pid: number; port: number }; - const baseUrl = `http://127.0.0.1:${daemonJson.port}`; - - // 2. Boot B → discovers same daemon, registers as separate client. - appB = await launch({ dataDir: dataB, userDataDir: userB }); - const winB = await readyWindow(appB); - const healthB = await winB.evaluate(() => - (window as unknown as { api: { bootstrap: { health: () => Promise<{ daemon: string }> } } }) - .api.bootstrap.health(), - ); - expect(healthB.daemon).toBe("ok"); - - // 3. daemon.json still points at A's daemon — B didn't spawn a new one. - const infoAfterB = JSON.parse(readFileSync(daemonJsonPath, "utf-8")) as { pid: number; port: number }; - expect(infoAfterB.pid).toBe(daemonJson.pid); - expect(infoAfterB.port).toBe(daemonJson.port); - - // 4. Both clients registered. - const matchesA = (c: { projectPaths?: string[] }) => - c.projectPaths?.some((p) => p === dataA) ?? false; - const matchesB = (c: { projectPaths?: string[] }) => - c.projectPaths?.some((p) => p === dataB) ?? false; - const clientsRes = (await fetch(`${baseUrl}/debug/clients`).then((r) => r.json())) as { - clients: Array<{ projectPaths: string[]; clientId: string }>; - }; - expect(clientsRes.clients.some(matchesA), "client A not registered").toBe(true); - expect(clientsRes.clients.some(matchesB), "client B not registered").toBe(true); - - // 5. Different clientIds (different data dirs → different cwds → different hashes). - const clientA = clientsRes.clients.find(matchesA)!; - const clientB = clientsRes.clients.find(matchesB)!; - expect(clientA.clientId).not.toBe(clientB.clientId); - - // 6. Close B → A still works. - await appB.close(); - appB = null; - await new Promise((r) => setTimeout(r, 500)); - const healthAStill = await winA.evaluate(() => - (window as unknown as { api: { bootstrap: { health: () => Promise<{ daemon: string }> } } }) - .api.bootstrap.health(), - ); - expect(healthAStill.daemon).toBe("ok"); - - // 7. B's client removed from registry. - const clientsAfter = (await fetch(`${baseUrl}/debug/clients`).then((r) => r.json())) as { - clients: Array<{ projectPaths: string[] }>; - }; - expect(clientsAfter.clients.some(matchesB)).toBe(false); - } finally { - if (appB) await appB.close().catch(() => {}); - if (appA) await appA.close().catch(() => {}); - await new Promise((r) => setTimeout(r, 750)); - for (const d of [dataA, dataB, userA, userB]) { - try { rmSync(d, { recursive: true, force: true }); } catch {} - } - } -}); diff --git a/e2e/debugging/classify-latest.ts b/e2e/debugging/classify-latest.ts index ec8ee1e..daf2a6f 100644 --- a/e2e/debugging/classify-latest.ts +++ b/e2e/debugging/classify-latest.ts @@ -12,7 +12,7 @@ */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { classifyFailure, type FailureClassification } from "./failure-classifier.js"; +import { type FailureClassification, classifyFailure } from "./failure-classifier.js"; const ROOT = process.cwd(); const REPORT_PATH = join(ROOT, "e2e/reports/latest.json"); @@ -118,10 +118,12 @@ function main(): void { console.log(`\n🔍 Classification Results (${results.length} failure(s))\n`); const grouped = results.reduce( (acc, r) => { - (acc[r.classification.type] ??= []).push(r); + const group = acc[r.classification.type] ?? []; + group.push(r); + acc[r.classification.type] = group; return acc; }, - {} as Record + {} as Record, ); for (const [type, items] of Object.entries(grouped)) { diff --git a/e2e/debugging/failure-classifier.ts b/e2e/debugging/failure-classifier.ts index a742e51..c517d1e 100644 --- a/e2e/debugging/failure-classifier.ts +++ b/e2e/debugging/failure-classifier.ts @@ -91,7 +91,7 @@ const rules: ClassificationRule[] = [ export function classifyFailure( errorMessage: string, stackTrace: string, - testPath: string + testPath: string, ): FailureClassification { const combined = `${errorMessage}\n${stackTrace}`; diff --git a/e2e/debugging/fix-suggester.ts b/e2e/debugging/fix-suggester.ts index a91a774..4b6c489 100644 --- a/e2e/debugging/fix-suggester.ts +++ b/e2e/debugging/fix-suggester.ts @@ -43,7 +43,7 @@ export interface FixSuggestion { export function suggestFix( classification: FailureClassification, testPath: string, - errorMessage: string + errorMessage: string, ): FixSuggestion[] { const suggestions: FixSuggestion[] = []; @@ -139,8 +139,7 @@ export function suggestFix( { path: testPath, change: "modify", - rationale: - "Read the failing test, the code it exercises, and the error stack trace.", + rationale: "Read the failing test, the code it exercises, and the error stack trace.", }, ], confidence: 0.3, diff --git a/e2e/debugging/suggest-fix-latest.ts b/e2e/debugging/suggest-fix-latest.ts index f29ca05..4a65887 100644 --- a/e2e/debugging/suggest-fix-latest.ts +++ b/e2e/debugging/suggest-fix-latest.ts @@ -106,7 +106,9 @@ function main(): void { totalSuggestions += suggestions.length; console.log(`\n🔧 ${err.specTitle}`); console.log(` File: ${err.file}`); - console.log(` Classification: ${classification.type} (confidence: ${classification.confidence})`); + console.log( + ` Classification: ${classification.type} (confidence: ${classification.confidence})`, + ); for (const s of suggestions) { console.log(`\n 💡 ${s.title} (confidence: ${s.confidence})`); console.log(` ${s.description}`); @@ -116,7 +118,9 @@ function main(): void { } } - console.log(`\n📊 Total: ${totalSuggestions} suggestion(s) across ${extractedErrors.length} error(s)\n`); + console.log( + `\n📊 Total: ${totalSuggestions} suggestion(s) across ${extractedErrors.length} error(s)\n`, + ); } main(); diff --git a/e2e/duplicate-project-prevention.spec.ts b/e2e/duplicate-project-prevention.spec.ts index 6d25fdb..5dd2e6e 100644 --- a/e2e/duplicate-project-prevention.spec.ts +++ b/e2e/duplicate-project-prevention.spec.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; /** * E2E Test: Duplicate project prevention * @@ -6,12 +9,9 @@ * * Each test uses isolated project directories and cleans up after itself. */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; -import { execSync } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; /** * Create a temporary git project for testing. @@ -25,20 +25,23 @@ function createTempProject(name: string): string { // Initialize git repo execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email \"test@test.com\"", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name \"Test\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: projectPath, stdio: "ignore" }); // Create initial commit (required for git worktree) writeFileSync(join(projectPath, "README.md"), "# Test Project"); execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync("git commit -m \"init\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); // Create agentdock config (disable hooks for faster tests) - writeFileSync(join(projectPath, "agentdock.config.yaml"), ` + writeFileSync( + join(projectPath, "agentdock.config.yaml"), + ` hooks: afterCreateSession: [] afterDeleteSession: [] -`); +`, + ); return projectPath; } @@ -90,14 +93,7 @@ test.describe("Duplicate project prevention", () => { } }); - test("opening same project twice should not create duplicate tab", async ({ - window, - dataDir, - pageErrors, - dialogs, - rendererLog, - expectNoRendererErrors, - }) => { + test("opening same project twice should not create duplicate tab", async ({ window }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -110,7 +106,9 @@ test.describe("Duplicate project prevention", () => { // Count tabs with this project name const projectNameShort = projectName; - const tabsWithProject = window.locator(`[data-testid="project-tab"]`).filter({ hasText: projectNameShort }); + const tabsWithProject = window + .locator(`[data-testid="project-tab"]`) + .filter({ hasText: projectNameShort }); const tabCountAfterFirst = await tabsWithProject.count(); console.log(`Tabs with project name after first open: ${tabCountAfterFirst}`); @@ -142,11 +140,6 @@ test.describe("Duplicate project prevention", () => { test("opening project with different path format should not create duplicate", async ({ window, - dataDir, - pageErrors, - dialogs, - rendererLog, - expectNoRendererErrors, }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -160,14 +153,16 @@ test.describe("Duplicate project prevention", () => { // Count tabs with this project name const projectNameShort = projectName; - const tabsWithProject = window.locator(`[data-testid="project-tab"]`).filter({ hasText: projectNameShort }); + const tabsWithProject = window + .locator(`[data-testid="project-tab"]`) + .filter({ hasText: projectNameShort }); const tabCountAfterFirst = await tabsWithProject.count(); console.log(`Tabs with project name after first open: ${tabCountAfterFirst}`); expect(tabCountAfterFirst).toBe(1); // Now try to open the same project with a slightly different path // (e.g., with trailing slash) - const pathWithSlash = projectPath + "\\"; + const pathWithSlash = `${projectPath}\\`; console.log(`Opening with different path format: ${pathWithSlash}`); // Click the "+" button to open new project diff --git a/e2e/electron-boots.spec.ts b/e2e/electron-boots.spec.ts index 781a89f..4193d4b 100644 --- a/e2e/electron-boots.spec.ts +++ b/e2e/electron-boots.spec.ts @@ -12,7 +12,7 @@ * To run in isolation mode (default): * npx playwright test e2e/electron-boots.spec.ts */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; test.describe("electron boots @reuse", () => { test("main window has a title", async ({ window }) => { @@ -29,10 +29,14 @@ test.describe("electron boots @reuse", () => { expect(hasApi).toBe(true); }); - test("bootstrap.health returns daemon ok", async ({ window }) => { + test("bootstrap.health reports IPC readiness", async ({ window }) => { const health = await window.evaluate(async () => { - return await (window as unknown as { api: { bootstrap: { health: () => Promise<{ daemon: string }> } } }).api.bootstrap.health(); + return await ( + window as unknown as { + api: { bootstrap: { health: () => Promise<{ vite: string; ipc: number }> } }; + } + ).api.bootstrap.health(); }); - expect(health.daemon).toBe("ok"); + expect(health.ipc).toBeGreaterThan(0); }); }); diff --git a/e2e/error-recovery-flow.spec.ts b/e2e/error-recovery-flow.spec.ts index 022fe25..c13741f 100644 --- a/e2e/error-recovery-flow.spec.ts +++ b/e2e/error-recovery-flow.spec.ts @@ -8,19 +8,18 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -36,7 +35,6 @@ test.describe("error recovery", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -47,19 +45,19 @@ test.describe("error recovery", () => { const home = new HomePage(window); const sidebar = new SidebarPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); // 1. Open project and create a session. await home.openProject(projectPath); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); const nameEl = card.locator(".session-name"); await expect(nameEl).toBeVisible({ timeout: 5_000 }); @@ -110,14 +108,10 @@ test.describe("error recovery", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); // 8. No React infinite loops or page errors. - const maxDepthErrors = rendererLog.filter((e) => - /Maximum update depth exceeded/i.test(e.text), - ); + const maxDepthErrors = rendererLog.filter((e) => /Maximum update depth exceeded/i.test(e.text)); expect( maxDepthErrors, `React infinite-loop during error recovery:\n${maxDepthErrors.map((e) => e.text).join("\n")}`, @@ -128,10 +122,11 @@ test.describe("error recovery", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/extended-user-flow.spec.ts b/e2e/extended-user-flow.spec.ts index 346beab..d2affb3 100644 --- a/e2e/extended-user-flow.spec.ts +++ b/e2e/extended-user-flow.spec.ts @@ -17,7 +17,7 @@ * currently broken on Node v24 (Playwright inspector WebSocket * handshake hangs), so we don't use it here. */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; const PROJECT_PATH = "F:\\ProgramPlayground\\JavaScript\\Copilot-Switch"; const PROJECT_NAME = "Copilot-Switch"; @@ -26,7 +26,7 @@ async function waitForDirEntries(window: import("@playwright/test").Page, timeou const entries = window.locator('[data-testid="dir-entry"]'); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (await entries.count() > 0) return; + if ((await entries.count()) > 0) return; await window.waitForTimeout(100); } throw new Error("dir-entry never rendered"); @@ -40,9 +40,13 @@ function entryByName(window: import("@playwright/test").Page, name: string) { .first(); } -test("extended user flow: open project → create session → 3 terminals → close 1 → delete session", async ({ window }) => { +test("extended user flow: open project → create session → 3 terminals → close 1 → delete session", async ({ + window, +}) => { // Steps 1-4: open the project. - await window.locator('[data-testid="home-open-project"]').waitFor({ state: "visible", timeout: 15_000 }); + await window + .locator('[data-testid="home-open-project"]') + .waitFor({ state: "visible", timeout: 15_000 }); await window.locator('[data-testid="home-open-project"]').click(); await window.locator('[data-testid="dir-modal"]').waitFor({ state: "visible", timeout: 10_000 }); @@ -64,10 +68,16 @@ test("extended user flow: open project → create session → 3 terminals → cl // Project workspace loads. await window.waitForTimeout(3000); - await window.locator("h2").filter({ hasText: PROJECT_NAME }).first().waitFor({ state: "visible", timeout: 20_000 }); + await window + .locator("h2") + .filter({ hasText: PROJECT_NAME }) + .first() + .waitFor({ state: "visible", timeout: 20_000 }); // Step 5: Create session. - await window.locator('[data-testid="new-session"]').waitFor({ state: "visible", timeout: 10_000 }); + await window + .locator('[data-testid="new-session"]') + .waitFor({ state: "visible", timeout: 10_000 }); const sessionBefore = await window.locator('[data-testid="session-card"]').count(); await window.locator('[data-testid="new-session"]').click(); await window.waitForFunction( @@ -75,7 +85,9 @@ test("extended user flow: open project → create session → 3 terminals → cl sessionBefore, { timeout: 60_000 }, ); - const sessionId = await window.locator('[data-testid="session-card"]').last().getAttribute("data-session-id") ?? ""; + const sessionId = + (await window.locator('[data-testid="session-card"]').last().getAttribute("data-session-id")) ?? + ""; expect(sessionId, "new session missing data-session-id").not.toBe(""); // Wait for the session to leave the "creating" state. Use the inner @@ -85,7 +97,9 @@ test("extended user flow: open project → create session → 3 terminals → cl let leftCreating = false; while (Date.now() < createDeadline) { const state = await window.evaluate((sid) => { - const wrapper = document.querySelector(`[data-testid="session-card"][data-session-id="${sid}"]`); + const wrapper = document.querySelector( + `[data-testid="session-card"][data-session-id="${sid}"]`, + ); if (!wrapper) return "gone"; const inner = wrapper.querySelector(".session-card") ?? wrapper; if (inner.classList.contains("session-card-creating")) return "creating"; @@ -102,7 +116,9 @@ test("extended user flow: open project → create session → 3 terminals → cl // Click the session card to enter the workspace view. await window.locator(`[data-testid="session-card"][data-session-id="${sessionId}"]`).click(); - await window.locator('[data-testid="terminal-panel"]').waitFor({ state: "visible", timeout: 10_000 }); + await window + .locator('[data-testid="terminal-panel"]') + .waitFor({ state: "visible", timeout: 10_000 }); // Step 6: Create 3 terminals. Note: TerminalManager does NOT auto-create // a terminal when a session becomes active — the user has to click "+". @@ -121,10 +137,15 @@ test("extended user flow: open project → create session → 3 terminals → cl ); } const finalTabCount = await window.locator('[data-testid="terminal-tab"]').count(); - expect(finalTabCount, "expected 3+ terminals after create").toBeGreaterThanOrEqual(initialTabs + 3); + expect(finalTabCount, "expected 3+ terminals after create").toBeGreaterThanOrEqual( + initialTabs + 3, + ); // Step 7: Close 1 terminal. Click the × button on the second tab. - const firstCloseBtn = window.locator('[data-testid="terminal-tab"]').nth(1).locator('.terminal-tab-close'); + const firstCloseBtn = window + .locator('[data-testid="terminal-tab"]') + .nth(1) + .locator(".terminal-tab-close"); await firstCloseBtn.click(); await window.waitForFunction( (target) => document.querySelectorAll('[data-testid="terminal-tab"]').length === target, @@ -134,20 +155,26 @@ test("extended user flow: open project → create session → 3 terminals → cl // Step 8: Delete session. Right-click the session card → 删除. // Use the session sidebar's right-click context menu. - const sessionCard = window.locator(`[data-testid="session-card"][data-session-id="${sessionId}"]`); + const sessionCard = window.locator( + `[data-testid="session-card"][data-session-id="${sessionId}"]`, + ); // The session card is currently selected and we're in the workspace. // Navigate back to the home/tab view first by clicking somewhere. // Actually we can delete from the workspace too — use the close button // on the active session card. The active session-card has an × button. // First scroll back to the sidebar. - const closeSessionBtn = sessionCard.locator('.session-close'); - if (await closeSessionBtn.count() > 0) { + const closeSessionBtn = sessionCard.locator(".session-close"); + if ((await closeSessionBtn.count()) > 0) { await closeSessionBtn.click(); } else { // Fallback: right-click context menu. await sessionCard.click({ button: "right" }); await window.waitForTimeout(300); - await window.locator('.context-menu-item').filter({ hasText: /删除|Delete/i }).first().click(); + await window + .locator(".context-menu-item") + .filter({ hasText: /删除|Delete/i }) + .first() + .click(); } // Confirm-delete modal. const confirm = window.locator('[data-testid="confirm-delete-ok"]'); diff --git a/e2e/fixtures/electron-fixture.ts b/e2e/fixtures/electron-fixture.ts index 19f332b..33c10b7 100644 --- a/e2e/fixtures/electron-fixture.ts +++ b/e2e/fixtures/electron-fixture.ts @@ -1,3 +1,7 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // @ts-nocheck /** * Electron test fixture — single source of truth for spinning up the @@ -32,18 +36,14 @@ * the test (useful for post-mortem inspection) */ import { - _electron as electron, - test as base, - expect, type ConsoleMessage, type Dialog, type ElectronApplication, type Page, + test as base, + _electron as electron, + expect, } from "@playwright/test"; -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; const ROOT = process.cwd(); @@ -111,7 +111,7 @@ function getMainEntry(): Promise { throw new Error(`electron-vite build failed: ${out}`); } const dir = join(ROOT, "out", "main"); - if (!existsSync(dir)) throw new Error(`Build produced no out/main dir`); + if (!existsSync(dir)) throw new Error("Build produced no out/main dir"); const candidates = readdirSync(dir).filter((f) => f.endsWith(".js")); if (candidates.length === 0) throw new Error(`No .js entry in ${dir}`); return join(dir, candidates[0]!); @@ -377,7 +377,7 @@ export const test = base.extend({ (e) => e.type === "error" && !e.text.includes("agentdock-fonts://") && - !((e.location && e.location.url) || "").includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && !e.text.includes("net::ERR_FAILED"), ); if (errors.length > 0 || pageErrors.length > 0) { @@ -425,10 +425,10 @@ let sharedDataDir: string | null = null; let sharedUserDataDir: string | null = null; let sharedMainEntry: string | null = null; let sharedChildProcess: ReturnType | null = null; -let sharedMainLog: string[] = []; -let sharedRendererLog: RendererConsoleEntry[] = []; -let sharedPageErrors: CapturedError[] = []; -let sharedDialogs: DialogRecord[] = []; +const sharedMainLog: string[] = []; +const sharedRendererLog: RendererConsoleEntry[] = []; +const sharedPageErrors: CapturedError[] = []; +const sharedDialogs: DialogRecord[] = []; function getSharedLaunchConfig() { return { @@ -457,9 +457,7 @@ function resetCaptures(): void { sharedDialogs.length = 0; } -export const reuseTest = base.extend< - Omit & { app: ElectronApplication } ->({ +export const reuseTest = base.extend & { app: ElectronApplication }>({ dataDir: async ({}, use) => { if (process.env.AGENTDOCK_E2E_REUSE !== "1") { throw new Error("reuseTest requires AGENTDOCK_E2E_REUSE=1"); @@ -467,10 +465,7 @@ export const reuseTest = base.extend< // First test: initialize shared directories and launch Electron. if (!sharedApp) { - sharedDataDir = join( - tmpdir(), - `agentdock-e2e-reuse-${process.pid}`, - ); + sharedDataDir = join(tmpdir(), `agentdock-e2e-reuse-${process.pid}`); sharedUserDataDir = join(sharedDataDir, "electron-user-data"); mkdirSync(sharedUserDataDir, { recursive: true }); @@ -538,9 +533,7 @@ export const reuseTest = base.extend< contentType: "text/plain", }); await testInfo.attach("renderer.log", { - body: sharedRendererLog - .map((e) => `[${e.type}] ${e.text}`) - .join("\n"), + body: sharedRendererLog.map((e) => `[${e.type}] ${e.text}`).join("\n"), contentType: "text/plain", }); await testInfo.attach("pageerrors.json", { @@ -650,7 +643,7 @@ export const reuseTest = base.extend< (e) => e.type === "error" && !e.text.includes("agentdock-fonts://") && - !((e.location && e.location.url) || "").includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && !e.text.includes("net::ERR_FAILED"), ); if (errors.length > 0 || pageErrors.length > 0) { diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts index 70e0525..7fb8c1f 100644 --- a/e2e/full-flow.spec.ts +++ b/e2e/full-flow.spec.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // @ts-nocheck /** * Comprehensive E2E — covers all main branch functionality via Playwright. @@ -13,7 +16,7 @@ * 4. Config: get, save * 5. Files: list * 6. Worktree: orphans (returns empty for fresh project) - * 7. Sessions: create (with SSE step tracking), list, delete + * 7. Sessions: create (with IPC step tracking), list, delete * 8. Terminals: create, list, open (port transfer) * 9. Shell: openExplorer, openTerminal * 10. bgHookStatus, hookErrors, retryHooks @@ -24,15 +27,12 @@ * IPC channels without needing a display server. */ import { - test as base, - expect, type ElectronApplication, type Page, + test as base, _electron as electron, + expect, } from "@playwright/test"; -import { join } from "node:path"; -import { existsSync, readdirSync, rmSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; const ROOT = process.cwd(); @@ -91,7 +91,9 @@ test_.beforeAll(async () => { window = await app.firstWindow({ timeout: 20_000 }); await window.waitForLoadState("domcontentloaded"); // Give preload a moment to expose window.api - await window.waitForFunction(() => typeof (window as { api?: unknown }).api === "object", null, { timeout: 10_000 }); + await window.waitForFunction(() => typeof (window as { api?: unknown }).api === "object", null, { + timeout: 10_000, + }); }); test_.afterAll(async () => { @@ -112,17 +114,31 @@ test_.describe("E2E: full IPC surface", () => { expect(window).not.toBeNull(); }); - test_("bootstrap:health returns daemon status + IPC count", async () => { + test_("bootstrap:health returns renderer status + IPC count", async () => { if (!window) throw new Error("window not initialized"); - const health = await window.evaluate(() => (window as unknown as { api: { bootstrap: { health: () => Promise<{ daemon: string; vite: string; ipc: number }> } } }).api.bootstrap.health()); - expect(health.daemon).toBe("ok"); + const health = await window.evaluate(() => + ( + window as unknown as { + api: { bootstrap: { health: () => Promise<{ vite: string; ipc: number }> } }; + } + ).api.bootstrap.health(), + ); + expect(health.ipc).toBeGreaterThan(0); expect(health.ipc).toBeGreaterThanOrEqual(29); }); test_("bootstrap:clientId returns a stable string", async () => { if (!window) throw new Error("window not initialized"); - const id1 = await window.evaluate(() => (window as unknown as { api: { bootstrap: { clientId: () => Promise } } }).api.bootstrap.clientId()); - const id2 = await window.evaluate(() => (window as unknown as { api: { bootstrap: { clientId: () => Promise } } }).api.bootstrap.clientId()); + const id1 = await window.evaluate(() => + ( + window as unknown as { api: { bootstrap: { clientId: () => Promise } } } + ).api.bootstrap.clientId(), + ); + const id2 = await window.evaluate(() => + ( + window as unknown as { api: { bootstrap: { clientId: () => Promise } } } + ).api.bootstrap.clientId(), + ); expect(id1).toBe(id2); expect(id1).toMatch(/^client_/); }); @@ -138,10 +154,14 @@ test_.describe("E2E: full IPC surface", () => { const projectDir = join(testDataDir, "projectA"); mkdirSync(projectDir, { recursive: true }); await window.evaluate((p) => { - return (window as unknown as { api: { db: { init: (path: string) => Promise } } }).api.db.init(p); + return ( + window as unknown as { api: { db: { init: (path: string) => Promise } } } + ).api.db.init(p); }, projectDir); const projects = await window.evaluate(() => - (window as unknown as { api: { db: { projects: { list: () => Promise } } } }).api.db.projects.list(), + ( + window as unknown as { api: { db: { projects: { list: () => Promise } } } } + ).api.db.projects.list(), ); expect(Array.isArray(projects)).toBe(true); }); @@ -149,7 +169,20 @@ test_.describe("E2E: full IPC surface", () => { test_("config:get returns parsed config (needs DB)", async () => { if (!window) throw new Error("window not initialized"); const cfg = await window.evaluate(() => - (window as unknown as { api: { config: { get: () => Promise<{ config: unknown; exists: boolean; yaml: string; envPorts: string[] }> } } }).api.config.get(), + ( + window as unknown as { + api: { + config: { + get: () => Promise<{ + config: unknown; + exists: boolean; + yaml: string; + envPorts: string[]; + }>; + }; + }; + } + ).api.config.get(), ); expect(cfg).toHaveProperty("config"); expect(cfg).toHaveProperty("exists"); @@ -159,7 +192,9 @@ test_.describe("E2E: full IPC surface", () => { test_("worktree:orphans returns empty array for fresh project (needs DB)", async () => { if (!window) throw new Error("window not initialized"); const orphans = await window.evaluate(() => - (window as unknown as { api: { worktree: { orphans: () => Promise } } }).api.worktree.orphans(), + ( + window as unknown as { api: { worktree: { orphans: () => Promise } } } + ).api.worktree.orphans(), ); expect(Array.isArray(orphans)).toBe(true); expect(orphans.length).toBe(0); @@ -172,9 +207,11 @@ test_.describe("E2E: full IPC surface", () => { // should return as a non-empty error string. const result = await window.evaluate(async () => { try { - return await (window as unknown as { api: { shell: { openExplorer: (p: string) => Promise<{ success: boolean }> } } }).api.shell.openExplorer( - "C:\\definitely\\not\\a\\real\\path", - ); + return await ( + window as unknown as { + api: { shell: { openExplorer: (p: string) => Promise<{ success: boolean }> } }; + } + ).api.shell.openExplorer("C:\\definitely\\not\\a\\real\\path"); } catch (err) { return { success: false, error: String(err) }; } @@ -194,7 +231,14 @@ test_.describe("E2E: full IPC surface", () => { mkdirSync(join(browseRoot, "b"), { recursive: true }); const entries = (await window.evaluate( - (p: string) => (window as unknown as { api: { fs: { browseDirs: (path: string) => Promise> } } }).api.fs.browseDirs(p), + (p: string) => + ( + window as unknown as { + api: { + fs: { browseDirs: (path: string) => Promise> }; + }; + } + ).api.fs.browseDirs(p), browseRoot, )) as Array<{ name: string; path: string }>; expect(Array.isArray(entries)).toBe(true); @@ -203,18 +247,34 @@ test_.describe("E2E: full IPC surface", () => { expect(names).toContain("b"); }); - test_.skip("fs:files lists project files with git status (needs DB for project context)", async () => { - if (!window) throw new Error("window not initialized"); - const projectDir = join(testDataDir, "files-test"); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(join(projectDir, "src"), { recursive: true }); + test_.skip( + "fs:files lists project files with git status (needs DB for project context)", + async () => { + if (!window) throw new Error("window not initialized"); + const projectDir = join(testDataDir, "files-test"); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, "src"), { recursive: true }); - const entries = (await window.evaluate( - (p: string) => (window as unknown as { api: { fs: { files: (rel: string) => Promise> } } }).api.fs.files(p), - projectDir, - )) as Array; - expect(Array.isArray(entries)).toBe(true); - }); + const entries = (await window.evaluate( + (p: string) => + ( + window as unknown as { + api: { + fs: { + files: ( + rel: string, + ) => Promise< + Array<{ name: string; path: string; isDir: boolean; size: number | null }> + >; + }; + }; + } + ).api.fs.files(p), + projectDir, + )) as Array; + expect(Array.isArray(entries)).toBe(true); + }, + ); // --- shell:openTerminal (no DB) --- @@ -222,9 +282,11 @@ test_.describe("E2E: full IPC surface", () => { if (!window) throw new Error("window not initialized"); const result = await window.evaluate(async () => { try { - return await (window as unknown as { api: { shell: { openTerminal: (p: string) => Promise<{ success: boolean }> } } }).api.shell.openTerminal( - "C:\\Temp\\agentdock-shell-test", - ); + return await ( + window as unknown as { + api: { shell: { openTerminal: (p: string) => Promise<{ success: boolean }> } }; + } + ).api.shell.openTerminal("C:\\Temp\\agentdock-shell-test"); } catch (err) { return { success: false, error: String(err) }; } @@ -239,7 +301,10 @@ test_.describe("E2E: full IPC surface", () => { // unit + acceptance suites. UI smoke tests should be re-enabled when a // dedicated test ID strategy is in place. - test_.skip("renderer: home page renders Open Project button (skipped — needs test IDs)", async () => { - expect(true).toBe(true); - }); + test_.skip( + "renderer: home page renders Open Project button (skipped — needs test IDs)", + async () => { + expect(true).toBe(true); + }, + ); }); diff --git a/e2e/full-ui-flow.spec.ts b/e2e/full-ui-flow.spec.ts index 7ef6fa7..727a9f9 100644 --- a/e2e/full-ui-flow.spec.ts +++ b/e2e/full-ui-flow.spec.ts @@ -5,7 +5,7 @@ * The React Query cache (used by TabBar/SessionSidebar) is not tested here * because the test fixture cannot force cache invalidation across renderer mounts. */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; const PROJECT_PATH = "F:\\ProgramPlayground\\JavaScript\\Copilot-Switch"; @@ -40,14 +40,18 @@ test.describe("Full user flow (IPC)", () => { await window.waitForTimeout(5000); // ── 3. Verify session exists in backend ── - const sessionData = await window.evaluate(async (args: { sid: string; pid: string }) => { - const all = await (window as any).api.db.projects.list(); - const project = all.find((x: any) => x.id === args.pid); - const s = project?.sessions?.find((x: any) => x.id === args.sid); - return s ? { id: s.id, name: s.name, status: s.status, steps: s.steps } : null; - }, { sid: sessionId, pid: projectId }); + const sessionData = await window.evaluate( + async (args: { sid: string; pid: string }) => { + const all = await (window as any).api.db.projects.list(); + const project = all.find((x: any) => x.id === args.pid); + const s = project?.sessions?.find((x: any) => x.id === args.sid); + return s ? { id: s.id, name: s.name, status: s.status, steps: s.steps } : null; + }, + { sid: sessionId, pid: projectId }, + ); console.log(`✓ Backend session: ${JSON.stringify(sessionData)}`); expect(sessionData).toBeTruthy(); + if (!sessionData) throw new Error(`Session ${sessionId} was not persisted`); expect(sessionData.id).toBe(sessionId); // ── 4. Delete session ── @@ -61,28 +65,34 @@ test.describe("Full user flow (IPC)", () => { await window.waitForTimeout(3000); // ── 5. Verify session is gone ── - const afterDelete = await window.evaluate(async (args: { pid: string }) => { - const all = await (window as any).api.db.projects.list(); - const project = all.find((x: any) => x.id === args.pid); - return { - sessionCount: project?.sessions?.length ?? 0, - sessions: project?.sessions?.map((s: any) => s.id) ?? [], - }; - }, { pid: projectId }); - console.log(`✓ After delete: ${JSON.stringify(afterDelete)}`); - // Session count may be beforeCount+1 if async hooks haven't finished deleting yet. - // In that case wait for the async hook to complete. - if (afterDelete.sessionCount > beforeCount) { - console.log(`Waiting for async delete hooks to complete...`); - await window.waitForTimeout(5000); - const recheck = await window.evaluate(async (args: { pid: string }) => { + const afterDelete = await window.evaluate( + async (args: { pid: string }) => { const all = await (window as any).api.db.projects.list(); const project = all.find((x: any) => x.id === args.pid); return { sessionCount: project?.sessions?.length ?? 0, sessions: project?.sessions?.map((s: any) => s.id) ?? [], }; - }, { pid: projectId }); + }, + { pid: projectId }, + ); + console.log(`✓ After delete: ${JSON.stringify(afterDelete)}`); + // Session count may be beforeCount+1 if async hooks haven't finished deleting yet. + // In that case wait for the async hook to complete. + if (afterDelete.sessionCount > beforeCount) { + console.log("Waiting for async delete hooks to complete..."); + await window.waitForTimeout(5000); + const recheck = await window.evaluate( + async (args: { pid: string }) => { + const all = await (window as any).api.db.projects.list(); + const project = all.find((x: any) => x.id === args.pid); + return { + sessionCount: project?.sessions?.length ?? 0, + sessions: project?.sessions?.map((s: any) => s.id) ?? [], + }; + }, + { pid: projectId }, + ); console.log(`✓ After wait: ${JSON.stringify(recheck)}`); expect(recheck.sessionCount).toBe(beforeCount); } else { diff --git a/e2e/global-projects-db.spec.ts b/e2e/global-projects-db.spec.ts index e02eaa9..d53dc81 100644 --- a/e2e/global-projects-db.spec.ts +++ b/e2e/global-projects-db.spec.ts @@ -17,19 +17,19 @@ * - New session card appears in sidebar */ import { execSync } from "node:child_process"; -import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir, stdio: "pipe" }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + stdio: "pipe", + }); } function writeEmptyConfig(dir: string): void { @@ -41,11 +41,7 @@ function writeEmptyConfig(dir: string): void { } /** Create a fake worktree directory under .agentdock/worktrees/ */ -function createFakeWorktree( - projectPath: string, - sessionId: string, - branch: string, -): void { +function createFakeWorktree(projectPath: string, sessionId: string, branch: string): void { const wtDir = join(projectPath, ".agentdock", "worktrees", sessionId); mkdirSync(wtDir, { recursive: true }); @@ -59,11 +55,7 @@ function createFakeWorktree( // Register the worktree in git's metadata mkdirSync(gitDir, { recursive: true }); writeFileSync(join(gitDir, "HEAD"), `ref: refs/heads/${branch}\n`, "utf-8"); - writeFileSync( - join(gitDir, "gitdir"), - `${wtDir}/.git\n`, - "utf-8", - ); + writeFileSync(join(gitDir, "gitdir"), `${wtDir}/.git\n`, "utf-8"); } test.describe("Global projects DB — worktree sync and session creation", () => { @@ -71,7 +63,6 @@ test.describe("Global projects DB — worktree sync and session creation", () => window, dataDir, mainLog, - dialogs, }) => { const projectPath = join(dataDir, "global-db-test"); prepareGitRepo(projectPath); @@ -83,8 +74,12 @@ test.describe("Global projects DB — worktree sync and session creation", () => createFakeWorktree(projectPath, "bbb22222-bbb", "agentdock/bbb22222-bbb"); // Verify worktrees exist on disk - expect(existsSync(join(projectPath, ".agentdock", "worktrees", "aaa11111-aaa", ".git"))).toBe(true); - expect(existsSync(join(projectPath, ".agentdock", "worktrees", "bbb22222-bbb", ".git"))).toBe(true); + expect(existsSync(join(projectPath, ".agentdock", "worktrees", "aaa11111-aaa", ".git"))).toBe( + true, + ); + expect(existsSync(join(projectPath, ".agentdock", "worktrees", "bbb22222-bbb", ".git"))).toBe( + true, + ); // Open the project in Electron const home = new HomePage(window); @@ -94,24 +89,27 @@ test.describe("Global projects DB — worktree sync and session creation", () => await expect(sidebar.sidebar).toBeVisible({ timeout: 15_000 }); // Wait for initial sync to complete — poll instead of flat wait - // because syncProject does async daemon takeover (~6s) then + // because syncProject performs asynchronous project reconciliation, then // React Query re-fetches after invalidateQueries. - const syncLines = mainLog.filter((l) => - /syncProject|inserted disk|projects:create|projects:list|table:.*projects|seed migration|user_version/.test(l) + const _syncLines = mainLog.filter((l) => + /syncProject|inserted disk|projects:create|projects:list|table:.*projects|seed migration|user_version/.test( + l, + ), ); await expect - .poll(async () => { - const count = await sidebar.cardCount(); - // Also log latest sync lines for debugging on failure - const newSync = mainLog.filter((l) => - /syncProject|query results/.test(l) - ).slice(-5); - if (newSync.length > 0 && count === 0) { - console.log("poll: count=0, sync logs:", newSync.join("\n")); - } - return count; - }, { timeout: 30_000 }) + .poll( + async () => { + const count = await sidebar.cardCount(); + // Also log latest sync lines for debugging on failure + const newSync = mainLog.filter((l) => /syncProject|query results/.test(l)).slice(-5); + if (newSync.length > 0 && count === 0) { + console.log("poll: count=0, sync logs:", newSync.join("\n")); + } + return count; + }, + { timeout: 30_000 }, + ) .toBeGreaterThanOrEqual(2); }); @@ -152,8 +150,8 @@ test.describe("Global projects DB — worktree sync and session creation", () => await window.waitForTimeout(5_000); // ASSERTION: No "no such table: main.projects" in console errors - const tableErrors = consoleErrors.filter((e) => - e.includes("no such table") && e.includes("projects"), + const tableErrors = consoleErrors.filter( + (e) => e.includes("no such table") && e.includes("projects"), ); expect(tableErrors).toEqual([]); }); diff --git a/e2e/helpers/dump.ts b/e2e/helpers/dump.ts index 8af6dfa..54d9497 100644 --- a/e2e/helpers/dump.ts +++ b/e2e/helpers/dump.ts @@ -127,9 +127,7 @@ export interface DaemonStateSnapshot { * The daemon URL is whatever the renderer's `bootstrap:health` reports * (the test passes it in so we don't duplicate discovery here). */ -export async function dumpDaemonState( - daemonBaseUrl: string, -): Promise { +export async function dumpDaemonState(daemonBaseUrl: string): Promise { const res = await fetch(`${daemonBaseUrl}/sessions/list`); if (!res.ok) { throw new Error(`daemon /sessions/list failed: ${res.status}`); diff --git a/e2e/helpers/invariants.ts b/e2e/helpers/invariants.ts index 5c62d04..fb1ff37 100644 --- a/e2e/helpers/invariants.ts +++ b/e2e/helpers/invariants.ts @@ -48,13 +48,9 @@ export interface CompositeResult { * - 503 + success=false + error.code=INVARIANT_VIOLATION → 至少 1 条失败 * - 其他 (daemon down / 404) → 抛 Error (网络错, 不是不变式失败) */ -export async function fetchInvariants( - window: Page, -): Promise { +export async function fetchInvariants(window: Page): Promise { return await window.evaluate(async () => { - const port = ( - window as unknown as { __agentdockDaemonPort?: number } - ).__agentdockDaemonPort; + const port = (window as unknown as { __agentdockDaemonPort?: number }).__agentdockDaemonPort; if (!port) { // Renderer 没暴露 port — 退回到通过 faultInject 路径 // (IPC `daemon:debugState` 已知可达, 但 invariants 端点不一定在 IPC 暴露). @@ -72,9 +68,7 @@ export async function fetchInvariants( error?: { code: string; message: string; failed: string[] }; }; if (!res.ok && body.error?.code !== "INVARIANT_VIOLATION") { - throw new Error( - `fetchInvariants: unexpected ${res.status} ${JSON.stringify(body)}`, - ); + throw new Error(`fetchInvariants: unexpected ${res.status} ${JSON.stringify(body)}`); } return { ok: body.ok ?? false, @@ -96,14 +90,7 @@ export async function fetchInvariants( export interface InvariantContext { envNotTrusted?: [number | undefined, number, boolean]; staleWriteStatus?: number; - displayNameIsolation?: [ - string, - string, - string, - string, - string, - string, - ]; + displayNameIsolation?: [string, string, string, string, string, string]; snapshotStream?: [number, Record, Record, number]; } @@ -117,7 +104,7 @@ export interface InvariantContext { */ export async function assertInvariantsAfterStep( window: Page, - ctx?: InvariantContext, + _ctx?: InvariantContext, ): Promise { const v2Enabled = await window.evaluate(() => ( @@ -137,8 +124,6 @@ export async function assertInvariantsAfterStep( // 扩展. 这里只保证默认 4 条全过即可. if (!composite.ok) { const lines = composite.failed.map((f) => ` - ${f}`).join("\n"); - throw new Error( - `[invariant] ${composite.failed.length} invariant(s) violated:\n${lines}`, - ); + throw new Error(`[invariant] ${composite.failed.length} invariant(s) violated:\n${lines}`); } -} \ No newline at end of file +} diff --git a/e2e/helpers/ipc.ts b/e2e/helpers/ipc.ts index 4b97a17..6abf9af 100644 --- a/e2e/helpers/ipc.ts +++ b/e2e/helpers/ipc.ts @@ -8,7 +8,7 @@ * * Streaming twist: `sessions:create` returns immediately with `{sessionId}` * but the real result arrives over `session::step` (per-step) and - * `session::complete` (terminal). The renderer's `useCreateSessionSSE` + * `session::complete` (terminal). The renderer's session mutation hook * subscribes via `window.api.sessions.stream(id).on{Step,Complete}`. We * can't subscribe before the id exists, so we install a one-time wrapper * around `window.api.sessions.create` that stashes events on @@ -78,39 +78,32 @@ export interface CreateSessionHandle { // ============================================================ export async function bootstrapHealth(window: Page): Promise<{ - daemon: string; vite: string; ipc: number; }> { return await window.evaluate(() => ( window as unknown as { - api: { bootstrap: { health: () => Promise<{ daemon: string; vite: string; ipc: number }> } }; + api: { bootstrap: { health: () => Promise<{ vite: string; ipc: number }> } }; } ).api.bootstrap.health(), ); } /** - * Wait for the daemon to reach "ready" state. Essential before session - * creation in tests — the daemon needs to be fully initialized to handle - * `/sessions/allocate` and other v2 endpoints. + * Wait for the Electron main process to finish registering IPC handlers. */ -export async function waitForDaemonReady( - window: Page, - timeoutMs = 30_000, -): Promise { +export async function waitForAppReady(window: Page, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const ready = await window.evaluate(async () => { try { const health = await ( window as unknown as { - api: { daemon: { health: () => Promise<{ state?: string; lifecycleState?: string }> } }; + api: { bootstrap: { health: () => Promise<{ vite: string; ipc: number }> } }; } - ).api.daemon.health(); - const state = health.lifecycleState ?? health.state ?? ""; - return state === "ready" || state === "READY"; + ).api.bootstrap.health(); + return health.ipc > 0; } catch { return false; } @@ -118,7 +111,7 @@ export async function waitForDaemonReady( if (ready) return; await new Promise((r) => setTimeout(r, 500)); } - throw new Error(`waitForDaemonReady: daemon not READY after ${timeoutMs}ms`); + throw new Error(`waitForAppReady: Electron IPC was not ready after ${timeoutMs}ms`); } // ============================================================ @@ -128,7 +121,9 @@ export async function waitForDaemonReady( export async function initDb(window: Page, projectPath: string): Promise { await window.evaluate( (p: string) => - (window as unknown as { api: { db: { init: (p: string) => Promise } } }).api.db.init(p), + (window as unknown as { api: { db: { init: (p: string) => Promise } } }).api.db.init( + p, + ), projectPath, ); } @@ -137,7 +132,7 @@ export async function createProject( window: Page, params: { name: string; path: string }, ): Promise { - return await window.evaluate( + return (await window.evaluate( (args: { name: string; path: string }) => ( window as unknown as { @@ -145,7 +140,7 @@ export async function createProject( } ).api.db.projects.create(args.name, args.path), params, - ) as ProjectSummary; + )) as ProjectSummary; } export async function listProjects(window: Page): Promise { @@ -197,37 +192,35 @@ export async function createSession( // Subscribe + create in one evaluate so the listener is registered // in the same microtask the promise resolves. Subscribing in a // *separate* evaluate would race main's async step emissions. - const { sessionId } = (await window.evaluate( - (p: typeof params) => { - interface ApiLike { - sessions: { - create: (p: unknown) => Promise<{ sessionId: string }>; - stream: (id: string) => { - onStep: (cb: (e: unknown) => void) => () => void; - onComplete: (cb: (e: unknown) => void) => () => void; - }; + const { sessionId } = (await window.evaluate((p: typeof params) => { + interface ApiLike { + sessions: { + create: (p: unknown) => Promise<{ sessionId: string }>; + stream: (id: string) => { + onStep: (cb: (e: unknown) => void) => () => void; + onComplete: (cb: (e: unknown) => void) => () => void; }; - } - const w = window as unknown as { - api: ApiLike; - __e2eSessionEvents?: Record; }; - const store = (w.__e2eSessionEvents ??= {}); - return w.api.sessions.create(p).then((r) => { - const slot: { steps: unknown[]; complete?: unknown } = { steps: [] }; - store[r.sessionId] = slot; - const stream = w.api.sessions.stream(r.sessionId); - stream.onStep((step: unknown) => { - slot.steps.push(step); - }); - stream.onComplete((complete: unknown) => { - slot.complete = complete; - }); - return r; + } + const w = window as unknown as { + api: ApiLike; + __e2eSessionEvents?: Record; + }; + w.__e2eSessionEvents ??= {}; + const store = w.__e2eSessionEvents; + return w.api.sessions.create(p).then((r) => { + const slot: { steps: unknown[]; complete?: unknown } = { steps: [] }; + store[r.sessionId] = slot; + const stream = w.api.sessions.stream(r.sessionId); + stream.onStep((step: unknown) => { + slot.steps.push(step); }); - }, - params, - )) as { sessionId: string }; + stream.onComplete((complete: unknown) => { + slot.complete = complete; + }); + return r; + }); + }, params)) as { sessionId: string }; return { sessionId, steps: [] }; } @@ -235,10 +228,7 @@ export async function createSession( * Take a snapshot of the steps streamed for a given session so far. * Returns a fresh array each call (so a test can diff between polls). */ -export async function getSessionSteps( - window: Page, - sessionId: string, -): Promise { +export async function getSessionSteps(window: Page, sessionId: string): Promise { return (await window.evaluate( (id: string) => ((window as unknown as { __e2eSessionEvents?: Record }) @@ -268,14 +258,17 @@ export async function awaitSessionComplete( while (Date.now() < deadline) { const snap = (await window.evaluate( (id: string) => - ((window as unknown as { __e2eSessionEvents?: Record }) - .__e2eSessionEvents?.[id] ?? null) as { + (( + window as unknown as { + __e2eSessionEvents?: Record; + } + ).__e2eSessionEvents?.[id] ?? null) as { steps: unknown[]; complete?: unknown; } | null, sessionId, )) as { steps: StepEvent[]; complete?: SessionCompleteEvent } | null; - if (snap && snap.complete) { + if (snap?.complete) { return { steps: snap.steps, result: snap.complete }; } await new Promise((r) => setTimeout(r, pollMs)); @@ -338,10 +331,7 @@ export async function reorderSessions( ); } -export async function bgHookStatus( - window: Page, - sessionId: string, -): Promise { +export async function bgHookStatus(window: Page, sessionId: string): Promise { return (await window.evaluate( (id: string) => ( diff --git a/e2e/home-i18n.spec.ts b/e2e/home-i18n.spec.ts new file mode 100644 index 0000000..cbc052a --- /dev/null +++ b/e2e/home-i18n.spec.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { expect, test } from "./fixtures/electron-fixture"; + +test.describe("empty home localization", () => { + test("English mode renders the open-project flow in English", async ({ window, pageErrors }) => { + const openProject = window.getByTestId("home-open-project"); + + await expect(openProject).toBeVisible(); + await expect(openProject).toHaveText("Open Project"); + await openProject.click(); + await expect(window.getByTestId("dir-modal")).toBeVisible(); + expect(pageErrors).toEqual([]); + }); +}); diff --git a/e2e/long-running-session.spec.ts b/e2e/long-running-session.spec.ts index 09f0c79..d5fc419 100644 --- a/e2e/long-running-session.spec.ts +++ b/e2e/long-running-session.spec.ts @@ -4,13 +4,13 @@ * * Creates a session, lets it run for 30+ seconds with background hook * activity, verifies the session card stays stable (no flicker/removal), - * verifies the terminal remains connected, verifies the daemon status bar + * verifies the terminal remains connected and the session remains stable * stays healthy, then deletes and verifies clean teardown. */ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TerminalPage } from "./pages/terminal"; @@ -19,20 +19,15 @@ import { TID } from "./pages/testids"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeProjectWithSlowHook(dir: string): void { // A slow afterCreateSession hook that runs for ~5 seconds — simulates // background activity during the "long running" observation window. - writeFileSync( - join(dir, "slow-hook.js"), - `setTimeout(() => {}, 5000);`, - "utf-8", - ); + writeFileSync(join(dir, "slow-hook.js"), "setTimeout(() => {}, 5000);", "utf-8"); writeFileSync( join(dir, "agentdock.config.yaml"), [ @@ -53,11 +48,10 @@ function writeProjectWithSlowHook(dir: string): void { } test.describe("long-running session", () => { - test("create session -> observe 30+ seconds -> stable card, connected terminal, healthy daemon -> delete clean", async ({ + test("create session -> observe 30+ seconds -> stable card and terminal -> delete clean", async ({ window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -75,25 +69,26 @@ test.describe("long-running session", () => { await home.openProject(projectPath); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); // Card initially has a tempId (`temp-`); wait for the - // SSE sync to replace it with the real UUID before capturing it. + // the persisted session update to replace it with the real UUID before capturing it. // Otherwise we observe the tempId here and the real ID later, and // the stability check would fail on a false ID mismatch. - // Best-effort: if the SSE sync doesn't land within 20s (suite + // Best-effort: if the persisted update doesn't land within 20s (suite // flakiness on Windows), fall back to whatever ID is on the card. let sessionId: string; try { const realId = await expect - .poll(async () => { - const id = await card.getAttribute("data-session-id"); - return id && !id.startsWith("temp-") ? id : null; - }, { timeout: 20_000 }) + .poll( + async () => { + const id = await card.getAttribute("data-session-id"); + return id && !id.startsWith("temp-") ? id : null; + }, + { timeout: 20_000 }, + ) .toBeTruthy() .then(() => card.getAttribute("data-session-id")); sessionId = realId!; @@ -105,9 +100,11 @@ test.describe("long-running session", () => { sessionId = (await card.getAttribute("data-session-id")) ?? "unknown"; } expect(sessionId).toBeTruthy(); - // ports may not be visible if the daemon is slow; the observation + // ports may not be visible while session creation is still settling; the observation // loop doesn't depend on them, only the ID and terminal status. - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); // 2. Activate the session and add a terminal. await card.click(); @@ -132,10 +129,9 @@ test.describe("long-running session", () => { // Terminal should still be connected. const termStatus = await terminalPage.currentTerminal.getAttribute("data-status"); - expect( - termStatus, - `terminal disconnected during observation at check #${checkCount}`, - ).toBe("connected"); + expect(termStatus, `terminal disconnected during observation at check #${checkCount}`).toBe( + "connected", + ); // Session ID should be the same (no replacement/new card). const currentId = await card.getAttribute("data-session-id"); @@ -145,9 +141,7 @@ test.describe("long-running session", () => { console.log(`[test] observation: ${checkCount} checks over ${observeDuration}ms`); // 5. No React infinite-loop errors during the observation period. - const maxDepthErrors = rendererLog.filter((e) => - /Maximum update depth exceeded/i.test(e.text), - ); + const maxDepthErrors = rendererLog.filter((e) => /Maximum update depth exceeded/i.test(e.text)); expect( maxDepthErrors, `React infinite-loop during long-running observation:\n${maxDepthErrors.map((e) => e.text).join("\n")}`, @@ -157,9 +151,7 @@ test.describe("long-running session", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); // 7. No renderer errors after deletion. expect(pageErrors).toHaveLength(0); @@ -167,10 +159,11 @@ test.describe("long-running session", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/multi-project-workflow.spec.ts b/e2e/multi-project-workflow.spec.ts index fe35039..6120276 100644 --- a/e2e/multi-project-workflow.spec.ts +++ b/e2e/multi-project-workflow.spec.ts @@ -8,25 +8,24 @@ * * NOTE: Session persistence across tabs is verified by UI state (tab counts, * no crashes) rather than DB queries, because the v2 architecture stores - * session state in the daemon rather than the local DB. + * session state through the main-process lifecycle and project DB. */ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TabBarPage } from "./pages/tab-bar"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -58,8 +57,8 @@ test.describe("multi-project workflow", () => { const tabBar = new TabBarPage(window); const sidebar = new SidebarPage(window); - // Wait for daemon to be ready before any session creation. - await waitForDaemonReady(window); + // Wait for main-process IPC to be ready before any session creation. + await waitForAppReady(window); // 2. Open project A from home. await expect(home.openProjectButton).toBeVisible(); @@ -76,9 +75,7 @@ test.describe("multi-project workflow", () => { await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); expect(await sidebar.cardCount()).toBe(0); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); // 5. Click "+" to open project B via the tab bar. await tabBar.openProjectViaPlusButton(); @@ -88,9 +85,7 @@ test.describe("multi-project workflow", () => { // 6. TabBar should now show two tabs. const allTabs = window.locator(`[data-testid="${TID.projectTab}"]`); - await expect - .poll(() => allTabs.count(), { timeout: 15_000 }) - .toBeGreaterThanOrEqual(2); + await expect.poll(() => allTabs.count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2); // Find project B's tab. const tabs = await allTabs.all(); @@ -107,9 +102,7 @@ test.describe("multi-project workflow", () => { // 7. Create a session in project B. await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); // 8. Switch back to project A by clicking its tab. await tabBar.switchTo(projectIdA!); @@ -126,8 +119,8 @@ test.describe("multi-project workflow", () => { await expect.poll(() => allTabs.count()).toBeGreaterThanOrEqual(2); // 11. No renderer errors. - // NOTE: alert dialogs from daemon /sessions/allocate failures are - // a known app-level issue (daemon v2 endpoint may not be ready); + // NOTE: alert dialogs from session allocation failures are + // captured as app-level errors; // we don't fail the test on them but log them for visibility. if (dialogs.filter((d) => d.type === "alert").length > 0) { console.log( @@ -140,10 +133,11 @@ test.describe("multi-project workflow", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect( consoleErrors, diff --git a/e2e/p9-v2-lifecycle.spec.ts b/e2e/p9-v2-lifecycle.spec.ts deleted file mode 100644 index add25dd..0000000 --- a/e2e/p9-v2-lifecycle.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -// @ts-nocheck -/** - * P9 — UI 点击 → v2 daemon 状态闭环 (新架构 §13.1 + §4.2). - * - * Drives a real Electron BrowserWindow through the full session lifecycle: - * 1. Open project → click "+" in SessionSidebar → wait for card - * 2. Verify daemon's v2 three-table is populated: - * - v2Sessions has one entry with status="active" - * - v2Owners has the matching entry with fencingToken ≥ 1 - * - v2Ports has the session's portKey entries - * - * Mirrors the proven session-ui.spec.ts click pattern — uses the same - * locator / data attributes — so we don't trip on UI markup. - */ -import { execSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; -import { HomePage } from "./pages/home"; -import { SidebarPage } from "./pages/sidebar"; -import { TID } from "./pages/testids"; - -function prepareGitRepo(dir: string): void { - mkdirSync(dir, { recursive: true }); - execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); -} - -function writeEmptyConfig(dir: string): void { - writeFileSync( - join(dir, "agentdock.config.yaml"), - `version: "1"\nresources:\n sync: []\nhooks: {}\n`, - "utf-8", - ); -} - -test.describe("P9 v2 lifecycle — UI click → daemon v2 three-table", () => { - test("create session populates v2Sessions/v2Owners/v2Ports", async ({ - window, - dataDir, - }) => { - const isV2Mode = process.env.AGENTDOCK_V2 === "1"; - if (!isV2Mode) { - test.skip(true, "P9 spec requires AGENTDOCK_V2=1 in the Electron env"); - return; - } - - // 1. Prepare a real git project on disk. - const projectPath = join(dataDir, "p9-v2-project"); - prepareGitRepo(projectPath); - writeEmptyConfig(projectPath); - - // 2. Open the project through the home → dir browser flow. - const home = new HomePage(window); - await expect(home.openProjectButton).toBeVisible(); - await home.openProject(projectPath); - - // 3. Wait for the sidebar to land in /app/$projectId. - const sidebar = new SidebarPage(window); - await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); - expect(await sidebar.cardCount()).toBe(0); - - // 4. Click "+" — under AGENTDOCK_V2=1, the IPC handler routes to - // v2 PortService which calls /session/create → /claim × N → - // /session/activate on the daemon. - await sidebar.clickNewSession(); - - // 5. Wait for the session card to materialize (optimistic insert). - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); - - // Wait an additional 10s for the lifecycle to finish (allocatePorts → activate) - await new Promise((r) => setTimeout(r, 10_000)); - - // 6. Read the v2 three-table from the daemon via the IPC bridge. - // First check whether AGENTDOCK_V2 actually reached main. - const v2Enabled = await window.evaluate(async () => { - return await window.api.bootstrap.v2Enabled(); - }); - const dbg = (await window.evaluate(async () => { - return await window.api.daemon.debugState(); - })) as { - v2Sessions: Record; - v2Owners: Record; - v2Ports: Record; - } | null; - - expect(dbg).not.toBeNull(); - expect(v2Enabled, "AGENTDOCK_V2=1 should have reached Electron main").toBe(true); - - // Print diagnostic so failure message is actionable. - // eslint-disable-next-line no-console - console.log("[P9 diag] v2Enabled:", v2Enabled); - // eslint-disable-next-line no-console - console.log("[P9 diag] v2Sessions:", Object.keys(dbg!.v2Sessions ?? {})); - // eslint-disable-next-line no-console - console.log("[P9 diag] v2Owners:", Object.keys(dbg!.v2Owners ?? {})); - // eslint-disable-next-line no-console - console.log("[P9 diag] v2Ports:", Object.keys(dbg!.v2Ports ?? {})); - expect(dbg!.v2Sessions).toBeDefined(); - expect(dbg!.v2Owners).toBeDefined(); - expect(dbg!.v2Ports).toBeDefined(); - - // Find the session matching our project. - const sessionEntries = Object.entries(dbg!.v2Sessions).filter( - ([, s]) => s.projectRoot === projectPath, - ); - expect( - sessionEntries.length, - `expected ≥1 v2Sessions entry for projectRoot=${projectPath}; saw ${sessionEntries.length}`, - ).toBeGreaterThanOrEqual(1); - const [v2Sid, session] = sessionEntries[0]!; - expect(session.status).toBe("active"); - - // v2Owners has the fencing token. - expect(dbg!.v2Owners[v2Sid]).toBeDefined(); - expect(dbg!.v2Owners[v2Sid]!.fencingToken).toBeGreaterThanOrEqual(1); - - // v2Ports has at least the default port keys. - const sessionPorts = Object.values(dbg!.v2Ports).filter( - (p) => p.sessionId === v2Sid, - ); - expect( - sessionPorts.length, - `expected ≥1 v2Ports entry for sessionId=${v2Sid}; saw ${sessionPorts.length}`, - ).toBeGreaterThanOrEqual(1); - // Each port key in our PORT_KEYS_DEFAULT should appear at least once. - const names = new Set(sessionPorts.map((p) => p.name)); - expect(names.has("FRONTEND_PORT")).toBe(true); - - // 7. The renderer card carries the daemon's v2 sessionId in its - // data-session-id. (Per AppSession <-> v2SessionId mapping.) - const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); - const sessionId = (await card.getAttribute("data-session-id")) ?? ""; - expect(sessionId).not.toBe(""); - }); -}); \ No newline at end of file diff --git a/e2e/pages/home.ts b/e2e/pages/home.ts index fb097d4..cae7f92 100644 --- a/e2e/pages/home.ts +++ b/e2e/pages/home.ts @@ -1,4 +1,4 @@ -import type { Page, Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { TID } from "./testids"; /** @@ -41,9 +41,7 @@ export class HomePage { await this.openProjectButton.click(); await this.modal.waitFor({ state: "visible" }); - const segments = absolutePath - .split(/[\\/]/) - .filter((s) => s.length > 0 && s !== "."); + const segments = absolutePath.split(/[\\/]/).filter((s) => s.length > 0 && s !== "."); if (segments.length === 0) throw new Error(`Empty path: ${absolutePath}`); // On Windows the first segment is the drive letter ("C:"); the @@ -121,9 +119,7 @@ export class HomePage { */ async navigateModalTo(absolutePath: string): Promise { await this.modal.waitFor({ state: "visible" }); - const segments = absolutePath - .split(/[\\/]/) - .filter((s) => s.length > 0 && s !== "."); + const segments = absolutePath.split(/[\\/]/).filter((s) => s.length > 0 && s !== "."); if (segments.length === 0) throw new Error(`Empty path: ${absolutePath}`); if (process.platform === "win32" && /^[A-Za-z]:$/.test(segments[0]!)) { @@ -165,7 +161,11 @@ export class HomePage { // is the row (data-testid="dir-entry"). return this.page .locator(`[data-testid="${TID.dirEntry}"]`) - .filter({ has: this.page.locator(".dir-entry-name", { hasText: new RegExp(`^${escapeRegex(name)}$`) }) }) + .filter({ + has: this.page.locator(".dir-entry-name", { + hasText: new RegExp(`^${escapeRegex(name)}$`), + }), + }) .first(); } } diff --git a/e2e/pages/sidebar.ts b/e2e/pages/sidebar.ts index cfd1278..1522410 100644 --- a/e2e/pages/sidebar.ts +++ b/e2e/pages/sidebar.ts @@ -1,4 +1,4 @@ -import type { Page, Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { TID } from "./testids"; /** @@ -15,9 +15,7 @@ export class SidebarPage { } card(sessionId: string): Locator { - return this.page.locator( - `[data-testid="${TID.sessionCard}"][data-session-id="${sessionId}"]`, - ); + return this.page.locator(`[data-testid="${TID.sessionCard}"][data-session-id="${sessionId}"]`); } get newSessionButton(): Locator { @@ -37,14 +35,9 @@ export class SidebarPage { * Accepts a string (exact match) or RegExp (pattern match against * data-session-id attribute). */ - async waitForCard( - sessionId: string | RegExp, - opts?: { timeout?: number }, - ): Promise { + async waitForCard(sessionId: string | RegExp, opts?: { timeout?: number }): Promise { const timeoutMs = opts?.timeout ?? 10_000; - const base = this.page.locator( - `[data-testid="${TID.sessionCard}"]`, - ); + const base = this.page.locator(`[data-testid="${TID.sessionCard}"]`); if (typeof sessionId === "string") { await this.card(sessionId).waitFor({ state: "visible", @@ -70,9 +63,7 @@ export class SidebarPage { * Return the data-session-id of the first visible session card. */ async firstCardId(): Promise { - const base = this.page.locator( - `[data-testid="${TID.sessionCard}"][data-session-id]`, - ); + const base = this.page.locator(`[data-testid="${TID.sessionCard}"][data-session-id]`); await base.first().waitFor({ state: "visible", timeout: 10_000 }); const id = await base.first().getAttribute("data-session-id"); if (!id) throw new Error("firstCardId: first card has no data-session-id"); diff --git a/e2e/pages/tab-bar.ts b/e2e/pages/tab-bar.ts index 3fc00d1..85ac02e 100644 --- a/e2e/pages/tab-bar.ts +++ b/e2e/pages/tab-bar.ts @@ -1,4 +1,4 @@ -import type { Page, Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { TID } from "./testids"; /** @@ -15,9 +15,7 @@ export class TabBarPage { } tab(projectId: string): Locator { - return this.page.locator( - `[data-testid="${TID.projectTab}"][data-project-id="${projectId}"]`, - ); + return this.page.locator(`[data-testid="${TID.projectTab}"][data-project-id="${projectId}"]`); } closeTab(projectId: string): Locator { diff --git a/e2e/pages/terminal.ts b/e2e/pages/terminal.ts index af72c67..f9ba30a 100644 --- a/e2e/pages/terminal.ts +++ b/e2e/pages/terminal.ts @@ -1,12 +1,7 @@ -import type { Page, Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { TID } from "./testids"; -export type TerminalStatus = - | "connecting" - | "connected" - | "disconnected" - | "error" - | "exited"; +export type TerminalStatus = "connecting" | "connected" | "disconnected" | "error" | "exited"; /** * TerminalManager + SessionTerminal — the xterm-hosting right pane. @@ -47,10 +42,7 @@ export class TerminalPage { * Wait until the current terminal reports the given status. Default * status is `connected` (the success signal for PTY readiness). */ - async waitForStatus( - status: TerminalStatus = "connected", - timeoutMs = 10_000, - ): Promise { + async waitForStatus(status: TerminalStatus = "connected", timeoutMs = 10_000): Promise { await this.currentTerminal.waitFor({ state: "visible", timeout: timeoutMs }); const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/e2e/port-allocation-stress.spec.ts b/e2e/port-allocation-stress.spec.ts index 63bc098..6581cac 100644 --- a/e2e/port-allocation-stress.spec.ts +++ b/e2e/port-allocation-stress.spec.ts @@ -8,19 +8,18 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { dumpDb } from "./helpers/dump"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TID } from "./pages/testids"; -import { dumpDb } from "./helpers/dump"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -42,7 +41,6 @@ test.describe("port allocation stress", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -67,18 +65,14 @@ test.describe("port allocation stress", () => { for (let i = 0; i < SESSION_COUNT; i++) { await sidebar.clickNewSession(); // Wait until card count increments. - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(i + 1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(i + 1); // Re-query the card each iteration because the sidebar may // re-render between iterations (e.g. when the next card // appears) and the .nth(i) locator chain can become stale. // Use .last() since the just-created card is the most recent // and lands at the end of the (creation-ordered) list. - const latestCard = window - .locator(`[data-testid="${TID.sessionCard}"]`) - .last(); + const latestCard = window.locator(`[data-testid="${TID.sessionCard}"]`).last(); await expect(latestCard).toBeVisible({ timeout: 10_000 }); // Wait for the latest card's ports to appear. Soft-asserted — @@ -109,18 +103,18 @@ test.describe("port allocation stress", () => { for (const sid of sessionIds) { const row = dump.sessions.find((s) => s.id === sid); expect(row, `session ${sid} not found in DB`).toBeTruthy(); - expect(row!.ports, `session ${sid} has no ports`).toBeTruthy(); - const ports = JSON.parse(row!.ports!) as Record; + expect(row?.ports, `session ${sid} has no ports`).toBeTruthy(); + const ports = JSON.parse(row?.ports!) as Record; expect(ports.FRONTEND_PORT).toBeGreaterThan(1024); frontendPorts.push(ports.FRONTEND_PORT); } // All frontend ports must be unique. const uniquePorts = new Set(frontendPorts); - expect(uniquePorts.size).toBe( - SESSION_COUNT, + expect( + uniquePorts.size, `expected ${SESSION_COUNT} unique ports, got ${uniquePorts.size}: [${frontendPorts.join(", ")}]`, - ); + ).toBe(SESSION_COUNT); // 5. Delete all sessions one by one. for (let i = SESSION_COUNT - 1; i >= 0; i--) { @@ -134,9 +128,7 @@ test.describe("port allocation stress", () => { } // 6. All cards should be gone. - await expect - .poll(() => sidebar.cardCount(), { timeout: 60_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 60_000 }).toBe(0); // 7. DB should have no sessions. const finalDump = dumpDb(dataDir); @@ -148,10 +140,11 @@ test.describe("port allocation stress", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/real-ai-agent-flow.spec.ts b/e2e/real-ai-agent-flow.spec.ts index 09ee341..9ce81bc 100644 --- a/e2e/real-ai-agent-flow.spec.ts +++ b/e2e/real-ai-agent-flow.spec.ts @@ -10,22 +10,20 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TerminalPage } from "./pages/terminal"; import { TID } from "./pages/testids"; -const HAS_API_KEY = - !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY; +const HAS_API_KEY = !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeAgentConfig(dir: string): void { @@ -53,7 +51,6 @@ test.describe("real AI agent flow", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -76,14 +73,13 @@ test.describe("real AI agent flow", () => { // 2. Create a session. await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); const sessionId = await card.getAttribute("data-session-id"); expect(sessionId).toBeTruthy(); + if (!sessionId) throw new Error("Created session card has no session id"); // 3. Wait for lifecycle to complete (ports visible). await expect(card.locator(".session-ports")).toBeVisible({ timeout: 30_000 }); @@ -138,9 +134,7 @@ test.describe("real AI agent flow", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); // 9. No renderer errors. expect(pageErrors).toHaveLength(0); @@ -148,10 +142,11 @@ test.describe("real AI agent flow", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/reserved-without-listener.spec.ts b/e2e/reserved-without-listener.spec.ts deleted file mode 100644 index 110e549..0000000 --- a/e2e/reserved-without-listener.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -// @ts-nocheck -/** - * 新架构 §11.4 — "端口预留不误收" 剧本. - * - * claim 后**不监听任何服务**(模拟 Ctrl+C 停服)→ owner **持续 heartbeat** - * 并等待 ≥ 一个 SYNC_INTERVAL (明显短于 HEARTBEAT_TIMEOUT=90s) → - * 端口仍 RESERVED 不被回收 (验证 "无监听 ≠ 回收", §3.5 不变式 2). - * - * 思路: - * 1. 通过 v2 API 创建 session + 全部 claim, 立刻 activate (端口被 daemon - * 标记为 RESERVED, 但**没有任何 dev server 在监听**). - * 2. 持续 /session/heartbeat 30s (1× SYNC_INTERVAL). - * 3. /debug/state 断言 RESERVED 集合未变 + status 仍为 active. - * 4. bonus: 用 v2PortService 的 lease 续约机制持续刷 lease, 端口仍稳. - * - * 这条 spec 守护 §3.5 的核心承诺 — "RESERVED 是归属, 不是监听态". - */ -import { test, expect } from "./fixtures/electron-fixture"; -import { TID } from "./pages/testids"; - -const ACTIVE_TIMEOUT_MS = 30_000; // 1× SYNC_INTERVAL, 明显短于 HEARTBEAT_TIMEOUT(90s) - -test.describe("新架构 §11.4 — 端口预留不误收 (§3.5 不变式 2)", () => { - test("claim 后无监听 + 持续 heartbeat, 端口仍 RESERVED", async ({ window }) => { - // 0. Wait for daemon READY — stale WAL may cause 15s RECOVERING. - const deadline = Date.now() + 20_000; - while (Date.now() < deadline) { - const h = (await window.evaluate(async () => { - return await window.api.daemon.health(); - })) as { state?: string; lifecycleState?: string; port: number }; - const s = h.lifecycleState ?? h.state; - if (s === "ready" || s === "READY") break; - await new Promise((r) => setTimeout(r, 500)); - } - - // 1. 通过 daemon 端 API 创建 + 全部 claim + activate, 但不启动 dev server. - // 用 daemon:health/health 探活 + 拿 port. - const health = (await window.evaluate(async () => { - return await window.api.daemon.health(); - })) as { port: number; state?: string; lifecycleState?: string }; - - expect(health.port, "daemon must be reachable").toBeGreaterThan(0); - - // 2. 通过 faultInject 走 v2 /session/create (简化: 直接走 IPC bridge 调 daemon) - const sync = (await window.evaluate(async () => { - return await window.api.daemon.sync(); - })) as { success: boolean; sessions?: unknown[]; error?: string }; - - expect(sync.success, `daemon /sync reachable: ${JSON.stringify(sync)}`).toBe(true); - - // 3. 通过 v2 routes 创建 session + 激活(不启动任何 dev server). - const create = (await window.evaluate(async () => { - return await window.api.sessionsV2.create({ - projectId: "p", - name: "reserved-without-listener", - }); - })) as { success: boolean; status?: number; body?: unknown }; - if (!create.success) { - // AGENTDOCK_V2 not enabled — skip with explanation - test.skip(true, "AGENTDOCK_V2 not enabled — this spec validates §3.5 only under v2"); - return; - } - // 简化: 真实场景下 v2PortService 会持续 heartbeat 30s, 这里断言 - // SYNC_INTERVAL 内 RESERVED 集合不变. - - // 4. 拿 v2 /debug/state 初始 baseline - const baseline = (await window.evaluate(async () => { - return await window.api.daemon.debugState(); - })) as { v2Ports: Record }; - const baselinePortCount = Object.keys(baseline.v2Ports ?? {}).length; - expect(baselinePortCount, "expected ≥1 RESERVED port for the test session").toBeGreaterThan(0); - - // 5. 等待 1× SYNC_INTERVAL, 在此期间不启动任何 dev server, 不释放端口. - // v2PortService 内部的 lease 续约会自动跑(每 5s). - await new Promise((resolve) => setTimeout(resolve, ACTIVE_TIMEOUT_MS)); - - // 6. 再次拉 /debug/state, 断言 RESERVED 集合**未变** + 没有 daemon 主动 - // 释放端口. - const after = (await window.evaluate(async () => { - return await window.api.daemon.debugState(); - })) as { v2Ports: Record }; - const afterPortCount = Object.keys(after.v2Ports ?? {}).length; - expect( - afterPortCount, - "RESERVED port set MUST NOT shrink while lease is alive (§3.5 不变式 2)", - ).toBeGreaterThanOrEqual(baselinePortCount); - - // 7. 清理: 调 v2 delete + purge 让测试 session 干净退出. - // (P9 v2 path: 调用 sessionsV2.delete 走 /session/delete 然后 - // worktree 清理完调 /session/purge, 由 IPC handler 编排.) - // 简化: 这里不调清理, 让 beforeEach/afterEach 兜底. - // (test fixture 的每个 test 独立, 不会污染下一个.) - }); -}); diff --git a/e2e/runner.ts b/e2e/runner.ts index a7ce28f..41adc9e 100644 --- a/e2e/runner.ts +++ b/e2e/runner.ts @@ -55,8 +55,7 @@ function loadConfig(): PipelineConfig { const parsed = parseYaml(raw) as PipelineConfig; if (!parsed || parsed.version !== "1") { throw new Error( - `Unsupported pipeline config version: ${parsed?.version ?? "(missing)"}. ` + - `Expected "1".` + `Unsupported pipeline config version: ${parsed?.version ?? "(missing)"}. Expected "1".`, ); } return parsed; @@ -115,7 +114,7 @@ function runGroup( mode: "isolate" | "reuse", specs: string[], config: PipelineConfig, - tags: string[] + tags: string[], ): Promise { return new Promise((resolve) => { const startedAt = new Date().toISOString(); @@ -127,10 +126,7 @@ function runGroup( const args = ["playwright", "test"]; if (specs.length > 0) args.push(...specs); if (tags.length > 0) { - args.push( - "--grep", - tags.map((t) => (t.startsWith("@") ? t : `@${t}`)).join("|") - ); + args.push("--grep", tags.map((t) => (t.startsWith("@") ? t : `@${t}`)).join("|")); } args.push(`--workers=${config.pipeline.global.parallel}`); @@ -169,10 +165,16 @@ async function main(): Promise { const allGroups: Array<{ mode: "isolate" | "reuse"; specs: string[] }> = []; if (!cliArgs.group || cliArgs.group === "isolate") { - allGroups.push({ mode: "isolate", specs: config.pipeline.specs.filter((s) => s.mode === "isolate").map((s) => s.pattern) }); + allGroups.push({ + mode: "isolate", + specs: config.pipeline.specs.filter((s) => s.mode === "isolate").map((s) => s.pattern), + }); } if (!cliArgs.group || cliArgs.group === "reuse") { - allGroups.push({ mode: "reuse", specs: config.pipeline.specs.filter((s) => s.mode === "reuse").map((s) => s.pattern) }); + allGroups.push({ + mode: "reuse", + specs: config.pipeline.specs.filter((s) => s.mode === "reuse").map((s) => s.pattern), + }); } const runState: RunState = { @@ -220,7 +222,9 @@ async function main(): Promise { const reportPath = join(REPORTS_DIR, `${runState.finishedAt.replace(/[:.]/g, "-")}.json`); writeFileSync(reportPath, JSON.stringify(finalReport, null, 2), "utf-8"); - console.log(`\n${finalReport.success ? "✅" : "❌"} Pipeline ${finalReport.success ? "passed" : "failed"}`); + console.log( + `\n${finalReport.success ? "✅" : "❌"} Pipeline ${finalReport.success ? "passed" : "failed"}`, + ); console.log(`📄 Report: ${reportPath}`); process.exit(finalReport.success ? 0 : 1); } diff --git a/e2e/session-create-progress-persistence.spec.ts b/e2e/session-create-progress-persistence.spec.ts index f6bb3fd..5564797 100644 --- a/e2e/session-create-progress-persistence.spec.ts +++ b/e2e/session-create-progress-persistence.spec.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; /** * E2E Test: Session creation/deletion progress persistence across project switches * @@ -10,12 +13,9 @@ * * Each test creates its own isolated project directories to avoid state leakage. */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; -import { execSync } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; /** * Create a temporary git project for testing. @@ -30,20 +30,23 @@ function createTempProject(name: string): string { // Initialize git repo execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email \"test@test.com\"", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name \"Test\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: projectPath, stdio: "ignore" }); // Create initial commit (required for git worktree) writeFileSync(join(projectPath, "README.md"), "# Test Project"); execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync("git commit -m \"init\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); // Create agentdock config (disable hooks for faster tests) - writeFileSync(join(projectPath, "agentdock.config.yaml"), ` + writeFileSync( + join(projectPath, "agentdock.config.yaml"), + ` hooks: afterCreateSession: [] afterDeleteSession: [] -`); +`, + ); return projectPath; } @@ -96,14 +99,7 @@ test.describe("Session creation progress persistence", () => { } }); - test("creation progress persists after switching projects", async ({ - window, - dataDir, - pageErrors, - dialogs, - rendererLog, - expectNoRendererErrors, - }) => { + test("creation progress persists after switching projects", async ({ window }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -151,7 +147,10 @@ test.describe("Session creation progress persistence", () => { // Switch back to first project tab const project1Name = project1.split("\\").pop()!; - const project1Tab = window.locator('[data-testid="project-tab"]').filter({ hasText: project1Name }).first(); + const project1Tab = window + .locator('[data-testid="project-tab"]') + .filter({ hasText: project1Name }) + .first(); await project1Tab.click(); // Wait for projects to be loaded @@ -172,20 +171,15 @@ test.describe("Session creation progress persistence", () => { expect(cardsAfterSwitch).toBe(initialCount + 1); // Verify the session is still there by checking the data-session-id attribute - const sessionCard = window.locator(`[data-testid="session-card"][data-session-id="${newSessionId}"]`); + const sessionCard = window.locator( + `[data-testid="session-card"][data-session-id="${newSessionId}"]`, + ); const sessionStillThere = await sessionCard.isVisible().catch(() => false); console.log(`Session ${newSessionId} visible after switch: ${sessionStillThere}`); expect(sessionStillThere).toBe(true); }); - test("deletion progress persists after switching projects", async ({ - window, - dataDir, - pageErrors, - dialogs, - rendererLog, - expectNoRendererErrors, - }) => { + test("deletion progress persists after switching projects", async ({ window }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); @@ -218,12 +212,14 @@ test.describe("Session creation progress persistence", () => { await sessionCard.click({ button: "right" }); // Look for delete option in context menu - const deleteOption = window.locator('text=Delete, text=删除').first(); + const deleteOption = window.locator("text=Delete, text=删除").first(); if (await deleteOption.isVisible()) { await deleteOption.click(); // Confirm deletion if modal appears - const confirmBtn = window.locator('button:has-text("Confirm"), button:has-text("确认")').first(); + const confirmBtn = window + .locator('button:has-text("Confirm"), button:has-text("确认")') + .first(); if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await confirmBtn.click(); } @@ -249,7 +245,10 @@ test.describe("Session creation progress persistence", () => { // Switch back to first project tab const project1Name = project1.split("\\").pop()!; - const project1Tab = window.locator('[data-testid="project-tab"]').filter({ hasText: project1Name }).first(); + const project1Tab = window + .locator('[data-testid="project-tab"]') + .filter({ hasText: project1Name }) + .first(); await project1Tab.click(); // Wait for projects to be loaded diff --git a/e2e/session-display-name-roundtrip.spec.ts b/e2e/session-display-name-roundtrip.spec.ts index 6f4cbb5..e56ec33 100644 --- a/e2e/session-display-name-roundtrip.spec.ts +++ b/e2e/session-display-name-roundtrip.spec.ts @@ -3,17 +3,17 @@ * displayName isolation E2E — §11.4 script #7. * * Verifies that a Unicode displayName is correctly round-tripped through - * the v2 daemon API without leaking into the git branch name: + * the main-process lifecycle without leaking into the git branch name: * * 1. Create a session via the full UI flow with displayName "我的中文名" - * 2. Assert the daemon v2 state stores the displayName correctly + * 2. Assert persisted session state stores the displayName correctly * 3. Assert the branch is "agentdock/" (not derived from displayName) * 4. Assert the sidebar renders the display name (session.name) */ import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { awaitSessionComplete, createProject, @@ -27,7 +27,17 @@ function prepareGitRepo(dir: string): void { execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir }); execFileSync( "git", - ["-c", "user.email=e2e@local", "-c", "user.name=E2E", "commit", "--allow-empty", "-q", "-m", "init"], + [ + "-c", + "user.email=e2e@local", + "-c", + "user.name=E2E", + "commit", + "--allow-empty", + "-q", + "-m", + "init", + ], { cwd: dir }, ); } @@ -55,7 +65,7 @@ test.describe("displayName isolation (§11.4 #7)", () => { // here — that flow uses db:projects:create internally and asserts on // the post-navigation state, which adds a flaky dependency on the // SessionSidebar being expanded. The displayName-isolation concern - // is purely about the daemon + git round-trip, not the UI. + // is purely about the lifecycle + git round-trip, not the UI. await initDb(window, projectPath); const project = await createProject(window, { name: "display-name-project", @@ -81,18 +91,17 @@ test.describe("displayName isolation (§11.4 #7)", () => { // session — its `name` is the displayName we set. const projects = await listProjects(window); expect(projects).toHaveLength(1); - expect(projects[0]!.sessions).toHaveLength(1); - expect(projects[0]!.sessions[0]!.name).toBe(displayName); + expect(projects[0]?.sessions).toHaveLength(1); + expect(projects[0]?.sessions[0]?.name).toBe(displayName); // 3. Verify the branch is "agentdock/" — NOT derived from // the displayName. This is the critical isolation assertion: no // matter how exotic the displayName is, the branch stays ASCII-safe. const expectedBranch = `agentdock/${sessionId}`; - const gitBranches = execFileSync( - "git", - ["branch", "--list", "agentdock/*"], - { cwd: projectPath, encoding: "utf-8" }, - ); + const gitBranches = execFileSync("git", ["branch", "--list", "agentdock/*"], { + cwd: projectPath, + encoding: "utf-8", + }); expect(gitBranches).toContain(expectedBranch); // Must NOT contain the displayName as a branch segment. expect(gitBranches).not.toContain("我的中文名"); diff --git a/e2e/session-hook.spec.ts b/e2e/session-hook.spec.ts index a6119bc..42509d3 100644 --- a/e2e/session-hook.spec.ts +++ b/e2e/session-hook.spec.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; /** * Real hook execution E2E. * @@ -17,9 +18,9 @@ * subsequent `sessions:retryHooks` re-runs the (now-fixed) hook. */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { dumpDb } from "./helpers/dump"; import { awaitSessionComplete, createProject, @@ -27,15 +28,13 @@ import { deleteSession, listProjects, } from "./helpers/ipc"; -import { dumpDb } from "./helpers/dump"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeHookConfig( @@ -87,10 +86,7 @@ function writeHookScript(projectDir: string, name: string, body: string): string } test.describe("afterCreateSession hook (real execution)", () => { - test("sync hook writes marker file before create completes", async ({ - window, - dataDir, - }) => { + test("sync hook writes marker file before create completes", async ({ window, dataDir }) => { const projectPath = join(dataDir, "hook-sync"); prepareGitRepo(projectPath); // Hook runs with `cwd: project` (set by writeHookConfig) so @@ -217,7 +213,7 @@ test.describe("afterCreateSession hook (real execution)", () => { const failedRow = dumpDb(dataDir).sessions.find((s) => s.id === sessionId); expect(failedRow?.background_hook_errors).toBeTruthy(); - const errors = JSON.parse(failedRow!.background_hook_errors!) as Array<{ + const errors = JSON.parse(failedRow?.background_hook_errors!) as Array<{ exitCode: number | null; stderr?: string; }>; diff --git a/e2e/session-lifecycle.spec.ts b/e2e/session-lifecycle.spec.ts index 66a5b8f..7c13c76 100644 --- a/e2e/session-lifecycle.spec.ts +++ b/e2e/session-lifecycle.spec.ts @@ -12,12 +12,12 @@ * → assert renderer-visible projects.list contains the session * → sessions:rename (exercises git branch rename — 8ec663a fix) * → assert DB row carries the renamed branch - * → sessions:delete → await complete via SSE-equivalent + * → sessions:delete → await IPC completion * → assert filesystem + DB cleanup * → projects:delete → assert no leftover state * * Runs the whole flow twice in the same spec to surface idempotency - * bugs (cached daemon state, lingering WAL handles, repeated branch + * bugs (stale in-memory state, lingering SQLite handles, repeated branch * collisions, etc.). * * Uses an isolated temp `agentdock.config.yaml` with no hooks so this @@ -26,7 +26,8 @@ import { execFileSync, execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { dumpDb, dumpWorktreeTree } from "./helpers/dump"; import { awaitSessionComplete, bootstrapHealth, @@ -38,7 +39,6 @@ import { listProjects, renameSession, } from "./helpers/ipc"; -import { dumpDb, dumpWorktreeTree } from "./helpers/dump"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); @@ -46,7 +46,7 @@ function prepareGitRepo(dir: string): void { // — every CI image we target has them. If this throws the e2e is meant // to fail loudly (worktree creation requires a git repo). execSync("git init -q -b main", { cwd: dir }); - execSync('git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', { + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { cwd: dir, }); } @@ -69,9 +69,9 @@ test.describe("session lifecycle (real data flow)", () => { rendererLog, expectNoRendererErrors, }) => { - // 1. Health check — confirms daemon + IPC layer are up. + // 1. Health check — confirms the renderer and IPC layer are up. const health = await bootstrapHealth(window); - expect(health.daemon).toBe("ok"); + expect(health.ipc).toBeGreaterThan(0); expect(health.ipc).toBeGreaterThanOrEqual(30); // 29 original + sync:project // 2. Prepare a real git repo + empty config so this spec is hook-free. @@ -93,8 +93,8 @@ test.describe("session lifecycle (real data flow)", () => { { const list = await listProjects(window); expect(list).toHaveLength(1); - expect(list[0]!.id).toBe(project.id); - expect(list[0]!.sessions).toHaveLength(0); + expect(list[0]?.id).toBe(project.id); + expect(list[0]?.sessions).toHaveLength(0); } // Two passes — second one catches idempotency / cache-staleness bugs. @@ -134,9 +134,9 @@ test.describe("session lifecycle (real data flow)", () => { ]) { const slot = byStep.get(required); expect(slot, `missing step "${required}"`).toBeDefined(); - expect(slot!.error, `step "${required}" errored: ${slot!.error ?? ""}`).toBeUndefined(); - expect(slot!.running, `step "${required}" never reported running`).toBe(true); - expect(slot!.done, `step "${required}" never reached done`).toBe(true); + expect(slot?.error, `step "${required}" errored: ${slot?.error ?? ""}`).toBeUndefined(); + expect(slot?.running, `step "${required}" never reported running`).toBe(true); + expect(slot?.done, `step "${required}" never reached done`).toBe(true); } // 9. Filesystem assertions — worktree on disk + .env populated. @@ -153,19 +153,21 @@ test.describe("session lifecycle (real data flow)", () => { const db = dumpDb(projectPath); const dbRow = db.sessions.find((s) => s.id === sessionId); expect(dbRow, "session row missing").toBeDefined(); - expect(dbRow!.worktree_path).toBe(worktreePath); - expect(dbRow!.branch).toBe(`agentdock/${sessionId}`); - const persistedPorts = dbRow!.ports ? (JSON.parse(dbRow!.ports) as Record) : null; + expect(dbRow?.worktree_path).toBe(worktreePath); + expect(dbRow?.branch).toBe(`agentdock/${sessionId}`); + const persistedPorts = dbRow?.ports + ? (JSON.parse(dbRow?.ports) as Record) + : null; expect(persistedPorts, "ports JSON missing in DB").not.toBeNull(); - expect(typeof persistedPorts!.FRONTEND_PORT).toBe("number"); + expect(typeof persistedPorts?.FRONTEND_PORT).toBe("number"); // 11. Renderer-visible projects.list contains this session. { const list = await listProjects(window); - const sess = list[0]!.sessions.find((s) => s.id === sessionId); + const sess = list[0]?.sessions.find((s) => s.id === sessionId); expect(sess, "renderer projects.list missing session").toBeDefined(); - expect(sess!.branch).toBe(`agentdock/${sessionId}`); - expect(sess!.ports).toBeTruthy(); + expect(sess?.branch).toBe(`agentdock/${sessionId}`); + expect(sess?.ports).toBeTruthy(); } // 12. Rename → confirm git branch is renamed (8ec663a fix). @@ -178,20 +180,19 @@ test.describe("session lifecycle (real data flow)", () => { { const db2 = dumpDb(projectPath); const row2 = db2.sessions.find((s) => s.id === sessionId); - expect(row2!.branch).toBe(`agentdock/${sessionId}`); - expect(row2!.name).toBe(renamed); + expect(row2?.branch).toBe(`agentdock/${sessionId}`); + expect(row2?.name).toBe(renamed); // git itself should have the original branch (rename is a no-op // since branch is always agentdock/). - const branches = execFileSync( - "git", - ["branch", "--list", "agentdock/*"], - { cwd: projectPath, encoding: "utf-8" }, - ); + const branches = execFileSync("git", ["branch", "--list", "agentdock/*"], { + cwd: projectPath, + encoding: "utf-8", + }); expect(branches).toContain(`agentdock/${sessionId}`); expect(branches).not.toContain(`agentdock/${renamed}`); } - // 13. Delete — exercises SSE-equivalent streaming on session:delete + // 13. Delete — exercises streamed IPC completion on session:delete // (await fires when our handler sends `session::complete`). const deletePromise = (async () => { const tail = await awaitSessionComplete(window, sessionId, 30_000).catch((err) => { @@ -218,19 +219,21 @@ test.describe("session lifecycle (real data flow)", () => { expect(existsSync(worktreePath), `worktree dir leaked: ${worktreePath}`).toBe(false); { const db3 = dumpDb(projectPath); - expect(db3.sessions.find((s) => s.id === sessionId), "DB row leaked").toBeUndefined(); + expect( + db3.sessions.find((s) => s.id === sessionId), + "DB row leaked", + ).toBeUndefined(); } { const list = await listProjects(window); - expect(list[0]!.sessions.find((s) => s.id === sessionId)).toBeUndefined(); + expect(list[0]?.sessions.find((s) => s.id === sessionId)).toBeUndefined(); } // The branch (agentdock/) should be gone after delete. // Rename never changes the branch, so verify both old and new names. - const branchesAfter = execFileSync( - "git", - ["branch", "--list", "agentdock/*"], - { cwd: projectPath, encoding: "utf-8" }, - ); + const branchesAfter = execFileSync("git", ["branch", "--list", "agentdock/*"], { + cwd: projectPath, + encoding: "utf-8", + }); expect(branchesAfter).not.toContain(`agentdock/${renamed}`); expect(branchesAfter).not.toContain(`agentdock/${sessionId}`); } @@ -246,7 +249,7 @@ test.describe("session lifecycle (real data flow)", () => { // 16. Surface diagnostic on failure of any subsequent step. if (mainLog.length === 0) { // No main-process output at all suggests Electron stderr piping - // is broken — this would silently swallow daemon errors in CI. + // is broken — this would silently swallow lifecycle errors in local E2E. throw new Error("mainLog is empty — Electron stderr piping not capturing"); } @@ -263,8 +266,9 @@ test.describe("session lifecycle (real data flow)", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const fontErrors = rendererLog.filter( - (e) => e.type === "error" - && (e.text.includes("agentdock-fonts://") || e.text.includes("net::ERR_FAILED")), + (e) => + e.type === "error" && + (e.text.includes("agentdock-fonts://") || e.text.includes("net::ERR_FAILED")), ); // Remove font errors from the capture buffer so expectNoRendererErrors // doesn't trip on them. diff --git a/e2e/session-orphan-branches.spec.ts b/e2e/session-orphan-branches.spec.ts index 041ebd3..48f64d1 100644 --- a/e2e/session-orphan-branches.spec.ts +++ b/e2e/session-orphan-branches.spec.ts @@ -19,24 +19,17 @@ import { execFileSync, execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { awaitSessionComplete, createSession, deleteOrphans, listOrphans } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; -import { - createSession, - listOrphans, - deleteOrphans, - awaitSessionComplete, -} from "./helpers/ipc"; -import { TID } from "./pages/testids"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -50,20 +43,21 @@ function writeEmptyConfig(dir: string): void { function listAgentdockBranches(projectPath: string): string[] { // execFile (no shell) — Windows cmd mangles the `'agentdock/*'` // glob via its single-quote handling otherwise. - const out = execFileSync( - "git", - ["branch", "--list", "agentdock/*"], - { cwd: projectPath, encoding: "utf-8" }, + const out = execFileSync("git", ["branch", "--list", "agentdock/*"], { + cwd: projectPath, + encoding: "utf-8", + }); + return ( + out + .split(/\r?\n/) + // `git branch --list` prefixes each line with a status marker: + // " branch" = local + // "* branch" = current branch (HEAD) + // "+ branch" = checked out in a registered worktree + // Strip whichever marker is present plus the trailing space. + .map((l) => l.replace(/^[*+]?\s+/, "").trim()) + .filter((l) => l.length > 0) ); - return out - .split(/\r?\n/) - // `git branch --list` prefixes each line with a status marker: - // " branch" = local - // "* branch" = current branch (HEAD) - // "+ branch" = checked out in a registered worktree - // Strip whichever marker is present plus the trailing space. - .map((l) => l.replace(/^[*+]?\s+/, "").trim()) - .filter((l) => l.length > 0); } test.describe("orphan branch scan + delete", () => { @@ -96,12 +90,13 @@ test.describe("orphan branch scan + delete", () => { )) as Array<{ id: string; path: string; sessions: unknown[] }>; const project = projects.find((p) => p.path === projectPath); expect(project, `project ${projectPath} not found in DB`).toBeDefined(); + if (!project) throw new Error(`project ${projectPath} not found in DB`); // 3. Create a live session through the IPC helper — this gives us // a real `agentdock/` branch that should NOT show // up in the orphans scan. const { sessionId } = await createSession(window, { - projectId: project!.id, + projectId: project.id, name: "live", }); const { result } = await awaitSessionComplete(window, sessionId, 30_000); @@ -124,7 +119,7 @@ test.describe("orphan branch scan + delete", () => { // Pass the projectId explicitly — the handler's "active project" // fallback points at launch cwd, not at the user-chosen project, // so multi-project setups need the explicit param to scope. - const orphans = await listOrphans(window, project!.id); + const orphans = await listOrphans(window, project?.id); const orphanBranches = orphans.filter((o) => o.reason === "orphan-branch"); expect( orphanBranches.map((o) => o.branch), @@ -139,7 +134,7 @@ test.describe("orphan branch scan + delete", () => { // the previous Electron version refused (it only took `paths`). const delResult = await deleteOrphans(window, { branches: [danglingBranch], - projectId: project!.id, + projectId: project?.id, }); expect(delResult.deleted).toContain(danglingBranch); expect( @@ -153,7 +148,7 @@ test.describe("orphan branch scan + delete", () => { expect(allAfter).not.toContain(danglingBranch); // 8. Another scan should now return empty for orphan-branches. - const orphansAfter = await listOrphans(window, project!.id); + const orphansAfter = await listOrphans(window, project?.id); expect(orphansAfter.filter((o) => o.reason === "orphan-branch")).toHaveLength(0); // 9. Safety: deleteOrphans must REFUSE non-agentdock branches @@ -162,9 +157,11 @@ test.describe("orphan branch scan + delete", () => { execFileSync("git", ["branch", "rogue/sneaky", "main"], { cwd: projectPath }); const rogueDelete = await deleteOrphans(window, { branches: ["rogue/sneaky"], - projectId: project!.id, + projectId: project?.id, }); - expect(rogueDelete.deleted, "rogue branch should NOT have been deleted").not.toContain("rogue/sneaky"); + expect(rogueDelete.deleted, "rogue branch should NOT have been deleted").not.toContain( + "rogue/sneaky", + ); expect(rogueDelete.failed.find((f) => f.branch === "rogue/sneaky")).toBeDefined(); expect( listAgentdockBranches(projectPath).concat( diff --git a/e2e/session-orphan-ui.spec.ts b/e2e/session-orphan-ui.spec.ts index 7849a4f..06c175f 100644 --- a/e2e/session-orphan-ui.spec.ts +++ b/e2e/session-orphan-ui.spec.ts @@ -22,17 +22,16 @@ import { execFileSync, execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -44,11 +43,10 @@ function writeEmptyConfig(dir: string): void { } function listAgentdockBranches(projectPath: string): string[] { - const out = execFileSync( - "git", - ["branch", "--list", "agentdock/*"], - { cwd: projectPath, encoding: "utf-8" }, - ); + const out = execFileSync("git", ["branch", "--list", "agentdock/*"], { + cwd: projectPath, + encoding: "utf-8", + }); return out .split(/\r?\n/) .map((l) => l.replace(/^[*+]?\s+/, "").trim()) @@ -111,9 +109,7 @@ test.describe("OrphanCleanModal real clicks", () => { // The success path triggers no alert (modal stays open with empty // list). The failure path WOULD pop an alert; we capture them via // the fixture and assert none happened. - const errorAlerts = dialogs.filter( - (d) => d.type === "alert" && /失败/.test(d.message), - ); + const errorAlerts = dialogs.filter((d) => d.type === "alert" && /失败/.test(d.message)); expect( errorAlerts, `unexpected delete-failure alerts: ${JSON.stringify(errorAlerts)}`, @@ -152,7 +148,9 @@ test.describe("OrphanCleanModal real clicks", () => { // The reason attribute sits ON the orphan-item itself, not a child, // so target by combined selector instead of `.filter({has:...})`. const dirItem = window.locator('[data-testid="orphan-item"][data-orphan-reason="no-git-file"]'); - const branchItem = window.locator('[data-testid="orphan-item"][data-orphan-reason="orphan-branch"]'); + const branchItem = window.locator( + '[data-testid="orphan-item"][data-orphan-reason="orphan-branch"]', + ); await expect(dirItem).toHaveCount(1); await expect(branchItem).toHaveCount(1); @@ -168,9 +166,7 @@ test.describe("OrphanCleanModal real clicks", () => { const { existsSync } = await import("node:fs"); expect(existsSync(fakeWtDir)).toBe(false); - const errorAlerts = dialogs.filter( - (d) => d.type === "alert" && /失败/.test(d.message), - ); + const errorAlerts = dialogs.filter((d) => d.type === "alert" && /失败/.test(d.message)); expect(errorAlerts).toHaveLength(0); }); }); diff --git a/e2e/session-port-reallocated.spec.ts b/e2e/session-port-reallocated.spec.ts deleted file mode 100644 index 28c8a94..0000000 --- a/e2e/session-port-reallocated.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -// @ts-nocheck -/** - * Reallocated-on-boot E2E. - * - * ⚠ SKIPPED — this test was written for the v1 `sync.declare` flow which - * was removed in F10-2a. The v2 equivalent is handled by - * `reconcileAndDeclareSessions()` in electron/main.ts, which uses the - * v2 `/sync` snapshot to detect port mismatches on boot. The test needs - * to be rewritten to exercise the v2 path: - * - * 1. Electron A boots, creates a session via v2 → daemon allocates port P. - * 2. Electron A quits (DB persists the session row). - * 3. We pre-occupy port P with a TCP listener. - * 4. Electron B boots → v2PortService listKnownSessions is empty (new - * process), but the v2 `/sync` snapshot shows the session. The - * reallocateAndDeclareSessions function detects the mismatch and - * pushes to reallocatedQueue. - * 5. Renderer picks up via bootstrap.reallocated(). - */ -import { - test as base, - expect, - _electron as electron, - type ElectronApplication, -} from "@playwright/test"; -import { execSync } from "node:child_process"; -import { createServer } from "node:net"; -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const ROOT = process.cwd(); -const test = base.extend({}); - -function mainEntry(): string { - const dir = join(ROOT, "out", "main"); - const files = readdirSync(dir).filter((f) => f.endsWith(".js")); - return join(dir, files[0]!); -} - -function tmp(label: string): string { - const d = join( - tmpdir(), - `agentdock-e2e-${label}-${process.hrtime.bigint().toString(36)}`, - ); - mkdirSync(d, { recursive: true }); - return d; -} - -async function launch(args: { - dataDir: string; - daemonDir: string; - userDataDir: string; -}): Promise { - return electron.launch({ - args: [mainEntry(), `--user-data-dir=${args.userDataDir}`], - cwd: args.dataDir, - env: { - ...process.env, - AGENTDOCK_DATA_DIR: args.dataDir, - AGENTDOCK_DAEMON_BASE_DIR: args.daemonDir, - AGENTDOCK_V2: "1", - FRONTEND_PORT: "5173", - AGENTDOCK_USE_BUN: "1", - ELECTRON_DISABLE_GPU: "1", - ELECTRON_ENABLE_LOGGING: "1", - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --experimental-sqlite`.trim(), - }, - timeout: 30_000, - }); -} - -async function readyWindow(app: ElectronApplication) { - const w = await app.firstWindow({ timeout: 20_000 }); - await w.waitForLoadState("domcontentloaded"); - await w.waitForFunction( - () => typeof (window as unknown as { api?: unknown }).api === "object", - null, - { timeout: 10_000 }, - ); - return w; -} - -function prepareGitRepo(dir: string): void { - mkdirSync(dir, { recursive: true }); - execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); -} - -function writeEmptyConfig(dir: string): void { - writeFileSync( - join(dir, "agentdock.config.yaml"), - `version: "1"\nresources:\n sync: []\nhooks: {}\n`, - "utf-8", - ); -} - -test.skip("port collision on boot → v2 /sync detects mismatch → bootstrap.reallocated surfaces", async () => { - const dataDir = tmp("data"); - const daemonDir = tmp("daemon"); - const userDataA = tmp("user-a"); - const userDataB = tmp("user-b"); - - const projectPath = join(dataDir, "reallocated-project"); - prepareGitRepo(projectPath); - writeEmptyConfig(projectPath); - - let appA: ElectronApplication | null = null; - let appB: ElectronApplication | null = null; - const blockers: ReturnType[] = []; - - try { - // ── Phase 1: boot A, create project + session ──────────────────── - appA = await launch({ dataDir, daemonDir, userDataDir: userDataA }); - const winA = await readyWindow(appA); - - const project = await winA.evaluate( - (p: string) => - ( - window as unknown as { - api: { - db: { - projects: { - create: (n: string, p: string) => Promise<{ id: string; path: string }>; - }; - }; - }; - } - ).api.db.projects.create("reallocated", p), - projectPath, - ); - - // Install the stream wrapper INSIDE the create call (same pattern - // as helpers/ipc.ts's createSession) so we can await complete. - const sessionId = (await winA.evaluate( - (pid: string) => { - const w = window as unknown as { - api: { - sessions: { - create: (p: unknown) => Promise<{ sessionId: string }>; - stream: (id: string) => { - onComplete: (cb: (e: unknown) => void) => () => void; - }; - }; - }; - __e2eDone?: Record; - }; - const store = (w.__e2eDone ??= {}); - return w.api.sessions - .create({ projectId: pid, name: "s" }) - .then((r) => { - w.api.sessions.stream(r.sessionId).onComplete((c) => { - store[r.sessionId] = c; - }); - return r; - }) - .then((r) => r.sessionId); - }, - project.id, - )) as string; - - await expect - .poll( - async () => - (await winA.evaluate( - (id: string) => - ((window as unknown as { __e2eDone?: Record }) - .__e2eDone?.[id] ?? null) as { success?: boolean } | null, - sessionId, - )) as { success?: boolean } | null, - { timeout: 30_000 }, - ) - .toMatchObject({ success: true }); - - // Grab the allocated ports from the renderer-side projects.list. - const projectsBefore = (await winA.evaluate(() => - ( - window as unknown as { - api: { db: { projects: { list: () => Promise } } }; - } - ).api.db.projects.list(), - )) as Array<{ - id: string; - sessions: Array<{ id: string; ports: Record | null }>; - }>; - const sessBefore = projectsBefore[0]!.sessions.find((s) => s.id === sessionId); - expect(sessBefore?.ports, "session has no ports yet").not.toBeNull(); - const oldPorts = sessBefore!.ports!; - const portValues = Object.values(oldPorts); - expect(portValues.length).toBeGreaterThan(0); - - // ── Phase 2: quit A ────────────────────────────────────────────── - await appA.close(); - appA = null; - // Give the daemon child + lock + WAL flush time to settle. - await new Promise((r) => setTimeout(r, 1_500)); - - // ── Phase 3: occupy the first allocated port ───────────────────── - // The daemon's reallocate trigger is "isPortAvailable returns false - // for ANY of the session's ports". Stealing the first one is - // enough. - const stolen = portValues[0]!; - const blocker = createServer(); - await new Promise((resolve, reject) => { - blocker.once("error", reject); - blocker.listen(stolen, "127.0.0.1", () => resolve()); - }); - blockers.push(blocker); - - // ── Phase 4: boot B with same data + daemon dir ────────────────── - appB = await launch({ dataDir, daemonDir, userDataDir: userDataB }); - const winB = await readyWindow(appB); - - // Give the boot-time reconcileAndDeclareSessions a beat to run. - await new Promise((r) => setTimeout(r, 1_500)); - - // ── Phase 5: bootstrap.reallocated should return our session ───── - const reallocs = (await winB.evaluate(() => - ( - window as unknown as { - api: { - bootstrap: { - reallocated: () => Promise< - Array<{ - sessionId: string; - oldPorts: Record; - newPorts: Record; - }> - >; - }; - }; - } - ).api.bootstrap.reallocated(), - )) as Array<{ - sessionId: string; - oldPorts: Record; - newPorts: Record; - }>; - - const ours = reallocs.find((r) => r.sessionId === sessionId); - expect( - ours, - `reallocated queue did NOT contain our session — v2 /sync reconciliation didn't run, or didn't detect the collision. queue=${JSON.stringify(reallocs)}`, - ).toBeDefined(); - expect(ours!.oldPorts).toMatchObject(oldPorts); - // At least the stolen port must have changed. - expect(ours!.newPorts[Object.keys(oldPorts)[0]!]).not.toBe(stolen); - - // ── Phase 6: DB + .env reflect the new ports ───────────────────── - const projectsAfter = (await winB.evaluate(() => - ( - window as unknown as { - api: { db: { projects: { list: () => Promise } } }; - } - ).api.db.projects.list(), - )) as Array<{ sessions: Array<{ id: string; ports: Record | null }> }>; - const sessAfter = projectsAfter[0]!.sessions.find((s) => s.id === sessionId); - expect(sessAfter?.ports).toMatchObject(ours!.newPorts); - - // .env file - const envPath = join( - projectPath, - ".agentdock", - "worktrees", - sessionId, - ".env", - ); - expect(existsSync(envPath), `.env missing at ${envPath}`).toBe(true); - const envBody = readFileSync(envPath, "utf-8"); - const firstKey = Object.keys(ours!.newPorts)[0]!; - expect(envBody).toMatch( - new RegExp(`^${firstKey}=${ours!.newPorts[firstKey]}\\b`, "m"), - ); - } finally { - for (const s of blockers) { - await new Promise((r) => s.close(() => r())); - } - if (appB) await appB.close().catch(() => {}); - if (appA) await appA.close().catch(() => {}); - await new Promise((r) => setTimeout(r, 1_000)); - for (const d of [dataDir, daemonDir, userDataA, userDataB]) { - try { - rmSync(d, { recursive: true, force: true }); - } catch {} - } - } -}); diff --git a/e2e/session-project-switch.spec.ts b/e2e/session-project-switch.spec.ts index bb5e0cb..3711452 100644 --- a/e2e/session-project-switch.spec.ts +++ b/e2e/session-project-switch.spec.ts @@ -3,7 +3,7 @@ * * Tests that session state persists when switching between projects */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; @@ -12,14 +12,7 @@ const PROJECT_1 = "D:\\ProgramTest\\e2e-test-project"; const PROJECT_2 = "D:\\ProgramTest\\e2e-test-project-2"; test.describe("Session state persistence", () => { - test("session persists after switching projects", async ({ - window, - dataDir, - pageErrors, - dialogs, - rendererLog, - expectNoRendererErrors, - }) => { + test("session persists after switching projects", async ({ window }) => { const home = new HomePage(window); const sidebar = new SidebarPage(window); diff --git a/e2e/session-rename-ui.spec.ts b/e2e/session-rename-ui.spec.ts index 257ffff..3438219 100644 --- a/e2e/session-rename-ui.spec.ts +++ b/e2e/session-rename-ui.spec.ts @@ -9,25 +9,24 @@ * 3. Right-click a card, pick "rename" from context menu -- verify rename works. * * NOTE: Name persistence in the DB is not verified because v2 architecture - * stores session state in the daemon, not the local DB. We verify the + * stores session state through the main-process lifecycle. We verify the * rename behavior through DOM assertions only. */ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -48,7 +47,6 @@ test.describe("session rename UI", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -59,38 +57,41 @@ test.describe("session rename UI", () => { const home = new HomePage(window); const sidebar = new SidebarPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); // Open project and create a session. await home.openProject(projectPath); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); // Wait for the card to settle (ports visible = lifecycle complete). - // If ports never appear (daemon /sessions/allocate 404), the rename + // If ports never appear while session setup is settling, the rename // may still work since it only needs the card to be interactive. - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => { - // Ports may not appear in v2 mode with broken daemon; continue. - }); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => { + // Ports may not appear before the card settles; continue. + }); - // Wait for the SSE sync to land the real sessionId. The card + // Wait for the persisted session update to land the real sessionId. The card // initially has a `temp-` id; full-suite runs in // particular can be slow enough that the real id doesn't appear // by the time we hit this assertion. await expect - .poll(async () => { - const id = await card.getAttribute("data-session-id"); - return id && !id.startsWith("temp-") ? id : null; - }, { timeout: 20_000 }) + .poll( + async () => { + const id = await card.getAttribute("data-session-id"); + return id && !id.startsWith("temp-") ? id : null; + }, + { timeout: 20_000 }, + ) .toBeTruthy() .catch(() => { - // Best-effort — if the SSE sync is still pending, the rename + // Best-effort — if the persisted update is still pending, the rename // will likely work but on a temp id; the DOM assertion below // still verifies the user-visible rename behavior. }); @@ -99,13 +100,11 @@ test.describe("session rename UI", () => { // in full-suite runs the card is briefly in creating state where // .session-name may not be visible yet. We also re-query the card // each time because the sidebar can re-render between polls when - // SSE syncs land, and the original `card` reference may be stale. + // Session updates can re-render the card, making the original reference stale. await expect .poll( async () => { - const fresh = window - .locator(`[data-testid="${TID.sessionCard}"]`) - .first(); + const fresh = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); const el = fresh.locator(".session-name"); if ((await el.count()) === 0) return null; return await el.textContent(); @@ -113,9 +112,7 @@ test.describe("session rename UI", () => { { timeout: 20_000, message: "session-name never appeared" }, ) .toBeTruthy(); - const freshCard = window - .locator(`[data-testid="${TID.sessionCard}"]`) - .first(); + const freshCard = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); const nameEl = freshCard.locator(".session-name"); const originalName = await nameEl.textContent(); expect(originalName).toBeTruthy(); @@ -137,16 +134,15 @@ test.describe("session rename UI", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); expect(pageErrors).toHaveLength(0); const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); @@ -156,7 +152,6 @@ test.describe("session rename UI", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -167,38 +162,39 @@ test.describe("session rename UI", () => { const home = new HomePage(window); const sidebar = new SidebarPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); await home.openProject(projectPath); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); - // Wait for the SSE sync to land the real sessionId. Best-effort + // Wait for the persisted session update to land the real sessionId. Best-effort // for full-suite runs where the sync can be slow. await expect - .poll(async () => { - const id = await card.getAttribute("data-session-id"); - return id && !id.startsWith("temp-") ? id : null; - }, { timeout: 20_000 }) + .poll( + async () => { + const id = await card.getAttribute("data-session-id"); + return id && !id.startsWith("temp-") ? id : null; + }, + { timeout: 20_000 }, + ) .toBeTruthy() .catch(() => {}); // Re-query the card fresh and poll for .session-name — the - // sidebar can re-render mid-test when SSE syncs land, leaving + // sidebar can re-render mid-test when session updates land, leaving // the original `card` reference stale. await expect .poll( async () => { - const fresh = window - .locator(`[data-testid="${TID.sessionCard}"]`) - .first(); + const fresh = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); const el = fresh.locator(".session-name"); if ((await el.count()) === 0) return null; return await el.textContent(); @@ -206,9 +202,7 @@ test.describe("session rename UI", () => { { timeout: 20_000, message: "session-name never appeared" }, ) .toBeTruthy(); - const freshCard = window - .locator(`[data-testid="${TID.sessionCard}"]`) - .first(); + const freshCard = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); const nameEl = freshCard.locator(".session-name"); const originalName = await nameEl.textContent(); @@ -231,16 +225,15 @@ test.describe("session rename UI", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); expect(pageErrors).toHaveLength(0); const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); @@ -250,7 +243,6 @@ test.describe("session rename UI", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -261,26 +253,29 @@ test.describe("session rename UI", () => { const home = new HomePage(window); const sidebar = new SidebarPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); await home.openProject(projectPath); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); - // Wait for the SSE sync to land the real sessionId. Best-effort + // Wait for the persisted session update to land the real sessionId. Best-effort // for full-suite runs where the sync can be slow. await expect - .poll(async () => { - const id = await card.getAttribute("data-session-id"); - return id && !id.startsWith("temp-") ? id : null; - }, { timeout: 20_000 }) + .poll( + async () => { + const id = await card.getAttribute("data-session-id"); + return id && !id.startsWith("temp-") ? id : null; + }, + { timeout: 20_000 }, + ) .toBeTruthy() .catch(() => {}); @@ -308,16 +303,15 @@ test.describe("session rename UI", () => { const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); expect(pageErrors).toHaveLength(0); const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/session-terminal-typing.spec.ts b/e2e/session-terminal-typing.spec.ts index 179cf8c..9db4cf7 100644 --- a/e2e/session-terminal-typing.spec.ts +++ b/e2e/session-terminal-typing.spec.ts @@ -10,20 +10,19 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TerminalPage } from "./pages/terminal"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -43,7 +42,6 @@ test.describe("terminal interaction flow", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -55,7 +53,7 @@ test.describe("terminal interaction flow", () => { const sidebar = new SidebarPage(window); const terminalPage = new TerminalPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); // 1. Open project. await home.openProject(projectPath); @@ -63,22 +61,22 @@ test.describe("terminal interaction flow", () => { // 2. Create a session. await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); // 3. Wait for ports to settle, then click the card to activate. const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); - // Soft-assert ports: in full-suite runs the daemon may not have + // Soft-assert ports: in full-suite runs session setup may not have // assigned ports by the time the card appears. The next // assertions (click card → terminal panel) work without ports. - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 20_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 20_000 }) + .catch(() => {}); const sessionId = (await card.getAttribute("data-session-id")) ?? ""; expect(sessionId, "session card missing data-session-id").not.toBe(""); // sessionId is captured to ensure the card is fully provisioned // (id is only set after the React Query projects list refreshes - // with the real UUID from the daemon). + // with the real persisted UUID). void sessionId; // Click the card to activate it (mounts TerminalManager). @@ -117,10 +115,7 @@ test.describe("terminal interaction flow", () => { }; }; } - ).api.terminals.write( - terminalId, - "echo hello\r", - ); + ).api.terminals.write(terminalId, "echo hello\r"); return { ok: true }; } catch (err) { return { @@ -131,10 +126,7 @@ test.describe("terminal interaction flow", () => { }, { terminalId }, ); - expect( - writeResult.ok, - `terminals.write failed: ${writeResult.error ?? "unknown"}`, - ).toBe(true); + expect(writeResult.ok, `terminals.write failed: ${writeResult.error ?? "unknown"}`).toBe(true); // 7. Give the shell a moment to echo the command, then verify the // terminal is still connected. The xterm canvas can't be @@ -142,28 +134,26 @@ test.describe("terminal interaction flow", () => { // authoritative health signal. await window.waitForTimeout(500); const status = await terminalPage.currentTerminal.getAttribute("data-status"); - expect( - status, - `terminal should still be connected after writing data (status=${status})`, - ).toBe("connected"); + expect(status, `terminal should still be connected after writing data (status=${status})`).toBe( + "connected", + ); // 9. Clean up: delete the session. const deleteBtn = card.locator(".session-close"); await deleteBtn.click(); await card.locator(".session-delete-confirm-yes").click(); - await expect - .poll(() => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(0); + await expect.poll(() => sidebar.cardCount(), { timeout: 30_000 }).toBe(0); expect(pageErrors).toHaveLength(0); // Filter out expected font CORS errors — the agentdock-fonts:// // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/session-ui-interaction.spec.ts b/e2e/session-ui-interaction.spec.ts index e92aea4..44933c2 100644 --- a/e2e/session-ui-interaction.spec.ts +++ b/e2e/session-ui-interaction.spec.ts @@ -19,7 +19,7 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TerminalPage } from "./pages/terminal"; @@ -28,16 +28,15 @@ import { TID } from "./pages/testids"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeProjectWithSlowHook(dir: string): void { // ~1 s afterCreateSession hook → background activity while user // interacts. Long enough that delete-during-hook is realistic. - writeFileSync(join(dir, "slow-hook.js"), `setTimeout(() => {}, 1000);`, "utf-8"); + writeFileSync(join(dir, "slow-hook.js"), "setTimeout(() => {}, 1000);", "utf-8"); writeFileSync( join(dir, "agentdock.config.yaml"), [ @@ -90,7 +89,9 @@ test.describe("real user interaction sequence", () => { expect(session1Id).toBeTruthy(); // Soft-assert ports visibility — the bug check is the React // infinite-loop, not the port panel render. - await expect(card1.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card1.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); // 3. ★ Click the card to activate it — this mounts TerminalManager // and triggers useSessionTerminals + the auto-select-first @@ -164,14 +165,13 @@ test.describe("real user interaction sequence", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const errors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), - ); - const maxDepth = errors.filter((e) => - /Maximum update depth exceeded/i.test(e.text), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); + const maxDepth = errors.filter((e) => /Maximum update depth exceeded/i.test(e.text)); expect( maxDepth, `React infinite-loop detected:\n${maxDepth.map((e) => e.text).join("\n---\n")}`, @@ -180,9 +180,6 @@ test.describe("real user interaction sequence", () => { errors, `unexpected renderer console.error logs:\n${errors.map((e) => `[${e.type}] ${e.text}`).join("\n---\n")}`, ).toHaveLength(0); - expect( - pageErrors, - `renderer pageerrors:\n${JSON.stringify(pageErrors)}`, - ).toHaveLength(0); + expect(pageErrors, `renderer pageerrors:\n${JSON.stringify(pageErrors)}`).toHaveLength(0); }); }); diff --git a/e2e/session-ui-slow-hook.spec.ts b/e2e/session-ui-slow-hook.spec.ts index e9816f3..b56cfde 100644 --- a/e2e/session-ui-slow-hook.spec.ts +++ b/e2e/session-ui-slow-hook.spec.ts @@ -19,7 +19,7 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TID } from "./pages/testids"; @@ -27,19 +27,14 @@ import { TID } from "./pages/testids"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeSlowHookProject(dir: string): void { // ~1.5 s afterCreateSession hook (long enough to overlap a delete). - writeFileSync( - join(dir, "slow-hook.js"), - `setTimeout(() => {}, 1500);`, - "utf-8", - ); + writeFileSync(join(dir, "slow-hook.js"), "setTimeout(() => {}, 1500);", "utf-8"); writeFileSync( join(dir, "agentdock.config.yaml"), [ @@ -70,7 +65,7 @@ test.describe("session UI flow with long-running hook (repro)", () => { rendererLog, expectNoRendererErrors, }) => { - test.setTimeout(120_000); // slow async hook + daemon cycles + test.setTimeout(120_000); // slow async hook + worktree cleanup const projectPath = join(dataDir, "slow-hook-project"); prepareGitRepo(projectPath); writeSlowHookProject(projectPath); @@ -83,9 +78,7 @@ test.describe("session UI flow with long-running hook (repro)", () => { // Create the session. await sidebar.clickNewSession(); - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); const card = window.locator(`[data-testid="${TID.sessionCard}"]`).first(); await expect(card).toBeVisible(); @@ -96,7 +89,9 @@ test.describe("session UI flow with long-running hook (repro)", () => { // Soft-assert: ports panel rendering is environment-dependent in // full-suite runs; the real assertion is the React infinite-loop // check below. - await expect(card.locator(".session-ports")).toBeVisible({ timeout: 15_000 }).catch(() => {}); + await expect(card.locator(".session-ports")) + .toBeVisible({ timeout: 15_000 }) + .catch(() => {}); // Immediately delete via UI — async hook may still be running and // bgHookStatus is polling every 2 s. Each delete step event + @@ -125,14 +120,13 @@ test.describe("session UI flow with long-running hook (repro)", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const errors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), - ); - const maxDepth = errors.filter((e) => - /Maximum update depth exceeded/i.test(e.text), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); + const maxDepth = errors.filter((e) => /Maximum update depth exceeded/i.test(e.text)); expect( maxDepth, `React infinite-loop detected:\n${maxDepth.map((e) => e.text).join("\n---\n")}`, diff --git a/e2e/session-ui.spec.ts b/e2e/session-ui.spec.ts index 7da3ef1..6bb7f09 100644 --- a/e2e/session-ui.spec.ts +++ b/e2e/session-ui.spec.ts @@ -19,20 +19,19 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { dumpDb } from "./helpers/dump"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TabBarPage } from "./pages/tab-bar"; import { TID } from "./pages/testids"; -import { dumpDb } from "./helpers/dump"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -73,16 +72,16 @@ test.describe("session UI flow (real clicks)", () => { // 4. The TabBar should now show a tab for the project. const tabBar = new TabBarPage(window); await expect(tabBar.tabBar).toBeVisible(); - await expect( - window.locator(`[data-testid="${TID.projectTab}"]`).first(), - ).toBeVisible({ timeout: 10_000 }); + await expect(window.locator(`[data-testid="${TID.projectTab}"]`).first()).toBeVisible({ + timeout: 10_000, + }); // 5. SessionSidebar should appear (we landed on /app/$projectId). const sidebar = new SidebarPage(window); await expect(sidebar.sidebar).toBeVisible({ timeout: 10_000 }); expect(await sidebar.cardCount()).toBe(0); - // 6. Click "+" to create a session. The renderer's useCreateSessionSSE + // 6. Click "+" to create a session. The renderer's session mutation hook // handles the IPC + optimistic update; the card should appear. await sidebar.clickNewSession(); @@ -90,9 +89,7 @@ test.describe("session UI flow (real clicks)", () => { // sidebar's optimistic insert runs as soon as `sessions:create` // resolves with `{sessionId}`. We don't know the id yet, so // wait by count. - await expect - .poll(async () => sidebar.cardCount(), { timeout: 30_000 }) - .toBe(1); + await expect.poll(async () => sidebar.cardCount(), { timeout: 30_000 }).toBe(1); // 8. Find the card's sessionId from its data attribute, then assert // its DB row landed with a real port. @@ -116,23 +113,26 @@ test.describe("session UI flow (real clicks)", () => { // On timeout the message should show what we DID see so we // know whether the DB is empty (wrong file?) vs has rows with // null ports (race / scan not run yet). - if (!row) return JSON.stringify({ sessionId, sawSessions: dump.sessions.map((s) => s.id), sawProjects: dump.projects.length }); + if (!row) + return JSON.stringify({ + sessionId, + sawSessions: dump.sessions.map((s) => s.id), + sawProjects: dump.projects.length, + }); return row.ports; }, { timeout: 30_000, message: "session ports never persisted" }, ) .not.toBeNull(); - // 10. Delete the session via UI: click the close ✕ inside the card - // (which arms the inline confirm), then click the ✓ to confirm. - // Selectors come from SessionCard.tsx — `.session-close` triggers - // the confirm row, `.session-delete-confirm-yes` accepts it. + // 10. Delete the session via UI: click the close ✕ inside the card, + // then confirm in the shared deletion modal. // Use a longer timeout — in full-suite runs the card may still // be transitioning to active state when we get here. const deleteBtn = card.locator(".session-close"); await deleteBtn.waitFor({ state: "visible", timeout: 15_000 }); await deleteBtn.click(); - const confirmYes = card.locator(".session-delete-confirm-yes"); + const confirmYes = window.getByTestId("confirm-delete-ok"); await confirmYes.waitFor({ state: "visible", timeout: 10_000 }); await confirmYes.click(); @@ -146,10 +146,7 @@ test.describe("session UI flow (real clicks)", () => { // 12. DB row should be gone too. await expect - .poll( - () => dumpDb(dataDir).sessions.find((s) => s.id === sessionId), - { timeout: 5_000 }, - ) + .poll(() => dumpDb(dataDir).sessions.find((s) => s.id === sessionId), { timeout: 5_000 }) .toBeUndefined(); // 13. No native dialogs should have popped during the flow. @@ -161,10 +158,7 @@ test.describe("session UI flow (real clicks)", () => { ).toHaveLength(0); // 14. No renderer pageerrors either. - expect( - pageErrors, - `renderer pageerrors: ${JSON.stringify(pageErrors)}`, - ).toHaveLength(0); + expect(pageErrors, `renderer pageerrors: ${JSON.stringify(pageErrors)}`).toHaveLength(0); // 15. Verify the main-process log doesn't contain an EPIPE // "uncaught exception" line — the bug surfaced as a popup @@ -187,7 +181,7 @@ test.describe("session UI flow (real clicks)", () => { (e) => e.type === "error" && !e.text.includes("agentdock-fonts://") && - !((e.location && e.location.url) || "").includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && !e.text.includes("net::ERR_FAILED"), ); expect( diff --git a/e2e/sse-resync-required.spec.ts b/e2e/sse-resync-required.spec.ts deleted file mode 100644 index 7d7282b..0000000 --- a/e2e/sse-resync-required.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -// @ts-nocheck -/** - * F7 — §7.3 resync-required end-to-end (新架构 §7.3). - * - * Verifies that the v2 daemon emits a `resync-required` SSE frame when a - * consumer reconnects with a Last-Event-ID that is older than the start - * of the ring buffer (buffer overflow or restart path). The - * SseConsumer.onResyncRequired wiring in electron/main.ts is covered by - * the unit test (electron/main/__tests__/sse-consumer-resync-wiring.test.ts); - * here we prove the daemon side of §7.3 works so the two halves compose. - * - * Strategy: - * 1. Boot a real daemon via the standard Electron fixture. - * 2. Wait for daemon READY (avoid RECOVERING-window 409s). - * 3. Trigger one /session/create so the SSE bus has at least one event - * (seq=1 published → buffer non-empty). - * 4. Open a side-channel SSE connection with Last-Event-ID=0. That is - * WITHIN the buffer — we should see the replayed event + hello. - * 5. Open a second side-channel SSE connection with Last-Event-ID=99999 - * (well past buffer[0].seq-1=0). Per sse-bus.replaySince(), the - * daemon must emit a `resync-required` frame. - * 6. Assert the second connection receives `resync-required`. - */ -import { execSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; - -function prepareGitRepo(dir: string): void { - mkdirSync(dir, { recursive: true }); - execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); -} - -function writeEmptyConfig(dir: string): void { - writeFileSync( - join(dir, "agentdock.config.yaml"), - `version: "1"\nresources:\n sync: []\nhooks: {}\n`, - "utf-8", - ); -} - -async function waitForDaemonReady( - window: import("@playwright/test").Page, - timeoutMs = 20_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const health = await window.evaluate(async () => { - return (await window.api.daemon.health()) as { - state?: string; - lifecycleState?: string; - }; - }); - const state = health.lifecycleState ?? health.state; - if (state === "ready" || state === "READY") return; - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`waitForDaemonReady: daemon not READY after ${timeoutMs}ms`); -} - -async function callV2( - window: import("@playwright/test").Page, - path: string, - body: unknown = {}, -): Promise<{ success: boolean; status?: number; body?: unknown }> { - return window.evaluate( - async ({ p, b }) => { - const res = (await window.api.daemon.faultInject(p, b)) as { - success: boolean; - status?: number; - body?: unknown; - }; - return res; - }, - { p: path, b: body }, - ); -} - -/** - * Open a side-channel SSE connection from the test process. Returns the - * concatenated text/event-stream body up to `deadlineMs`. We don't try - * to parse frames here — the test asserts on substring presence to - * keep the harness simple. - */ -async function fetchSse( - port: number, - lastEventId: number, - deadlineMs: number, -): Promise { - const ac = new AbortController(); - const t = setTimeout(() => ac.abort(), deadlineMs); - try { - const res = await fetch(`http://127.0.0.1:${port}/events`, { - headers: { - Accept: "text/event-stream", - "Last-Event-ID": String(lastEventId), - }, - signal: ac.signal, - }); - if (!res.ok || !res.body) return ""; - const reader = res.body.getReader(); - const decoder = new TextDecoder("utf-8"); - let acc = ""; - const start = Date.now(); - while (Date.now() - start < deadlineMs) { - const { value, done } = await reader.read(); - if (done) break; - acc += decoder.decode(value, { stream: true }); - // Early exit once we've collected enough signal. - if (acc.includes("resync-required") || acc.includes("event: hello")) { - break; - } - } - try { - await reader.cancel(); - } catch { - /* already cancelled */ - } - return acc; - } catch { - return ""; - } finally { - clearTimeout(t); - } -} - -test.describe("F7 — SSE resync-required wiring (新架构 §7.3)", () => { - test("daemon emits resync-required when Last-Event-ID is before buffer", async ({ - window, - dataDir, - }) => { - const isV2Mode = process.env.AGENTDOCK_V2 === "1"; - if (!isV2Mode) { - test.skip(true, "F7 spec runs under AGENTDOCK_V2=1"); - return; - } - - await waitForDaemonReady(window); - - // Resolve the daemon port via the public IPC. - const health = (await window.evaluate(async () => { - return (await window.api.daemon.health()) as { port: number }; - })) as { port: number }; - expect(health.port).toBeGreaterThan(0); - - // Need an active project for /session/create to be valid in v2. - const projectPath = join(dataDir, "f7-resync-project"); - prepareGitRepo(projectPath); - writeEmptyConfig(projectPath); - const { HomePage } = await import("./pages/home"); - await new HomePage(window).openProject(projectPath); - - // Publish at least one event so the SSE bus buffer is non-empty - // (buffer length > 0 is required for replaySince() to ever return null). - const create = await callV2(window, "/session/create", { - clientId: "f7-e2e", - pid: 4242, - projectRoot: projectPath, - displayName: "f7-trigger", - }); - expect(create.success).toBe(true); - - // Side-channel consumer #1 — Last-Event-ID=0 is within the buffer - // (buffer starts at seq=1, and replaySince(0) returns events with - // seq>0, so we get the replay + hello, NOT resync-required). - const freshStream = await fetchSse(health.port, 0, 2000); - expect(freshStream).toContain("event: hello"); - expect(freshStream).not.toContain("event: resync-required"); - - // Side-channel consumer #2 — stale Last-Event-ID past the buffer. - // buffer[0].seq=1; replaySince(99999) compares 99999 < 1-1=0 which - // is false; but replaySince(99999) returns events with seq > 99999 - // which is empty — so we instead use a negative ID. The cleanest - // way to trigger overflow is to send Last-Event-ID < 0 (which the - // route coerces to 0). So we need a *different* trigger: send - // Last-Event-ID far BELOW buffer[0].seq-1. With buffer starting at - // seq=1, sending Last-Event-ID=0 and 99999 both behave identically - // (0 not below -1, 99999 above buffer end). We therefore drain the - // buffer by publishing many events so a stale seq can be forced. - // - // Quick path: publish until buffer overflows past the SSE_REPLAY_BUFFER - // cap, then reconnect with the original seq. The exact buffer size - // lives in plugins/constants.ts — we read it from the public debug - // surface. - const dbg = (await window.evaluate(async () => { - return (await window.api.daemon.debugState()) as { - sseBus?: { bufferSize?: number; firstSeq?: number; lastSeq?: number }; - } | null; - })) as { sseBus?: { bufferSize?: number; firstSeq?: number; lastSeq?: number } } | null; - expect(dbg?.sseBus).toBeDefined(); - const bufSize = dbg!.sseBus!.bufferSize ?? 0; - expect(bufSize).toBeGreaterThan(0); - - // Force a buffer overflow: publish enough events to evict the first - // seq from the ring buffer. Then reconnect with Last-Event-ID just - // before the new first seq. - const overflowCount = bufSize + 5; - for (let i = 0; i < overflowCount; i++) { - const sid = `f7-drain-${i}`; - const r = await callV2(window, "/session/create", { - clientId: "f7-drain", - pid: 9000 + i, - projectRoot: projectPath, - displayName: sid, - }); - expect(r.success).toBe(true); - } - - // Now the buffer has shifted forward. Read its current first seq. - const dbg2 = (await window.evaluate(async () => { - return (await window.api.daemon.debugState()) as { - sseBus?: { bufferSize?: number; firstSeq?: number; lastSeq?: number }; - } | null; - })) as { sseBus?: { firstSeq?: number; lastSeq?: number } } | null; - const firstSeq = dbg2?.sseBus?.firstSeq ?? 0; - const lastSeq = dbg2?.sseBus?.lastSeq ?? 0; - expect(firstSeq).toBeGreaterThan(0); - expect(lastSeq).toBeGreaterThan(firstSeq); - - // Connect with Last-Event-ID = firstSeq - 2 (i.e. before buffer[0].seq-1). - const staleStream = await fetchSse(health.port, firstSeq - 2, 3000); - expect(staleStream).toContain("event: resync-required"); - }); -}); \ No newline at end of file diff --git a/e2e/tab-management.spec.ts b/e2e/tab-management.spec.ts index 21300f9..e1a9e7e 100644 --- a/e2e/tab-management.spec.ts +++ b/e2e/tab-management.spec.ts @@ -11,20 +11,19 @@ import { execSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; +import { waitForAppReady } from "./helpers/ipc"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; import { TabBarPage } from "./pages/tab-bar"; import { TID } from "./pages/testids"; -import { waitForDaemonReady } from "./helpers/ipc"; function prepareGitRepo(dir: string): void { mkdirSync(dir, { recursive: true }); execSync("git init -q -b main", { cwd: dir }); - execSync( - 'git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init', - { cwd: dir }, - ); + execSync("git -c user.email=e2e@local -c user.name=E2E commit --allow-empty -q -m init", { + cwd: dir, + }); } function writeEmptyConfig(dir: string): void { @@ -40,7 +39,6 @@ test.describe("tab management", () => { window, dataDir, pageErrors, - dialogs, rendererLog, expectNoRendererErrors, }) => { @@ -55,7 +53,7 @@ test.describe("tab management", () => { const tabBar = new TabBarPage(window); const sidebar = new SidebarPage(window); - await waitForDaemonReady(window); + await waitForAppReady(window); // 1. Open project A from the home page. await expect(home.openProjectButton).toBeVisible(); @@ -78,9 +76,7 @@ test.describe("tab management", () => { // Both tabs should be visible. const allTabs = window.locator(`[data-testid="${TID.projectTab}"]`); - await expect - .poll(() => allTabs.count(), { timeout: 10_000 }) - .toBeGreaterThanOrEqual(2); + await expect.poll(() => allTabs.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2); // Find project B's tab. const tabs = await allTabs.all(); @@ -114,9 +110,7 @@ test.describe("tab management", () => { await window.waitForTimeout(500); // Only one tab (A) should remain. - await expect - .poll(() => allTabs.count(), { timeout: 5_000 }) - .toBe(1); + await expect.poll(() => allTabs.count(), { timeout: 5_000 }).toBe(1); // A's tab should still be visible and active. await expect(tabA).toBeVisible(); @@ -128,9 +122,7 @@ test.describe("tab management", () => { await home.navigateModalTo(projectBPath); // Two tabs should be visible again. - await expect - .poll(() => allTabs.count(), { timeout: 10_000 }) - .toBeGreaterThanOrEqual(2); + await expect.poll(() => allTabs.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2); // 8. Find the re-opened B's project ID. const tabsAfterReopen = await allTabs.all(); @@ -167,10 +159,11 @@ test.describe("tab management", () => { // protocol may not resolve in test environments where fonts // haven't been downloaded yet. These are benign. const consoleErrors = rendererLog.filter( - (e) => e.type === "error" - && !e.text.includes("agentdock-fonts://") - && !((e.location && e.location.url) || "").includes("agentdock-fonts://") - && !e.text.includes("net::ERR_FAILED"), + (e) => + e.type === "error" && + !e.text.includes("agentdock-fonts://") && + !(e.location?.url || "").includes("agentdock-fonts://") && + !e.text.includes("net::ERR_FAILED"), ); expect(consoleErrors).toHaveLength(0); expectNoRendererErrors(); diff --git a/e2e/test-preload.spec.ts b/e2e/test-preload.spec.ts index e5f4fb5..efbb1fc 100644 --- a/e2e/test-preload.spec.ts +++ b/e2e/test-preload.spec.ts @@ -1,7 +1,7 @@ -import { test as base, expect, _electron as electron } from "@playwright/test"; -import { join } from "node:path"; -import { existsSync, readdirSync, mkdirSync, rmSync } from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test as base, _electron as electron } from "@playwright/test"; const ROOT = process.cwd(); @@ -28,14 +28,16 @@ test_("debug: what is window.api", async () => { // Print what's in window const windowKeys = await window.evaluate(() => { - return Object.keys(window).filter(k => k.includes('api') || k === 'api' || k === 'electron'); + return Object.keys(window).filter((k) => k.includes("api") || k === "api" || k === "electron"); }); console.log("Window keys with 'api':", windowKeys); const apiType = await window.evaluate(() => typeof (window as { api?: unknown }).api); console.log("typeof window.api:", apiType); - const electronType = await window.evaluate(() => typeof (window as { electron?: unknown }).electron); + const electronType = await window.evaluate( + () => typeof (window as { electron?: unknown }).electron, + ); console.log("typeof window.electron:", electronType); await app.close(); diff --git a/e2e/ui-lifecycle-render.spec.ts b/e2e/ui-lifecycle-render.spec.ts index b403040..85bb18f 100644 --- a/e2e/ui-lifecycle-render.spec.ts +++ b/e2e/ui-lifecycle-render.spec.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; /** * E2E Test: Full lifecycle — create + verify + delete (skipped: UI timing issues) * @@ -6,29 +9,33 @@ * with the test fixture (sidebar not rendering immediately after openProject). * Re-enable once the fixture timing is resolved. */ -import { test, expect } from "./fixtures/electron-fixture"; +import { expect, test } from "./fixtures/electron-fixture"; import { HomePage } from "./pages/home"; import { SidebarPage } from "./pages/sidebar"; -import { execSync } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; function createTempProject(name: string): string { const projectPath = join("D:\\ProgramTest", name); if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); mkdirSync(projectPath, { recursive: true }); execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email \"test@test.com\"", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name \"Test\"", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: projectPath, stdio: "ignore" }); writeFileSync(join(projectPath, "README.md"), "# Test Project"); execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync("git commit -m \"init\"", { cwd: projectPath, stdio: "ignore" }); - writeFileSync(join(projectPath, "agentdock.config.yaml"), "hooks:\n afterCreateSession: []\n afterDeleteSession: []\n"); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); + writeFileSync( + join(projectPath, "agentdock.config.yaml"), + "hooks:\n afterCreateSession: []\n afterDeleteSession: []\n", + ); return projectPath; } function cleanupTempProject(projectPath: string): void { - try { if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); } catch { /* */ } + try { + if (existsSync(projectPath)) rmSync(projectPath, { recursive: true, force: true }); + } catch { + /* */ + } } test.describe("Session lifecycle E2E (UI)", () => { diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c3c01c8..d7b1f03 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,8 +11,8 @@ */ import { cpSync, existsSync } from "node:fs"; import { resolve } from "node:path"; -import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import react from "@vitejs/plugin-react"; import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import type { Plugin } from "vite"; import { loadDotEnvIntoProcess } from "./plugins/env.js"; @@ -24,7 +24,11 @@ import { loadDotEnvIntoProcess } from "./plugins/env.js"; // loads .env — see 新架构 §8. // Gracefully skip when .env is absent (CI/CD, fresh clone) instead of // throwing — the build still works, just without env-var port overrides. -try { loadDotEnvIntoProcess(); } catch { /* .env optional */ } +try { + loadDotEnvIntoProcess(); +} catch { + /* .env optional */ +} /** * Copy `plugins/pty-host.cjs` next to the main bundle. The PTY host is @@ -66,7 +70,7 @@ function copyBundledFontsPlugin(): Plugin { const src = resolve(__dirname, "public/fonts"); const dest = resolve(__dirname, "out/main/fonts"); if (!existsSync(src)) { - this.warn?.(`public/fonts/ missing — run \`bun run download-fonts\` first`); + this.warn?.("public/fonts/ missing — run `bun run download-fonts` first"); return; } cpSync(src, dest, { recursive: true }); @@ -117,9 +121,9 @@ export default defineConfig({ }, server: { port: (() => { - const p = Number(process.env.FRONTEND_PORT); - if (!p || p < 1 || p > 65535) { - throw new Error("FRONTEND_PORT is required — set it in .env or environment"); + const p = Number(process.env.FRONTEND_PORT ?? "5200"); + if (!Number.isInteger(p) || p < 1 || p > 65535) { + throw new Error("FRONTEND_PORT must be an integer between 1 and 65535"); } return p; })(), @@ -129,4 +133,4 @@ export default defineConfig({ }, }, }, -}); \ No newline at end of file +}); diff --git a/electron/main.ts b/electron/main.ts index 8fc2132..234ac40 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,7 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { eq } from "drizzle-orm"; /** * Electron Main Process Entry — Single-Instance Architecture * @@ -13,25 +17,24 @@ * * Prod mode: loadFile from the renderer dist (dist/index.html). */ -import { app, BrowserWindow, dialog, ipcMain, protocol } from "electron"; -import { fileURLToPath } from "node:url"; -import { dirname, join, resolve } from "node:path"; -import { existsSync, unlinkSync } from "node:fs"; -import { IPC_CHANNEL_COUNT } from "./shared/api-types.js"; -import { registerAllIpc, type AllIpcDeps } from "./main/ipc/index.js"; +import { BrowserWindow, app, dialog, ipcMain, protocol } from "electron"; +import { migrateProjectsToGlobal, openGlobalDb } from "../plugins/db/global.js"; +import { getDbPath, openDb, setDbBasePath } from "../plugins/db/index.js"; +import * as schema from "../plugins/db/schema.js"; import { log } from "../plugins/logger.js"; -import { openGlobalDb } from "../plugins/db/global.js"; -import { setDbBasePath } from "../plugins/db/index.js"; -import { registerE2eReset } from "./main/e2e-reset.js"; -import { registerFontProtocol, ensureFontsReady } from "./main/fonts.js"; import { initAutoUpdater } from "./main/auto-updater.js"; +import { registerE2eReset } from "./main/e2e-reset.js"; +import { ensureFontsReady, registerFontProtocol } from "./main/fonts.js"; +import { type AllIpcDeps, registerAllIpc } from "./main/ipc/index.js"; import { createWindow } from "./main/window.js"; +import { IPC_CHANNEL_COUNT } from "./shared/api-types.js"; -// --- New single-instance imports --- -import { acquireInstanceLock, type FileLock } from "./main/instance-lock.js"; -import { createPortPool, type PortPoolInternal } from "./main/port-pool.js"; -import { createSessionManager, type SessionManager } from "./main/session-manager.js"; import { initGlobalSettings } from "../plugins/global-settings.js"; +// --- New single-instance imports --- +import { type FileLock, acquireInstanceLock } from "./main/instance-lock.js"; +import { type PortPoolInternal, createPortPool } from "./main/port-pool.js"; +import { type SessionManager, createSessionManager } from "./main/session-manager.js"; +import { restorePersistedSessions } from "./main/session-recovery.js"; // Resolve paths relative to this file (works in both dev and prod). const __filename = fileURLToPath(import.meta.url); @@ -167,6 +170,38 @@ async function bootstrap() { globalDbHandle = openGlobalDb(userDataDir); } + // Rehydrate every persisted worktree's port ownership before any renderer + // request can create a new session. The project DB is process-global in the + // single-instance architecture, so this must cover all projects, not only + // the currently selected one. + try { + const { db: persistedDb, sqlite } = openDb(); + try { + if (globalDbHandle) { + migrateProjectsToGlobal(globalDbHandle.db, getDbPath()); + } + const persistedSessions = persistedDb.select().from(schema.sessions).all(); + const persistedProjects = globalDbHandle?.db.select().from(schema.projects).all() ?? []; + const recovery = restorePersistedSessions( + persistedSessions, + persistedProjects, + sessionManager, + ); + for (const sessionId of recovery.staleCreatingSessionIds) { + persistedDb.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); + log.warn( + { sessionId }, + "removed interrupted creating session without committed ports during recovery", + ); + } + log.info(recovery, "persisted session ownership restored"); + } finally { + sqlite.close(); + } + } catch (err) { + log.warn({ err }, "persisted session ownership restore failed"); + } + // 4. Register ALL IPC handlers const ipcDeps: AllIpcDeps = { getProjectPath: () => activeProjectPath, @@ -205,14 +240,20 @@ async function bootstrap() { // E2E reset handler (simplified — no daemon to reset) registerE2eReset({ getProjectPath: () => activeProjectPath, - setProjectPath: (p) => { activeProjectPath = p; }, + setProjectPath: (p) => { + activeProjectPath = p; + }, }); log.info({ ipcChannels: IPC_CHANNEL_COUNT }, "IPC handlers registered"); // 4. Create the window and load the renderer - const win = createWindow((w) => { mainWindow = w; }); - win.on("closed", () => { mainWindow = null; }); + const win = createWindow((w) => { + mainWindow = w; + }); + win.on("closed", () => { + mainWindow = null; + }); // Kick off background font download — non-blocking, notifies renderer when done. void ensureFontsReady(win); @@ -266,7 +307,7 @@ protocol.registerSchemesAsPrivileged([ // // The decision is made here — before app.whenReady — so every IPC handler // that reads the DB path sees the right location. -import { resolveUserDataPath, migrateLegacyUserData, detectInstallMode } from "./main/userdata.js"; +import { detectInstallMode, migrateLegacyUserData, resolveUserDataPath } from "./main/userdata.js"; if (process.env.AGENTDOCK_USER_DATA_DIR) { app.setPath("userData", resolve(process.env.AGENTDOCK_USER_DATA_DIR)); @@ -279,10 +320,7 @@ if (process.env.AGENTDOCK_USER_DATA_DIR) { if (app.isPackaged) { const { migratedFrom } = migrateLegacyUserData(userDataPath); if (migratedFrom) { - log.info( - { userDataPath, migratedFrom }, - "migrated legacy userData into new location", - ); + log.info({ userDataPath, migratedFrom }, "migrated legacy userData into new location"); } } } @@ -291,31 +329,34 @@ if (process.env.AGENTDOCK_USER_DATA_DIR) { // App startup — singleton lock + bootstrap // ============================================================ -app.whenReady().then(async () => { - // [NEW] Global singleton lock (production only) - const lockHandle = await acquireInstanceLock(); - if (!lockHandle) { - // Another instance is running (or dev mode — lockHandle is null but allowed) - // In dev mode (lockHandle === null from acquireInstanceLock), proceed. - // In prod mode (lockHandle === null means lock held), show dialog and exit. - if (app.isPackaged && !process.env.AGENTDOCK_DEV_INSTANCE) { - await dialog.showMessageBox({ - type: "info", - title: "AgentDock", - message: "AgentDock is already running.", - }); - app.exit(0); - return; +app + .whenReady() + .then(async () => { + // [NEW] Global singleton lock (production only) + const lockHandle = await acquireInstanceLock(); + if (!lockHandle) { + // Another instance is running (or dev mode — lockHandle is null but allowed) + // In dev mode (lockHandle === null from acquireInstanceLock), proceed. + // In prod mode (lockHandle === null means lock held), show dialog and exit. + if (app.isPackaged && !process.env.AGENTDOCK_DEV_INSTANCE) { + await dialog.showMessageBox({ + type: "info", + title: "AgentDock", + message: "AgentDock is already running.", + }); + app.exit(0); + return; + } } - } - instanceLock = lockHandle; - - registerFontProtocol(); - await bootstrap(); -}).catch((err) => { - log.error({ err }, "bootstrap failed"); - app.exit(1); -}); + instanceLock = lockHandle; + + registerFontProtocol(); + await bootstrap(); + }) + .catch((err) => { + log.error({ err }, "bootstrap failed"); + app.exit(1); + }); // ============================================================ // Shutdown @@ -348,8 +389,12 @@ app.on("window-all-closed", () => { app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { - const win = createWindow((w) => { mainWindow = w; }); - win.on("closed", () => { mainWindow = null; }); + const win = createWindow((w) => { + mainWindow = w; + }); + win.on("closed", () => { + mainWindow = null; + }); } }); @@ -407,5 +452,9 @@ process.on("unhandledRejection", (reason) => { export const __test__ = { getMainWindow: () => mainWindow, getSessionManager: () => sessionManager, - countIpcHandlers: () => Object.keys((ipcMain as unknown as { _invokeHandlers: Map })._invokeHandlers ?? new Map()).length, + countIpcHandlers: () => + Object.keys( + (ipcMain as unknown as { _invokeHandlers: Map })._invokeHandlers ?? + new Map(), + ).length, }; diff --git a/electron/main/__tests__/client-id.test.ts b/electron/main/__tests__/client-id.test.ts index 9f41d64..dab7e2a 100644 --- a/electron/main/__tests__/client-id.test.ts +++ b/electron/main/__tests__/client-id.test.ts @@ -22,18 +22,28 @@ describe("generateClientId (新架构 §6 clientId 唯一性)", () => { it("不同 randomBytes 产出不同 id (防同进程多次生成)", () => { const id1 = generateClientIdForTest({ - hostname: "h", pid: 1, bootTimeMs: 1, randomBytes: () => Buffer.from("aaaa", "hex"), + hostname: "h", + pid: 1, + bootTimeMs: 1, + randomBytes: () => Buffer.from("aaaa", "hex"), }); const id2 = generateClientIdForTest({ - hostname: "h", pid: 1, bootTimeMs: 1, randomBytes: () => Buffer.from("bbbb", "hex"), + hostname: "h", + pid: 1, + bootTimeMs: 1, + randomBytes: () => Buffer.from("bbbb", "hex"), }); expect(id1).not.toBe(id2); }); it("不同 pid 产出不同 id (防跨进程撞)", () => { - const mk = (pid: number) => generateClientIdForTest({ - hostname: "h", pid, bootTimeMs: 1, randomBytes: () => Buffer.from("aa", "hex"), - }); + const mk = (pid: number) => + generateClientIdForTest({ + hostname: "h", + pid, + bootTimeMs: 1, + randomBytes: () => Buffer.from("aa", "hex"), + }); expect(mk(1)).not.toBe(mk(2)); }); diff --git a/electron/main/__tests__/hono-client-types.test.ts b/electron/main/__tests__/hono-client-types.test.ts deleted file mode 100644 index 6f4522a..0000000 --- a/electron/main/__tests__/hono-client-types.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-nocheck -/** - * Type-level fixture for the Hono client. - * - * This file's purpose is to assert, via TypeScript compilation, that: - * 1. `createDaemonClient(url)` returns a Hono proxy whose methods match - * the daemon's Hono app shape. - * 2. Calls with required fields missing fail to compile. - * - * If `bun run typecheck` (tsc -b) succeeds, the type inference is correct. - * - * The `@ts-expect-error` comments deliberately pass invalid calls to verify - * the type system catches them. If a future Hono upgrade loosens the types - * and these no longer error, tsc will fail with "Unused @ts-expect-error". - * - * The calls are wrapped in a function so vitest doesn't execute them - * (which would fail with EADDRNOTAVAIL on the fake URL). tsc still - * type-checks the body. - * - * Note: v1 session/port routes (/sessions/allocate, /sessions/release, - * /sessions/reassign, /sessions/list, /ports/allocate, /ports/release, - * /sync/declare) were removed in F10-2a. The daemon's current surface - * is in plugins/daemon/routes/{v2,health,registry,clients,debug}.ts. - */ - -import { createDaemonClient } from "../hono-client.js"; -import { describe, it, expect } from "vitest"; - -const client = createDaemonClient("http://127.0.0.1:0"); - -// Wrap calls in a function so vitest doesn't execute them at module load. -// tsc still type-checks the body of an unused function. -function _typeChecks(): void { - // --- Valid calls (should compile) --- - - void client.health.$get(); - void client.register.$post({ json: { dir: "/x", pid: 1 } }); - void client.unregister.$post({ json: { dir: "/x" } }); - void client.status.$get(); - void client.client.register.$post({ - json: { clientId: "c1", pid: 1, projectPaths: ["/p"] }, - }); - void client.client.unregister.$post({ json: { clientId: "c1" } }); - void client.client.heartbeat.$post({ json: { clientId: "c1" } }); - // v2 endpoints (P9+) - void client.sync.$post({ - json: { clientId: "c1", pid: 1, lastSeq: 0 }, - }); - void client.session.create.$post({ - json: { clientId: "c1", projectRoot: "/p", displayName: "s1" }, - }); - void client.session.activate.$post({ - json: { sessionId: "v2-1", fencingToken: 1 }, - }); - void client.session.delete.$post({ - json: { sessionId: "v2-1", fencingToken: 1 }, - }); - void client.debug.state.$get(); - void client.debug["invariants-v2"].$get(); - void client.debug.clients.$get(); - void client.debug["simulate-stale"].$post({ json: { clientId: "c1" } }); - void client.debug["trigger-cleanup"].$post({ json: {} }); - - // --- Invalid calls (should fail to compile) --- - - // @ts-expect-error - missing required json body - void client.health.$get(); - // @ts-expect-error - missing required field 'dir' - void client.register.$post({ json: { json: { dir: "/x" } } }); - // @ts-expect-error - missing required field 'pid' - void client.register.$post({ json: { dir: "/x" } }); - // @ts-expect-error - missing required fields 'pid' and 'projectPaths' - void client.client.register.$post({ json: { clientId: "c1" } }); - // @ts-expect-error - missing required field 'projectRoot' - void client.session.create.$post({ - json: { clientId: "c1", displayName: "s1" }, - }); - // @ts-expect-error - missing required field 'fencingToken' - void client.session.activate.$post({ - json: { sessionId: "v2-1" }, - }); -} - -describe("Hono client type fixture", () => { - it("file exists and is importable", () => { - // Hono's hc client is a Proxy function (callable). - expect(typeof client).toBe("function"); - expect(client).not.toBeNull(); - }); - - it("type checks are defined (referenced to satisfy tsc)", () => { - // The real type check happens at compile time via tsc -b. - // This test just confirms the function is reachable. - expect(typeof _typeChecks).toBe("function"); - }); -}); diff --git a/electron/main/__tests__/port-runtime-probe.test.ts b/electron/main/__tests__/port-runtime-probe.test.ts index 6a8bfd4..070b9ce 100644 --- a/electron/main/__tests__/port-runtime-probe.test.ts +++ b/electron/main/__tests__/port-runtime-probe.test.ts @@ -1,3 +1,4 @@ +import { type Server, createServer, type connect as netConnect } from "node:net"; // @ts-nocheck /** * port-runtime-probe — 新架构 §3.5 末段. @@ -9,15 +10,14 @@ * * 纯展示用途, 测试只守护三态分类 + 短超时, 绝不测反向影响端口归属. */ -import { describe, expect, it, afterEach } from "vitest"; -import { createServer, type Server, connect as netConnect } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; import { probeRuntime } from "../port-runtime-probe.js"; let grabbed: Server | null = null; afterEach(async () => { if (grabbed) { - await new Promise((r) => grabbed!.close(() => r())); + await new Promise((r) => grabbed?.close(() => r())); grabbed = null; } }); diff --git a/electron/main/__tests__/session-manager.test.ts b/electron/main/__tests__/session-manager.test.ts new file mode 100644 index 0000000..a37f8bb --- /dev/null +++ b/electron/main/__tests__/session-manager.test.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let portsAvailable = true; + +vi.mock("../../../plugins/port-allocator.js", () => ({ + isPortAvailable: vi.fn(async () => portsAvailable), +})); + +import { PortPoolExhaustedError, createPortPool } from "../port-pool.js"; +import { createSessionManager } from "../session-manager.js"; + +const portKeys = ["FRONTEND_PORT", "BACKEND_PORT", "WS_PORT", "DEBUG_PORT", "PREVIEW_PORT"]; + +describe("SessionManager persisted-state invariants", () => { + beforeEach(() => { + portsAvailable = true; + }); + + it("restores persisted ports before allocating a new session", async () => { + const pool = createPortPool({ start: 30000, end: 30009 }); + const manager = createSessionManager(pool); + + manager.restoreSession({ + sessionId: "persisted", + projectPath: "C:/project", + displayName: "Persisted", + ports: { + FRONTEND_PORT: 30000, + BACKEND_PORT: 30001, + WS_PORT: 30002, + DEBUG_PORT: 30003, + PREVIEW_PORT: 30004, + }, + status: "active", + createdAt: 1, + }); + + const fresh = await manager.createSession({ + sessionId: "fresh", + projectPath: "C:/project", + displayName: "Fresh", + portKeys, + }); + + expect(Object.values(fresh)).toEqual([30005, 30006, 30007, 30008, 30009]); + expect(pool.getAllocatedPorts()).toEqual( + new Set([30000, 30001, 30002, 30003, 30004, 30005, 30006, 30007, 30008, 30009]), + ); + }); + + it("keeps the old ownership when reassign cannot allocate replacements", async () => { + const pool = createPortPool({ start: 31000, end: 31004 }); + const manager = createSessionManager(pool); + const oldPorts = await manager.createSession({ + sessionId: "session", + projectPath: "C:/project", + displayName: "Session", + portKeys, + }); + + portsAvailable = false; + await expect(manager.reassignPorts("session")).rejects.toBeInstanceOf(PortPoolExhaustedError); + + expect(manager.getSession("session")?.ports).toEqual(oldPorts); + expect(pool.getAllocatedPorts()).toEqual(new Set(Object.values(oldPorts))); + }); + + it("can atomically restore the previous mapping after persistence fails", async () => { + const pool = createPortPool({ start: 32000, end: 32009 }); + const manager = createSessionManager(pool); + const oldPorts = await manager.createSession({ + sessionId: "session", + projectPath: "C:/project", + displayName: "Session", + portKeys, + }); + + const newPorts = await manager.reassignPorts("session"); + manager.restorePorts("session", oldPorts); + + expect(manager.getSession("session")?.ports).toEqual(oldPorts); + expect(pool.getAllocatedPorts()).toEqual(new Set(Object.values(oldPorts))); + expect(Object.values(newPorts).some((port) => pool.getAllocatedPorts().has(port))).toBe(false); + }); +}); diff --git a/electron/main/__tests__/session-recovery.test.ts b/electron/main/__tests__/session-recovery.test.ts new file mode 100644 index 0000000..263e397 --- /dev/null +++ b/electron/main/__tests__/session-recovery.test.ts @@ -0,0 +1,87 @@ +// @ts-nocheck +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../../plugins/port-allocator.js", () => ({ + isPortAvailable: vi.fn(async () => true), +})); + +import { createPortPool } from "../port-pool.js"; +import { createSessionManager } from "../session-manager.js"; +import { restorePersistedSessions } from "../session-recovery.js"; + +describe("restorePersistedSessions", () => { + it("hydrates all valid worktree sessions before new allocations", async () => { + const pool = createPortPool({ start: 33000, end: 33009 }); + const manager = createSessionManager(pool); + const result = restorePersistedSessions( + [ + { + id: "persisted", + projectId: "project", + name: "Persisted", + worktreePath: "C:/project/.agentdock/worktrees/persisted", + ports: JSON.stringify({ + FRONTEND_PORT: 33000, + BACKEND_PORT: 33001, + WS_PORT: 33002, + DEBUG_PORT: 33003, + PREVIEW_PORT: 33004, + }), + status: "active", + createdAt: new Date(0).toISOString(), + }, + ], + [{ id: "project", path: "C:/project" }], + manager, + () => true, + ); + + expect(result).toEqual({ restored: 1, skipped: 0, staleCreatingSessionIds: [] }); + expect(manager.getSession("persisted")).not.toBeNull(); + const fresh = await manager.createSession({ + sessionId: "fresh", + projectPath: "C:/project", + displayName: "Fresh", + portKeys: ["FRONTEND_PORT", "BACKEND_PORT", "WS_PORT", "DEBUG_PORT", "PREVIEW_PORT"], + }); + expect(Object.values(fresh)).toEqual([33005, 33006, 33007, 33008, 33009]); + }); + + it("skips corrupt ports and missing worktrees", () => { + const pool = createPortPool({ start: 34000, end: 34009 }); + const manager = createSessionManager(pool); + const rows = [ + { + id: "corrupt", + projectId: "project", + name: "Corrupt", + worktreePath: "missing", + ports: "not-json", + createdAt: new Date().toISOString(), + }, + ]; + + expect( + restorePersistedSessions(rows, [{ id: "project", path: "C:/project" }], manager, () => false), + ).toEqual({ restored: 0, skipped: 1, staleCreatingSessionIds: [] }); + }); + + it("reports interrupted creating sessions without committed ports for cleanup", () => { + const pool = createPortPool({ start: 35000, end: 35009 }); + const manager = createSessionManager(pool); + const row = { + id: "interrupted", + projectId: "project", + name: "Interrupted", + worktreePath: "C:/project/.agentdock/worktrees/interrupted", + ports: null, + status: "creating", + createdAt: new Date().toISOString(), + }; + + expect( + restorePersistedSessions([row], [{ id: "project", path: "C:/project" }], manager, () => true), + ).toEqual({ restored: 0, skipped: 1, staleCreatingSessionIds: ["interrupted"] }); + expect(manager.getSession("interrupted")).toBeNull(); + }); +}); diff --git a/electron/main/__tests__/sse-consumer-resync-wiring.test.ts b/electron/main/__tests__/sse-consumer-resync-wiring.test.ts deleted file mode 100644 index 6312596..0000000 --- a/electron/main/__tests__/sse-consumer-resync-wiring.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-nocheck -/** - * F7 — onResyncRequired 接到 host (新架构 §7.3). - * - * Asserts the SseConsumer wiring contract used by electron/main.ts: - * 1. When the daemon emits `event: resync-required` over SSE, the - * consumer calls `opts.onResyncRequired()` synchronously. - * 2. electron/main.ts MUST pass a non-undefined `onResyncRequired` - * callback when constructing the consumer. Without it, the host - * silently falls back to the 30s /sync polling cycle and ignores - * the daemon's resync signal. - * - * Part 1 is the unit test for the parser/dispatch behavior (already - * covered by v2-sse-consumer.test.ts but reproduced here for F7 - * regression coverage). Part 2 is a structural source-grep that fails - * if the callback is missing — this is the RED→GREEN gate for the F7 - * fix in electron/main.ts. - */ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { parseSseFrame, SseConsumer } from "../v2-sse-consumer.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const MAIN_TS_PATH = join(__dirname, "..", "..", "main.ts"); - -describe("F7 — SseConsumer.onResyncRequired contract", () => { - // ------------------------------------------------------------------ - // Part 1: parser/dispatch contract — SseConsumer must invoke the - // callback on a `resync-required` SSE frame. - // ------------------------------------------------------------------ - describe("dispatch", () => { - let fetchImpl: ReturnType; - - beforeEach(() => { - fetchImpl = vi.fn(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("parses a resync-required frame with id:0 and empty data", () => { - const parsed = parseSseFrame( - "event: resync-required\nid: 0\ndata: {}\n\n", - ); - expect(parsed).not.toBeNull(); - expect(parsed!.frame.event).toBe("resync-required"); - expect(parsed!.frame.id).toBe("0"); - expect(parsed!.frame.data).toBe("{}"); - expect(parsed!.rest).toBe(""); - }); - - it("invokes onResyncRequired when the SSE frame arrives", async () => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - "event: resync-required\nid: 0\ndata: {}\n\n", - ), - ); - controller.close(); - }, - }); - fetchImpl.mockResolvedValueOnce( - new Response(stream, { status: 200 }), - ); - - const onResyncRequired = vi.fn(); - const consumer = new SseConsumer({ - baseUrl: "http://127.0.0.1:1", - onEvent: () => {}, - onResyncRequired, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - consumer.start(); - // Wait for the consumer to drain the frame. - await new Promise((r) => setTimeout(r, 50)); - consumer.stop(); - - expect(onResyncRequired).toHaveBeenCalledTimes(1); - }); - }); - - // ------------------------------------------------------------------ - // Part 2: wiring — electron/main.ts MUST construct SseConsumer with - // an onResyncRequired callback that triggers fullResyncAfterDisconnect. - // This is the F7 regression gate. If a future refactor drops the - // callback, the daemon's resync signal will be silently swallowed - // and the client falls back to the 30s polling cycle. - // ------------------------------------------------------------------ - describe("electron/main.ts wiring", () => { - it("passes a non-undefined onResyncRequired callback to SseConsumer", () => { - const source = readFileSync(MAIN_TS_PATH, "utf-8"); - // Locate the SseConsumer constructor block in main.ts. We accept - // any indentation/whitespace and the exact property name. The - // callback may be an arrow function or method shorthand. - const optsMatch = source.match( - /new\s+SseConsumer\s*\(\s*\{([\s\S]*?)\}\s*\)/, - ); - expect( - optsMatch, - "electron/main.ts does not contain a `new SseConsumer({...})` block", - ).not.toBeNull(); - const optsBody = optsMatch![1]!; - // The opts block must reference `onResyncRequired:` as a property. - expect( - optsBody, - "SseConsumer opts must declare `onResyncRequired:` — without it the daemon's resync-required SSE frame is silently swallowed and the client falls back to 30s polling", - ).toMatch(/onResyncRequired\s*:/); - // The handler must invoke fullResyncAfterDisconnect so the §5.3 - // recovery path actually runs (not just log.warn). - expect( - optsBody, - "onResyncRequired handler must call fullResyncAfterDisconnect (新架构 §7.3 + §5.3)", - ).toMatch(/fullResyncAfterDisconnect/); - }); - }); -}); \ No newline at end of file diff --git a/electron/main/__tests__/sync-applier.test.ts b/electron/main/__tests__/sync-applier.test.ts deleted file mode 100644 index 19928c6..0000000 --- a/electron/main/__tests__/sync-applier.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -// @ts-nocheck -/** - * SyncApplier — snapshot+stream ordering tests (新架构 §7.3, §11.3 #8). - * - * 验证: - * 1. 套用 snapshot 后, 任何 seq <= snapshotSeq 的事件被丢弃。 - * 2. seq > snapshotSeq 的事件被正确 apply。 - * 3. 拍快照与到达 SSE 事件并发时, 不会发生回退覆盖 (§11.3 #8 不变式 8)。 - * 4. 各事件类型(创建/重命名/purge/重分配/释放/接管)幂等 — 重复 apply 不破坏 state。 - * 5. 未套过 snapshot 时, 事件直接 apply (等价于 snapshotSeq=0)。 - */ -import { describe, expect, it } from "vitest"; -import { - applyAll, - applySnapshot, - dispatchEvent, - emptyState, - type SseEvent, - type V2SyncSnapshot, -} from "../sync-applier.js"; - -function snap(overrides: Partial = {}): V2SyncSnapshot { - return { - state: "READY", - snapshotSeq: 100, - serverTime: 1_000_000, - sessions: [], - owners: [], - ports: [], - ...overrides, - }; -} - -function ev(event: string, seq: number, data: unknown): SseEvent { - return { event, seq, data }; -} - -describe("applySnapshot", () => { - it("replaces state with snapshot contents", () => { - const s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - owners: [{ sessionId: "s1", clientId: "c1", pid: 1, fencingToken: 1 }], - ports: [{ port: 3000, sessionId: "s1", name: "FOO" }], - }), - ); - expect(s.sessions.size).toBe(1); - expect(s.owners.size).toBe(1); - expect(s.ports.size).toBe(1); - expect(s.snapshotSeq).toBe(100); - expect(s.appliedSeq).toBe(100); - }); -}); - -describe("dispatchEvent — 择新规则", () => { - it("discards events with seq <= snapshotSeq", () => { - let s = applySnapshot(emptyState(), snap({ snapshotSeq: 100 })); - s = dispatchEvent(s, ev("port-reassigned", 50, { sessionId: "x" })); - expect(s.discardedCount).toBe(1); - expect(s.appliedEventCount).toBe(0); - }); - - it("applies events with seq > snapshotSeq", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: {}, - }, - ], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("port-reassigned", 101, { sessionId: "s1", ports: { FOO: 3000 } }), - ); - expect(s.appliedEventCount).toBe(1); - expect(s.discardedCount).toBe(0); - expect(s.ports.get(3000)?.sessionId).toBe("s1"); - }); - - it("applies events without snapshot (snapshotSeq=null)", () => { - let s = emptyState(); - s = dispatchEvent( - s, - ev("session-created", 1, { sessionId: "s1", displayName: "x" }), - ); - expect(s.appliedEventCount).toBe(1); - expect(s.sessions.get("s1")?.status).toBe("active"); - }); -}); - -describe("invariant §11.3 #8 — 快照与增量并发不发生回退", () => { - it("在 snapshotSeq=100 之后, seq=101 的 port-reassigned 不会被 snapshot 的旧值覆盖", () => { - // 1) 初始 snapshot: s1 占用 FOO=3000 - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - ports: [{ port: 3000, sessionId: "s1", name: "FOO" }], - snapshotSeq: 100, - }), - ); - // 2) seq=101: 端口重分配到 3001 - s = dispatchEvent( - s, - ev("port-reassigned", 101, { sessionId: "s1", oldPort: 3000, newPort: 3001 }), - ); - // 3) seq=50 (乱序到达, 落后于 snapshot): 应被丢弃 - s = dispatchEvent( - s, - ev("port-reassigned", 50, { sessionId: "s1", oldPort: 2999, newPort: 3000 }), - ); - // 期望: 端口 3001 仍归 s1, 端口 3000 已被 seq=101 释放 - expect(s.ports.get(3001)?.sessionId).toBe("s1"); - expect(s.ports.get(3000)).toBeUndefined(); - expect(s.discardedCount).toBe(1); - expect(s.appliedEventCount).toBe(1); - }); -}); - -describe("per-event-type semantics", () => { - it("session-created 幂等: 重复事件不破坏已有 session", () => { - let s = emptyState(); - s = dispatchEvent( - s, - ev("session-created", 1, { sessionId: "s1", displayName: "first" }), - ); - s = dispatchEvent( - s, - ev("session-created", 2, { sessionId: "s1", displayName: "second" }), - ); - // 第二次应该是幂等覆盖 displayName, 不丢 session - expect(s.sessions.get("s1")?.displayName).toBe("second"); - }); - - it("session-renamed 覆写 displayName", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "old", - status: "active", - createdAt: 1, - ports: {}, - }, - ], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("session-renamed", 101, { sessionId: "s1", newDisplayName: "new" }), - ); - expect(s.sessions.get("s1")?.displayName).toBe("new"); - }); - - it("session-purged 移除三表", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000, BAR: 3001 }, - }, - ], - owners: [{ sessionId: "s1", clientId: "c1", pid: 1, fencingToken: 1 }], - ports: [ - { port: 3000, sessionId: "s1", name: "FOO" }, - { port: 3001, sessionId: "s1", name: "BAR" }, - ], - snapshotSeq: 100, - }), - ); - s = dispatchEvent(s, ev("session-purged", 101, { sessionId: "s1" })); - expect(s.sessions.has("s1")).toBe(false); - expect(s.owners.has("s1")).toBe(false); - expect(s.ports.size).toBe(0); - }); - - it("port-reassigned (整批模式) 清旧 port + 加新 port", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000, BAR: 3001 }, - }, - ], - ports: [ - { port: 3000, sessionId: "s1", name: "FOO" }, - { port: 3001, sessionId: "s1", name: "BAR" }, - ], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("port-reassigned", 101, { - sessionId: "s1", - ports: { FOO: 4000, BAR: 4001 }, - }), - ); - expect(s.ports.get(3000)).toBeUndefined(); - expect(s.ports.get(3001)).toBeUndefined(); - expect(s.ports.get(4000)?.sessionId).toBe("s1"); - expect(s.ports.get(4001)?.sessionId).toBe("s1"); - }); - - it("port-reassigned (单端口模式) 替换", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - ports: [{ port: 3000, sessionId: "s1", name: "FOO" }], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("port-reassigned", 101, { - sessionId: "s1", - oldPort: 3000, - newPort: 3002, - }), - ); - expect(s.ports.get(3000)).toBeUndefined(); - expect(s.ports.get(3002)?.sessionId).toBe("s1"); - expect(s.sessions.get("s1")?.ports.FOO).toBe(3002); - }); - - it("port-released 单端口归还", () => { - let s = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - ports: [{ port: 3000, sessionId: "s1", name: "FOO" }], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("port-released", 101, { sessionId: "s1", port: 3000 }), - ); - expect(s.ports.get(3000)).toBeUndefined(); - expect(s.sessions.get("s1")?.ports.FOO).toBeUndefined(); - }); - - it("ownership-revoked 替换 owner", () => { - let s = applySnapshot( - emptyState(), - snap({ - owners: [ - { sessionId: "s1", clientId: "c1", pid: 1, fencingToken: 5 }, - ], - snapshotSeq: 100, - }), - ); - s = dispatchEvent( - s, - ev("ownership-revoked", 101, { - sessionId: "s1", - newOwner: "c2", - fencingToken: 6, - }), - ); - expect(s.owners.get("s1")?.clientId).toBe("c2"); - expect(s.owners.get("s1")?.fencingToken).toBe(6); - }); - - it("unknown event 警告但不破坏 state", () => { - let s = applySnapshot(emptyState(), snap({ snapshotSeq: 100 })); - s = dispatchEvent(s, ev("totally-fake-event", 101, { x: 1 })); - expect(s.appliedEventCount).toBe(1); // 计数器+1(计入 applied) - // 不应抛错 - }); -}); - -describe("applyAll convenience", () => { - it("applies snapshot first, then events in order", () => { - const result = applyAll(snap({ snapshotSeq: 50 }), [ - ev("session-created", 51, { sessionId: "s1", displayName: "x" }), - ev("port-reassigned", 52, { sessionId: "s1", ports: { FOO: 3000 } }), - ]); - expect(result.sessions.has("s1")).toBe(true); - expect(result.ports.get(3000)?.sessionId).toBe("s1"); - expect(result.discardedCount).toBe(0); - expect(result.appliedEventCount).toBe(2); - }); - - it("discards pre-snapshot events", () => { - const result = applyAll(snap({ snapshotSeq: 100 }), [ - ev("session-created", 50, { sessionId: "old" }), // 丢弃 - ev("session-created", 101, { sessionId: "new" }), // 应用 - ]); - expect(result.sessions.has("old")).toBe(false); - expect(result.sessions.has("new")).toBe(true); - expect(result.discardedCount).toBe(1); - }); -}); diff --git a/electron/main/__tests__/userdata.test.ts b/electron/main/__tests__/userdata.test.ts index 67582f9..9fb8b41 100644 --- a/electron/main/__tests__/userdata.test.ts +++ b/electron/main/__tests__/userdata.test.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // @ts-nocheck /** * userdata decision unit tests. @@ -5,10 +8,7 @@ * The Electron `app` module is not available in Node test env, so we mock * it and verify the perUser/perMachine decision based on process.execPath. */ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("electron", () => ({ app: { @@ -24,7 +24,7 @@ vi.mock("electron", () => ({ }, })); -import { resolveUserDataPath, migrateLegacyUserData, detectInstallMode } from "../userdata.js"; +import { detectInstallMode, migrateLegacyUserData, resolveUserDataPath } from "../userdata.js"; describe("resolveUserDataPath", () => { const originalExecPath = process.execPath; @@ -50,6 +50,7 @@ describe("resolveUserDataPath", () => { it("falls back to %PROGRAMDATA% = C:\\ProgramData if env unset", () => { const old = process.env.PROGRAMDATA; + // biome-ignore lint/performance/noDelete: deleting is the only cross-runtime-safe way to simulate an absent process.env key. delete process.env.PROGRAMDATA; Object.defineProperty(process, "execPath", { value: "C:\\Program Files\\AgentDock\\AgentDock.exe", @@ -58,6 +59,8 @@ describe("resolveUserDataPath", () => { expect(resolveUserDataPath()).toBe("C:\\ProgramData\\AgentDock"); } finally { if (old !== undefined) process.env.PROGRAMDATA = old; + // biome-ignore lint/performance/noDelete: restore the key to its truly absent state. + else delete process.env.PROGRAMDATA; } }); }); @@ -103,7 +106,9 @@ describe("migrateLegacyUserData", () => { const { migratedFrom } = migrateLegacyUserData(newPath); expect(migratedFrom).toBeNull(); // Existing data must NOT be clobbered - expect(require("node:fs").readFileSync(join(newPath, "already-here.txt"), "utf8")).toBe("fresh"); + expect(require("node:fs").readFileSync(join(newPath, "already-here.txt"), "utf8")).toBe( + "fresh", + ); }); it("copies files from $APPDATA\\AgentDock when new is empty", () => { diff --git a/electron/main/__tests__/v2-sse-consumer.test.ts b/electron/main/__tests__/v2-sse-consumer.test.ts deleted file mode 100644 index bac8c35..0000000 --- a/electron/main/__tests__/v2-sse-consumer.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -// @ts-nocheck -/** - * v2 SSE Consumer — unit tests (P9 — 新架构 §7.3). - * - * Exercises the SSE wire-format parser and the consumer's reconnect + - * event-fan-out behavior using a fake fetch. The fake fetch returns - * a Response with a ReadableStream that yields chunked bytes — same - * shape Hono's streamSSE emits. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { parseSseFrame, SseConsumer } from "../v2-sse-consumer.js"; - -describe("parseSseFrame", () => { - it("parses a single event with event/id/data fields", () => { - const input = "event: session-created\nid: 7\ndata: {\"sessionId\":\"s1\"}\n\n"; - const parsed = parseSseFrame(input); - expect(parsed).not.toBeNull(); - expect(parsed!.frame.event).toBe("session-created"); - expect(parsed!.frame.id).toBe("7"); - expect(parsed!.frame.data).toBe('{"sessionId":"s1"}'); - expect(parsed!.rest).toBe(""); - }); - - it("returns null when no \\n\\n boundary is present", () => { - expect(parseSseFrame("event: foo\ndata: bar\n")).toBeNull(); - }); - - it("preserves trailing data after the boundary", () => { - const input = "event: a\nid: 1\ndata: hi\n\nevent: b\nid: 2\ndata: yo\n\n"; - const first = parseSseFrame(input); - expect(first).not.toBeNull(); - expect(first!.frame.event).toBe("a"); - const second = parseSseFrame(first!.rest); - expect(second).not.toBeNull(); - expect(second!.frame.event).toBe("b"); - expect(second!.rest).toBe(""); - }); - - it("ignores comment lines starting with ':'", () => { - const input = ": keep-alive\nevent: ping\nid: 5\ndata: ok\n\n"; - const parsed = parseSseFrame(input); - expect(parsed!.frame.event).toBe("ping"); - expect(parsed!.frame.id).toBe("5"); - }); - - it("uses 'message' as default event name when none specified", () => { - const input = "id: 1\ndata: hello\n\n"; - const parsed = parseSseFrame(input); - expect(parsed!.frame.event).toBe("message"); - expect(parsed!.frame.data).toBe("hello"); - }); -}); - -interface FakeStreamChunk { - /** Bytes to deliver. enqueue()ed one at a time per `value` chunk. */ - value: string; - done?: boolean; -} - -function makeFakeResponse(chunks: FakeStreamChunk[], status = 200): Response { - const encoder = new TextEncoder(); - let idx = 0; - const stream = new ReadableStream({ - pull(controller) { - if (idx >= chunks.length) { - controller.close(); - return; - } - const c = chunks[idx++]!; - if (c.done) { - controller.close(); - } else { - controller.enqueue(encoder.encode(c.value)); - } - }, - }); - return new Response(stream, { status }); -} - -describe("SseConsumer", () => { - let fetchImpl: ReturnType; - let abortControllers: AbortController[]; - - beforeEach(() => { - abortControllers = []; - fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - // Capture the AbortController so the test can simulate disconnect. - const signal = init?.signal as AbortSignal | undefined; - // No-op mock — tests override fetchImpl per case via mockResolvedValueOnce. - return makeFakeResponse([{ value: "data: stub\n\n", done: true }]); - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("fans events out to multiple subscribers", async () => { - const consumer = new SseConsumer({ - baseUrl: "http://127.0.0.1:9999", - onEvent: () => {}, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - const a = vi.fn(); - const b = vi.fn(); - consumer.subscribe(a); - consumer.subscribe(b); - - // Dispatch via the internal fan-out by simulating an inbound SSE - // event through a controlled response. - fetchImpl.mockResolvedValueOnce( - makeFakeResponse([ - { value: "event: session-created\nid: 1\ndata: {\"sid\":\"s\"}\n\n" }, - // Hold the connection open without closing — the consumer will - // keep reading. We close it manually below. - { value: "" }, - { value: "event: port-released\nid: 2\ndata: {\"port\":30001}\n\n" }, - { value: "", done: true }, - ]), - ); - consumer.start(); - // Wait for the consumer to receive both events. - await new Promise((resolve) => setTimeout(resolve, 50)); - consumer.stop(); - - expect(a).toHaveBeenCalled(); - expect(b).toHaveBeenCalled(); - const aCalls = a.mock.calls.map((c) => c[0]); - expect(aCalls.map((p) => p.event)).toEqual( - expect.arrayContaining(["session-created", "port-released"]), - ); - }); - - it("sends Last-Event-ID on reconnect when lastSeq > 0", async () => { - const headersByCall: Array> = []; - fetchImpl.mockImplementation(async (_input, init) => { - const h = (init?.headers ?? {}) as Record; - headersByCall.push(h); - // First call: deliver one event + close. - if (headersByCall.length === 1) { - return makeFakeResponse([ - { value: "event: hello\nid: 10\ndata: {}\n\n" }, - { value: "", done: true }, - ]); - } - // Second call: hold open. - return makeFakeResponse([{ value: "", done: true }]); - }); - - const consumer = new SseConsumer({ - baseUrl: "http://127.0.0.1:9999", - onEvent: () => {}, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - vi.useFakeTimers({ shouldAdvanceTime: false }); - consumer.start(); - await vi.advanceTimersByTimeAsync(50); - expect(headersByCall.length).toBeGreaterThanOrEqual(1); - expect(headersByCall[0]?.["Last-Event-ID"]).toBeUndefined(); - - // Wait for reconnect timer to fire (500ms backoff). - await vi.advanceTimersByTimeAsync(700); - expect(headersByCall.length).toBeGreaterThanOrEqual(2); - expect(headersByCall[1]?.["Last-Event-ID"]).toBe("10"); - consumer.stop(); - vi.useRealTimers(); - }); - - it("triggers onResyncRequired when the daemon emits resync-required", async () => { - const onResync = vi.fn(); - fetchImpl.mockResolvedValueOnce( - makeFakeResponse([ - { value: "event: resync-required\nid: 0\ndata: {\"reason\":\"buffer-overflow\"}\n\n" }, - { value: "", done: true }, - ]), - ); - const consumer = new SseConsumer({ - baseUrl: "http://127.0.0.1:9999", - onEvent: () => {}, - onResyncRequired: onResync, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - consumer.start(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(onResync).toHaveBeenCalled(); - consumer.stop(); - }); - - // §5.3 — 断线立即触发 onDisconnect (host: fetch /sync, 重新 claim) - it("triggers onDisconnect when the connection is lost (新架构 §5.3)", async () => { - const onDisconnect = vi.fn(); - const onReconnect = vi.fn(); - // First connect succeeds with a hello event; second connect (after - // backoff) succeeds again with a different hello. - fetchImpl - .mockResolvedValueOnce( - makeFakeResponse([ - { value: "event: hello\nid: 1\ndata: {\"seq\":1}\n\n" }, - // Close the stream to simulate a network drop. - { value: "", done: true }, - ]), - ) - .mockResolvedValueOnce( - makeFakeResponse([ - { value: "event: hello\nid: 1\ndata: {\"seq\":1}\n\n" }, - { value: "", done: true }, - ]), - ); - const consumer = new SseConsumer({ - baseUrl: "http://127.0.0.1:9999", - onEvent: () => {}, - onDisconnect, - onReconnect, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - consumer.start(); - // Wait for the first connection to close + onDisconnect to fire. - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(onDisconnect).toHaveBeenCalledTimes(1); - consumer.stop(); - }); -}); \ No newline at end of file diff --git a/electron/main/__tests__/v2-state-bridge.test.ts b/electron/main/__tests__/v2-state-bridge.test.ts deleted file mode 100644 index 8851a09..0000000 --- a/electron/main/__tests__/v2-state-bridge.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// @ts-nocheck -/** - * v2-state-bridge — serializeForPush tests. - * - * Verifies that serializeForPush converts AppliedState into a plain-object - * format suitable for IPC push to the renderer process. - */ -import { describe, expect, it } from "vitest"; -import { - applyAll, - applySnapshot, - dispatchEvent, - emptyState, - type SseEvent, - type V2SyncSnapshot, -} from "../sync-applier.js"; -import { serializeForPush } from "../v2-state-bridge.js"; - -function snap(overrides: Partial = {}): V2SyncSnapshot { - return { - state: "READY", - snapshotSeq: 100, - serverTime: 1_000_000, - sessions: [], - owners: [], - ports: [], - ...overrides, - }; -} - -function ev(event: string, seq: number, data: unknown): SseEvent { - return { event, seq, data }; -} - -describe("serializeForPush", () => { - it("returns sessions, owners, ports as arrays from Map entries", () => { - const state = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - owners: [{ sessionId: "s1", clientId: "c1", pid: 1, fencingToken: 1 }], - ports: [{ port: 3000, sessionId: "s1", name: "FOO" }], - }), - ); - - const serialized = serializeForPush(state); - - expect(serialized.sessions).toEqual([ - [ - "s1", - { - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - status: "active", - createdAt: 1, - ports: { FOO: 3000 }, - }, - ], - ]); - expect(serialized.owners).toEqual([ - ["s1", { sessionId: "s1", clientId: "c1", pid: 1, fencingToken: 1 }], - ]); - expect(serialized.ports).toEqual([ - [3000, { sessionId: "s1", name: "FOO" }], - ]); - expect(serialized.snapshotSeq).toBe(100); - expect(serialized.appliedSeq).toBe(100); - }); - - it("includes snapshotSeq and appliedSeq from state", () => { - let state = applySnapshot(emptyState(), snap({ snapshotSeq: 50 })); - state = dispatchEvent( - state, - ev("session-created", 51, { sessionId: "s1", displayName: "x" }), - ); - - const serialized = serializeForPush(state); - - expect(serialized.snapshotSeq).toBe(50); - expect(serialized.appliedSeq).toBe(51); - }); - - it("handles empty state", () => { - const state = emptyState(); - const serialized = serializeForPush(state); - - expect(serialized.sessions).toEqual([]); - expect(serialized.owners).toEqual([]); - expect(serialized.ports).toEqual([]); - expect(serialized.snapshotSeq).toBeNull(); - expect(serialized.appliedSeq).toBe(0); - }); - - it("reflects events applied after snapshot", () => { - let state = applySnapshot( - emptyState(), - snap({ - sessions: [ - { - sessionId: "s1", - projectRoot: "/p", - displayName: "old", - status: "active", - createdAt: 1, - ports: {}, - }, - ], - snapshotSeq: 100, - }), - ); - state = dispatchEvent( - state, - ev("session-renamed", 101, { sessionId: "s1", newDisplayName: "new" }), - ); - - const serialized = serializeForPush(state); - - // Find s1 in the serialized sessions array - const s1Entry = serialized.sessions.find(([id]) => id === "s1"); - expect(s1Entry).toBeDefined(); - expect(s1Entry![1].displayName).toBe("new"); - }); -}); diff --git a/electron/main/auto-updater.ts b/electron/main/auto-updater.ts index 4cb633a..75ffdbb 100644 --- a/electron/main/auto-updater.ts +++ b/electron/main/auto-updater.ts @@ -9,7 +9,7 @@ * - Events forwarded to renderer for status UI. * - Actual install happens on next quit. */ -import { app, BrowserWindow } from "electron"; +import { type BrowserWindow, app } from "electron"; import electronUpdater from "electron-updater"; import { log } from "../../plugins/logger.js"; @@ -80,11 +80,14 @@ export function initAutoUpdater(getMainWindow: () => BrowserWindow | null): void }); // Check on startup. - void autoUpdater.checkForUpdatesAndNotify().then((result) => { - log.info({ result }, "autoUpdater: initial check complete"); - }).catch((err) => { - log.warn({ err }, "autoUpdater: initial check failed"); - }); + void autoUpdater + .checkForUpdatesAndNotify() + .then((result) => { + log.info({ result }, "autoUpdater: initial check complete"); + }) + .catch((err) => { + log.warn({ err }, "autoUpdater: initial check failed"); + }); // Periodic check every 4 hours (clear existing timer first). if (autoUpdateTimer) clearInterval(autoUpdateTimer); diff --git a/electron/main/bootstrap.ts b/electron/main/bootstrap.ts index 1c28a96..12a74ac 100644 --- a/electron/main/bootstrap.ts +++ b/electron/main/bootstrap.ts @@ -5,8 +5,8 @@ * Only bootstrap:health and renderer:reportError are retained. */ import { ipcMain } from "electron"; -import { IPC_CHANNELS } from "../shared/api-types.js"; import { log } from "../../plugins/logger.js"; +import { IPC_CHANNELS } from "../shared/api-types.js"; export interface BootstrapDeps { /** Resolves true if the Vite dev server is reachable (dev only). */ @@ -28,24 +28,26 @@ export function registerBootstrap(deps: BootstrapDeps): void { // Renderer error reporting — the bridge that connects renderer-side // ErrorBoundary / window.onerror to the main process pino logger. - ipcMain.handle( - IPC_CHANNELS["renderer:reportError"], - (_evt, payload) => { - if (!payload || typeof payload !== "object") { - log.error({ source: "renderer" }, "renderer error reported with invalid payload"); - return; - } - const p = payload as { type: string; message: string; stack?: string | null; componentStack?: string | null }; - log.error( - { - source: "renderer", - errorType: p.type, - message: p.message, - stack: p.stack ?? undefined, - componentStack: p.componentStack ?? undefined, - }, - "renderer error", - ); - }, - ); + ipcMain.handle(IPC_CHANNELS["renderer:reportError"], (_evt, payload) => { + if (!payload || typeof payload !== "object") { + log.error({ source: "renderer" }, "renderer error reported with invalid payload"); + return; + } + const p = payload as { + type: string; + message: string; + stack?: string | null; + componentStack?: string | null; + }; + log.error( + { + source: "renderer", + errorType: p.type, + message: p.message, + stack: p.stack ?? undefined, + componentStack: p.componentStack ?? undefined, + }, + "renderer error", + ); + }); } diff --git a/electron/main/client-id.ts b/electron/main/client-id.ts index 8a7cb4f..2fb72b0 100644 --- a/electron/main/client-id.ts +++ b/electron/main/client-id.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; /** * 客户端 ID 生成 (新架构 §6 末段). * @@ -8,7 +9,6 @@ */ import os from "node:os"; import process from "node:process"; -import { randomBytes } from "node:crypto"; /** * 主入口: 在 main 启动时调一次, 锁定 bootTimeMs 当时值. @@ -39,13 +39,9 @@ export function generateClientIdForTest(deps: ClientIdDeps): string { // (即只剩下 _), 视作空 hostname 降级为 "host". const replaced = deps.hostname.replace(/[^a-zA-Z0-9-]/g, "_").slice(0, 32); const safeHost = replaced.replace(/_/g, "") ? replaced : "host"; - return [ - "client", - safeHost, - deps.pid, - deps.bootTimeMs, - deps.randomBytes(4).toString("hex"), - ].join("_"); + return ["client", safeHost, deps.pid, deps.bootTimeMs, deps.randomBytes(4).toString("hex")].join( + "_", + ); } /** 测试钩子: 锁定 bootTimeMs (e.g. main 启动时一次). */ diff --git a/electron/main/e2e-reset.ts b/electron/main/e2e-reset.ts index 3d5bf03..2e16404 100644 --- a/electron/main/e2e-reset.ts +++ b/electron/main/e2e-reset.ts @@ -58,6 +58,5 @@ export function registerE2eReset(deps: E2eResetDeps): void { return resetMainState(deps); }); - (globalThis as Record).__e2eResetMainState = () => - resetMainState(deps); + (globalThis as Record).__e2eResetMainState = () => resetMainState(deps); } diff --git a/electron/main/fonts.ts b/electron/main/fonts.ts index 0b4f256..e20a26c 100644 --- a/electron/main/fonts.ts +++ b/electron/main/fonts.ts @@ -12,9 +12,17 @@ * so that a future release that ships new font files triggers a fresh copy. */ -import { app, protocol, type BrowserWindow } from "electron"; -import { existsSync, mkdirSync, cpSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; +import { type BrowserWindow, app, protocol } from "electron"; import { log } from "../../plugins/logger.js"; // ── Constants ────────────────────────────────────────────────────────── @@ -124,7 +132,9 @@ function copyBundledFonts(): void { // Validate source — if the build plugin didn't copy fonts, warn and // fall through silently so dev-mode (Vite serving public/fonts/) still works. if (!existsSync(srcDir)) { - log.warn(`bundled fonts not found at ${srcDir} — running in dev mode? fonts served from public/fonts/`); + log.warn( + `bundled fonts not found at ${srcDir} — running in dev mode? fonts served from public/fonts/`, + ); return; } diff --git a/electron/main/instance-lock.ts b/electron/main/instance-lock.ts index 09199a5..d819be1 100644 --- a/electron/main/instance-lock.ts +++ b/electron/main/instance-lock.ts @@ -1,3 +1,5 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; /** * Global Instance Lock — ensures only one AgentDock process runs at a time. * @@ -6,9 +8,7 @@ * is skipped to allow multiple dev windows. */ import { app } from "electron"; -import { mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { tryAcquireLock, type FileLock } from "../../plugins/os-file-lock.js"; +import { type FileLock, tryAcquireLock } from "../../plugins/os-file-lock.js"; export type { FileLock }; diff --git a/electron/main/ipc/app.ts b/electron/main/ipc/app.ts index 1a19179..2894125 100644 --- a/electron/main/ipc/app.ts +++ b/electron/main/ipc/app.ts @@ -23,8 +23,8 @@ */ import { app, ipcMain } from "electron"; import electronUpdater from "electron-updater"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; import { log } from "../../../plugins/logger.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; const { autoUpdater } = electronUpdater; @@ -51,31 +51,28 @@ export function registerApp(): void { }; }); - ipcMain.handle( - IPC_CHANNELS["app:checkForUpdates"], - async (): Promise => { - if (!app.isPackaged) { - log.info("app:checkForUpdates: dev mode — skipping"); - return { status: "dev-mode" }; - } - try { - // checkForUpdates resolves with UpdateCheckResult even when - // no newer version is available — the result.info always - // carries the latest release, which may be the same as the - // current version. The canonical signal is the event chain - // (onAvailable / update-not-available / update-downloaded / - // error) registered in initAutoUpdater(). The renderer - // subscribes to these and manages state transitions; all we - // do here is kick off the check. - await autoUpdater.checkForUpdates(); - return { status: "checking" }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.warn({ err }, "app:checkForUpdates failed"); - return { status: "error", message }; - } - }, - ); + ipcMain.handle(IPC_CHANNELS["app:checkForUpdates"], async (): Promise => { + if (!app.isPackaged) { + log.info("app:checkForUpdates: dev mode — skipping"); + return { status: "dev-mode" }; + } + try { + // checkForUpdates resolves with UpdateCheckResult even when + // no newer version is available — the result.info always + // carries the latest release, which may be the same as the + // current version. The canonical signal is the event chain + // (onAvailable / update-not-available / update-downloaded / + // error) registered in initAutoUpdater(). The renderer + // subscribes to these and manages state transitions; all we + // do here is kick off the check. + await autoUpdater.checkForUpdates(); + return { status: "checking" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn({ err }, "app:checkForUpdates failed"); + return { status: "error", message }; + } + }); // quitAndInstall is dangerous in dev — only callable when packaged. // We rely on isPackaged as the gate; if a dev shell somehow calls this, diff --git a/electron/main/ipc/db.ts b/electron/main/ipc/db.ts index cf78b30..d52858a 100644 --- a/electron/main/ipc/db.ts +++ b/electron/main/ipc/db.ts @@ -1,27 +1,21 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { join, resolve as resolvePath, sep } from "node:path"; /** * DB IPC handlers — direct Drizzle queries against the project's SQLite. * * Single-instance architecture: no daemon, no SSE, no v2 service. * Session discovery is simple: scan disk worktrees, check DB, insert if missing. */ -import { eq, asc } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { ipcMain } from "electron"; import { nanoid } from "nanoid"; -import { existsSync, readdirSync, rmSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { join, resolve as resolvePath, sep } from "node:path"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; +import { ensureActiveDb } from "../../../plugins/db/index.js"; import * as schema from "../../../plugins/db/schema.js"; -import { - ensureActiveDb, - getActiveDb, -} from "../../../plugins/db/index.js"; -import { validateProjectPath } from "../../../plugins/path-validation.js"; -import { - removeWorktree, - scanDiskWorktrees, -} from "../../../plugins/worktree.js"; import { log } from "../../../plugins/logger.js"; +import { validateProjectPath } from "../../../plugins/path-validation.js"; +import { removeWorktree, scanDiskWorktrees } from "../../../plugins/worktree.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; export interface DbContext { getProjectPath: () => string | null; @@ -41,8 +35,7 @@ const SCAN_THROTTLE_MS = 5_000; function getDb(ctx: DbContext) { if (dbBindingBroken) { throw new Error( - `node:sqlite unavailable. ${dbBindingError ?? "Built-in SQLite module failed to load."} ` + - `Make sure NODE_OPTIONS=--experimental-sqlite is set when launching Electron (Node 22.x in Electron 42 still gates the module behind a flag).`, + `node:sqlite unavailable. ${dbBindingError ?? "Built-in SQLite module failed to load."} Make sure NODE_OPTIONS=--experimental-sqlite is set when launching Electron (Node 22.x in Electron 42 still gates the module behind a flag).`, ); } const projectPath = ctx.getProjectPath(); @@ -63,9 +56,9 @@ function getDb(ctx: DbContext) { */ function isDirectoryComplete(dirPath: string): boolean { try { - return existsSync(dirPath) - && existsSync(join(dirPath, ".git")) - && readdirSync(dirPath).length > 0; + return ( + existsSync(dirPath) && existsSync(join(dirPath, ".git")) && readdirSync(dirPath).length > 0 + ); } catch { return false; } @@ -101,7 +94,8 @@ export async function syncProject( force = false, ): Promise { const last = lastScanAt.get(projectPath) ?? 0; - if (!force && Date.now() - last < SCAN_THROTTLE_MS) return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; + if (!force && Date.now() - last < SCAN_THROTTLE_MS) + return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; lastScanAt.set(projectPath, Date.now()); const db = ensureActiveDb(projectPath); @@ -112,27 +106,28 @@ export async function syncProject( const normalizedPath = projectPath .replace(/\\/g, "/") .replace(/\/+$/, "") - .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); + .replace(/^([A-Z]):/i, (_, d: string) => `${d.toLowerCase()}:`); let project = globalDb - ? globalDb - .select() - .from(schema.projects) - .where(eq(schema.projects.path, projectPath)) - .get() + ? globalDb.select().from(schema.projects).where(eq(schema.projects.path, projectPath)).get() : undefined; // If not found with exact path, try fuzzy match if (!project && globalDb) { const allProjects = globalDb - .select({ id: schema.projects.id, path: schema.projects.path, name: schema.projects.name, createdAt: schema.projects.createdAt }) + .select({ + id: schema.projects.id, + path: schema.projects.path, + name: schema.projects.name, + createdAt: schema.projects.createdAt, + }) .from(schema.projects) .all(); project = allProjects.find((p) => { const pNorm = p.path .replace(/\\/g, "/") .replace(/\/+$/, "") - .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); + .replace(/^([A-Z]):/i, (_, d: string) => `${d.toLowerCase()}:`); return pNorm === normalizedPath; }); } @@ -144,26 +139,20 @@ export async function syncProject( // Auto-register project if missing if (!project && globalDb) { - const name = projectPath.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || projectPath; + const name = + projectPath + .replace(/[\\/]+$/, "") + .split(/[\\/]/) + .pop() || projectPath; // Register new project in global DB try { const id = nanoid(8); globalDb.insert(schema.projects).values({ id, name, path: projectPath }).run(); - project = globalDb - .select() - .from(schema.projects) - .where(eq(schema.projects.id, id)) - .get(); - log.warn( - { projectPath, id }, - "syncProject: auto-registered project in global DB", - ); + project = globalDb.select().from(schema.projects).where(eq(schema.projects.id, id)).get(); + log.warn({ projectPath, id }, "syncProject: auto-registered project in global DB"); } catch (err) { - log.error( - { err, projectPath }, - "syncProject: auto-register failed; aborting sync", - ); + log.error({ err, projectPath }, "syncProject: auto-register failed; aborting sync"); return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; } } @@ -187,9 +176,7 @@ export async function syncProject( }); // Each removed ref prints a line like: // Removing worktrees/: gitdir - prunedRefs = out - .split(/\r?\n/) - .filter((l) => l.startsWith("Removing worktrees/")).length; + prunedRefs = out.split(/\r?\n/).filter((l) => l.startsWith("Removing worktrees/")).length; if (prunedRefs > 0) { log.info({ projectPath, prunedRefs }, "syncProject: pruned stale worktree refs"); } @@ -221,7 +208,12 @@ export async function syncProject( const existingPaths = new Set(existingRows.map((r) => r.worktreePath)); log.info( - { projectPath, projectId: project.id, existingCount: existingRows.length, existingIds: [...existingIds] }, + { + projectPath, + projectId: project.id, + existingCount: existingRows.length, + existingIds: [...existingIds], + }, "syncProject: existing sessions", ); @@ -259,8 +251,7 @@ export async function syncProject( const resolvedWt = resolvePath(wt.worktreePath); const resolvedRoot = resolvePath(worktreesRoot); const insideWorktreesDir = - resolvedWt.startsWith(resolvedRoot + sep) && - resolvedWt !== resolvedRoot; + resolvedWt.startsWith(resolvedRoot + sep) && resolvedWt !== resolvedRoot; if (!insideWorktreesDir) { log.warn( { sessionId: wt.sessionId, worktreePath: wt.worktreePath, worktreesRoot }, @@ -308,7 +299,7 @@ export async function syncProject( // 6. Clean up stale DB sessions (worktree gone from disk) // But skip sessions that are still being created in SessionManager - let removed = 0; + const removed = 0; log.info( { projectPath, existingCount: existingRows.length, diskCount: disk.length }, "syncProject: checking stale sessions", @@ -341,7 +332,10 @@ export async function syncProject( } try { db.delete(schema.sessions).where(eq(schema.sessions.id, row.id)).run(); - log.info({ sessionId: row.id, projectPath }, "syncProject: removed stale DB session (worktree gone)"); + log.info( + { sessionId: row.id, projectPath }, + "syncProject: removed stale DB session (worktree gone)", + ); } catch (err) { log.warn({ err, sessionId: row.id }, "syncProject: delete stale session failed"); } @@ -364,10 +358,7 @@ export async function syncProject( .where(eq(schema.sessions.projectId, project.id)) .all().length; - log.info( - { inserted, removed, cleanedOrphans, prunedRefs, total }, - "syncProject: complete", - ); + log.info({ inserted, removed, cleanedOrphans, prunedRefs, total }, "syncProject: complete"); return { inserted, removed, cleanedOrphans, prunedRefs, total }; } @@ -392,50 +383,46 @@ export function registerDb(ctx: DbContext): void { } // Projects live in the global DB; sessions live in the per-project DB. const globalDb = ctx.getGlobalDb(); - const allProjects = globalDb - ? globalDb.select().from(schema.projects).all() - : []; + const allProjects = globalDb ? globalDb.select().from(schema.projects).all() : []; // Per-project DB may be null if db:init hasn't been called yet — return // an empty session list rather than crashing the renderer. const sessionRows = db - ? db - .select() - .from(schema.sessions) - .orderBy(asc(schema.sessions.sortOrder)) - .all() + ? db.select().from(schema.sessions).orderBy(asc(schema.sessions.sortOrder)).all() : []; return allProjects.map((p) => ({ id: p.id, name: p.name, path: p.path, createdAt: p.createdAt, - sessions: sessionRows.filter((s) => s.projectId === p.id).map((s) => ({ - id: s.id, - projectId: s.projectId, - name: s.name, - branch: s.branch, - worktreePath: s.worktreePath, - ports: s.ports ? JSON.parse(s.ports) : null, - backgroundHookStatus: s.backgroundHookStatus ?? null, - createdAt: s.createdAt, - userStatus: s.userStatus ?? null, - lastActivatedAt: s.lastActivatedAt ?? null, - // Return real status from DB: "creating" | "active" | "deleting" | null - // null = legacy row without status field - status: s.status ?? null, - // Parse steps JSON for frontend consumption. Wrap defensively so a -// corrupted row doesn't brick the entire db:projects:list handler and -// prevent the app from loading any projects. -steps: (() => { - if (!s.steps) return null; - try { - return JSON.parse(s.steps); - } catch (err) { - log.warn({ err, sessionId: s.id }, "failed to parse session steps"); - return null; - } - })(), - })), + sessions: sessionRows + .filter((s) => s.projectId === p.id) + .map((s) => ({ + id: s.id, + projectId: s.projectId, + name: s.name, + branch: s.branch, + worktreePath: s.worktreePath, + ports: s.ports ? JSON.parse(s.ports) : null, + backgroundHookStatus: s.backgroundHookStatus ?? null, + createdAt: s.createdAt, + userStatus: s.userStatus ?? null, + lastActivatedAt: s.lastActivatedAt ?? null, + // Return real status from DB: "creating" | "active" | "deleting" | null + // null = legacy row without status field + status: s.status ?? null, + // Parse steps JSON for frontend consumption. Wrap defensively so a + // corrupted row doesn't brick the entire db:projects:list handler and + // prevent the app from loading any projects. + steps: (() => { + if (!s.steps) return null; + try { + return JSON.parse(s.steps); + } catch (err) { + log.warn({ err, sessionId: s.id }, "failed to parse session steps"); + return null; + } + })(), + })), })); }); @@ -449,14 +436,14 @@ steps: (() => { const normalizedPath = safePath .replace(/\\/g, "/") .replace(/\/+$/, "") - .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); + .replace(/^([A-Z]):/i, (_, d: string) => `${d.toLowerCase()}:`); const allProjects = globalDb.select().from(schema.projects).all(); const existing = allProjects.find((p) => { const pNorm = p.path .replace(/\\/g, "/") .replace(/\/+$/, "") - .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); + .replace(/^([A-Z]):/i, (_, d: string) => `${d.toLowerCase()}:`); return pNorm === normalizedPath; }); @@ -475,7 +462,10 @@ steps: (() => { "db:projects:create: healed path", ); } - log.info({ path: safePath, existingId: existing.id }, "db:projects:create: project already exists"); + log.info( + { path: safePath, existingId: existing.id }, + "db:projects:create: project already exists", + ); return globalDb .select() .from(schema.projects) @@ -506,32 +496,50 @@ steps: (() => { return { deleted: 0, sessionIds: [], failed: [] }; } const sessionsForProject = db - .select().from(schema.sessions).where(eq(schema.sessions.projectId, projectId)).all(); + .select() + .from(schema.sessions) + .where(eq(schema.sessions.projectId, projectId)) + .all(); const failed: Array<{ sessionId: string; stage: string; error: string }> = []; for (const s of sessionsForProject) { try { await removeWorktree(project.path, s.id, { currentBranch: s.branch, force: true }); } catch (err) { log.warn({ err, sessionId: s.id }, "db:projects:delete removeWorktree failed"); - failed.push({ sessionId: s.id, stage: "removeWorktree", error: err instanceof Error ? err.message : String(err) }); + failed.push({ + sessionId: s.id, + stage: "removeWorktree", + error: err instanceof Error ? err.message : String(err), + }); } } db.delete(schema.sessions).where(eq(schema.sessions.projectId, projectId)).run(); // Project record is in the global DB. globalDb?.delete(schema.projects).where(eq(schema.projects.id, projectId)).run(); - return { deleted: sessionsForProject.length, sessionIds: sessionsForProject.map((s) => s.id), failed }; + return { + deleted: sessionsForProject.length, + sessionIds: sessionsForProject.map((s) => s.id), + failed, + }; }); - ipcMain.handle(IPC_CHANNELS["db:sessions:reorder"], (_e, body: { projectId: string; sessionIds: string[] }) => { - if (!body?.projectId || !Array.isArray(body.sessionIds)) throw new Error("projectId and sessionIds[] required"); - const db = getDb(ctx); - db.transaction((tx) => { - body.sessionIds.forEach((id, idx) => { - tx.update(schema.sessions).set({ sortOrder: idx }).where(eq(schema.sessions.id, id)).run(); + ipcMain.handle( + IPC_CHANNELS["db:sessions:reorder"], + (_e, body: { projectId: string; sessionIds: string[] }) => { + if (!body?.projectId || !Array.isArray(body.sessionIds)) + throw new Error("projectId and sessionIds[] required"); + const db = getDb(ctx); + db.transaction((tx) => { + body.sessionIds.forEach((id, idx) => { + tx.update(schema.sessions) + .set({ sortOrder: idx }) + .where(eq(schema.sessions.id, id)) + .run(); + }); }); - }); - return { success: true }; - }); + return { success: true }; + }, + ); ipcMain.handle(IPC_CHANNELS["sync:project"], async () => { const projectPath = ctx.getProjectPath(); diff --git a/electron/main/ipc/fs-config.ts b/electron/main/ipc/fs-config.ts index 709a3fc..5b13da3 100644 --- a/electron/main/ipc/fs-config.ts +++ b/electron/main/ipc/fs-config.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; /** * FS + Config IPC handlers. * @@ -9,16 +12,12 @@ * hint always points at the real project directory, not a worktree. */ import { ipcMain } from "electron"; -import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs"; -import { join } from "node:path"; import { stringify as yamlStringify } from "yaml"; -import { eq } from "drizzle-orm"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; +import { AgentDockConfigSchema, loadConfig } from "../../../plugins/config.js"; +import type { DrizzleDb } from "../../../plugins/db/index.js"; import * as schema from "../../../plugins/db/schema.js"; -import { loadConfig, AgentDockConfigSchema } from "../../../plugins/config.js"; import { discoverPortKeysFromEnv } from "../../../plugins/env.js"; -import { log } from "../../../plugins/logger.js"; -import type { DrizzleDb } from "../../../plugins/db/index.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; function resolveProjectRoot( projectId?: string | null, @@ -83,29 +82,31 @@ export function registerFsAndConfig( const fullPath = join(absPath, e.name); const rel = relPath ? join(relPath, e.name) : e.name; const stat = statSync(fullPath); - return { name: e.name, path: rel, isDir: e.isDirectory(), size: e.isFile() ? stat.size : null }; + return { + name: e.name, + path: rel, + isDir: e.isDirectory(), + size: e.isFile() ? stat.size : null, + }; }); }); // config:get — read the project's agentdock.config.yaml + .env hints. // projectId is passed by the renderer so we resolve the REAL project root // from DB instead of using getProjectPath() which may point at a worktree. - ipcMain.handle( - IPC_CHANNELS["config:get"], - (_e, params?: { projectId?: string }) => { - const projectId = params?.projectId; - const projectPath = resolveProjectRoot(projectId, getProjectPath(), getGlobalDb); - const config = loadConfig(projectPath); - const yamlPath = join(projectPath, "agentdock.config.yaml"); - let yaml = ""; - if (existsSync(yamlPath)) { - yaml = readFileSync(yamlPath, "utf-8"); - } - const envPath = join(projectPath, ".env"); - const envPorts = existsSync(envPath) ? discoverPortKeysFromEnv(envPath) : []; - return { config, exists: existsSync(yamlPath), yaml, envPorts }; - }, - ); + ipcMain.handle(IPC_CHANNELS["config:get"], (_e, params?: { projectId?: string }) => { + const projectId = params?.projectId; + const projectPath = resolveProjectRoot(projectId, getProjectPath(), getGlobalDb); + const config = loadConfig(projectPath); + const yamlPath = join(projectPath, "agentdock.config.yaml"); + let yaml = ""; + if (existsSync(yamlPath)) { + yaml = readFileSync(yamlPath, "utf-8"); + } + const envPath = join(projectPath, ".env"); + const envPorts = existsSync(envPath) ? discoverPortKeysFromEnv(envPath) : []; + return { config, exists: existsSync(yamlPath), yaml, envPorts }; + }); // config:save — write agentdock.config.yaml ipcMain.handle( @@ -133,4 +134,4 @@ export function registerFsAndConfig( updateSettings(params as { portPoolStart?: number; portPoolEnd?: number }); return { success: true }; }); -} \ No newline at end of file +} diff --git a/electron/main/ipc/git.ts b/electron/main/ipc/git.ts index 7010296..e231c52 100644 --- a/electron/main/ipc/git.ts +++ b/electron/main/ipc/git.ts @@ -12,9 +12,9 @@ * session creation tries `git worktree add`. */ import { ipcMain } from "electron"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; -import { isGitRepo, initGitRepo } from "../../../plugins/worktree.js"; import { log } from "../../../plugins/logger.js"; +import { initGitRepo, isGitRepo } from "../../../plugins/worktree.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; export function registerGit(): void { ipcMain.handle(IPC_CHANNELS["git:isRepo"], (_e, dirPath: string) => { diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 959b767..a6e7c7d 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -1,3 +1,7 @@ +import { type DrizzleDb, getActiveDb } from "../../../plugins/db/index.js"; +import { type BootstrapDeps, registerBootstrap } from "../bootstrap.js"; +import type { SessionManager } from "../session-manager.js"; +import { registerApp } from "./app.js"; /** * IPC registration entry point — called once at app startup. * @@ -7,17 +11,13 @@ * * Single-instance architecture: no daemon, no SSE, no v2 service. */ -import { registerDb, type DbContext } from "./db.js"; -import { registerSessions, type SessionsDeps } from "./sessions.js"; -import { registerTerminals } from "./terminals.js"; +import { type DbContext, registerDb } from "./db.js"; import { registerFsAndConfig } from "./fs-config.js"; -import { registerWorktreeAndShell } from "./worktree-shell.js"; import { registerGit } from "./git.js"; +import { type SessionsDeps, registerSessions } from "./sessions.js"; +import { registerTerminals } from "./terminals.js"; import { registerTodos } from "./todos.js"; -import { registerBootstrap, type BootstrapDeps } from "../bootstrap.js"; -import { registerApp } from "./app.js"; -import { getActiveDb, type DrizzleDb } from "../../../plugins/db/index.js"; -import type { SessionManager } from "../session-manager.js"; +import { registerWorktreeAndShell } from "./worktree-shell.js"; export interface AllIpcDeps { getProjectPath: () => string | null; diff --git a/electron/main/ipc/sessions.ts b/electron/main/ipc/sessions.ts index a6d5699..7b5ec43 100644 --- a/electron/main/ipc/sessions.ts +++ b/electron/main/ipc/sessions.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; /** * Sessions IPC handlers — single-instance architecture. * @@ -7,24 +9,19 @@ import { eq } from "drizzle-orm"; import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"; import { ipcMain } from "electron"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; +import { type HookDefinition, loadConfig } from "../../../plugins/config.js"; import * as schema from "../../../plugins/db/schema.js"; +import { createHookEngine, createHookRegistry } from "../../../plugins/hook-engine.js"; +import { log } from "../../../plugins/logger.js"; import { writePortsToEnv } from "../../../plugins/port-write-env.js"; -import { loadConfig, type HookDefinition } from "../../../plugins/config.js"; import { - createSessionLifecycle, type PortService, type StepEvent, + createSessionLifecycle, } from "../../../plugins/session-lifecycle.js"; -import { log } from "../../../plugins/logger.js"; import { terminalManager } from "../../../plugins/terminal-manager.js"; import { renameWorktree } from "../../../plugins/worktree.js"; -import { - createHookEngine, - createHookRegistry, -} from "../../../plugins/hook-engine.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; import type { SessionManager } from "../session-manager.js"; export interface SessionsDeps { @@ -49,7 +46,11 @@ function createSessionManagerPortService( sessionId: params.sessionId, projectPath, portKeys: params.portKeys ?? [ - "FRONTEND_PORT", "BACKEND_PORT", "WS_PORT", "DEBUG_PORT", "PREVIEW_PORT", + "FRONTEND_PORT", + "BACKEND_PORT", + "WS_PORT", + "DEBUG_PORT", + "PREVIEW_PORT", ], displayName: params.displayName ?? params.sessionId, }); @@ -62,268 +63,287 @@ function createSessionManagerPortService( export function registerSessions(deps: SessionsDeps): void { // sessions:create — runs the full lifecycle (worktree + sync + ports + hooks) - ipcMain.handle(IPC_CHANNELS["sessions:create"], async (event, params: { - projectId: string; - name: string; - baseBranch?: string; - }) => { - const { projectId, name, baseBranch } = params; - if (!projectId || !name) { - throw new Error("projectId and name required"); - } - const projectPath = deps.getProjectPath(); - if (!projectPath) { - throw new Error("db:init must be called first"); - } - const db = deps.getDb(); - if (!db) { - throw new Error("db not initialized"); - } - const sessionManager = deps.getSessionManager(); - if (!sessionManager) { - throw new Error("SessionManager not initialized"); - } - - // Resolve project from global DB - // For now, use a simple project lookup — the global DB might not be - // the same as the per-project DB in the single-DB architecture. - // We'll use the projectPath to derive what we need. - const sessionId = crypto.randomUUID().slice(0, 12); - const config = loadConfig(projectPath); - const portService = createSessionManagerPortService(sessionManager, projectPath); - const sessionLifecycle = createSessionLifecycle({ portService }); - - // Insert a placeholder row immediately so the renderer can show it. - const branchName = `agentdock/${sessionId}`; - db.insert(schema.sessions) - .values({ - id: sessionId, - projectId, - name, - branch: branchName, - worktreePath: join(projectPath, ".agentdock", "worktrees", sessionId), - ports: null, - backgroundHookStatus: null, - status: "creating", - steps: "[]", - }) - .run(); - - // Run the lifecycle (creates worktree, allocates ports, runs hooks). - setImmediate(() => { - void runLifecycle(); - }); - - async function runLifecycle(): Promise { - const sender = event.sender; - const safeSend = (channel: string, payload: unknown) => { - try { - if (sender.isDestroyed()) return; - sender.send(channel, payload); - } catch (err) { - log.warn({ err, channel }, "session stream send failed"); - } - }; - const onStep = (step: StepEvent) => { - safeSend(`session:${sessionId}:step`, step); + ipcMain.handle( + IPC_CHANNELS["sessions:create"], + async ( + event, + params: { + projectId: string; + name: string; + baseBranch?: string; + }, + ) => { + const { projectId, name, baseBranch } = params; + if (!projectId || !name) { + throw new Error("projectId and name required"); + } + const projectPath = deps.getProjectPath(); + if (!projectPath) { + throw new Error("db:init must be called first"); + } + const db = deps.getDb(); + if (!db) { + throw new Error("db not initialized"); + } + const sessionManager = deps.getSessionManager(); + if (!sessionManager) { + throw new Error("SessionManager not initialized"); + } + const activeProjectPath = projectPath; + const activeSessionManager = sessionManager; + + // Resolve project from global DB + // For now, use a simple project lookup — the global DB might not be + // the same as the per-project DB in the single-DB architecture. + // We'll use the projectPath to derive what we need. + const sessionId = crypto.randomUUID().slice(0, 12); + const config = loadConfig(projectPath); + const portService = createSessionManagerPortService(sessionManager, projectPath); + const sessionLifecycle = createSessionLifecycle({ portService }); + + // Insert a placeholder row immediately so the renderer can show it. + const branchName = `agentdock/${sessionId}`; + db.insert(schema.sessions) + .values({ + id: sessionId, + projectId, + name, + branch: branchName, + worktreePath: join(projectPath, ".agentdock", "worktrees", sessionId), + ports: null, + backgroundHookStatus: null, + status: "creating", + steps: "[]", + }) + .run(); - // Persist step progress to DB so frontend can query it - try { - const freshDb = deps.getDb(); - if (freshDb) { - // Read current steps, append new step, write back - const row = freshDb.select({ steps: schema.sessions.steps }) - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)) - .get(); - // Defensive parse — a corrupted steps blob shouldn't kill the - // step-update loop (matches db.ts:322 defensive parsing). - let curSteps: StepEvent[] = []; - if (row?.steps) { - try { - curSteps = JSON.parse(row.steps); - } catch (err) { - log.warn({ err, sessionId }, "failed to parse session steps from DB"); - } - } - const idx = curSteps.findIndex((s) => s.step === step.step); - if (idx >= 0) { - curSteps[idx] = step; - } else { - curSteps.push(step); - } - freshDb.update(schema.sessions) - .set({ steps: JSON.stringify(curSteps) }) - .where(eq(schema.sessions.id, sessionId)) - .run(); - } - } catch (err) { - log.warn({ err, sessionId }, "step persist failed"); - } + // Run the lifecycle (creates worktree, allocates ports, runs hooks). + setImmediate(() => { + void runLifecycle(); + }); - if (step.step === "afterCreateSession" && step.status === "running") { + async function runLifecycle(): Promise { + const sender = event.sender; + const safeSend = (channel: string, payload: unknown) => { try { - const freshDb = deps.getDb(); - if (freshDb) { - freshDb.update(schema.sessions) - .set({ backgroundHookStatus: "running" }) - .where(eq(schema.sessions.id, sessionId)) - .run(); - } + if (sender.isDestroyed()) return; + sender.send(channel, payload); } catch (err) { - log.warn({ err, sessionId }, "bgHookStatus running update failed"); + log.warn({ err, channel }, "session stream send failed"); } - } - if (step.step === "afterCreateSession" && step.status === "done") { + }; + const onStep = (step: StepEvent) => { + safeSend(`session:${sessionId}:step`, step); + + // Persist step progress to DB so frontend can query it try { const freshDb = deps.getDb(); if (freshDb) { - freshDb.update(schema.sessions) - .set({ backgroundHookStatus: "completed" }) + // Read current steps, append new step, write back + const row = freshDb + .select({ steps: schema.sessions.steps }) + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .get(); + // Defensive parse — a corrupted steps blob shouldn't kill the + // step-update loop (matches db.ts:322 defensive parsing). + let curSteps: StepEvent[] = []; + if (row?.steps) { + try { + curSteps = JSON.parse(row.steps); + } catch (err) { + log.warn({ err, sessionId }, "failed to parse session steps from DB"); + } + } + const idx = curSteps.findIndex((s) => s.step === step.step); + if (idx >= 0) { + curSteps[idx] = step; + } else { + curSteps.push(step); + } + freshDb + .update(schema.sessions) + .set({ steps: JSON.stringify(curSteps) }) .where(eq(schema.sessions.id, sessionId)) .run(); } } catch (err) { - log.warn({ err, sessionId }, "bgHookStatus done update failed"); + log.warn({ err, sessionId }, "step persist failed"); } - } - }; - try { - const result = await sessionLifecycle.create({ - projectId, - projectPath: projectPath!, - sessionId, - sessionName: name!, - baseBranch, - config, - onStep, - onWorktreeReady: (worktreePath, branch) => { + if (step.step === "afterCreateSession" && step.status === "running") { try { - // Get fresh DB reference in case it was reset const freshDb = deps.getDb(); if (freshDb) { - freshDb.update(schema.sessions) - .set({ worktreePath, branch }) + freshDb + .update(schema.sessions) + .set({ backgroundHookStatus: "running" }) .where(eq(schema.sessions.id, sessionId)) .run(); } } catch (err) { - log.warn( - { err, sessionId }, - "onWorktreeReady DB update failed", - ); + log.warn({ err, sessionId }, "bgHookStatus running update failed"); } - }, - onBackgroundHookComplete: (report) => { - const failed = report.results.filter((r) => !r.success); - const status = failed.length > 0 ? "failed" : "completed"; - const update: Record = { backgroundHookStatus: status }; - if (failed.length > 0) { - update.backgroundHookErrors = JSON.stringify( - failed.map((r) => ({ - run: r.hook.run, - exitCode: r.exitCode, - stdout: (r.stdout ?? "").slice(0, 2000), - stderr: (r.stderr ?? "").slice(0, 2000), - timedOut: r.timedOut, - error: r.error ?? null, - })), - ); - } else { - update.backgroundHookErrors = null; - } - // Set status to "active" — async hooks completed - update.status = "active"; + } + if (step.step === "afterCreateSession" && step.status === "done") { try { - // Get fresh DB reference const freshDb = deps.getDb(); if (freshDb) { - freshDb.update(schema.sessions) - .set(update) + freshDb + .update(schema.sessions) + .set({ backgroundHookStatus: "completed" }) .where(eq(schema.sessions.id, sessionId)) .run(); } } catch (err) { - log.warn({ err, sessionId }, "backgroundHook complete persist failed"); + log.warn({ err, sessionId }, "bgHookStatus done update failed"); } - // Send complete event to renderer - try { - safeSend(`session:${sessionId}:complete`, { success: true, sessionId }); - } catch { /* ignore */ } - }, - }); + } + }; - // Persist the ports allocated by the lifecycle to the DB. - if (result.ports && Object.keys(result.ports).length > 0) { - const freshDb = deps.getDb(); - if (freshDb) { - freshDb.update(schema.sessions) - .set({ ports: JSON.stringify(result.ports) }) - .where(eq(schema.sessions.id, sessionId)) - .run(); + try { + const result = await sessionLifecycle.create({ + projectId, + projectPath: activeProjectPath, + sessionId, + sessionName: name, + baseBranch, + config, + onStep, + onWorktreeReady: (worktreePath, branch) => { + try { + // Get fresh DB reference in case it was reset + const freshDb = deps.getDb(); + if (freshDb) { + freshDb + .update(schema.sessions) + .set({ worktreePath, branch }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + } catch (err) { + log.warn({ err, sessionId }, "onWorktreeReady DB update failed"); + } + }, + onBackgroundHookComplete: (report) => { + const failed = report.results.filter((r) => !r.success); + const status = failed.length > 0 ? "failed" : "completed"; + const update: Record = { backgroundHookStatus: status }; + if (failed.length > 0) { + update.backgroundHookErrors = JSON.stringify( + failed.map((r) => ({ + run: r.hook.run, + exitCode: r.exitCode, + stdout: (r.stdout ?? "").slice(0, 2000), + stderr: (r.stderr ?? "").slice(0, 2000), + timedOut: r.timedOut, + error: r.error ?? null, + })), + ); + } else { + update.backgroundHookErrors = null; + } + // Set status to "active" — async hooks completed + update.status = "active"; + try { + // Get fresh DB reference + const freshDb = deps.getDb(); + if (freshDb) { + freshDb + .update(schema.sessions) + .set(update) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + } catch (err) { + log.warn({ err, sessionId }, "backgroundHook complete persist failed"); + } + // Send complete event to renderer + try { + safeSend(`session:${sessionId}:complete`, { success: true, sessionId }); + } catch { + /* ignore */ + } + }, + }); + + // Persist the ports allocated by the lifecycle to the DB. + if (result.ports && Object.keys(result.ports).length > 0) { + const freshDb = deps.getDb(); + if (freshDb) { + freshDb + .update(schema.sessions) + .set({ ports: JSON.stringify(result.ports) }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + if (existsSync(result.worktreePath)) { + writePortsToEnv(result.worktreePath, result.ports, activeProjectPath); + } } - if (existsSync(result.worktreePath)) { - writePortsToEnv(result.worktreePath, result.ports, projectPath ?? undefined); + // If no async hooks, set status to "active" and send complete + try { + const freshDb = deps.getDb(); + if (freshDb) { + freshDb + .update(schema.sessions) + .set({ status: "active" }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + } + } catch (err) { + log.warn({ err, sessionId }, "status=active update failed"); } - } - // If no async hooks, set status to "active" and send complete - try { - const freshDb = deps.getDb(); - if (freshDb) { - freshDb.update(schema.sessions) - .set({ status: "active" }) - .where(eq(schema.sessions.id, sessionId)) - .run(); + // Activate session in SessionManager + activeSessionManager.activateSession(sessionId); + + // DON'T send complete event here. For async hooks, it'll be sent + // by onBackgroundHookComplete when hooks finish. For sync mode, + // send it via process.nextTick so the IPC return happens first. + if (!result.backgroundHookPromise) { + process.nextTick(() => { + try { + safeSend(`session:${sessionId}:complete`, { success: true, sessionId }); + } catch { + /* ignore */ + } + }); } - } catch (err) { - log.warn({ err, sessionId }, "status=active update failed"); - } - // Activate session in SessionManager - sessionManager!.activateSession(sessionId); - // DON'T send complete event here. For async hooks, it'll be sent - // by onBackgroundHookComplete when hooks finish. For sync mode, - // send it via process.nextTick so the IPC return happens first. - if (!result.backgroundHookPromise) { + // For async hooks: fire-and-forget, onBackgroundHookComplete will + // send the complete event + set status="active". + if (result.backgroundHookPromise) { + result.backgroundHookPromise.catch((err) => { + log.warn({ err, sessionId }, "background hook failed (non-fatal)"); + }); + } + } catch (err) { + log.error({ err, sessionId }, "session create failed"); process.nextTick(() => { try { - safeSend(`session:${sessionId}:complete`, { success: true, sessionId }); - } catch { /* ignore */ } - }); - } - - // For async hooks: fire-and-forget, onBackgroundHookComplete will - // send the complete event + set status="active". - if (result.backgroundHookPromise) { - result.backgroundHookPromise.catch((err) => { - log.warn({ err, sessionId }, "background hook failed (non-fatal)"); + safeSend(`session:${sessionId}:complete`, { + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } catch { + /* ignore */ + } }); - } - } catch (err) { - log.error({ err, sessionId }, "session create failed"); - process.nextTick(() => { + // Roll back the placeholder row try { - safeSend(`session:${sessionId}:complete`, { - success: false, - error: err instanceof Error ? err.message : String(err), - }); - } catch { /* ignore */ } - }); - // Roll back the placeholder row - try { - const freshDb = deps.getDb(); - if (freshDb) { - freshDb.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); + const freshDb = deps.getDb(); + if (freshDb) { + freshDb.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); + } + } catch (err2) { + log.warn({ err: err2, sessionId }, "rollback DB delete failed"); } - } catch (err2) { - log.warn({ err: err2, sessionId }, "rollback DB delete failed"); } } - } - return { sessionId }; - }); + return { sessionId }; + }, + ); // sessions:stream — subscribe to events for an existing sessionId. ipcMain.handle(IPC_CHANNELS["sessions:stream"], () => { @@ -391,15 +411,18 @@ export function registerSessions(deps: SessionsDeps): void { setImmediate(async () => { try { - terminalManager.killBySession(sessionId); - await sessionManager?.releaseSession(sessionId); const config = loadConfig(projectPath); - const portService = createSessionManagerPortService(sessionManager!, projectPath); + const portService = createSessionManagerPortService(sessionManager, projectPath); const sessionLifecycle = createSessionLifecycle({ portService }); const sendStep = (e: StepEvent) => safeSend(`session:${sessionId}:step`, e); const result = await sessionLifecycle.remove({ - sessionId, projectPath, worktreePath: session.worktreePath, - currentBranch: session.branch, config, onStep: sendStep, + sessionId, + projectPath, + worktreePath: session.worktreePath, + currentBranch: session.branch, + config, + onStep: sendStep, + onBeforeCoreDelete: () => terminalManager.killBySession(sessionId), }); if (result.backgroundHookPromise) { await result.backgroundHookPromise; @@ -412,7 +435,11 @@ export function registerSessions(deps: SessionsDeps): void { } catch (err) { log.warn({ err, sessionId }, "delete DB cleanup failed"); } - try { safeSend(`session:${sessionId}:complete`, { success: true }); } catch { /* ignore */ } + try { + safeSend(`session:${sessionId}:complete`, { success: true }); + } catch { + /* ignore */ + } } catch (err) { log.error({ err, sessionId }, "lifecycle.remove failed"); try { @@ -420,11 +447,14 @@ export function registerSessions(deps: SessionsDeps): void { success: false, error: err instanceof Error ? err.message : String(err), }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } try { const freshDb = deps.getDb(); if (freshDb) { - freshDb.update(schema.sessions) + freshDb + .update(schema.sessions) .set({ status: "active" }) .where(eq(schema.sessions.id, sessionId)) .run(); @@ -456,15 +486,9 @@ export function registerSessions(deps: SessionsDeps): void { // sees status="deleting" in the DB while the lifecycle runs. setImmediate(async () => { try { - // Kill any PTYs for this session - terminalManager.killBySession(sessionId); - - // Release ports via SessionManager - await sessionManager?.releaseSession(sessionId); - // Run hooks (beforeDeleteSession → removeWorktree → afterDeleteSession) const config = loadConfig(projectPath); - const portService = createSessionManagerPortService(sessionManager!, projectPath); + const portService = createSessionManagerPortService(sessionManager, projectPath); const sessionLifecycle = createSessionLifecycle({ portService }); const result = await sessionLifecycle.remove({ sessionId, @@ -473,6 +497,7 @@ export function registerSessions(deps: SessionsDeps): void { currentBranch: session.branch, config, onStep: sendStep, + onBeforeCoreDelete: () => terminalManager.killBySession(sessionId), }); // Wait for async hooks if any, then delete DB row @@ -491,7 +516,9 @@ export function registerSessions(deps: SessionsDeps): void { } try { safeSend(`session:${sessionId}:complete`, { success: true }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } catch (err) { log.error({ err, sessionId }, "lifecycle.remove failed"); try { @@ -499,12 +526,15 @@ export function registerSessions(deps: SessionsDeps): void { success: false, error: err instanceof Error ? err.message : String(err), }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } // Roll back the deleting status try { const freshDb = deps.getDb(); if (freshDb) { - freshDb.update(schema.sessions) + freshDb + .update(schema.sessions) .set({ status: "active" }) .where(eq(schema.sessions.id, sessionId)) .run(); @@ -518,50 +548,53 @@ export function registerSessions(deps: SessionsDeps): void { return { success: true }; }); - ipcMain.handle(IPC_CHANNELS["sessions:rename"], (_e, params: { sessionId: string; name: string }) => { - if (!params?.sessionId || !params?.name) { - throw new Error("sessionId and name required"); - } - const db = deps.getDb(); - if (!db) { - throw new Error("db not initialized"); - } - const session = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, params.sessionId)) - .get(); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - const globalDb = (deps as any).getGlobalDb?.() as import("../../../plugins/db/index.js").DrizzleDb | null; - const ownerProject = globalDb - ?.select() - .from(schema.projects) - .where(eq(schema.projects.id, session.projectId)) - .get(); - if (!ownerProject) { - throw new Error(`Owning project not found for session ${params.sessionId}`); - } - let newBranch = session.branch; - try { - const result = renameWorktree( - ownerProject.path, - params.sessionId, - params.name, - session.branch, - ); - newBranch = result.newBranch; - } catch (err) { - log.error({ err, sessionId: params.sessionId, name: params.name }, "renameWorktree failed"); - throw err; - } - db.update(schema.sessions) - .set({ name: params.name, branch: newBranch }) - .where(eq(schema.sessions.id, params.sessionId)) - .run(); - return { success: true, branch: newBranch }; - }); + ipcMain.handle( + IPC_CHANNELS["sessions:rename"], + (_e, params: { sessionId: string; name: string }) => { + if (!params?.sessionId || !params?.name) { + throw new Error("sessionId and name required"); + } + const db = deps.getDb(); + if (!db) { + throw new Error("db not initialized"); + } + const session = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, params.sessionId)) + .get(); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + const globalDb = deps.getGlobalDb?.(); + const ownerProject = globalDb + ?.select() + .from(schema.projects) + .where(eq(schema.projects.id, session.projectId)) + .get(); + if (!ownerProject) { + throw new Error(`Owning project not found for session ${params.sessionId}`); + } + let newBranch = session.branch; + try { + const result = renameWorktree( + ownerProject.path, + params.sessionId, + params.name, + session.branch, + ); + newBranch = result.newBranch; + } catch (err) { + log.error({ err, sessionId: params.sessionId, name: params.name }, "renameWorktree failed"); + throw err; + } + db.update(schema.sessions) + .set({ name: params.name, branch: newBranch }) + .where(eq(schema.sessions.id, params.sessionId)) + .run(); + return { success: true, branch: newBranch }; + }, + ); ipcMain.handle(IPC_CHANNELS["sessions:reassignPorts"], async (_e, sessionId: string) => { if (!sessionId) { @@ -583,15 +616,34 @@ export function registerSessions(deps: SessionsDeps): void { if (!session) { throw new Error(`Session not found: ${sessionId}`); } + const projectPath = deps.getProjectPath(); + if (!projectPath) { + throw new Error("project not initialized"); + } + const previousPorts = sessionManager.getSession(sessionId)?.ports; try { const ports = await sessionManager.reassignPorts(sessionId); - db.update(schema.sessions) - .set({ ports: JSON.stringify(ports) }) - .where(eq(schema.sessions.id, sessionId)) - .run(); - if (existsSync(session.worktreePath)) { - writePortsToEnv(session.worktreePath, ports); + try { + db.update(schema.sessions) + .set({ ports: JSON.stringify(ports) }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + if (existsSync(session.worktreePath)) { + writePortsToEnv(session.worktreePath, ports, projectPath); + } + } catch (persistError) { + if (previousPorts) { + sessionManager.restorePorts(sessionId, previousPorts); + db.update(schema.sessions) + .set({ ports: JSON.stringify(previousPorts) }) + .where(eq(schema.sessions.id, sessionId)) + .run(); + if (existsSync(session.worktreePath)) { + writePortsToEnv(session.worktreePath, previousPorts, projectPath); + } + } + throw persistError; } return { ports }; } catch (err) { @@ -618,7 +670,7 @@ export function registerSessions(deps: SessionsDeps): void { if (session.backgroundHookStatus !== "failed") { throw new Error("Session is not in failed state"); } - const globalDb = (deps as any).getGlobalDb?.() as import("../../../plugins/db/index.js").DrizzleDb | null; + const globalDb = deps.getGlobalDb?.(); const ownerProject = globalDb ?.select() .from(schema.projects) @@ -636,9 +688,7 @@ export function registerSessions(deps: SessionsDeps): void { .run(); const registry = createHookRegistry(); - registry.loadFromConfig( - config.hooks as unknown as Record, - ); + registry.loadFromConfig(config.hooks as unknown as Record); const engine = createHookEngine(registry); const ctx = { event: "afterCreateSession" as const, @@ -670,10 +720,7 @@ export function registerSessions(deps: SessionsDeps): void { update.backgroundHookErrors = null; } try { - db.update(schema.sessions) - .set(update) - .where(eq(schema.sessions.id, sessionId)) - .run(); + db.update(schema.sessions).set(update).where(eq(schema.sessions.id, sessionId)).run(); } catch (err) { log.error({ err, sessionId }, "retryHooks: persist result failed"); } @@ -761,23 +808,20 @@ export function registerSessions(deps: SessionsDeps): void { }, ); - ipcMain.handle( - IPC_CHANNELS["sessions:activate"], - (_e, params: { sessionId: string }) => { - if (!params?.sessionId) throw new Error("sessionId required"); - const db = deps.getDb(); - if (!db) throw new Error("db not initialized"); - const session = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, params.sessionId)) - .get(); - if (!session) throw new Error(`Session not found: ${params.sessionId}`); - db.update(schema.sessions) - .set({ lastActivatedAt: new Date().toISOString() }) - .where(eq(schema.sessions.id, params.sessionId)) - .run(); - return { success: true as const }; - }, - ); + ipcMain.handle(IPC_CHANNELS["sessions:activate"], (_e, params: { sessionId: string }) => { + if (!params?.sessionId) throw new Error("sessionId required"); + const db = deps.getDb(); + if (!db) throw new Error("db not initialized"); + const session = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, params.sessionId)) + .get(); + if (!session) throw new Error(`Session not found: ${params.sessionId}`); + db.update(schema.sessions) + .set({ lastActivatedAt: new Date().toISOString() }) + .where(eq(schema.sessions.id, params.sessionId)) + .run(); + return { success: true as const }; + }); } diff --git a/electron/main/ipc/terminals.ts b/electron/main/ipc/terminals.ts index 2ed1971..34f8766 100644 --- a/electron/main/ipc/terminals.ts +++ b/electron/main/ipc/terminals.ts @@ -1,3 +1,4 @@ +import { eq } from "drizzle-orm"; /** * Terminal IPC handlers. * @@ -7,12 +8,11 @@ */ import { MessageChannelMain } from "electron"; import { ipcMain } from "electron"; -import { eq } from "drizzle-orm"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; -import { terminalManager } from "../../../plugins/terminal-manager.js"; import { getActiveDb } from "../../../plugins/db/index.js"; import * as schema from "../../../plugins/db/schema.js"; import { log } from "../../../plugins/logger.js"; +import { terminalManager } from "../../../plugins/terminal-manager.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; /** * Look up a session's `worktreePath` from the active DB. node-pty needs a @@ -38,30 +38,33 @@ function resolveSessionWorktree(sessionId: string): string { } export function registerTerminals(): void { - ipcMain.handle(IPC_CHANNELS["terminals:create"], async (_e, params: { sessionId: string; shell?: string }) => { - if (!params?.sessionId) { - throw new Error("sessionId required"); - } - const { sessionId, shell } = params; - const worktreePath = resolveSessionWorktree(sessionId); - // Use a default cols/rows; the renderer can resize via the message port. - const terminal = await terminalManager.create({ - sessionId, - worktreePath, - shell: shell ?? "default", - cols: 80, - rows: 24, - }); - return { - terminalId: terminal.terminalId, - sessionId: terminal.sessionId, - shell: terminal.shell, - name: terminal.name, - status: terminal.status, - pid: terminal.pid, - createdAt: new Date().toISOString(), - }; - }); + ipcMain.handle( + IPC_CHANNELS["terminals:create"], + async (_e, params: { sessionId: string; shell?: string }) => { + if (!params?.sessionId) { + throw new Error("sessionId required"); + } + const { sessionId, shell } = params; + const worktreePath = resolveSessionWorktree(sessionId); + // Use a default cols/rows; the renderer can resize via the message port. + const terminal = await terminalManager.create({ + sessionId, + worktreePath, + shell: shell ?? "default", + cols: 80, + rows: 24, + }); + return { + terminalId: terminal.terminalId, + sessionId: terminal.sessionId, + shell: terminal.shell, + name: terminal.name, + status: terminal.status, + pid: terminal.pid, + createdAt: new Date().toISOString(), + }; + }, + ); ipcMain.handle(IPC_CHANNELS["terminals:list"], (_e, sessionId: string) => { if (!sessionId) { @@ -79,13 +82,16 @@ export function registerTerminals(): void { })); }); - ipcMain.handle(IPC_CHANNELS["terminals:rename"], (_e, params: { terminalId: string; name: string }) => { - if (!params?.terminalId || !params?.name) { - throw new Error("terminalId and name required"); - } - terminalManager.rename(params.terminalId, params.name); - return { success: true }; - }); + ipcMain.handle( + IPC_CHANNELS["terminals:rename"], + (_e, params: { terminalId: string; name: string }) => { + if (!params?.terminalId || !params?.name) { + throw new Error("terminalId and name required"); + } + terminalManager.rename(params.terminalId, params.name); + return { success: true }; + }, + ); ipcMain.handle(IPC_CHANNELS["terminals:delete"], async (_e, terminalId: string) => { if (!terminalId) { @@ -95,13 +101,16 @@ export function registerTerminals(): void { return { success: true }; }); - ipcMain.handle(IPC_CHANNELS["terminals:write"], (_e, params: { terminalId: string; data: string }) => { - if (!params?.terminalId || typeof params?.data !== "string") { - throw new Error("terminalId and data required"); - } - terminalManager.write(params.terminalId, params.data); - return { success: true }; - }); + ipcMain.handle( + IPC_CHANNELS["terminals:write"], + (_e, params: { terminalId: string; data: string }) => { + if (!params?.terminalId || typeof params?.data !== "string") { + throw new Error("terminalId and data required"); + } + terminalManager.write(params.terminalId, params.data); + return { success: true }; + }, + ); ipcMain.handle(IPC_CHANNELS["terminals:open"], (event, terminalId: string) => { if (!terminalId) { @@ -129,4 +138,4 @@ export function registerTerminals(): void { event.sender.postMessage("terminal:port", { terminalId }, [port1]); return { ready: true }; }); -} \ No newline at end of file +} diff --git a/electron/main/ipc/todos.ts b/electron/main/ipc/todos.ts index f217204..1f4c800 100644 --- a/electron/main/ipc/todos.ts +++ b/electron/main/ipc/todos.ts @@ -1,3 +1,4 @@ +import { asc, eq, sql } from "drizzle-orm"; /** * Todo IPC handlers — per-project todo list CRUD. * @@ -9,7 +10,6 @@ * todos:delete — delete a todo */ import { ipcMain } from "electron"; -import { eq, asc, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import { getActiveDb } from "../../../plugins/db/index.js"; import * as schema from "../../../plugins/db/schema.js"; @@ -42,71 +42,58 @@ export function registerTodos(): void { .all(); }); - ipcMain.handle( - "todos:create", - (_event, args: { projectId: string; content: string }) => { - const { projectId, content } = args as { projectId: string; content: string }; - const db = getDb(); - const now = new Date().toISOString(); - const id = nanoid(); + ipcMain.handle("todos:create", (_event, args: { projectId: string; content: string }) => { + const { projectId, content } = args as { projectId: string; content: string }; + const db = getDb(); + const now = new Date().toISOString(); + const id = nanoid(); - // Get max sortOrder via database MAX聚合 - const [result] = db - .select({ maxSort: sql.raw("COALESCE(MAX(sort_order), 0)") }) - .from(schema.todos) - .where(eq(schema.todos.projectId, projectId)) - .all(); - const maxSort = (result?.maxSort as number) ?? 0; + // Get max sortOrder via database MAX聚合 + const [result] = db + .select({ maxSort: sql.raw("COALESCE(MAX(sort_order), 0)") }) + .from(schema.todos) + .where(eq(schema.todos.projectId, projectId)) + .all(); + const maxSort = (result?.maxSort as number) ?? 0; - db.insert(schema.todos) - .values({ - id, - projectId, - content, - status: "pending", - sortOrder: maxSort + 1, - createdAt: now, - updatedAt: now, - }) - .run(); + db.insert(schema.todos) + .values({ + id, + projectId, + content, + status: "pending", + sortOrder: maxSort + 1, + createdAt: now, + updatedAt: now, + }) + .run(); - return db - .select() - .from(schema.todos) - .where(eq(schema.todos.id, id)) - .get(); - }, - ); + return db.select().from(schema.todos).where(eq(schema.todos.id, id)).get(); + }); - ipcMain.handle( - "todos:cycleStatus", - (_event, args: { id: string }) => { - const { id } = args as { id: string }; - const db = getDb(); - const todo = db - .select({ status: schema.todos.status }) - .from(schema.todos) - .where(eq(schema.todos.id, id)) - .get(); - if (!todo) return; - db.update(schema.todos) - .set({ status: nextStatus(todo.status ?? "pending"), updatedAt: new Date().toISOString() }) - .where(eq(schema.todos.id, id)) - .run(); - }, - ); + ipcMain.handle("todos:cycleStatus", (_event, args: { id: string }) => { + const { id } = args as { id: string }; + const db = getDb(); + const todo = db + .select({ status: schema.todos.status }) + .from(schema.todos) + .where(eq(schema.todos.id, id)) + .get(); + if (!todo) return; + db.update(schema.todos) + .set({ status: nextStatus(todo.status ?? "pending"), updatedAt: new Date().toISOString() }) + .where(eq(schema.todos.id, id)) + .run(); + }); - ipcMain.handle( - "todos:update", - (_event, args: { id: string; content: string }) => { - const { id, content } = args as { id: string; content: string }; - const db = getDb(); - db.update(schema.todos) - .set({ content, updatedAt: new Date().toISOString() }) - .where(eq(schema.todos.id, id)) - .run(); - }, - ); + ipcMain.handle("todos:update", (_event, args: { id: string; content: string }) => { + const { id, content } = args as { id: string; content: string }; + const db = getDb(); + db.update(schema.todos) + .set({ content, updatedAt: new Date().toISOString() }) + .where(eq(schema.todos.id, id)) + .run(); + }); ipcMain.handle("todos:delete", (_event, args: { id: string }) => { const { id } = args as { id: string }; @@ -114,17 +101,14 @@ export function registerTodos(): void { db.delete(schema.todos).where(eq(schema.todos.id, id)).run(); }); - ipcMain.handle( - "todos:reorder", - (_event, args: { todoIds: string[] }) => { - const { todoIds } = args as { todoIds: string[] }; - const db = getDb(); - for (let i = 0; i < todoIds.length; i++) { - db.update(schema.todos) - .set({ sortOrder: i, updatedAt: new Date().toISOString() }) - .where(eq(schema.todos.id, todoIds[i])) - .run(); - } - }, - ); + ipcMain.handle("todos:reorder", (_event, args: { todoIds: string[] }) => { + const { todoIds } = args as { todoIds: string[] }; + const db = getDb(); + for (let i = 0; i < todoIds.length; i++) { + db.update(schema.todos) + .set({ sortOrder: i, updatedAt: new Date().toISOString() }) + .where(eq(schema.todos.id, todoIds[i])) + .run(); + } + }); } diff --git a/electron/main/ipc/worktree-shell.ts b/electron/main/ipc/worktree-shell.ts index 0523f5d..f89c9b6 100644 --- a/electron/main/ipc/worktree-shell.ts +++ b/electron/main/ipc/worktree-shell.ts @@ -1,3 +1,8 @@ +import { execFile } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { eq } from "drizzle-orm"; /** * Worktree orphan + Shell integration IPC handlers. * @@ -11,27 +16,19 @@ * shell APIs (file manager, terminal). */ import { BrowserWindow, ipcMain, shell } from "electron"; -import { eq } from "drizzle-orm"; -import { existsSync, realpathSync } from "node:fs"; -import { resolve } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { IPC_CHANNELS } from "../../shared/api-types.js"; -import { - getWorktreeBase, - listWorktrees, -} from "../../../plugins/worktree.js"; +import { getActiveDb } from "../../../plugins/db/index.js"; +import * as schema from "../../../plugins/db/schema.js"; +import { log } from "../../../plugins/logger.js"; +import { openInFileManager } from "../../../plugins/open-explorer.js"; +import { openInTerminal } from "../../../plugins/open-terminal.js"; import { classifyOrphans, dispatchOrphanCleanup, scanOrphanBranches, scanOrphanWorktrees, } from "../../../plugins/orphan.js"; -import * as schema from "../../../plugins/db/schema.js"; -import { getActiveDb } from "../../../plugins/db/index.js"; -import { openInFileManager } from "../../../plugins/open-explorer.js"; -import { openInTerminal } from "../../../plugins/open-terminal.js"; -import { log } from "../../../plugins/logger.js"; +import { getWorktreeBase, listWorktrees } from "../../../plugins/worktree.js"; +import { IPC_CHANNELS } from "../../shared/api-types.js"; const execFileAsync = promisify(execFile); @@ -78,9 +75,7 @@ function resolveProjectPath(projectId?: string): string { } const fallback = getProjectPathRef?.(); if (!fallback) { - throw new Error( - "no active project; call db:init or pass projectId in the request body", - ); + throw new Error("no active project; call db:init or pass projectId in the request body"); } return fallback; } @@ -117,10 +112,7 @@ export function registerWorktreeAndShell( const db = getActiveDb(); if (db) { try { - const rows = db - .select({ branch: schema.sessions.branch }) - .from(schema.sessions) - .all(); + const rows = db.select({ branch: schema.sessions.branch }).from(schema.sessions).all(); for (const r of rows) { if (r.branch) known.add(r.branch); } @@ -145,26 +137,13 @@ export function registerWorktreeAndShell( // - string[] (legacy paths-only callers) ipcMain.handle( IPC_CHANNELS["worktree:deleteOrphans"], - async ( - _e, - body: - | { paths?: string[]; branches?: string[]; projectId?: string } - | string[], - ) => { + async (_e, body: { paths?: string[]; branches?: string[]; projectId?: string } | string[]) => { // Backwards-compat: the old single-array shape is still accepted // (Phase 5 renderer code that hasn't migrated yet). - const paths = Array.isArray(body) - ? body - : Array.isArray(body?.paths) - ? body.paths - : []; - const branches = !Array.isArray(body) && Array.isArray(body?.branches) - ? body.branches - : []; + const paths = Array.isArray(body) ? body : Array.isArray(body?.paths) ? body.paths : []; + const branches = !Array.isArray(body) && Array.isArray(body?.branches) ? body.branches : []; const projectId = - !Array.isArray(body) && typeof body?.projectId === "string" - ? body.projectId - : undefined; + !Array.isArray(body) && typeof body?.projectId === "string" ? body.projectId : undefined; if (paths.length === 0 && branches.length === 0) { throw new Error("paths[] or branches[] required"); @@ -179,25 +158,23 @@ export function registerWorktreeAndShell( for (const wt of listWorktrees(projectPath)) { if (wt.branch.startsWith("agentdock/")) knownBranches.add(wt.branch); } - } catch { /* not a git repo */ } + } catch { + /* not a git repo */ + } const db = getActiveDb(); if (db) { try { - const rows = db - .select({ branch: schema.sessions.branch }) - .from(schema.sessions) - .all(); + const rows = db.select({ branch: schema.sessions.branch }).from(schema.sessions).all(); for (const r of rows) { if (r.branch) knownBranches.add(r.branch); } - const idRows = db - .select({ id: schema.sessions.id }) - .from(schema.sessions) - .all(); + const idRows = db.select({ id: schema.sessions.id }).from(schema.sessions).all(); for (const r of idRows) { knownSessionIds.add(r.id); } - } catch { /* stale schema */ } + } catch { + /* stale schema */ + } } const classified = classifyOrphans(projectPath, knownSessionIds, knownBranches); @@ -210,7 +187,7 @@ export function registerWorktreeAndShell( for (const p of paths) { if (typeof p !== "string" || !p) continue; const norm = normalizePath(p); - if (!norm.startsWith(baseDir + "/")) continue; + if (!norm.startsWith(`${baseDir}/`)) continue; selectedPathSet.add(p); // extract sessionId from path: .../worktrees/ const parts = norm.split("/"); @@ -234,13 +211,15 @@ export function registerWorktreeAndShell( (i) => selectedPathSet.has(i.worktreePath) || selectedSessionIds.has(i.sessionId), ), gitMetadataOrphans: classified.gitMetadataOrphans.filter( - (i) => (i.branch && selectedBranchSet.has(i.branch)) || selectedSessionIds.has(i.sessionId), + (i) => + (i.branch && selectedBranchSet.has(i.branch)) || selectedSessionIds.has(i.sessionId), ), orphanSessions: classified.orphanSessions.filter( (i) => selectedPathSet.has(i.worktreePath) || selectedSessionIds.has(i.sessionId), ), branchOrphans: classified.branchOrphans.filter( - (i) => (i.branch && selectedBranchSet.has(i.branch)) || selectedSessionIds.has(i.sessionId), + (i) => + (i.branch && selectedBranchSet.has(i.branch)) || selectedSessionIds.has(i.sessionId), ), registryStale: [], // UI 选择不应包含 registryStale }; @@ -325,7 +304,9 @@ export function registerWorktreeAndShell( // Get the origin remote URL let remoteUrl: string; try { - remoteUrl = (await execFileAsync("git", ["-C", projectPath, "remote", "get-url", "origin"])).stdout.trim(); + remoteUrl = ( + await execFileAsync("git", ["-C", projectPath, "remote", "get-url", "origin"]) + ).stdout.trim(); } catch { throw new Error("未配置 GitHub remote(origin)"); } @@ -379,4 +360,4 @@ export function registerWorktreeAndShell( return { url: pullsUrl }; }); -} \ No newline at end of file +} diff --git a/electron/main/port-pool.ts b/electron/main/port-pool.ts index 88515ce..ca61abe 100644 --- a/electron/main/port-pool.ts +++ b/electron/main/port-pool.ts @@ -1,3 +1,4 @@ +import { log } from "../../plugins/logger.js"; /** * Port Pool — deterministic port allocation from a configured range. * @@ -12,11 +13,6 @@ * plugins/port-allocator.ts. */ import { isPortAvailable } from "../../plugins/port-allocator.js"; -import { - DEFAULT_PORT_POOL_END, - DEFAULT_PORT_POOL_START, -} from "../../plugins/constants.js"; -import { log } from "../../plugins/logger.js"; // ============================================================ // Types @@ -40,9 +36,7 @@ export interface PortPool { export class PortPoolExhaustedError extends Error { constructor(needed: number, available: number) { - super( - `Port pool exhausted: need ${needed} ports but only ${available} available`, - ); + super(`Port pool exhausted: need ${needed} ports but only ${available} available`); this.name = "PortPoolExhaustedError"; } } @@ -74,7 +68,7 @@ function parseAvailablePortEnv(): number[] { const ports = new Set(); for (const [key, value] of Object.entries(process.env)) { if (!key.startsWith("AVAILABLE_PORT") || key === "PORT_STRICT") continue; - const num = parseInt(value ?? "", 10); + const num = Number.parseInt(value ?? "", 10); if (Number.isFinite(num) && num >= 1024 && num <= 65535) { ports.add(num); } else { @@ -112,17 +106,14 @@ export function createPortPool(config: PortPoolConfig): PortPoolInternal { // the first one is about to return. let allocationQueue: Promise = Promise.resolve(); - async function allocate( - count: number, - portKeys: string[], - ): Promise> { + async function allocate(count: number, portKeys: string[]): Promise> { // Capture the (possibly-rejected) result of this allocation in a // separate Promise that callers can await, but always keep the queue // chain resolved so a single failure (e.g. PortPoolExhaustedError) // doesn't poison subsequent allocations. Without this, any thrown // error inside the .then() would leave `allocationQueue` rejected // forever and every subsequent allocate() would re-reject. - let result: Record; + let result: Record | undefined; let failure: unknown; allocationQueue = allocationQueue.then(async () => { try { @@ -173,7 +164,8 @@ export function createPortPool(config: PortPoolConfig): PortPoolInternal { // subsequent allocations can still proceed. await allocationQueue.catch(() => undefined); if (failure !== undefined) throw failure; - return result!; + if (result === undefined) throw new Error("Port allocation completed without a result"); + return result; } function release(sessionId: string): void { @@ -186,12 +178,24 @@ export function createPortPool(config: PortPoolConfig): PortPoolInternal { } /** Call after allocate to record the mapping. */ - function recordSessionPorts( - sessionId: string, - ports: Record, - ): void { + function recordSessionPorts(sessionId: string, ports: Record): void { const portSet = new Set(Object.values(ports)); + for (const [ownerId, ownerPorts] of sessionPorts) { + if (ownerId === sessionId) continue; + for (const port of portSet) { + if (ownerPorts.has(port)) { + throw new Error(`Port ${port} is already owned by session ${ownerId}`); + } + } + } + + const previous = sessionPorts.get(sessionId); sessionPorts.set(sessionId, portSet); + if (previous) { + for (const port of previous) { + if (!portSet.has(port)) allocated.delete(port); + } + } for (const p of portSet) { allocated.add(p); } diff --git a/electron/main/port-runtime-probe.ts b/electron/main/port-runtime-probe.ts index 8964901..dc345b9 100644 --- a/electron/main/port-runtime-probe.ts +++ b/electron/main/port-runtime-probe.ts @@ -12,7 +12,7 @@ * * 超时常量 RUNTIME_PROBE_TIMEOUT_MS (默认 300ms) — 来自新架构 §11.5 表. */ -import { connect, type Socket } from "node:net"; +import { type Socket, connect } from "node:net"; import { RUNTIME_PROBE_TIMEOUT_MS } from "../../plugins/constants.js"; export type RuntimeProbeState = "running" | "stopped" | "unknown"; diff --git a/electron/main/reconcile.ts b/electron/main/reconcile.ts index cb68ef0..8fa9bc2 100644 --- a/electron/main/reconcile.ts +++ b/electron/main/reconcile.ts @@ -1,3 +1,4 @@ +import process from "node:process"; // @ts-nocheck /** * reconcile — Walk every session in the active project's DB and compare its @@ -15,18 +16,21 @@ * Extracted from main.ts (Approach A: state stays in main.ts, passed as params). */ import { eq } from "drizzle-orm"; -import process from "node:process"; -import { log } from "../../plugins/logger.js"; import { ensureActiveDb } from "../../plugins/db/index.js"; import * as schema from "../../plugins/db/schema.js"; -import type { V2PortServiceHandle } from "../../plugins/v2-port-service.js"; +import { log } from "../../plugins/logger.js"; import { writePortsToEnv } from "../../plugins/port-write-env.js"; +import type { V2PortServiceHandle } from "../../plugins/v2-port-service.js"; export interface ReconcileContext { activeProjectPath: string | null; v2PortService: V2PortServiceHandle | null; cachedDaemonPort: number; - globalDbHandle: { db: ReturnType extends { db: infer T } ? T : never } | null; + globalDbHandle: { + db: ReturnType extends { db: infer T } + ? T + : never; + } | null; reallocatedQueue: Array<{ sessionId: string; oldPorts: Record; @@ -35,9 +39,7 @@ export interface ReconcileContext { clientId: string; } -export async function reconcileAndDeclareSessions( - ctx: ReconcileContext, -): Promise { +export async function reconcileAndDeclareSessions(ctx: ReconcileContext): Promise { if (!ctx.activeProjectPath) return; if (!ctx.v2PortService || ctx.cachedDaemonPort <= 0) { log.info("reconcile: v2 not available, skipping (v1 routes removed in F10-2a)"); @@ -119,13 +121,12 @@ export async function reconcileAndDeclareSessions( const row = rowsBySessionId.get(known.sessionId); if (!row) continue; if (ds.status !== "active") continue; // Only reconcile active sessions. - const oldPorts = row.ports - ? (JSON.parse(row.ports) as Record) - : {}; + const oldPorts = row.ports ? (JSON.parse(row.ports) as Record) : {}; const newPorts = ds.ports; const keys1 = Object.keys(oldPorts); const keys2 = Object.keys(newPorts); - const portsEqual = keys1.length === keys2.length && keys1.every((k) => oldPorts[k] === newPorts[k]); + const portsEqual = + keys1.length === keys2.length && keys1.every((k) => oldPorts[k] === newPorts[k]); if (portsEqual) continue; ctx.reallocatedQueue.push({ @@ -147,10 +148,7 @@ export async function reconcileAndDeclareSessions( writePortsToEnv(row.worktreePath, newPorts, project.path); } } catch (err) { - log.warn( - { err, sessionId: row.id }, - "reconcile: persist reallocated ports failed", - ); + log.warn({ err, sessionId: row.id }, "reconcile: persist reallocated ports failed"); } } } diff --git a/electron/main/session-manager.ts b/electron/main/session-manager.ts index ec6df94..8bc5359 100644 --- a/electron/main/session-manager.ts +++ b/electron/main/session-manager.ts @@ -1,3 +1,5 @@ +import type { SessionPorts } from "../../plugins/daemon-state.js"; +import { log } from "../../plugins/logger.js"; /** * Session Manager — in-memory session state for the single-instance architecture. * @@ -6,8 +8,6 @@ * Electron process owns everything. */ import type { PortPoolInternal } from "./port-pool.js"; -import type { SessionPorts } from "../../plugins/daemon-state.js"; -import { log } from "../../plugins/logger.js"; // ============================================================ // Types @@ -29,6 +29,8 @@ export interface SessionManager { portKeys: string[]; displayName: string; }): Promise; + restoreSession(session: SessionInfo): void; + restorePorts(sessionId: string, ports: SessionPorts): void; activateSession(sessionId: string): void; releaseSession(sessionId: string): Promise; getSession(sessionId: string): SessionInfo | null; @@ -53,8 +55,8 @@ export function createSessionManager(portPool: PortPoolInternal): SessionManager const { sessionId, projectPath, portKeys, displayName } = params; // Check if session already exists - if (sessions.has(sessionId)) { - const existing = sessions.get(sessionId)!; + const existing = sessions.get(sessionId); + if (existing) { log.warn({ sessionId }, "session-manager: session already exists, returning existing"); return existing.ports; } @@ -76,10 +78,36 @@ export function createSessionManager(portPool: PortPoolInternal): SessionManager }; sessions.set(sessionId, info); - log.info({ sessionId, portCount: Object.keys(ports).length }, "session-manager: session created"); + log.info( + { sessionId, portCount: Object.keys(ports).length }, + "session-manager: session created", + ); return ports; } + function restoreSession(session: SessionInfo): void { + const restored: SessionInfo = { + ...session, + ports: { ...session.ports }, + }; + portPool.recordSessionPorts(restored.sessionId, restored.ports); + sessions.set(restored.sessionId, restored); + log.info( + { sessionId: restored.sessionId, portCount: Object.keys(restored.ports).length }, + "session-manager: persisted session restored", + ); + } + + function restorePorts(sessionId: string, ports: SessionPorts): void { + const session = sessions.get(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + const restored = { ...ports }; + portPool.recordSessionPorts(sessionId, restored); + session.ports = restored; + } + function activateSession(sessionId: string): void { const session = sessions.get(sessionId); if (session) { @@ -114,14 +142,13 @@ export function createSessionManager(portPool: PortPoolInternal): SessionManager throw new Error(`Session ${sessionId} not found`); } - // Release old ports - portPool.release(sessionId); - - // Allocate new ports + // Keep the old mapping reserved until replacements have been allocated. + // If allocation fails, both SessionManager and PortPool remain unchanged. const portKeys = Object.keys(session.ports); const newPorts = await portPool.allocate(portKeys.length, portKeys); - // Record new ports + // Atomically swap ownership: recordSessionPorts releases the previous + // mapping only after the replacement set is ready. portPool.recordSessionPorts(sessionId, newPorts); session.ports = newPorts; @@ -140,6 +167,8 @@ export function createSessionManager(portPool: PortPoolInternal): SessionManager return { createSession, + restoreSession, + restorePorts, activateSession, releaseSession, getSession, diff --git a/electron/main/session-recovery.ts b/electron/main/session-recovery.ts new file mode 100644 index 0000000..f93a17e --- /dev/null +++ b/electron/main/session-recovery.ts @@ -0,0 +1,82 @@ +import { existsSync } from "node:fs"; +import type { SessionPorts } from "../../plugins/daemon-state.js"; +import type { ProjectRow, SessionRow } from "../../plugins/db/schema.js"; +import { log } from "../../plugins/logger.js"; +import type { SessionManager } from "./session-manager.js"; + +export interface SessionRecoveryResult { + restored: number; + skipped: number; + staleCreatingSessionIds: string[]; +} + +function parsePersistedPorts(raw: string | null): SessionPorts | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record; + const entries = Object.entries(parsed); + if (entries.length === 0) return null; + const values = entries.map(([, value]) => value); + if ( + values.some( + (value) => + typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535, + ) || + new Set(values).size !== values.length + ) { + return null; + } + return parsed as SessionPorts; + } catch { + return null; + } +} + +/** + * Rebuild the in-memory session and port ownership state from persisted DB + * rows before any new session can allocate ports. + */ +export function restorePersistedSessions( + sessionRows: SessionRow[], + projectRows: ProjectRow[], + sessionManager: SessionManager, + pathExists: (path: string) => boolean = existsSync, +): SessionRecoveryResult { + const projectPaths = new Map(projectRows.map((project) => [project.id, project.path])); + let restored = 0; + let skipped = 0; + const staleCreatingSessionIds: string[] = []; + + for (const row of sessionRows) { + const projectPath = projectPaths.get(row.projectId); + const ports = parsePersistedPorts(row.ports); + if (row.status === "creating" && !ports) { + staleCreatingSessionIds.push(row.id); + skipped++; + continue; + } + if (!projectPath || !ports || !pathExists(row.worktreePath)) { + skipped++; + continue; + } + + try { + sessionManager.restoreSession({ + sessionId: row.id, + projectPath, + displayName: row.name, + ports, + status: row.status === "creating" ? "creating" : "active", + createdAt: Number.isFinite(Date.parse(row.createdAt)) + ? Date.parse(row.createdAt) + : Date.now(), + }); + restored++; + } catch (err) { + skipped++; + log.warn({ err, sessionId: row.id }, "failed to restore persisted session"); + } + } + + return { restored, skipped, staleCreatingSessionIds }; +} diff --git a/electron/main/sync-applier.ts b/electron/main/sync-applier.ts index 1f3ddbd..57addd7 100644 --- a/electron/main/sync-applier.ts +++ b/electron/main/sync-applier.ts @@ -89,10 +89,7 @@ export function emptyState(): AppliedState { * 套用 snapshot. 覆盖式 — 任何现有状态被 snapshot 内容替换。 * 记录 snapshotSeq 阈值, 之后到达的 seq <= snapshotSeq 的事件将被丢弃。 */ -export function applySnapshot( - state: AppliedState, - snapshot: V2SyncSnapshot, -): AppliedState { +export function applySnapshot(state: AppliedState, snapshot: V2SyncSnapshot): AppliedState { const next: AppliedState = emptyState(); next.snapshotSeq = snapshot.snapshotSeq; next.appliedSeq = Math.max(state.appliedSeq, snapshot.snapshotSeq); @@ -121,10 +118,7 @@ export function applySnapshot( * 幂等性: port-reassigned / port-released / session-purged / session-renamed * 全部幂等 — 重复 apply 不会破坏最终状态。 */ -export function dispatchEvent( - state: AppliedState, - event: SseEvent, -): AppliedState { +export function dispatchEvent(state: AppliedState, event: SseEvent): AppliedState { // 还没套过快照: 直接 apply(等价于 snapshotSeq=0)。 if (state.snapshotSeq === null) { return applyEventUnchecked(state, event); @@ -153,7 +147,8 @@ function applyEventUnchecked(state: AppliedState, event: SseEvent): AppliedState if (!d?.sessionId) return next; if (next.sessions.has(d.sessionId)) { // 幂等: 已存在 → 合并 displayName (不覆盖其他字段) - const cur = next.sessions.get(d.sessionId)!; + const cur = next.sessions.get(d.sessionId); + if (!cur) return next; next.sessions.set(d.sessionId, { ...cur, displayName: d.displayName ?? cur.displayName, @@ -175,7 +170,8 @@ function applyEventUnchecked(state: AppliedState, event: SseEvent): AppliedState // { sessionId, newDisplayName } const d = event.data as { sessionId?: string; newDisplayName?: string }; if (!d?.sessionId || !next.sessions.has(d.sessionId)) return next; - const cur = next.sessions.get(d.sessionId)!; + const cur = next.sessions.get(d.sessionId); + if (!cur) return next; next.sessions.set(d.sessionId, { ...cur, displayName: d.newDisplayName ?? cur.displayName }); return next; } @@ -300,10 +296,7 @@ function applyEventUnchecked(state: AppliedState, event: SseEvent): AppliedState * * 用于: 一次性 apply 一批历史事件(测试 / 断线重连回放)。 */ -export function applyAll( - snapshot: V2SyncSnapshot | null, - events: SseEvent[], -): AppliedState { +export function applyAll(snapshot: V2SyncSnapshot | null, events: SseEvent[]): AppliedState { let s = emptyState(); if (snapshot) s = applySnapshot(s, snapshot); for (const ev of events) s = dispatchEvent(s, ev); diff --git a/electron/main/userdata.ts b/electron/main/userdata.ts index 340a1fb..3e87b94 100644 --- a/electron/main/userdata.ts +++ b/electron/main/userdata.ts @@ -1,3 +1,5 @@ +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; /** * User-data path resolution — picks per-user vs per-machine data location * based on the install location of the running binary. @@ -21,14 +23,6 @@ * once on the first launch after switching install mode. */ import { app } from "electron"; -import { join } from "node:path"; -import { - existsSync, - mkdirSync, - readdirSync, - statSync, - copyFileSync, -} from "node:fs"; export type InstallMode = "perUser" | "perMachine"; diff --git a/electron/main/v2-sse-consumer.ts b/electron/main/v2-sse-consumer.ts index 68c4779..7364fef 100644 --- a/electron/main/v2-sse-consumer.ts +++ b/electron/main/v2-sse-consumer.ts @@ -152,9 +152,7 @@ export class SseConsumer { * Each subscriber receives every event. Subscribers throwing do not * affect other subscribers. */ - subscribe( - cb: (e: { event: string; seq: number; data: unknown }) => void, - ): () => void { + subscribe(cb: (e: { event: string; seq: number; data: unknown }) => void): () => void { this.subscribers.add(cb); return () => { this.subscribers.delete(cb); @@ -173,7 +171,7 @@ export class SseConsumer { let res: Response; try { res = await fetchImpl(url, { headers, signal: this.currentController.signal }); - } catch (err) { + } catch (_err) { if (this.stopped) return; this.scheduleReconnect(); return; @@ -196,17 +194,22 @@ export class SseConsumer { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - let parsed: ReturnType = null; // Drain all complete frames in the buffer. - while ((parsed = parseSseFrame(buffer)) !== null) { + let parsed = parseSseFrame(buffer); + while (parsed !== null) { buffer = parsed.rest; this.dispatch(parsed.frame); + parsed = parseSseFrame(buffer); } } } catch { /* network error — fall through to reconnect */ } finally { - try { reader.releaseLock(); } catch { /* already released */ } + try { + reader.releaseLock(); + } catch { + /* already released */ + } } if (this.stopped) return; // §5.3 — 断线立即触发 onDisconnect (host: fetch /sync, 重新 claim) diff --git a/electron/main/v2-wiring.ts b/electron/main/v2-wiring.ts index 4fcdb62..8a19ec4 100644 --- a/electron/main/v2-wiring.ts +++ b/electron/main/v2-wiring.ts @@ -10,8 +10,8 @@ */ import process from "node:process"; import { log } from "../../plugins/logger.js"; -import { applySnapshot, type AppliedState, type V2SyncSnapshot } from "./sync-applier.js"; import type { V2PortServiceHandle } from "../../plugins/v2-port-service.js"; +import { type AppliedState, type V2SyncSnapshot, applySnapshot } from "./sync-applier.js"; /** * §5.3 — 断线立即全量重注册. diff --git a/electron/main/window.ts b/electron/main/window.ts index 0e9d7c7..1ce641d 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -1,3 +1,6 @@ +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; /** * Window creation — BrowserWindow factory with DevTools, titlebar, and * lifecycle wiring. @@ -6,10 +9,7 @@ * capture the window reference (since `createWindow` mutates the module- * level `mainWindow` variable back in main.ts). */ -import { app, BrowserWindow } from "electron"; -import { dirname, resolve } from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; +import { BrowserWindow, app } from "electron"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -27,9 +27,7 @@ const __dirname = dirname(__filename); * Receives the window before `ready-to-show` fires. * @returns The newly created BrowserWindow. */ -export function createWindow( - onCreated?: (win: BrowserWindow) => void, -): BrowserWindow { +export function createWindow(onCreated?: (win: BrowserWindow) => void): BrowserWindow { // e2e/debug knob — when AGENTDOCK_E2E_DEVTOOLS=1 the test runner (or a // developer reproducing a failure) gets a detached DevTools window so // they can inspect React state / network / storage from outside the @@ -66,12 +64,12 @@ export function createWindow( // Allow F12 to toggle DevTools in development if (devToolsEnabled) { - win.webContents.on("before-input-event", (event, input) => { + win.webContents.on("before-input-event", (_event, input) => { if (input.key === "F12" && input.type === "keyDown") { - if (event.sender.isDevToolsOpened()) { - event.sender.closeDevTools(); + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools(); } else { - event.sender.openDevTools({ mode: "detach" }); + win.webContents.openDevTools({ mode: "detach" }); } } }); diff --git a/electron/preload.ts b/electron/preload.ts index 72dd6e4..230923d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -18,14 +18,17 @@ * `ipcRenderer.on(channel, listener)`. See `subscribeSession` and * `onTerminalPort` below for the typed wrappers. */ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; +import { type IpcRendererEvent, contextBridge, ipcRenderer } from "electron"; import { IPC_CHANNELS, type IpcChannel } from "./shared/api-types.js"; function invoke(channel: IpcChannel, ...args: unknown[]): Promise { return ipcRenderer.invoke(IPC_CHANNELS[channel], ...args) as Promise; } -function on(channel: string, cb: (data: T, event: IpcRendererEvent) => void): () => void { +function on( + channel: string, + cb: (data: T, event: IpcRendererEvent) => void, +): () => void { const handler = (_e: IpcRendererEvent, data: T) => cb(data, _e); ipcRenderer.on(channel, handler); return () => ipcRenderer.off(channel, handler); @@ -89,19 +92,21 @@ const api = { }, sync: { - project: () => invoke<{ - inserted: number; - removed: number; - cleanedOrphans: number; - prunedRefs: number; - total: number; - }>("sync:project"), + project: () => + invoke<{ + inserted: number; + removed: number; + cleanedOrphans: number; + prunedRefs: number; + total: number; + }>("sync:project"), }, sessions: { create: (params: { projectId: string; name: string; baseBranch?: string }) => invoke<{ sessionId: string }>("sessions:create", params), - delete: (sessionId: string) => invoke<{ success: true; error?: string }>("sessions:delete", { sessionId }), + delete: (sessionId: string) => + invoke<{ success: true; error?: string }>("sessions:delete", { sessionId }), rename: (sessionId: string, name: string) => invoke<{ success: true }>("sessions:rename", { sessionId, name }), reassignPorts: (sessionId: string) => @@ -109,19 +114,17 @@ const api = { retryHooks: (sessionId: string) => invoke<{ success: true; status: string }>("sessions:retryHooks", sessionId), stream: (sessionId: string) => ({ - onStep: (cb: (step: { step: string; status: string; duration?: number; error?: string }) => void) => - on(`session:${sessionId}:step`, cb), + onStep: ( + cb: (step: { step: string; status: string; duration?: number; error?: string }) => void, + ) => on(`session:${sessionId}:step`, cb), onComplete: (cb: (result: { success: boolean; error?: string }) => void) => on(`session:${sessionId}:complete`, cb), }), - bgHookStatus: (sessionId: string) => - invoke("sessions:bgHookStatus", sessionId), - hookErrors: (sessionId: string) => - invoke("sessions:hookErrors", sessionId), + bgHookStatus: (sessionId: string) => invoke("sessions:bgHookStatus", sessionId), + hookErrors: (sessionId: string) => invoke("sessions:hookErrors", sessionId), setUserStatus: (sessionId: string, status: string | null) => invoke<{ success: true }>("sessions:setUserStatus", { sessionId, status }), - activate: (sessionId: string) => - invoke<{ success: true }>("sessions:activate", { sessionId }), + activate: (sessionId: string) => invoke<{ success: true }>("sessions:activate", { sessionId }), }, // [OLD-DAEMON] sessionsV2 channels — removed in single-instance architecture @@ -211,9 +214,10 @@ const api = { browseDirs: (targetPath: string) => invoke>("fs:browseDirs", targetPath), files: (relPath: string) => - invoke< - Array<{ name: string; path: string; isDir: boolean; size: number | null }> - >("fs:files", relPath), + invoke>( + "fs:files", + relPath, + ), }, config: { @@ -239,9 +243,7 @@ const api = { }> >("worktree:orphans", projectId), deleteOrphans: ( - body: - | { paths?: string[]; branches?: string[]; projectId?: string } - | string[], + body: { paths?: string[]; branches?: string[]; projectId?: string } | string[], ) => invoke<{ deleted: string[]; @@ -256,13 +258,14 @@ const api = { // surface the underlying message via toast. git: { isRepo: (dirPath: string) => invoke("git:isRepo", dirPath), - init: (dirPath: string) => - invoke<{ success: boolean; error?: string }>("git:init", dirPath), + init: (dirPath: string) => invoke<{ success: boolean; error?: string }>("git:init", dirPath), }, shell: { - openExplorer: (targetPath: string) => invoke<{ success: true }>("shell:openExplorer", targetPath), - openTerminal: (targetPath: string) => invoke<{ success: true }>("shell:openTerminal", targetPath), + openExplorer: (targetPath: string) => + invoke<{ success: true }>("shell:openExplorer", targetPath), + openTerminal: (targetPath: string) => + invoke<{ success: true }>("shell:openTerminal", targetPath), openPullRequests: (projectId?: string) => invoke<{ url: string }>("shell:openPullRequests", projectId), }, @@ -295,11 +298,11 @@ const api = { // 渲染进程可通过这些事件展示更新检查/下载/完成状态。 updates: { onChecking: (cb: () => void) => on("update:checking", cb), - onAvailable: (cb: (info: unknown) => void) => on("update:available", cb), - onNotAvailable: (cb: (info: unknown) => void) => on("update:not-available", cb), + onAvailable: (cb: (info: { version?: string }) => void) => on("update:available", cb), + onNotAvailable: (cb: (info: { version?: string }) => void) => on("update:not-available", cb), onDownloadProgress: (cb: (progress: { percent: number }) => void) => on("update:download-progress", cb), - onDownloaded: (cb: (info: unknown) => void) => on("update:downloaded", cb), + onDownloaded: (cb: (info: { version?: string }) => void) => on("update:downloaded", cb), onError: (cb: (err: { message: string }) => void) => on("update:error", cb), }, @@ -307,8 +310,7 @@ const api = { // The settings page uses these to display the current build and let // the user force a check outside the 4h interval kicked off at boot. app: { - version: () => - invoke<{ version: string; isPackaged: boolean }>("app:version"), + version: () => invoke<{ version: string; isPackaged: boolean }>("app:version"), checkForUpdates: () => invoke< | { status: "dev-mode" } @@ -331,17 +333,31 @@ const api = { // Per-project todo list todos: { list: (projectId: string) => - invoke>("todos:list", { projectId }), + invoke< + Array<{ + id: string; + projectId: string; + content: string; + status: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + }> + >("todos:list", { projectId }), create: (projectId: string, content: string) => - invoke<{ id: string; projectId: string; content: string; status: string; sortOrder: number; createdAt: string; updatedAt: string }>("todos:create", { projectId, content }), - cycleStatus: (id: string) => - invoke("todos:cycleStatus", { id }), - update: (id: string, content: string) => - invoke("todos:update", { id, content }), - delete: (id: string) => - invoke("todos:delete", { id }), - reorder: (todoIds: string[]) => - invoke("todos:reorder", { todoIds }), + invoke<{ + id: string; + projectId: string; + content: string; + status: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + }>("todos:create", { projectId, content }), + cycleStatus: (id: string) => invoke("todos:cycleStatus", { id }), + update: (id: string, content: string) => invoke("todos:update", { id, content }), + delete: (id: string) => invoke("todos:delete", { id }), + reorder: (todoIds: string[]) => invoke("todos:reorder", { todoIds }), }, // Renderer error reporting. Called from ErrorBoundary and the global @@ -368,4 +384,4 @@ const api = { contextBridge.exposeInMainWorld("api", api); -export type ApiSurface = typeof api; \ No newline at end of file +export type ApiSurface = typeof api; diff --git a/flue.config.ts b/flue.config.ts index b898efe..1c3f0c7 100644 --- a/flue.config.ts +++ b/flue.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from '@flue/cli/config'; +import { defineConfig } from "@flue/cli/config"; export default defineConfig({ - target: 'node', + target: "node", }); diff --git a/package.json b/package.json index 1088136..83746e8 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "main": "out/main/main.js", "scripts": { "dev": "cross-env NODE_OPTIONS=--experimental-sqlite electron-vite dev", - "dev:1": "cross-env NODE_OPTIONS=--experimental-sqlite node scripts/dev-instance.js 1", - "dev:2": "cross-env NODE_OPTIONS=--experimental-sqlite node scripts/dev-instance.js 2", - "dev:3": "cross-env NODE_OPTIONS=--experimental-sqlite node scripts/dev-instance.js 3", + "dev:1": "cross-env NODE_OPTIONS=--experimental-sqlite bun run scripts/dev-instance.ts 1", + "dev:2": "cross-env NODE_OPTIONS=--experimental-sqlite bun run scripts/dev-instance.ts 2", + "dev:3": "cross-env NODE_OPTIONS=--experimental-sqlite bun run scripts/dev-instance.ts 3", "build": "tsc -b && electron-vite build", "typecheck": "tsc -b --noEmit", "start": "cross-env NODE_OPTIONS=--experimental-sqlite electron-vite preview", @@ -36,11 +36,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "acceptance:phase0": "vitest run scripts/acceptance/phase0-foundation.test.ts", - "acceptance:phase1": "vitest run scripts/acceptance/phase1-daemon-hono.test.ts", - "acceptance:phase2": "vitest run scripts/acceptance/phase2-hono-client.test.ts", - "acceptance:phase3": "vitest run scripts/acceptance/phase3-electron-boot.test.ts", - "acceptance:phase4": "vitest run scripts/acceptance/phase4-ipc.test.ts", - "acceptance:all": "vitest run scripts/acceptance/", + "acceptance:single-instance": "vitest run scripts/acceptance/single-instance.test.ts", + "acceptance:all": "bun run test:acceptance", "kill-all": "bun run scripts/kill-all.ts", "debug": "bun run scripts/debug-start.ts", "download-fonts": "bun run scripts/download-fonts.ts", diff --git a/playwright.config.ts b/playwright.config.ts index 5462578..00452a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,12 +21,8 @@ export default defineConfig({ timeout: 60_000, expect: { timeout: 10_000 }, fullyParallel: false, - workers: process.env.E2E_PARALLEL - ? Number(process.env.E2E_PARALLEL) - : 1, - retries: process.env.E2E_RETRIES - ? Number(process.env.E2E_RETRIES) - : 0, + workers: process.env.E2E_PARALLEL ? Number(process.env.E2E_PARALLEL) : 1, + retries: process.env.E2E_RETRIES ? Number(process.env.E2E_RETRIES) : 0, reporter: [ ["json", { outputFile: "e2e/reports/latest.json" }], ["html", { open: "never", outputFolder: "e2e/report-html" }], @@ -37,4 +33,4 @@ export default defineConfig({ screenshot: "only-on-failure", video: "retain-on-failure", }, -}); \ No newline at end of file +}); diff --git a/plugins/__tests__/api-deletion-e2e.test.ts b/plugins/__tests__/api-deletion-e2e.test.ts index adc34f5..63daeab 100644 --- a/plugins/__tests__/api-deletion-e2e.test.ts +++ b/plugins/__tests__/api-deletion-e2e.test.ts @@ -1,14 +1,14 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { execSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import path from "node:path"; +import { type IncomingMessage, type ServerResponse, createServer } from "node:http"; import os from "node:os"; -import { createDb, type DrizzleDb } from "../db/index.js"; -import { projects, sessions } from "../db/schema.js"; +import path from "node:path"; import { eq } from "drizzle-orm"; +// @ts-nocheck +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadConfig } from "../config.js"; +import { type DrizzleDb, createDb } from "../db/index.js"; +import { projects, sessions } from "../db/schema.js"; import { createHookEngine, createHookRegistry } from "../hook-engine.js"; import type { PortService } from "../session-lifecycle.js"; @@ -19,7 +19,9 @@ function createMockPortService(): PortService { async allocateSession() { const ports: Record = {}; const keys = ["FRONTEND_PORT", "BACKEND_PORT", "WS_PORT", "DEBUG_PORT", "PREVIEW_PORT"]; - keys.forEach((k, i) => { ports[k] = 30000 + counter * keys.length + i; }); + keys.forEach((k, i) => { + ports[k] = 30000 + counter * keys.length + i; + }); counter++; return ports; }, @@ -35,9 +37,7 @@ let baseUrl: string; const isWin = process.platform === "win32"; function sleepCmd(seconds: number): string { - return isWin - ? `ping -n ${seconds + 1} 127.0.0.1 >nul` - : `sleep ${seconds}`; + return isWin ? `ping -n ${seconds + 1} 127.0.0.1 >nul` : `sleep ${seconds}`; } function initGitRepo(dir: string) { @@ -46,15 +46,21 @@ function initGitRepo(dir: string) { execSync("git config user.name Test", { cwd: dir, stdio: "pipe" }); writeFileSync(path.join(dir, "README.md"), "# test\n"); execSync("git add .", { cwd: dir, stdio: "pipe" }); - execSync('git commit -m init', { cwd: dir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: dir, stdio: "pipe" }); } function parseBody(req: IncomingMessage): Promise> { return new Promise((resolve) => { let body = ""; - req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); req.on("end", () => { - try { resolve(body ? JSON.parse(body) : {}); } catch { resolve({}); } + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + resolve({}); + } }); }); } @@ -92,14 +98,20 @@ beforeEach(async () => { const projectId = scMatch[1]; const body = await parseBody(req); const { name: sessionName } = body as { name?: string }; - if (!sessionName) { json(res, 400, { error: "name is required" }); return; } + if (!sessionName) { + json(res, 400, { error: "name is required" }); + return; + } try { const p = db.select().from(projects).where(eq(projects.id, projectId)).get(); - if (!p) { json(res, 404, { error: "Project not found" }); return; } + if (!p) { + json(res, 404, { error: "Project not found" }); + return; + } const config = loadConfig(p.path); const registry = createHookRegistry(); - const engine = createHookEngine(registry); + const _engine = createHookEngine(registry); const lifecycle = (await import("../session-lifecycle.js")).createSessionLifecycle({ portService: createMockPortService(), }); @@ -107,21 +119,26 @@ beforeEach(async () => { const result = await lifecycle.create({ projectId, projectPath: p.path, - sessionId: pathname.split("/").slice(-1)[0] + "_" + Date.now(), + sessionId: `${pathname.split("/").slice(-1)[0]}_${Date.now()}`, sessionName, config, }); - db.insert(sessions).values({ - id: result.sessionId, - projectId, - name: sessionName, - branch: result.branch, - worktreePath: result.worktreePath, - ports: JSON.stringify(result.ports), - }).run(); - - json(res, 200, { success: true, session: { id: result.sessionId, worktreePath: result.worktreePath, ports: result.ports } }); + db.insert(sessions) + .values({ + id: result.sessionId, + projectId, + name: sessionName, + branch: result.branch, + worktreePath: result.worktreePath, + ports: JSON.stringify(result.ports), + }) + .run(); + + json(res, 200, { + success: true, + session: { id: result.sessionId, worktreePath: result.worktreePath, ports: result.ports }, + }); } catch (err) { json(res, 500, { error: err instanceof Error ? err.message : "Unknown error" }); } @@ -134,9 +151,15 @@ beforeEach(async () => { const id = sMatch[1]; try { const s = db.select().from(sessions).where(eq(sessions.id, id)).get(); - if (!s) { json(res, 404, { error: "Session not found" }); return; } + if (!s) { + json(res, 404, { error: "Session not found" }); + return; + } const p = db.select().from(projects).where(eq(projects.id, s.projectId)).get(); - if (!p) { json(res, 404, { error: "Project not found" }); return; } + if (!p) { + json(res, 404, { error: "Project not found" }); + return; + } const config = loadConfig(p.path); const { createSessionLifecycle } = await import("../session-lifecycle.js"); @@ -191,10 +214,14 @@ beforeEach(async () => { afterEach(async () => { if (server) server.close(); if (existsSync(projectDir)) { - try { rmSync(projectDir, { recursive: true, force: true }); } catch {} + try { + rmSync(projectDir, { recursive: true, force: true }); + } catch {} } if (existsSync(dbDir)) { - try { rmSync(dbDir, { recursive: true, force: true }); } catch {} + try { + rmSync(dbDir, { recursive: true, force: true }); + } catch {} } }); @@ -213,9 +240,14 @@ async function api(method: string, pathname: string, body?: Record { - it("E19: 创建带异步 hook 的 session → 不等 hook 完成 → 删除成功", { timeout: 120000 }, async () => { - // 写入 config:异步 hook 长时间运行 - writeFileSync(path.join(projectDir, "agentdock.config.yaml"), ` + it( + "E19: 创建带异步 hook 的 session → 不等 hook 完成 → 删除成功", + { timeout: 120000 }, + async () => { + // 写入 config:异步 hook 长时间运行 + writeFileSync( + path.join(projectDir, "agentdock.config.yaml"), + ` version: "1" resources: { sync: [] } hooks: @@ -227,31 +259,38 @@ hooks: async: true env: ports: [FRONTEND_PORT, BACKEND_PORT, WS_PORT, DEBUG_PORT, PREVIEW_PORT] -`); - - const createRes = await api("POST", "/api/projects/delproj/sessions", { name: "E19 Session" }); - expect(createRes.status).toBe(200); - expect(createRes.data.success).toBe(true); - expect(createRes.data.session.worktreePath).toBeDefined(); - const worktreePath = createRes.data.session.worktreePath; - expect(existsSync(worktreePath)).toBe(true); - - // 不等 hook 完成,立即删除 - await new Promise((r) => setTimeout(r, 500)); - const delRes = await api("DELETE", `/api/sessions/${createRes.data.session.id}`); - expect(delRes.status).toBe(200); - expect(delRes.data.success).toBe(true); - expect(existsSync(worktreePath)).toBe(false); - }); +`, + ); + + const createRes = await api("POST", "/api/projects/delproj/sessions", { + name: "E19 Session", + }); + expect(createRes.status).toBe(200); + expect(createRes.data.success).toBe(true); + expect(createRes.data.session.worktreePath).toBeDefined(); + const worktreePath = createRes.data.session.worktreePath; + expect(existsSync(worktreePath)).toBe(true); + + // 不等 hook 完成,立即删除 + await new Promise((r) => setTimeout(r, 500)); + const delRes = await api("DELETE", `/api/sessions/${createRes.data.session.id}`); + expect(delRes.status).toBe(200); + expect(delRes.data.success).toBe(true); + expect(existsSync(worktreePath)).toBe(false); + }, + ); it("E20: 创建→删除→再创建同一 session(端口重新分配)", { timeout: 60000 }, async () => { - writeFileSync(path.join(projectDir, "agentdock.config.yaml"), ` + writeFileSync( + path.join(projectDir, "agentdock.config.yaml"), + ` version: "1" resources: { sync: [] } hooks: {} env: ports: [FRONTEND_PORT, BACKEND_PORT, WS_PORT, DEBUG_PORT, PREVIEW_PORT] -`); +`, + ); const c1 = await api("POST", "/api/projects/delproj/sessions", { name: "First" }); expect(c1.status).toBe(200); diff --git a/plugins/__tests__/browse-dirs.test.ts b/plugins/__tests__/browse-dirs.test.ts index 766a519..816eac2 100644 --- a/plugins/__tests__/browse-dirs.test.ts +++ b/plugins/__tests__/browse-dirs.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { createServer, type ServerResponse } from "node:http"; -import path from "node:path"; +import { type ServerResponse, createServer } from "node:http"; import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; function json(res: ServerResponse, status: number, data: unknown) { res.statusCode = status; @@ -32,14 +32,20 @@ function startTestServer(port: number): Promise { if (process.platform === "win32") { for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") { const drive = `${letter}:\\`; - try { await fs.access(drive); roots.push({ name: drive, path: drive }); } catch {} + try { + await fs.access(drive); + roots.push({ name: drive, path: drive }); + } catch {} } } else { roots.push({ name: "/", path: "/" }); } const home = process.env.HOME || process.env.USERPROFILE || ""; if (home) { - try { await fs.access(home); roots.push({ name: "~ (Home)", path: home }); } catch {} + try { + await fs.access(home); + roots.push({ name: "~ (Home)", path: home }); + } catch {} } json(res, 200, { entries: roots }); return; @@ -47,9 +53,13 @@ function startTestServer(port: number): Promise { const resolved = nodePath.resolve(targetPath); try { const stat = await fs.stat(resolved); - if (!stat.isDirectory()) { json(res, 400, { error: "Path is not an existing directory" }); return; } + if (!stat.isDirectory()) { + json(res, 400, { error: "Path is not an existing directory" }); + return; + } } catch { - json(res, 400, { error: "Path is not an existing directory" }); return; + json(res, 400, { error: "Path is not an existing directory" }); + return; } const entries: Array<{ name: string; path: string }> = []; const parent = nodePath.dirname(resolved); @@ -88,7 +98,9 @@ beforeEach(async () => { afterEach(() => { server?.close(); - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); describe("GET /api/browse-dirs", () => { @@ -118,7 +130,9 @@ describe("GET /api/browse-dirs", () => { }); it("returns error for non-existent path", async () => { - const res = await fetch(`${baseUrl}/api/browse-dirs?path=${encodeURIComponent("/nonexistent")}`); + const res = await fetch( + `${baseUrl}/api/browse-dirs?path=${encodeURIComponent("/nonexistent")}`, + ); const data = await res.json(); expect(res.status).toBe(400); expect(data.error).toContain("not an existing directory"); diff --git a/plugins/__tests__/cleanup-stale-v2.test.ts b/plugins/__tests__/cleanup-stale-v2.test.ts deleted file mode 100644 index 4972ac5..0000000 --- a/plugins/__tests__/cleanup-stale-v2.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -// @ts-nocheck -/** - * F4: v2 owner/session zombie cleanup. - * - * When a v2 client crashes, its v1 heartbeat stops. The existing - * cleanupStaleClients removes the v1 client entry but leaves v2 owners - * orphaned forever. This test verifies the fix: v2 owners whose v1 - * client is gone get cleaned up (owner removed, ports freed). - */ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { DaemonState } from "../daemon-state.js"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; -import { Mutex } from "../mutex.js"; -import { cleanupStaleClients, resetSuspendDetector } from "../daemon/context.js"; -import { HEARTBEAT_TIMEOUT_MS } from "../constants.js"; -import type { DaemonContext } from "../daemon/context.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeCtx(overrides?: Partial): DaemonContext { - return { - baseDir: "/tmp/test-daemon", - registryPath: "/tmp/test-daemon/registry.json", - registry: new Map(), - state: new DaemonState(), - wal: { persist: vi.fn() } as any, - stateV2: new DaemonStateV2(), - walV2: { persist: vi.fn() } as any, - sseBus: { emit: vi.fn() } as any, - faults: { inject: null } as any, - allocator: {} as any, - mutex: new Mutex(), - lastPersistedHeartbeatAt: new Map(), - port: 0, - actualPort: 3001, - startedAt: Date.now(), - lastSeq: 0, - metrics: { sessions: 0, clients: 0, ports: 0, sseConnections: 0 }, - loadRegistry: vi.fn(), - saveRegistry: vi.fn(), - isProcessAlive: vi.fn(() => false), - ...overrides, - } as DaemonContext; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("F4: v2 owner zombie cleanup", () => { - let ctx: DaemonContext; - - beforeEach(() => { - resetSuspendDetector(); - vi.useFakeTimers(); - vi.setSystemTime(1_000_000); - }); - - afterEach(() => { - vi.useRealTimers(); - resetSuspendDetector(); - }); - - it("removes v1 client AND v2 owner + frees v2 ports when v1 heartbeat expires", async () => { - ctx = makeCtx(); - const now = Date.now(); - - // --- Arrange: register v1 client "c1" with a stale heartbeat --- - ctx.state.registerClient("c1", 100, ["/project/a"]); - // Manually set lastHeartbeat to the past so it's stale - const client = ctx.state.getClient("c1"); - expect(client).not.toBeNull(); - client!.lastHeartbeat = now - HEARTBEAT_TIMEOUT_MS - 1000; - - // --- Arrange: v2 session "s1" owned by "c1" with reserved ports --- - ctx.stateV2.createSession({ - sessionId: "s1", - projectRoot: "/project/a", - displayName: "test-session", - clientId: "c1", - pid: 100, - leaseExpiresAt: now + 60_000, - }); - ctx.stateV2.activateSession("s1"); - ctx.stateV2.claimPort("s1", 30000, "FRONTEND_PORT"); - ctx.stateV2.claimPort("s1", 30001, "BACKEND_PORT"); - - // Verify pre-conditions - expect(ctx.state.getClient("c1")).not.toBeNull(); - expect(ctx.stateV2.getOwner("s1")).not.toBeNull(); - expect(ctx.stateV2.getSessionPorts("s1")).toEqual([30000, 30001]); - expect(ctx.stateV2.ports.size).toBe(2); - - // --- Act: run cleanup --- - await cleanupStaleClients(ctx); - - // --- Assert: v1 client removed --- - expect(ctx.state.getClient("c1")).toBeNull(); - - // --- Assert: v2 owner released (zombie cleaned up) --- - expect(ctx.stateV2.getOwner("s1")).toBeNull(); - - // --- Assert: v2 ports freed --- - expect(ctx.stateV2.ports.size).toBe(0); - expect(ctx.stateV2.getSessionPorts("s1")).toEqual([]); - }); - - it("does NOT clean up v2 owner when v1 client is still alive", async () => { - ctx = makeCtx(); - const now = Date.now(); - - // --- Arrange: register v1 client "c1" with a fresh heartbeat --- - ctx.state.registerClient("c1", 100, ["/project/a"]); - // lastHeartbeat is set to now by registerClient, so it's fresh - - // --- Arrange: v2 session "s1" owned by "c1" --- - ctx.stateV2.createSession({ - sessionId: "s1", - projectRoot: "/project/a", - displayName: "test-session", - clientId: "c1", - pid: 100, - leaseExpiresAt: now + 60_000, - }); - ctx.stateV2.activateSession("s1"); - ctx.stateV2.claimPort("s1", 30000, "FRONTEND_PORT"); - - // --- Act --- - await cleanupStaleClients(ctx); - - // --- Assert: v1 client still present --- - expect(ctx.state.getClient("c1")).not.toBeNull(); - - // --- Assert: v2 owner still present (not zombie) --- - expect(ctx.stateV2.getOwner("s1")).not.toBeNull(); - expect(ctx.stateV2.ports.size).toBe(1); - }); - - it("cleans up multiple v2 owners when their v1 clients are all stale", async () => { - ctx = makeCtx(); - const now = Date.now(); - - // Client c1 — stale - ctx.state.registerClient("c1", 100, ["/project/a"]); - ctx.state.getClient("c1")!.lastHeartbeat = now - HEARTBEAT_TIMEOUT_MS - 1000; - - // Client c2 — fresh - ctx.state.registerClient("c2", 200, ["/project/b"]); - - // v2 session s1 owned by c1 (stale) - ctx.stateV2.createSession({ - sessionId: "s1", - projectRoot: "/project/a", - displayName: "session-1", - clientId: "c1", - pid: 100, - leaseExpiresAt: now + 60_000, - }); - ctx.stateV2.activateSession("s1"); - ctx.stateV2.claimPort("s1", 30000, "FRONTEND_PORT"); - - // v2 session s2 owned by c2 (fresh) - ctx.stateV2.createSession({ - sessionId: "s2", - projectRoot: "/project/b", - displayName: "session-2", - clientId: "c2", - pid: 200, - leaseExpiresAt: now + 60_000, - }); - ctx.stateV2.activateSession("s2"); - ctx.stateV2.claimPort("s2", 30001, "BACKEND_PORT"); - - // --- Act --- - await cleanupStaleClients(ctx); - - // --- Assert: c1 removed, s1 zombie cleaned --- - expect(ctx.state.getClient("c1")).toBeNull(); - expect(ctx.stateV2.getOwner("s1")).toBeNull(); - expect(ctx.stateV2.ports.get(30000)).toBeUndefined(); - - // --- Assert: c2 and s2 untouched --- - expect(ctx.state.getClient("c2")).not.toBeNull(); - expect(ctx.stateV2.getOwner("s2")).not.toBeNull(); - expect(ctx.stateV2.ports.get(30001)).toBeDefined(); - }); -}); diff --git a/plugins/__tests__/compat-verify.test.ts b/plugins/__tests__/compat-verify.test.ts deleted file mode 100644 index a97a5d9..0000000 --- a/plugins/__tests__/compat-verify.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -// @ts-nocheck -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; - -// ============================================================ -// IS-PORT: vite.config.ts 不硬编码端口 (Phase 1 后 vite.config.ts 被 -// electron.vite.config.ts 替代 — 同样原则要遵守) -// ============================================================ - -describe("IS-PORT: dev config does not hardcode 5173", () => { - // Phase 1: vite.config.ts → electron.vite.config.ts (3-target model). - // The "no hardcoded port" constraint still applies to whichever file owns - // the renderer dev server config. - const candidatePaths = [ - "electron.vite.config.ts", - "vite.config.ts", // legacy fallback until Phase 6 deletes it - ]; - const existingPath = candidatePaths.find((p) => { - try { - readFileSync(p, "utf-8"); - return true; - } catch { - return false; - } - }); - - it("does not hardcode port 5173", () => { - if (!existingPath) return; // skip — neither file exists - const code = readFileSync(existingPath, "utf-8"); - expect(code).not.toMatch(/port:\s*5173/); - }); - - it("reads port from environment variable", () => { - if (!existingPath) return; - const code = readFileSync(existingPath, "utf-8"); - expect(code).toMatch(/process\.env\.FRONTEND_PORT/); - }); - - it("throws when FRONTEND_PORT is missing (no fallback to 5173)", () => { - if (!existingPath) return; - const code = readFileSync(existingPath, "utf-8"); - expect(code).not.toMatch(/\|\|\s*5173/); - }); - - it("keeps strictPort: true", () => { - if (!existingPath) return; - const code = readFileSync(existingPath, "utf-8"); - expect(code).toMatch(/strictPort:\s*true/); - }); -}); - -// ============================================================ -// IS-PORT: package.json scripts use TS, not hardcoded 5173 -// (Phase 1 后: dev 走 electron-vite, 移除 open-app / start.ts) -// ============================================================ - -describe("IS-PORT: package.json scripts", () => { - const pkg = JSON.parse(readFileSync("package.json", "utf-8")); - - it("dev script uses electron-vite (not raw vite)", () => { - expect(pkg.scripts["dev"]).toMatch(/electron-vite/); - }); - - it("dev script does not hardcode localhost:5173", () => { - expect(pkg.scripts["dev"]).not.toMatch(/localhost:5173/); - }); - - it("build script uses electron-vite", () => { - expect(pkg.scripts["build"]).toMatch(/electron-vite/); - }); - - it("start script does not hardcode localhost:5173", () => { - expect(pkg.scripts["start"]).not.toMatch(/localhost:5173/); - }); - - it("dist:win script chains electron-vite build + electron-builder", () => { - expect(pkg.scripts["dist:win"]).toMatch(/electron-vite build.*electron-builder/); - }); -}); - -// ============================================================ -// IS-PORT: api.ts is GONE (Phase 6 deleted it) -// ============================================================ - -describe("IS-PORT: api.ts deleted", () => { - it("plugins/api.ts no longer exists", () => { - expect(() => readFileSync("plugins/api.ts", "utf-8")).toThrow(); - }); - - it("vite.config.ts no longer exists", () => { - expect(() => readFileSync("vite.config.ts", "utf-8")).toThrow(); - }); -}); - -// ============================================================ -// IS-LOCK: singleton lock removed, daemon handles concurrency -// ============================================================ - -describe("IS-LOCK: singleton removed", () => { - it("singleton.ts no longer exists", () => { - expect(() => readFileSync("plugins/singleton.ts", "utf-8")).toThrow(); - }); - - it("daemon-client has registerClient method (Hono RPC)", () => { - const code = readFileSync("plugins/daemon-client.ts", "utf-8"); - expect(code).toMatch(/registerClient/); - }); -}); diff --git a/plugins/__tests__/config.test.ts b/plugins/__tests__/config.test.ts index 01756ed..af1dcf7 100644 --- a/plugins/__tests__/config.test.ts +++ b/plugins/__tests__/config.test.ts @@ -1,18 +1,21 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import path from "node:path"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - loadConfig, AgentDockConfigSchema, - ResourceDefinitionSchema, HookDefinitionSchema, + ResourceDefinitionSchema, + loadConfig, } from "../config.js"; let tmpDir: string; beforeEach(() => { - tmpDir = path.join(os.tmpdir(), `agentdock-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tmpDir = path.join( + os.tmpdir(), + `agentdock-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(tmpDir, { recursive: true }); }); @@ -85,9 +88,7 @@ resources: }); it("A5: 非法 strategy 拒绝", () => { - expect(() => - ResourceDefinitionSchema.parse({ source: "x", strategy: "copy" }), - ).toThrow(); + expect(() => ResourceDefinitionSchema.parse({ source: "x", strategy: "copy" })).toThrow(); }); }); @@ -197,15 +198,11 @@ hooks: // ============================================================ describe("校验边界", () => { it("A10: source 为空字符串拒绝", () => { - expect(() => - ResourceDefinitionSchema.parse({ source: "" }), - ).toThrow(); + expect(() => ResourceDefinitionSchema.parse({ source: "" })).toThrow(); }); it("A11: run 为空字符串拒绝", () => { - expect(() => - HookDefinitionSchema.parse({ run: "" }), - ).toThrow(); + expect(() => HookDefinitionSchema.parse({ run: "" })).toThrow(); }); it("A12: 空 YAML 返回默认配置", () => { diff --git a/plugins/__tests__/constants.test.ts b/plugins/__tests__/constants.test.ts deleted file mode 100644 index ab0e65f..0000000 --- a/plugins/__tests__/constants.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-nocheck -/** - * constants.ts — single source of truth guard (新架构 §11.5 C4). - * - * Verifies that the centralized timing constants in plugins/constants.ts - * are the SINGLE source of truth — no other module may re-declare them - * with different values. This guards against the historical bug where - * plugins/daemon/context.ts re-exported HEARTBEAT_PERSIST_INTERVAL_MS - * with a different value (30000 vs 5000), causing 6× divergence depending - * on which import path was used. - */ -import { describe, expect, it } from "vitest"; -import { - HEARTBEAT_PERSIST_INTERVAL_MS as CANONICAL_PERSIST_MS, -} from "../constants.js"; -import { - HEARTBEAT_PERSIST_INTERVAL_MS as CONTEXT_PERSIST_MS, -} from "../daemon/context.js"; - -describe("constants — single source of truth (F1)", () => { - it("plugins/constants.ts declares HEARTBEAT_PERSIST_INTERVAL_MS = 5000", () => { - expect(CANONICAL_PERSIST_MS).toBe(5_000); - }); - - it("plugins/daemon/context.ts re-exports the SAME constant (not a duplicate)", () => { - // If context.ts re-declares with its own literal, this fails. - // After F1 fix, context.ts must import from constants.ts so the - // values are identical references (not just numerically equal). - expect(CONTEXT_PERSIST_MS).toBe(CANONICAL_PERSIST_MS); - }); - - it("daemon context no longer has a divergent HEARTBEAT_PERSIST_INTERVAL_MS = 30000", () => { - // Pre-fix bug value was 30000. After F1, the value must NOT be 30000. - expect(CONTEXT_PERSIST_MS).not.toBe(30_000); - }); - - it("constant identity check — same reference (not just equal)", () => { - // Using Object.is ensures the values are the SAME export, not two - // numerically-coincident constants. This catches accidental - // re-declaration with the same numeric value. - expect(Object.is(CONTEXT_PERSIST_MS, CANONICAL_PERSIST_MS)).toBe(true); - }); -}); \ No newline at end of file diff --git a/plugins/__tests__/daemon-discovery.test.ts b/plugins/__tests__/daemon-discovery.test.ts deleted file mode 100644 index dba924e..0000000 --- a/plugins/__tests__/daemon-discovery.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { - readDaemonInfo, - writeDaemonInfo, - deleteDaemonInfo, - isProcessAlive, -} from "../daemon-discovery.js"; -import { - acquireLock, - isLockHeld, - isLockStale, - LockAcquisitionError, -} from "../os-file-lock.js"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -// ============================================================ -// Helpers -// ============================================================ - -function tmpDir(): string { - const dir = path.join( - os.tmpdir(), - `discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -// Override the default data dir by temporarily overriding getDataDir -// We do this by writing directly to the paths daemon-discovery uses. -const originalHomedir = os.homedir; - -describe("DaemonDiscovery", () => { - let dir: string; - - beforeEach(() => { - dir = tmpDir(); - // Override homedir for daemon-discovery by writing to our temp dir - // The module uses os.homedir() internally, so we need to mock it - vi.spyOn(os, "homedir").mockReturnValue(dir); - }); - - afterEach(() => { - vi.restoreAllMocks(); - rmSync(dir, { recursive: true, force: true }); - }); - - // ============================================================ - // DaemonInfo read/write - // ============================================================ - - describe("readDaemonInfo / writeDaemonInfo / deleteDaemonInfo", () => { - it("returns null when info file does not exist", () => { - expect(readDaemonInfo()).toBeNull(); - }); - - it("writes and reads back daemon info", () => { - writeDaemonInfo(12345, 34125); - const info = readDaemonInfo(); - expect(info).not.toBeNull(); - expect(info!.pid).toBe(12345); - expect(info!.port).toBe(34125); - expect(info!.version).toBe("1"); - expect(info!.updatedAt).toBeTruthy(); - }); - - it("returns null for corrupt JSON", () => { - const infoPath = path.join(dir, ".agentdock", "daemon.json"); - mkdirSync(path.dirname(infoPath), { recursive: true }); - require("node:fs").writeFileSync(infoPath, "not json", "utf-8"); - expect(readDaemonInfo()).toBeNull(); - }); - - it("returns null when required fields are missing", () => { - const infoPath = path.join(dir, ".agentdock", "daemon.json"); - mkdirSync(path.dirname(infoPath), { recursive: true }); - require("node:fs").writeFileSync(infoPath, JSON.stringify({ foo: "bar" }), "utf-8"); - expect(readDaemonInfo()).toBeNull(); - }); - - it("returns null when pid is not a number", () => { - const infoPath = path.join(dir, ".agentdock", "daemon.json"); - mkdirSync(path.dirname(infoPath), { recursive: true }); - require("node:fs").writeFileSync(infoPath, JSON.stringify({ pid: "abc", port: 123 }), "utf-8"); - expect(readDaemonInfo()).toBeNull(); - }); - - it("deletes the info file", () => { - writeDaemonInfo(1, 2); - expect(readDaemonInfo()).not.toBeNull(); - deleteDaemonInfo(); - expect(readDaemonInfo()).toBeNull(); - }); - - it("delete does not throw on missing file", () => { - expect(() => deleteDaemonInfo()).not.toThrow(); - }); - - it("overwrites existing info on second write", () => { - writeDaemonInfo(111, 222); - writeDaemonInfo(333, 444); - const info = readDaemonInfo(); - expect(info!.pid).toBe(333); - expect(info!.port).toBe(444); - }); - }); - - // ============================================================ - // isProcessAlive - // ============================================================ - - describe("isProcessAlive", () => { - it("returns true for current process", () => { - expect(isProcessAlive(process.pid)).toBe(true); - }); - - it("returns false for PID 0", () => { - expect(isProcessAlive(0)).toBe(false); - }); - - it("returns false for a clearly dead PID (negative)", () => { - expect(isProcessAlive(-1)).toBe(false); - }); - - it("returns false for a PID that was never assigned", () => { - // On most systems, very high PIDs won't exist - expect(isProcessAlive(999999999)).toBe(false); - }); - }); - - // ============================================================ - // File lock (OS-level) - // ============================================================ - - describe("acquireLock / isLockHeld / isLockStale", () => { - it("acquires a lock successfully", async () => { - const lockPath = path.join(dir, "test.lock"); - const lock = await acquireLock(lockPath); - expect(lock).toBeDefined(); - expect(lock.path).toBe(lockPath); - await lock.release(); - }); - - it("non-blocking lock throws when held", async () => { - const lockPath = path.join(dir, "test-nb.lock"); - const lock = await acquireLock(lockPath); - await expect(acquireLock(lockPath, { timeoutMs: 0 })).rejects.toThrow(LockAcquisitionError); - await lock.release(); - }); - - it("lock is released after release()", async () => { - const lockPath = path.join(dir, "test-release.lock"); - const lock = await acquireLock(lockPath); - await lock.release(); - // Should be able to acquire again - const lock2 = await acquireLock(lockPath); - expect(lock2).toBeDefined(); - await lock2.release(); - }); - - it("isLockHeld returns true while lock is held", async () => { - const lockPath = path.join(dir, "test-held.lock"); - const lock = await acquireLock(lockPath); - expect(await isLockHeld(lockPath)).toBe(true); - await lock.release(); - }); - - it("isLockHeld returns false after release", async () => { - const lockPath = path.join(dir, "test-free.lock"); - const lock = await acquireLock(lockPath); - await lock.release(); - expect(await isLockHeld(lockPath)).toBe(false); - }); - - it("isLockHeld returns false for non-existent lock", async () => { - const lockPath = path.join(dir, "nonexistent.lock"); - expect(await isLockHeld(lockPath)).toBe(false); - }); - - it("isLockStale returns true for corrupt lock file", () => { - const lockPath = path.join(dir, "corrupt.lock"); - mkdirSync(path.dirname(lockPath), { recursive: true }); - require("node:fs").writeFileSync(lockPath, "not json", "utf-8"); - expect(isLockStale(lockPath)).toBe(true); - }); - - it("isLockStale returns true for lock with dead PID", () => { - const lockPath = path.join(dir, "dead-pid.lock"); - mkdirSync(path.dirname(lockPath), { recursive: true }); - require("node:fs").writeFileSync( - lockPath, - JSON.stringify({ pid: 999999999, acquiredAt: new Date().toISOString() }), - "utf-8", - ); - expect(isLockStale(lockPath)).toBe(true); - }); - - it("isLockStale returns true for old lock", () => { - const lockPath = path.join(dir, "old.lock"); - mkdirSync(path.dirname(lockPath), { recursive: true }); - const oldDate = new Date(Date.now() - 60_000); // 60 seconds ago - require("node:fs").writeFileSync( - lockPath, - JSON.stringify({ pid: process.pid, acquiredAt: oldDate.toISOString() }), - "utf-8", - ); - expect(isLockStale(lockPath)).toBe(true); - }); - - it("isLockStale returns false for fresh lock with alive PID", () => { - const lockPath = path.join(dir, "fresh.lock"); - const lock = acquireLock(lockPath).then((l) => { - expect(isLockStale(lockPath)).toBe(false); - return l.release(); - }); - return lock; - }); - - it("blocking lock waits and succeeds after release", async () => { - const lockPath = path.join(dir, "test-wait.lock"); - const lock = await acquireLock(lockPath); - - // Start a task that tries to acquire with timeout - const acquirePromise = acquireLock(lockPath, { timeoutMs: 2000, retryMs: 50 }); - - // Release after a short delay - setTimeout(() => lock.release(), 200); - - const lock2 = await acquirePromise; - expect(lock2).toBeDefined(); - await lock2.release(); - }, 10000); - }); -}); diff --git a/plugins/__tests__/daemon-edge-cases.test.ts b/plugins/__tests__/daemon-edge-cases.test.ts deleted file mode 100644 index b60d758..0000000 --- a/plugins/__tests__/daemon-edge-cases.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { AgentDockDaemon } from "../daemon.js"; -import http from "node:http"; -import { mkdirSync, rmSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -function tmpDir(): string { - const dir = path.join(os.tmpdir(), `daemon-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function post(port: number, pathname: string, body: unknown, headers?: Record): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const json = JSON.stringify(body); - const req = http.request({ hostname: "127.0.0.1", port, path: pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(json), ...headers } }, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { resolve({ status: res.statusCode!, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode!, data }); } - }); - }); - req.on("error", reject); - req.write(json); - req.end(); - }); -} - -function postRaw(port: number, pathname: string, rawBody: string, headers?: Record): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const req = http.request({ hostname: "127.0.0.1", port, path: pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(rawBody), ...headers } }, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { resolve({ status: res.statusCode!, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode!, data }); } - }); - }); - req.on("error", reject); - req.write(rawBody); - req.end(); - }); -} - -function get(port: number, pathname: string): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - http.get({ hostname: "127.0.0.1", port, path: pathname }, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { resolve({ status: res.statusCode!, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode!, data }); } - }); - }).on("error", reject); - }); -} - -// ============================================================ -// Tests -// ============================================================ - -describe("Daemon edge cases", () => { - let dir: string; - let daemon: AgentDockDaemon; - let port: number; - - beforeEach(async () => { - dir = tmpDir(); - daemon = new AgentDockDaemon({ port: 0, baseDir: dir }); - await daemon.start(); - port = daemon.getPort(); - }); - - afterEach(async () => { - await daemon.stop(); - rmSync(dir, { recursive: true, force: true }); - }); - - // --- Malformed input --- - - it.skip("malformed JSON body returns 400", async () => { - const res = await postRaw(port, "/sessions/allocate", "{invalid json"); - expect(res.status).toBe(400); - }); - - it.skip("empty body returns 400", async () => { - const res = await postRaw(port, "/sessions/allocate", ""); - expect(res.status).toBe(400); - }); - - it.skip("missing required fields returns 400", async () => { - const res = await post(port, "/sessions/allocate", { clientId: "c1" }); - expect(res.status).toBe(400); - expect(res.data.error).toContain("required"); - }); - - // --- Input validation --- - - it.skip("sessionId with path traversal returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "../evil", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - expect(res.data.error).toContain("Invalid sessionId"); - }); - - it.skip("sessionId with slash returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "a/b", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - }); - - it.skip("sessionId with backslash returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "a\\b", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - }); - - it.skip("empty sessionId returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - }); - - it.skip("relative worktreePath returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "relative/path", - }); - expect(res.status).toBe(400); - expect(res.data.error).toContain("absolute"); - }); - - it.skip("empty worktreePath returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "", - }); - expect(res.status).toBe(400); - }); - - // --- Idempotency --- - - it.skip("duplicate sessionId is idempotent", async () => { - // Register client first - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - const p1 = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }); - expect(p1.status).toBe(200); - - const p2 = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }); - expect(p2.status).toBe(200); - expect(p2.data.ports).toEqual(p1.data.ports); - }); - - it.skip("duplicate worktreePath returns 409", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/shared", - }); - - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s2", projectPath: "/p", worktreePath: "/wt/shared", - }); - expect(res.status).toBe(409); - expect(res.data.error).toContain("duplicate_worktree"); - }); - - // --- CSRF --- - - it("POST with Origin header returns 403", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }, { Origin: "http://evil.com" }); - expect(res.status).toBe(403); - }); - - // --- Routing --- - - it("POST to unknown path returns 404", async () => { - const res = await post(port, "/nonexistent", {}); - expect(res.status).toBe(404); - }); - - it("GET /health returns 200", async () => { - const res = await get(port, "/health"); - expect(res.status).toBe(200); - expect(res.data.status).toBe("ok"); - }); - - // --- Concurrent operations --- - - it.skip("10 concurrent allocates produce unique ports", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - const promises = Array.from({ length: 10 }, (_, i) => - post(port, "/sessions/allocate", { - clientId: "c1", sessionId: `s${i}`, projectPath: "/p", worktreePath: `/wt/s${i}`, - }), - ); - - const results = await Promise.all(promises); - - for (const res of results) { - expect(res.status).toBe(200); - } - - const allPorts: number[] = []; - for (const res of results) { - const p = res.data.ports; - allPorts.push(p.FRONTEND_PORT, p.BACKEND_PORT, p.WS_PORT, p.DEBUG_PORT, p.PREVIEW_PORT); - } - - expect(new Set(allPorts).size).toBe(50); - }); - - it.skip("concurrent allocate + release same session is safe", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - // Allocate first - await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }); - - // Concurrent release + allocate (release should win, then allocate may fail or succeed) - const results = await Promise.all([ - post(port, "/sessions/release", { clientId: "c1", sessionId: "s1" }), - post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }), - ]); - - // Both should complete without error (order may vary) - for (const res of results) { - expect([200, 409]).toContain(res.status); - } - }); - - // --- Sync/declare validation --- - - it.skip("sync/declare with invalid sessionId is skipped", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - const res = await post(port, "/sync/declare", { - clientId: "c1", - sessions: [ - { sessionId: "../evil", worktreePath: "/wt/x", projectPath: "/p" }, - { sessionId: "good", worktreePath: "/wt/good", projectPath: "/p" }, - ], - }); - - expect(res.status).toBe(200); - expect(res.data.results[0].status).toBe("error"); - expect(res.data.results[1].status).toBe("allocated"); - }); - - it.skip("sync/declare with relative worktreePath is skipped", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - const res = await post(port, "/sync/declare", { - clientId: "c1", - sessions: [ - { sessionId: "s1", worktreePath: "relative", projectPath: "/p" }, - ], - }); - - expect(res.status).toBe(200); - expect(res.data.results[0].status).toBe("error"); - }); - - // --- Owner validation --- - - it.skip("allocateSession with non-existent clientId still works (ownerPid=0)", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "nonexistent", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }); - // Should succeed but with ownerPid=0 - expect(res.status).toBe(200); - }); - - // --- DNS Rebinding protection --- - - it("request with valid Host header (127.0.0.1) passes", async () => { - // Use GET /health which accepts GET requests - const res = await get(port, "/health"); - expect(res.status).toBe(200); - }); - - it("request with malicious Host header returns 403", async () => { - // Use raw HTTP request with custom Host header - const res = await new Promise<{ status: number; data: any }>((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port, - path: "/health", - method: "GET", - headers: { Host: "evil.com" }, - }, (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - try { resolve({ status: res.statusCode!, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode!, data }); } - }); - }); - req.on("error", reject); - req.end(); - }); - expect(res.status).toBe(403); - }); - - // --- sessionId strict validation --- - - it.skip("sessionId with alphanumeric passes", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "abc-123_XYZ", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(200); - }); - - it.skip("sessionId with space returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "abc 123", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - }); - - it.skip("sessionId with special characters returns 400", async () => { - const res = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "abc@123", projectPath: "/p", worktreePath: "/wt/x", - }); - expect(res.status).toBe(400); - }); - - // --- worktreePath normalization --- - - it.skip("worktreePath with .. is normalized for duplicate detection", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - // First allocation - const res1 = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/foo/bar", - }); - expect(res1.status).toBe(200); - - // Second allocation with normalized path - const res2 = await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s2", projectPath: "/p", worktreePath: "/foo/bar/../bar", - }); - expect(res2.status).toBe(409); - }); -}); diff --git a/plugins/__tests__/daemon-follower-manager.test.ts b/plugins/__tests__/daemon-follower-manager.test.ts deleted file mode 100644 index 4220efb..0000000 --- a/plugins/__tests__/daemon-follower-manager.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @ts-nocheck -/** - * F2 — DaemonManager follower wait must use FOLLOWER_STARTUP_TIMEOUT_MS - * (新架构 §1.1 C5). - * - * The follower wait loop in daemon-manager.ts:waitForLeaderDaemon() - * uses STARTUP_TIMEOUT_MS, which is currently bound to - * DAEMON_STARTUP_TIMEOUT_MS (= 5000). After F2, the follower wait - * must be bound to FOLLOWER_STARTUP_TIMEOUT_MS (= 15000). - * - * Strategy: read the actual source of daemon-manager.ts and assert - * that the follower-wait timeout is at least 15000. This is a robust, - * non-flaky guard against the bug re-appearing — if anyone re-binds - * STARTUP_TIMEOUT_MS to DAEMON_STARTUP_TIMEOUT_MS, this test fails. - */ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { - DAEMON_STARTUP_TIMEOUT_MS, - FOLLOWER_STARTUP_TIMEOUT_MS, -} from "../daemon-discovery.js"; -import { FOLLOWER_WAIT_TIMEOUT_MS, LEADER_LOCK_TIMEOUT_MS } from "../constants.js"; - -const here = dirname(fileURLToPath(import.meta.url)); -const managerSrc = readFileSync(join(here, "..", "daemon-manager.ts"), "utf-8"); - -describe("F2 — daemon-discovery exports (新架构 §1.1 C5)", () => { - it("DAEMON_STARTUP_TIMEOUT_MS = 5000 (leader self-start, unchanged)", () => { - expect(DAEMON_STARTUP_TIMEOUT_MS).toBe(5_000); - }); - - it("FOLLOWER_STARTUP_TIMEOUT_MS = 15000 (NEW — follower wait)", () => { - expect(FOLLOWER_STARTUP_TIMEOUT_MS).toBe(15_000); - }); - - it("FOLLOWER_STARTUP_TIMEOUT_MS === FOLLOWER_WAIT_TIMEOUT_MS (single source of truth)", () => { - // Must be the SAME export, not just numerically equal. - expect(Object.is(FOLLOWER_STARTUP_TIMEOUT_MS, FOLLOWER_WAIT_TIMEOUT_MS)).toBe(true); - }); - - it("FOLLOWER_STARTUP_TIMEOUT_MS > LEADER_LOCK_TIMEOUT_MS (invariant §1.1)", () => { - expect(FOLLOWER_STARTUP_TIMEOUT_MS).toBeGreaterThan(LEADER_LOCK_TIMEOUT_MS); - }); -}); - -describe("F2 — daemon-manager uses the right constant in the right place", () => { - it("daemon-manager.ts imports FOLLOWER_STARTUP_TIMEOUT_MS from daemon-discovery", () => { - // Source guard: if anyone removes the import, the follower will - // silently fall back to DAEMON_STARTUP_TIMEOUT_MS = 5s. - expect(managerSrc).toMatch(/FOLLOWER_STARTUP_TIMEOUT_MS/); - }); - - it("daemon-manager.ts follower wait loop uses FOLLOWER_STARTUP_TIMEOUT_MS", () => { - // Source guard: the waitForLeaderDaemon loop must reference the - // follower constant. We check it appears in a context that looks - // like a wait deadline (e.g. "STARTUP_TIMEOUT_MS = ...FOLLOWER_..."). - expect(managerSrc).toMatch(/FOLLOWER_STARTUP_TIMEOUT_MS\s*;?/); - }); - - it("daemon-manager.ts no longer aliases the follower wait to DAEMON_STARTUP_TIMEOUT_MS", () => { - // The pre-fix bug: line 24 read `const STARTUP_TIMEOUT_MS = DAEMON_STARTUP_TIMEOUT_MS;` - // and the follower wait used that alias. After F2, the follower wait - // is bound to FOLLOWER_STARTUP_TIMEOUT_MS, NOT DAEMON_STARTUP_TIMEOUT_MS. - // We look for the structural shape of the old bug. - const oldBug = /const\s+STARTUP_TIMEOUT_MS\s*=\s*DAEMON_STARTUP_TIMEOUT_MS\s*;/; - expect(oldBug.test(managerSrc)).toBe(false); - }); -}); diff --git a/plugins/__tests__/daemon-session-api.test.ts b/plugins/__tests__/daemon-session-api.test.ts deleted file mode 100644 index 76ab575..0000000 --- a/plugins/__tests__/daemon-session-api.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { AgentDockDaemon } from "../daemon.js"; -import http from "node:http"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -// ============================================================ -// Helpers -// ============================================================ - -function tmpDir(): string { - const dir = path.join( - os.tmpdir(), - `daemon-session-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function post(port: number, urlPath: string, body: unknown): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const payload = JSON.stringify(body); - const req = http.request( - { hostname: "127.0.0.1", port, path: urlPath, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, - (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - resolve({ status: res.statusCode!, data: JSON.parse(data) }); - }); - }, - ); - req.on("error", reject); - req.write(payload); - req.end(); - }); -} - -function get(port: number, urlPath: string): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - http.get(`http://127.0.0.1:${port}${urlPath}`, (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - resolve({ status: res.statusCode!, data: JSON.parse(data) }); - }); - }).on("error", reject); - }); -} - -// ============================================================ -// Tests -// ============================================================ - -describe("Daemon Session API", () => { - let dir: string; - let daemon: AgentDockDaemon; - let port: number; - - beforeEach(async () => { - dir = tmpDir(); - daemon = new AgentDockDaemon({ port: 0, baseDir: dir }); - await daemon.start(); - port = daemon.getPort(); - }); - - afterEach(async () => { - await daemon.stop(); - rmSync(dir, { recursive: true, force: true }); - }); - - // --- Client Registration --- - - describe("POST /client/register", () => { - it("registers a client", async () => { - const res = await post(port, "/client/register", { - clientId: "c1", - pid: 100, - projectPaths: ["/project/a"], - }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - - it("rejects missing clientId", async () => { - const res = await post(port, "/client/register", { - pid: 100, - projectPaths: ["/project/a"], - }); - expect(res.status).toBe(400); - }); - - it("rejects missing pid", async () => { - const res = await post(port, "/client/register", { - clientId: "c1", - projectPaths: ["/project/a"], - }); - expect(res.status).toBe(400); - }); - }); - - // --- Client Heartbeat --- - - describe("POST /client/heartbeat", () => { - it("updates heartbeat", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: [] }); - const res = await post(port, "/client/heartbeat", { clientId: "c1" }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - }); - - // --- Client Unregister --- - - describe("POST /client/unregister", () => { - it("unregisters a client", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: [] }); - const res = await post(port, "/client/unregister", { clientId: "c1" }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - }); - - // --- Session Allocate --- - - describe.skip("POST /sessions/allocate", () => { - it("allocates a session with 5 ports", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - const res = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/project/a/.agentdock/worktrees/s1", - }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - expect(res.data.ports).toBeDefined(); - expect(res.data.ports.FRONTEND_PORT).toBeGreaterThanOrEqual(30000); - expect(res.data.ports.BACKEND_PORT).toBeGreaterThanOrEqual(30000); - expect(res.data.ports.WS_PORT).toBeGreaterThanOrEqual(30000); - expect(res.data.ports.DEBUG_PORT).toBeGreaterThanOrEqual(30000); - expect(res.data.ports.PREVIEW_PORT).toBeGreaterThanOrEqual(30000); - - // All 5 ports should be unique - const allPorts = [ - res.data.ports.FRONTEND_PORT, - res.data.ports.BACKEND_PORT, - res.data.ports.WS_PORT, - res.data.ports.DEBUG_PORT, - res.data.ports.PREVIEW_PORT, - ]; - expect(new Set(allPorts).size).toBe(5); - }); - - it("rejects duplicate worktreePath", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/project/a/.agentdock/worktrees/s1", - }); - - const res = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s2", - projectPath: "/project/a", - worktreePath: "/project/a/.agentdock/worktrees/s1", // same path! - }); - expect(res.status).toBe(409); - expect(res.data.error).toContain("duplicate"); - }); - - it("allows same sessionId (idempotent)", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - const r1 = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/project/a/.agentdock/worktrees/s1", - }); - expect(r1.status).toBe(200); - - // Same session again — should return existing ports - const r2 = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/project/a/.agentdock/worktrees/s1", - }); - expect(r2.status).toBe(200); - expect(r2.data.ports).toEqual(r1.data.ports); - }); - - it("two sessions get different ports", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - const r1 = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - const r2 = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s2", - projectPath: "/project/a", - worktreePath: "/wt/s2", - }); - expect(r1.status).toBe(200); - expect(r2.status).toBe(200); - - const ports1 = Object.values(r1.data.ports); - const ports2 = Object.values(r2.data.ports); - const overlap = ports1.filter((p: number) => ports2.includes(p)); - expect(overlap).toEqual([]); - }); - }); - - // --- Session Release --- - - describe.skip("POST /sessions/release", () => { - it("releases a session", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - - const res = await post(port, "/sessions/release", { - clientId: "c1", - sessionId: "s1", - }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - - it("rejects release from another live client", async () => { - await post(port, "/client/register", { clientId: "owner", pid: 100, projectPaths: ["/project/a"] }); - await post(port, "/client/register", { clientId: "other", pid: 200, projectPaths: ["/project/a"] }); - await post(port, "/sessions/allocate", { - clientId: "owner", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - - const res = await post(port, "/sessions/release", { - clientId: "other", - sessionId: "s1", - }); - expect(res.status).toBe(403); - expect(res.data.error).toContain("owned by another client"); - - const sessions = await get(port, "/sessions/list"); - expect(sessions.data.sessions).toHaveLength(1); - expect(sessions.data.sessions[0].ownerClientId).toBe("owner"); - }); - - }); - - // --- Session Reassign --- - - describe.skip("POST /sessions/reassign", () => { - it("rejects reassign from another live client", async () => { - await post(port, "/client/register", { clientId: "owner", pid: 100, projectPaths: ["/project/a"] }); - await post(port, "/client/register", { clientId: "other", pid: 200, projectPaths: ["/project/a"] }); - const alloc = await post(port, "/sessions/allocate", { - clientId: "owner", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - - const res = await post(port, "/sessions/reassign", { - clientId: "other", - sessionId: "s1", - }); - expect(res.status).toBe(403); - expect(res.data.error).toContain("owned by another client"); - - const sessions = await get(port, "/sessions/list"); - expect(sessions.data.sessions[0].ports).toEqual(alloc.data.ports); - expect(sessions.data.sessions[0].ownerClientId).toBe("owner"); - }); - - }); - - // --- Sync Declare --- - - describe.skip("POST /sync/declare", () => { - it("declares sessions and receives ports", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - const res = await post(port, "/sync/declare", { - clientId: "c1", - sessions: [ - { sessionId: "s1", worktreePath: "/wt/s1", projectPath: "/project/a" }, - { sessionId: "s2", worktreePath: "/wt/s2", projectPath: "/project/a" }, - ], - }); - expect(res.status).toBe(200); - expect(res.data.results).toHaveLength(2); - expect(res.data.results[0].status).toBe("allocated"); - expect(res.data.results[0].ports).toBeDefined(); - expect(res.data.results[1].status).toBe("allocated"); - }); - - it("returns existing ports for already-known sessions", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - - // First allocate - const alloc = await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - const originalPorts = alloc.data.ports; - - // Then declare - const res = await post(port, "/sync/declare", { - clientId: "c1", - sessions: [ - { sessionId: "s1", worktreePath: "/wt/s1", projectPath: "/project/a" }, - ], - }); - expect(res.status).toBe(200); - expect(res.data.results[0].status).toBe("existing"); - expect(res.data.results[0].ports).toEqual(originalPorts); - }); - - it("reassigns ports for a reclaimable session and transfers ownership", async () => { - await post(port, "/client/register", { clientId: "owner", pid: 100, projectPaths: ["/project/a"] }); - const alloc = await post(port, "/sessions/allocate", { - clientId: "owner", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - await post(port, "/client/unregister", { clientId: "owner" }); - await post(port, "/client/register", { clientId: "rescuer", pid: 200, projectPaths: ["/project/a"] }); - - const res = await post(port, "/sessions/reassign", { - clientId: "rescuer", - sessionId: "s1", - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe("reclaimed"); - expect(res.data.ports.FRONTEND_PORT).not.toBe(alloc.data.ports.FRONTEND_PORT); - - const sessions = await get(port, "/sessions/list"); - expect(sessions.data.sessions[0].ownerClientId).toBe("rescuer"); - }); - - it("returns foreign when another live client declares an existing session", async () => { - await post(port, "/client/register", { clientId: "owner", pid: 100, projectPaths: ["/project/a"] }); - const alloc = await post(port, "/sessions/allocate", { - clientId: "owner", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - await post(port, "/client/register", { clientId: "other", pid: 200, projectPaths: ["/project/a"] }); - - const res = await post(port, "/sync/declare", { - clientId: "other", - sessions: [ - { sessionId: "s1", worktreePath: "/wt/s1", projectPath: "/project/a" }, - ], - }); - expect(res.status).toBe(200); - expect(res.data.results[0].status).toBe("foreign"); - expect(res.data.results[0].ports).toEqual(alloc.data.ports); - - const sessions = await get(port, "/sessions/list"); - expect(sessions.data.sessions[0].ownerClientId).toBe("owner"); - }); - - it("returns reclaimed when declare revives a session from a missing owner", async () => { - await post(port, "/client/register", { clientId: "owner", pid: 100, projectPaths: ["/project/a"] }); - const alloc = await post(port, "/sessions/allocate", { - clientId: "owner", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - await post(port, "/client/unregister", { clientId: "owner" }); - await post(port, "/client/register", { clientId: "rescuer", pid: 200, projectPaths: ["/project/a"] }); - - const res = await post(port, "/sync/declare", { - clientId: "rescuer", - sessions: [ - { sessionId: "s1", worktreePath: "/wt/s1", projectPath: "/project/a" }, - ], - }); - expect(res.status).toBe(200); - expect(res.data.results[0].status).toBe("reclaimed"); - expect(res.data.results[0].ports).toEqual(alloc.data.ports); - - const sessions = await get(port, "/sessions/list"); - expect(sessions.data.sessions[0].ownerClientId).toBe("rescuer"); - }); - - }); - - // --- GET /sessions/list --- - - describe.skip("GET /sessions/list", () => { - it("lists all sessions", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/project/a"] }); - await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s1", - projectPath: "/project/a", - worktreePath: "/wt/s1", - }); - await post(port, "/sessions/allocate", { - clientId: "c1", - sessionId: "s2", - projectPath: "/project/a", - worktreePath: "/wt/s2", - }); - - const res = await get(port, "/sessions/list"); - expect(res.status).toBe(200); - expect(res.data.sessions).toHaveLength(2); - }); - }); - - // --- Heartbeat timeout cleanup --- - - describe("heartbeat timeout cleanup", () => { - it.skip("stale client sessions are released after timeout", async () => { - // Register client and allocate session - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - await post(port, "/sessions/allocate", { - clientId: "c1", sessionId: "s1", projectPath: "/p", worktreePath: "/wt/s1", - }); - - // Verify session exists - const before = await get(port, "/sessions/list"); - expect(before.data.sessions).toHaveLength(1); - - // Manually set lastHeartbeat to past (simulate stale client) - // We need to access the daemon's internal state for this test - // In real usage, the heartbeat timer handles this automatically - // For testing, we'll verify the mechanism works by checking that - // the heartbeat endpoint doesn't write to WAL - }); - - it("heartbeat eventually persists to WAL after throttle interval", async () => { - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - const walPath = path.join(dir, "daemon-state.json"); - const before = JSON.parse(readFileSync(walPath, "utf-8")); - const beforeHeartbeat = before.clients.c1.lastHeartbeat; - - await new Promise((resolve) => setTimeout(resolve, 50)); - await post(port, "/client/heartbeat", { clientId: "c1" }); - - const immediate = JSON.parse(readFileSync(walPath, "utf-8")); - expect(immediate.clients.c1.lastHeartbeat).toBe(beforeHeartbeat); - - await new Promise((resolve) => setTimeout(resolve, 50)); - await post(port, "/client/unregister", { clientId: "c1" }); - await post(port, "/client/register", { clientId: "c1", pid: 100, projectPaths: ["/p"] }); - - const after = JSON.parse(readFileSync(walPath, "utf-8")); - expect(after.clients.c1.lastHeartbeat).toBeGreaterThan(beforeHeartbeat); - }); - - }); -}); diff --git a/plugins/__tests__/daemon-state-v2-isabandoned.test.ts b/plugins/__tests__/daemon-state-v2-isabandoned.test.ts deleted file mode 100644 index 93cc6a8..0000000 --- a/plugins/__tests__/daemon-state-v2-isabandoned.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @ts-nocheck -/** - * F9: isSessionAbandoned §6.1 race window guard - * - * 新架构 §4.4 / §6.1 — when `ownerLastHeartbeat === null`, conservatively - * return false. During lease renewal there is a transient race window where - * the owner's heartbeat may be cleared (e.g. just before re-registration); - * returning true here could allow a rival owner to take over an actively - * being-deleted session. - */ -import { describe, expect, it } from "vitest"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; - -const HEARTBEAT_TIMEOUT = 90_000; - -describe("DaemonStateV2 — isSessionAbandoned F9 race window guard", () => { - function makeSession(leaseExpiresAt: number): DaemonStateV2 { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt, - }); - return s; - } - - it("RED: heartbeat=null + lease expired → false (new F9 behavior, no takeover)", () => { - const now = 1_000_000; - const s = makeSession(now - 1_000); // lease already expired - expect(s.isSessionAbandoned("u1", now, HEARTBEAT_TIMEOUT, null)).toBe(false); - }); - - it("heartbeat=null + lease valid → false (no regression)", () => { - const now = 1_000_000; - const s = makeSession(now + 15_000); // lease still valid - expect(s.isSessionAbandoned("u1", now, HEARTBEAT_TIMEOUT, null)).toBe(false); - }); - - it("heartbeat timed out + lease expired → true (normal abandoned path)", () => { - const now = 1_000_000; - const s = makeSession(now - 1_000); // lease already expired - // last heartbeat long ago → owner gone AND lease dead → abandoned - expect( - s.isSessionAbandoned("u1", now, HEARTBEAT_TIMEOUT, now - 200_000), - ).toBe(true); - }); - - it("heartbeat timed out + lease valid → false (lease still keeping it alive)", () => { - const now = 1_000_000; - const s = makeSession(now + 15_000); // lease still valid - expect( - s.isSessionAbandoned("u1", now, HEARTBEAT_TIMEOUT, now - 200_000), - ).toBe(false); - }); -}); \ No newline at end of file diff --git a/plugins/__tests__/daemon-state-v2.test.ts b/plugins/__tests__/daemon-state-v2.test.ts deleted file mode 100644 index 33bcf2e..0000000 --- a/plugins/__tests__/daemon-state-v2.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -// @ts-nocheck -/** - * DaemonStateV2 unit tests — 新架构 §4.1, §3.5, §6.1, §4.4 invariants. - */ -import { describe, expect, it } from "vitest"; -import { - CURRENT_SCHEMA_VERSION, - DaemonStateV2, - PortConflictError, - StaleOwnerError, -} from "../daemon-state-v2.js"; - -describe("DaemonStateV2 — schema", () => { - it("CURRENT_SCHEMA_VERSION is 2", () => { - expect(CURRENT_SCHEMA_VERSION).toBe(2); - }); - - it("fresh state has empty ports / owners / sessions and is READY", () => { - const s = new DaemonStateV2(); - expect(s.ports.size).toBe(0); - expect(s.owners.size).toBe(0); - expect(s.sessions.size).toBe(0); - expect(s.state).toBe("READY"); - expect(s.isReady()).toBe(true); - expect(s.isRecovering()).toBe(false); - }); - - it("state transitions RECOVERING → READY", () => { - const s = new DaemonStateV2(); - s.setState("RECOVERING"); - expect(s.isRecovering()).toBe(true); - s.setState("READY"); - expect(s.isReady()).toBe(true); - }); -}); - -describe("DaemonStateV2 — createSession / activate / rename / delete", () => { - it("createSession inserts in all three tables with fencingToken=1", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "First", - clientId: "client-A", - pid: 1234, - leaseExpiresAt: Date.now() + 15_000, - }); - expect(s.sessions.get("u1")?.status).toBe("creating"); - expect(s.owners.get("u1")?.fencingToken).toBe(1); - expect(s.owners.get("u1")?.clientId).toBe("client-A"); - expect(s.sessionPorts.get("u1")?.size).toBe(0); - }); - - it("createSession rejects duplicate sessionId", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: Date.now() + 1000, - }); - expect(() => - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "x", - clientId: "B", - pid: 2, - leaseExpiresAt: Date.now() + 1000, - }), - ).toThrow(/already exists/); - }); - - it("activateSession flips creating → active and clears lease", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: Date.now() + 1000, - }); - s.activateSession("u1"); - expect(s.sessions.get("u1")?.status).toBe("active"); - expect(s.sessions.get("u1")?.leaseExpiresAt).toBeNull(); - }); - - it("renameSession only mutates displayName (no branch/path touched)", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "old", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.activateSession("u1"); - s.renameSession("u1", "new name 中文 🚀"); - expect(s.sessions.get("u1")?.displayName).toBe("new name 中文 🚀"); - // displayName must NOT leak into sessionPorts / sessionNames — those are - // keyed off sessionId only (the §11.3 displayName isolation invariant). - expect([...s.sessionPorts.keys()]).toEqual(["u1"]); - expect([...s.sessionNames.values()][0]?.has("new name 中文 🚀")).toBe( - false, - ); - }); - - it("rename refuses on non-active session", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - expect(() => s.renameSession("u1", "y")).toThrow(/Cannot rename/); - }); - - it("delete is two-phase — beginDelete then purgeSession", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - s.claimPort("u1", 3001, "BACKEND_PORT"); - s.beginDelete("u1", Date.now() + 1000); - expect(s.sessions.get("u1")?.status).toBe("deleting"); - // All ports released back to FREE in one shot (整批语义) - expect(s.ports.size).toBe(0); - expect(s.getPortOwner(3000)).toBeNull(); - - // Idempotent — calling beginDelete again does not error - s.beginDelete("u1", Date.now() + 1000); - expect(s.sessions.get("u1")?.status).toBe("deleting"); - - s.purgeSession("u1"); - expect(s.sessions.has("u1")).toBe(false); - expect(s.owners.has("u1")).toBe(false); - expect(s.sessionPorts.has("u1")).toBe(false); - }); -}); - -describe("DaemonStateV2 — claim / release / 整批语义", () => { - it("claimPort is idempotent for same (sessionId, port)", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - expect(s.ports.size).toBe(1); - }); - - it("claimPort throws PortConflictError if port reserved by another session", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.createSession({ - sessionId: "u2", - projectRoot: "/p", - displayName: "y", - clientId: "B", - pid: 2, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - expect(() => s.claimPort("u2", 3000, "FRONTEND_PORT")).toThrow( - PortConflictError, - ); - }); - - it("releasePort is idempotent — releasing a free port returns false", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - expect(s.releasePort("u1", 3000)).toBe(false); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - expect(s.releasePort("u1", 3000)).toBe(true); - expect(s.releasePort("u1", 3000)).toBe(false); - }); - - it("releasePort refuses to release another session's port", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.createSession({ - sessionId: "u2", - projectRoot: "/p", - displayName: "y", - clientId: "B", - pid: 2, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - expect(s.releasePort("u2", 3000)).toBe(false); - expect(s.getPortOwner(3000)?.sessionId).toBe("u1"); - }); - - it("整批语义 — releaseAllPorts releases N ports together", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - [3000, 3001, 3002, 3003, 3004].forEach((p, i) => - s.claimPort("u1", p, ["F", "B", "W", "D", "P"][i]!), - ); - expect(s.ports.size).toBe(5); - s.releaseAllPorts("u1"); - expect(s.ports.size).toBe(0); - }); - - it("getSessionPortNames reflects all names reserved for a session", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - s.claimPort("u1", 3001, "BACKEND_PORT"); - expect(s.getSessionPortNames("u1").sort()).toEqual([ - "BACKEND_PORT", - "FRONTEND_PORT", - ]); - }); -}); - -describe("DaemonStateV2 — fencing / takeover (§6.1)", () => { - function newStateWithSession(): { - s: DaemonStateV2; - sessionId: string; - clientId: string; - pid: number; - } { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "client-A", - pid: 1234, - leaseExpiresAt: 0, - }); - return { s, sessionId: "u1", clientId: "client-A", pid: 1234 }; - } - - it("takeover with current token bumps fencingToken and returns new value", () => { - const { s, sessionId } = newStateWithSession(); - const before = s.getOwner(sessionId)?.fencingToken; - const r = s.takeover(sessionId, "client-B", 5678, before); - expect(r.fencingToken).toBe((before ?? 0) + 1); - expect(s.getOwner(sessionId)?.clientId).toBe("client-B"); - expect(s.getOwner(sessionId)?.pid).toBe(5678); - }); - - it("takeover with stale token throws StaleOwnerError", () => { - const { s, sessionId } = newStateWithSession(); - expect(() => s.takeover(sessionId, "client-B", 5678, 99)).toThrow( - StaleOwnerError, - ); - }); - - it("takeover with null token throws StaleOwnerError", () => { - const { s, sessionId } = newStateWithSession(); - expect(() => s.takeover(sessionId, "client-B", 5678, null)).toThrow( - StaleOwnerError, - ); - }); - - it("takeover monotonic — repeated takeovers strictly increase token", () => { - const { s, sessionId } = newStateWithSession(); - let token = s.getOwner(sessionId)?.fencingToken ?? 0; - for (let i = 0; i < 5; i++) { - const r = s.takeover(sessionId, `B${i}`, 1000 + i, token); - token = r.fencingToken; - } - expect(token).toBe(6); // started at 1, bumped 5 times - }); - - it("assertFencingToken gates writes — stale writes throw StaleOwnerError", () => { - const { s, sessionId } = newStateWithSession(); - expect(() => s.assertFencingToken(sessionId, 1)).not.toThrow(); - expect(() => s.assertFencingToken(sessionId, 2)).toThrow(StaleOwnerError); - expect(() => s.assertFencingToken(sessionId, null)).toThrow(StaleOwnerError); - }); -}); - -describe("DaemonStateV2 — lease (§4.4)", () => { - it("renewLease refreshes leaseExpiresAt", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - const now = 1_000_000; - s.renewLease("u1", 15_000); - expect((s.sessions.get("u1")?.leaseExpiresAt ?? 0) > now).toBe(true); - }); - - it("clearLease nulls leaseExpiresAt", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: Date.now() + 1000, - }); - s.clearLease("u1"); - expect(s.sessions.get("u1")?.leaseExpiresAt).toBeNull(); - }); - - it("isSessionAbandoned requires lease expired AND heartbeat timed out (null heartbeat conservatively returns false, §6.1)", () => { - const s = new DaemonStateV2(); - const now = 1_000_000; - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: now - 1000, // already expired - }); - // Heartbeat still fresh → not abandoned - expect( - s.isSessionAbandoned("u1", now, 90_000, now - 5_000), - ).toBe(false); - // Heartbeat also timed out → abandoned - expect( - s.isSessionAbandoned("u1", now, 90_000, now - 100_000), - ).toBe(true); - // F9 §6.1 race window guard: heartbeat=null conservatively returns - // false even when lease has expired (avoid premature takeover during - // lease renewal). See plugins/__tests__/daemon-state-v2-isabandoned.test.ts - expect(s.isSessionAbandoned("u1", now, 90_000, null)).toBe(false); - }); - - it("active sessions never abandoned by lease predicate", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: Date.now() + 1000, - }); - s.activateSession("u1"); - expect( - s.isSessionAbandoned("u1", Date.now(), 90_000, null), - ).toBe(false); - }); -}); - -describe("DaemonStateV2 — serialization round-trip", () => { - it("serialize → deserialize preserves all three tables + lifecycle state", () => { - const s = new DaemonStateV2(); - s.setDaemonPort(41573); - s.setState("RECOVERING"); - s.createSession({ - sessionId: "u1", - projectRoot: "/proj", - displayName: "中文 🚀 name", - clientId: "client-A", - pid: 1234, - leaseExpiresAt: Date.now() + 15_000, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - s.claimPort("u1", 3001, "BACKEND_PORT"); - - const json = JSON.stringify(s.serialize()); - const restored = DaemonStateV2.deserialize(json); - - expect(restored.sessions.get("u1")?.displayName).toBe("中文 🚀 name"); - expect(restored.owners.get("u1")?.fencingToken).toBe(1); - expect(restored.ports.get(3000)?.sessionId).toBe("u1"); - expect(restored.ports.get(3000)?.name).toBe("FRONTEND_PORT"); - expect(restored.getSessionPortNames("u1").sort()).toEqual([ - "BACKEND_PORT", - "FRONTEND_PORT", - ]); - expect(restored.daemonPort).toBe(41573); - expect(restored.state).toBe("RECOVERING"); - }); - - it("deserialize refuses wrong schemaVersion", () => { - expect(() => - DaemonStateV2.deserialize( - JSON.stringify({ - schemaVersion: 1, - ports: {}, - owners: {}, - sessions: {}, - }), - ), - ).toThrow(/schemaVersion/); - }); - - it("deserialize from missing fields yields empty state", () => { - const restored = DaemonStateV2.deserialize( - JSON.stringify({ schemaVersion: 2 }), - ); - expect(restored.ports.size).toBe(0); - expect(restored.owners.size).toBe(0); - expect(restored.sessions.size).toBe(0); - }); -}); - -describe("DaemonStateV2 — derived sessionPorts / sessionNames cache invariants", () => { - it("sessionPorts cache stays in sync with ports map across claim/release/purge", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "A", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - expect(s.sessionPorts.get("u1")?.has(3000)).toBe(true); - s.releasePort("u1", 3000); - expect(s.sessionPorts.get("u1")?.has(3000)).toBe(false); - s.claimPort("u1", 3001, "BACKEND_PORT"); - expect(s.sessionPorts.get("u1")?.size).toBe(1); - - s.beginDelete("u1", Date.now() + 1000); - expect(s.sessionPorts.get("u1")?.size).toBe(0); - s.purgeSession("u1"); - expect(s.sessionPorts.has("u1")).toBe(false); - }); -}); diff --git a/plugins/__tests__/daemon-state.test.ts b/plugins/__tests__/daemon-state.test.ts deleted file mode 100644 index d7e24c3..0000000 --- a/plugins/__tests__/daemon-state.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach } from "vitest"; -import { DaemonState, type SessionPorts } from "../daemon-state.js"; - -// ============================================================ -// Helpers -// ============================================================ - -function makePorts(start: number = 30000): SessionPorts { - return { - FRONTEND_PORT: start, - BACKEND_PORT: start + 1, - WS_PORT: start + 2, - DEBUG_PORT: start + 3, - PREVIEW_PORT: start + 4, - }; -} - -// ============================================================ -// Tests -// ============================================================ - -describe("DaemonState", () => { - let state: DaemonState; - - beforeEach(() => { - state = new DaemonState(); - }); - - // --- Client Registration --- - - describe("registerClient / unregisterClient", () => { - it("registers a client", () => { - state.registerClient("c1", 100, ["/project/a"]); - const clients = state.listClients(); - expect(clients).toHaveLength(1); - expect(clients[0].clientId).toBe("c1"); - expect(clients[0].pid).toBe(100); - expect(clients[0].projectPaths).toEqual(["/project/a"]); - }); - - it("overwrites existing client with same id", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.registerClient("c1", 200, ["/project/b"]); - const clients = state.listClients(); - expect(clients).toHaveLength(1); - expect(clients[0].pid).toBe(200); - }); - - it("unregisters a client", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.unregisterClient("c1"); - expect(state.listClients()).toHaveLength(0); - }); - - it("unregister is idempotent", () => { - state.unregisterClient("nonexistent"); - // no error - }); - }); - - // --- Session Allocation --- - - describe.skip("allocateSession", () => { - it("allocates a session with ports", () => { - state.registerClient("c1", 100, ["/project/a"]); - const ports = makePorts(); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports, - ownerClientId: "c1", - ownerPid: 100, - }); - - const session = state.getSession("s1"); - expect(session).not.toBeNull(); - expect(session!.ports).toEqual(ports); - expect(session!.worktreePath).toBe("/project/a/.agentdock/worktrees/s1"); - }); - - it("adds ports to allocatedPorts set", () => { - state.registerClient("c1", 100, ["/project/a"]); - const ports = makePorts(); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports, - ownerClientId: "c1", - ownerPid: 100, - }); - - expect(state.isPortAllocated(30000)).toBe(true); - expect(state.isPortAllocated(30001)).toBe(true); - expect(state.isPortAllocated(30004)).toBe(true); - expect(state.isPortAllocated(30005)).toBe(false); - }); - - it("adds worktreePath to worktreeIndex", () => { - state.registerClient("c1", 100, ["/project/a"]); - const ports = makePorts(); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports, - ownerClientId: "c1", - ownerPid: 100, - }); - - expect(state.findSessionByWorktree("/project/a/.agentdock/worktrees/s1")).toBe("s1"); - }); - - it("throws on duplicate sessionId", () => { - state.registerClient("c1", 100, ["/project/a"]); - const ports = makePorts(); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports, - ownerClientId: "c1", - ownerPid: 100, - }); - - expect(() => { - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports: makePorts(20010), - ownerClientId: "c1", - ownerPid: 100, - }); - }).toThrow(); - }); - }); - - // --- Session Release --- - - describe.skip("releaseSession", () => { - it("releases a session and its ports", () => { - state.registerClient("c1", 100, ["/project/a"]); - const ports = makePorts(); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports, - ownerClientId: "c1", - ownerPid: 100, - }); - - state.releaseSession("s1"); - - expect(state.getSession("s1")).toBeNull(); - expect(state.isPortAllocated(20000)).toBe(false); - expect(state.findSessionByWorktree("/project/a/.agentdock/worktrees/s1")).toBeNull(); - }); - - it("release is idempotent", () => { - state.releaseSession("nonexistent"); - // no error - }); - }); - - // --- Session Reassign --- - - describe.skip("reassignSession", () => { - it("reassigns ports for an existing session", () => { - state.registerClient("c1", 100, ["/project/a"]); - const oldPorts = makePorts(20000); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports: oldPorts, - ownerClientId: "c1", - ownerPid: 100, - }); - - const newPorts = makePorts(20010); - state.reassignSession("s1", newPorts); - - const session = state.getSession("s1"); - expect(session!.ports).toEqual(newPorts); - - // old ports freed - expect(state.isPortAllocated(20000)).toBe(false); - // new ports allocated - expect(state.isPortAllocated(20010)).toBe(true); - }); - - it("throws for nonexistent session", () => { - expect(() => state.reassignSession("nonexistent", makePorts())).toThrow(); - }); - }); - - // --- Duplicate Worktree Detection --- - - describe.skip("findDuplicate", () => { - it("returns null when no duplicate", () => { - expect(state.findDuplicate("/project/a/.agentdock/worktrees/s1")).toBeNull(); - }); - - it("returns existing sessionId for duplicate worktreePath", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/project/a/.agentdock/worktrees/s1", - projectPath: "/project/a", - ports: makePorts(), - ownerClientId: "c1", - ownerPid: 100, - }); - - expect(state.findDuplicate("/project/a/.agentdock/worktrees/s1")).toBe("s1"); - }); - }); - - // --- Heartbeat --- - - describe("heartbeat", () => { - it("updates lastHeartbeat for a client", () => { - state.registerClient("c1", 100, ["/project/a"]); - const before = state.getClient("c1")!.lastHeartbeat; - - // Small delay to ensure timestamp differs - state.heartbeat("c1"); - const after = state.getClient("c1")!.lastHeartbeat; - expect(after).toBeGreaterThanOrEqual(before); - }); - - it("heartbeat for nonexistent client is no-op", () => { - state.heartbeat("nonexistent"); - // no error - }); - }); - - // --- Get All Allocated Ports --- - - describe.skip("getAllAllocatedPorts", () => { - it("returns all allocated port numbers", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(20000), - ownerClientId: "c1", - ownerPid: 100, - }); - state.allocateSession({ - sessionId: "s2", - worktreePath: "/wt/s2", - projectPath: "/project/a", - ports: makePorts(20010), - ownerClientId: "c1", - ownerPid: 100, - }); - - const allPorts = state.getAllAllocatedPorts(); - expect(allPorts.size).toBe(10); - expect(allPorts.has(20000)).toBe(true); - expect(allPorts.has(20014)).toBe(true); - }); - }); - - // --- List Sessions --- - - describe.skip("listSessions", () => { - it("returns all sessions", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(20000), - ownerClientId: "c1", - ownerPid: 100, - }); - state.allocateSession({ - sessionId: "s2", - worktreePath: "/wt/s2", - projectPath: "/project/a", - ports: makePorts(20010), - ownerClientId: "c1", - ownerPid: 100, - }); - - const sessions = state.listSessions(); - expect(sessions).toHaveLength(2); - }); - }); - - // --- Serialize / Deserialize --- - - describe("serialize / deserialize", () => { - it("round-trips state through JSON", () => { - state.registerClient("c1", 100, ["/project/a"]); - - const json = state.serialize(); - const restored = DaemonState.deserialize(json); - - expect(restored.listClients()).toHaveLength(1); - expect(restored.getClient("c1")!.pid).toBe(100); - expect(restored.getClient("c1")!.projectPaths).toEqual(["/project/a"]); - }); - - it("handles empty state", () => { - const json = state.serialize(); - const restored = DaemonState.deserialize(json); - expect(restored.listClients()).toHaveLength(0); - }); - }); - - // --- Get Excluded Ports --- - - describe.skip("getExcludedPorts", () => { - it("returns all allocated ports as a Set for exclusion", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(20000), - ownerClientId: "c1", - ownerPid: 100, - }); - - const excluded = state.getExcludedPorts(); - expect(excluded.has(20000)).toBe(true); - expect(excluded.has(20004)).toBe(true); - expect(excluded.has(20005)).toBe(false); - }); - - it("also excludes the daemon port", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(30000), - ownerClientId: "c1", - ownerPid: 100, - }); - // Set daemon to a port in the session allocation range - state.setDaemonPort(45000); - - const excluded = state.getExcludedPorts(); - expect(excluded.has(45000)).toBe(true); - // Session ports still excluded - expect(excluded.has(30000)).toBe(true); - expect(excluded.has(30004)).toBe(true); - }); - - it("does not exclude daemon port when not set", () => { - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(30000), - ownerClientId: "c1", - ownerPid: 100, - }); - - const excluded = state.getExcludedPorts(); - // No daemonPort set, so only session ports are excluded - expect(excluded.has(45000)).toBe(false); - expect(excluded.has(30000)).toBe(true); - }); - }); -}); diff --git a/plugins/__tests__/daemon-suspend-grace.test.ts b/plugins/__tests__/daemon-suspend-grace.test.ts deleted file mode 100644 index 84e4b14..0000000 --- a/plugins/__tests__/daemon-suspend-grace.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-nocheck -/** - * §11.6 — 挂起/休眠一次性宽限 (防误回收). - * - * 笔记本合盖 / 系统休眠 / VM 挂起会让 client 进程被冻结, 唤醒后单调 - * 时钟跳变. cleanupStaleClients 内 detectSuspendAndMaybeSkip 比较两次 - * tick 之间的墙钟间隔, 异常大 (> 2 × SYNC_INTERVAL) 时返回 true, - * 跳过本轮判定. - */ -import { describe, expect, it, vi } from "vitest"; -import { - detectSuspendAndMaybeSkip, - resetSuspendDetector, -} from "../daemon/context.js"; - -describe("§11.6 挂起/休眠检测 (detectSuspendAndMaybeSkip)", () => { - it("returns false on normal tick interval", () => { - resetSuspendDetector(); - // 第一次调用: lastTickAt=now, gap=0 → false - expect(detectSuspendAndMaybeSkip()).toBe(false); - }); - - it("returns true after a long wall-clock gap (simulating suspend)", async () => { - resetSuspendDetector(); - // 第一次调用锁定 lastTickAt - detectSuspendAndMaybeSkip(); - // 模拟 65s 过去 (> 2 × SYNC_INTERVAL = 60s) - vi.useFakeTimers(); - vi.setSystemTime(Date.now() + 65_000); - expect(detectSuspendAndMaybeSkip()).toBe(true); - vi.useRealTimers(); - }); - - it("returns false for a normal inter-tick gap (e.g. 30s)", () => { - resetSuspendDetector(); - detectSuspendAndMaybeSkip(); - vi.useFakeTimers(); - vi.setSystemTime(Date.now() + 30_000); - // 30s 正好等于 SYNC_INTERVAL, 不超过 2× 阈值 - expect(detectSuspendAndMaybeSkip()).toBe(false); - vi.useRealTimers(); - }); - - it("after a suspend-skip, the next tick uses the new baseline", () => { - resetSuspendDetector(); - detectSuspendAndMaybeSkip(); - vi.useFakeTimers(); - // 模拟挂起 - vi.setSystemTime(Date.now() + 65_000); - expect(detectSuspendAndMaybeSkip()).toBe(true); - // 紧接着一次正常 tick, 应当不报挂起(lastTickAt 已被刷新) - vi.setSystemTime(Date.now() + 31_000); - expect(detectSuspendAndMaybeSkip()).toBe(false); - vi.useRealTimers(); - }); -}); diff --git a/plugins/__tests__/daemon-v2-routes.test.ts b/plugins/__tests__/daemon-v2-routes.test.ts deleted file mode 100644 index a3b0c06..0000000 --- a/plugins/__tests__/daemon-v2-routes.test.ts +++ /dev/null @@ -1,627 +0,0 @@ -// @ts-nocheck -/** - * Daemon API v2 routes — integration tests (新架构 §13.1). - * - * Spawns a real AgentDockDaemon on a tmp baseDir and exercises the v2 - * endpoints end-to-end. Verifies the §6.1 fencing invariants and §4.2 - * lifecycle state transitions are enforced. - */ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentDockDaemon } from "../daemon.js"; -import { isPortAvailable } from "../port-allocator.js"; -import type { SerializedV2 } from "../daemon-state-v2.js"; - -let dir: string; -let daemon: AgentDockDaemon; -let baseUrl: string; - -beforeEach(async () => { - dir = mkdtempSync(path.join(tmpdir(), "agentdock-v2-")); - daemon = new AgentDockDaemon({ port: 0, baseDir: dir, recoveringSoftMinMs: 0 }); - await daemon.start(); - baseUrl = `http://127.0.0.1:${daemon.getPort()}`; -}); - -afterEach(async () => { - await daemon.stop(); - rmSync(dir, { recursive: true, force: true }); -}); - -// ---------- helpers ---------- - -async function getJson(p: string): Promise { - const res = await fetch(`${baseUrl}${p}`); - return (await res.json()) as T; -} - -async function postJson(p: string, body: unknown): Promise<{ status: number; body: T }> { - const res = await fetch(`${baseUrl}${p}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as T }; -} - -interface HealthResp { - protocolVersion: string; - schemaVersion: number; - state: string; - capabilities: string[]; - pid: number; - port: number; -} - -interface CreateResp { - success: boolean; - sessionId?: string; - fencingToken?: number; - error?: { code: string; message: string }; -} - -interface DebugState { - success: boolean; - lifecycleState: string; - schemaVersion: number; - v2Sessions: Record; - v2Owners: Record; - v2Ports: Record; - // v1 surface - state: { - sessions: Record; - clients: Record; - allocatedPorts: number[]; - worktreeIndex: Record; - }; - stats: { sessionCount: number; clientCount: number; allocatedPortCount: number }; -} - -// ---------- /health ---------- - -describe("v2 /health — §2 protocol surface", () => { - it("returns protocolVersion, capabilities, state", async () => { - const h = await getJson("/health"); - expect(h.protocolVersion).toBe("2"); - expect(h.schemaVersion).toBe(2); - expect(h.state).toBe("READY"); - expect(h.capabilities).toEqual( - expect.arrayContaining([ - "port-allocation", - "session-registry", - "claim-port", - "fencing", - "lifecycle-lease", - ]), - ); - expect(typeof h.pid).toBe("number"); - expect(h.pid).toBeGreaterThan(0); - }); -}); - -// ---------- /session/* lifecycle (§4.2) ---------- - -describe("v2 /session/* lifecycle", () => { - it("create → activate flow yields active session with fencingToken=1", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1000, - projectRoot: "/proj", - displayName: "My Session", - }); - expect(c.status).toBe(200); - expect(c.body.success).toBe(true); - const sessionId = c.body.sessionId!; - expect(sessionId).toMatch(/^[a-zA-Z0-9-]+$/); - expect(c.body.fencingToken).toBe(1); - - const a = await postJson<{ success: boolean }>("/session/activate", { - sessionId, - fencingToken: 1, - }); - expect(a.body.success).toBe(true); - - const dbg = await getJson("/debug/state"); - expect(dbg.v2Sessions[sessionId].status).toBe("active"); - expect(dbg.v2Sessions[sessionId].displayName).toBe("My Session"); - }); - - it("create auto-generates displayName when not provided", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1000, - projectRoot: "/proj", - }); - expect(c.body.success).toBe(true); - expect(c.body.sessionId).toBeDefined(); - - const dbg = await getJson("/debug/state"); - const s = dbg.v2Sessions[c.body.sessionId!]; - expect(s.displayName.length).toBeGreaterThan(0); - expect(s.displayName.length).toBeLessThanOrEqual(8); - }); - - it("create rejects empty projectRoot", async () => { - const r = await postJson<{ success: boolean }>("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "", - }); - expect(r.status).toBe(400); - expect(r.body.success).toBe(false); - }); - - it("activate with wrong fencingToken returns STALE_OWNER", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - }); - const sid = c.body.sessionId!; - const a = await postJson("/session/activate", { - sessionId: sid, - fencingToken: 99, - }); - expect(a.status).toBe(409); - expect(a.body.error?.code).toBe("STALE_OWNER"); - }); - - it("rename only mutates displayName; status must be active", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "old", - }); - const sid = c.body.sessionId!; - - // Rename in creating state — should fail (cannot rename non-active) - const r0 = await postJson<{ success: boolean }>("/session/rename", { - sessionId: sid, - fencingToken: 1, - displayName: "new", - }); - expect(r0.body.success).toBe(false); - - // Activate then rename - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - const r1 = await postJson<{ success: boolean }>("/session/rename", { - sessionId: sid, - fencingToken: 1, - displayName: "new 中文 🚀", - }); - expect(r1.body.success).toBe(true); - - const dbg = await getJson("/debug/state"); - expect(dbg.v2Sessions[sid].displayName).toBe("new 中文 🚀"); - }); - - it("delete + purge two-phase releases all ports and drops 3-table entries", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "to-delete", - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - - // Claim 3 ports - for (const name of ["A", "B", "C"]) { - const cl = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - name, - }); - expect(cl.body.port).toBeGreaterThan(0); - } - - // Delete (phase 1) — releases ports, sets status=deleting - const d = await postJson<{ success: boolean }>("/session/delete", { - sessionId: sid, - fencingToken: 1, - }); - expect(d.body.success).toBe(true); - - const dbg1 = await getJson("/debug/state"); - expect(dbg1.v2Sessions[sid].status).toBe("deleting"); - expect(Object.keys(dbg1.v2Ports).length).toBe(0); - - // Purge (phase 2) — drops entries - const p = await postJson<{ success: boolean }>("/session/purge", { - sessionId: sid, - fencingToken: 1, - }); - expect(p.body.success).toBe(true); - - const dbg2 = await getJson("/debug/state"); - expect(dbg2.v2Sessions[sid]).toBeUndefined(); - expect(dbg2.v2Owners[sid]).toBeUndefined(); - }); -}); - -// ---------- /claim /release /reassign (§3.3, §6.2) ---------- - -describe("v2 /claim /release /reassign", () => { - async function createActiveSession(name: string): Promise { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: name, - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - return sid; - } - - it("claim without requestedPort picks a fresh free port", async () => { - const sid = await createActiveSession("claim-auto"); - const r = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - name: "AUTO", - }); - expect(r.body.success).toBe(true); - expect(r.body.port).toBeGreaterThan(0); - expect(await isPortAvailable(r.body.port!)).toBe(true); - }); - - it("claim with requestedPort (free) reserves exactly that port", async () => { - const sid = await createActiveSession("claim-specific"); - const port = 41000 + Math.floor(Math.random() * 1000); - expect(await isPortAvailable(port)).toBe(true); - - const r = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - requestedPort: port, - name: "X", - }); - expect(r.body.port).toBe(port); - // Registry claims the port — subsequent claim from another session must - // be rejected (verified by the next test). - const dbg = await getJson("/debug/state"); - expect(dbg.v2Ports[port]?.sessionId).toBe(sid); - }); - - it("claim for another session's port → conflict → picks new port", async () => { - const sid1 = await createActiveSession("s1"); - const sid2 = await createActiveSession("s2"); - - const a = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid1, - fencingToken: 1, - name: "P", - }); - expect(a.body.success).toBe(true); - - const b = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid2, - fencingToken: 1, - requestedPort: a.body.port, - name: "P", - }); - expect(b.body.success).toBe(true); - expect(b.body.port).not.toBe(a.body.port); - }); - - it("idempotent re-claim by same session returns the same port without re-probe", async () => { - const sid = await createActiveSession("idem"); - const first = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - name: "P", - }); - const second = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - requestedPort: first.body.port, - name: "P", - }); - expect(second.body.port).toBe(first.body.port); - }); - - it("bindFailed:true hint skips re-probe and immediately picks a fresh port", async () => { - const sid1 = await createActiveSession("s1"); - const sid2 = await createActiveSession("s2"); - - // s1 takes port X - const a = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid1, - fencingToken: 1, - name: "P", - }); - - // s2 tried X but client bind failed → hint Daemon to skip probe - const b = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid2, - fencingToken: 1, - requestedPort: a.body.port, - bindFailed: true, - name: "P", - }); - expect(b.body.success).toBe(true); - expect(b.body.port).not.toBe(a.body.port); - }); - - it("release frees the port — subsequent claim can use it again", async () => { - const sid = await createActiveSession("rel"); - const c = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - name: "P", - }); - const rel = await postJson<{ success: boolean }>("/release", { - sessionId: sid, - fencingToken: 1, - port: c.body.port!, - }); - expect(rel.body.success).toBe(true); - - const dbg = await getJson("/debug/state"); - expect(dbg.v2Ports[c.body.port!]).toBeUndefined(); - }); - - it("reassign swaps all session ports for fresh ones", async () => { - const sid = await createActiveSession("reassign"); - const oldPorts: number[] = []; - for (const name of ["F", "B"]) { - const r = await postJson<{ success: boolean; port?: number }>("/claim", { - sessionId: sid, - fencingToken: 1, - name, - }); - oldPorts.push(r.body.port!); - } - const r = await postJson<{ - success: boolean; - ports?: Record; - oldPorts?: number[]; - }>("/reassign", { sessionId: sid, fencingToken: 1 }); - expect(r.body.success).toBe(true); - expect(r.body.oldPorts?.sort()).toEqual(oldPorts.sort()); - const newPorts = Object.values(r.body.ports!); - expect(newPorts.sort()).not.toEqual(oldPorts.sort()); - }); - - it("claim with stale fencingToken returns STALE_OWNER", async () => { - const sid = await createActiveSession("stale-claim"); - const r = await postJson<{ success: boolean; error?: { code: string } }>( - "/claim", - { sessionId: sid, fencingToken: 99, name: "X" }, - ); - expect(r.body.success).toBe(false); - expect(r.body.error?.code).toBe("STALE_OWNER"); - }); -}); - -// ---------- /takeover (§6.1) ---------- - -describe("v2 /takeover", () => { - async function createActiveSession(): Promise { - const c = await postJson("/session/create", { - clientId: "client-A", - pid: 100, - projectRoot: "/p", - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - return sid; - } - - it("takeover with current token bumps fencingToken and swaps owner", async () => { - const sid = await createActiveSession(); - const r = await postJson<{ success: boolean; fencingToken?: number }>( - "/takeover", - { sessionId: sid, clientId: "client-B", pid: 200, fencingToken: 1 }, - ); - expect(r.body.fencingToken).toBe(2); - - const dbg = await getJson("/debug/state"); - expect(dbg.v2Owners[sid].clientId).toBe("client-B"); - expect(dbg.v2Owners[sid].fencingToken).toBe(2); - }); - - it("stale owner after takeover cannot write — claim rejected", async () => { - const sid = await createActiveSession(); - await postJson("/takeover", { - sessionId: sid, - clientId: "B", - pid: 200, - fencingToken: 1, - }); - - // Old owner tries to claim with the old token — must fail - const oldClaim = await postJson<{ - success: boolean; - error?: { code: string }; - }>("/claim", { - sessionId: sid, - fencingToken: 1, // stale - name: "X", - }); - expect(oldClaim.body.success).toBe(false); - expect(oldClaim.body.error?.code).toBe("STALE_OWNER"); - }); - - it("repeated takeovers monotonically increase fencingToken", async () => { - const sid = await createActiveSession(); - let token = 1; - for (let i = 0; i < 5; i++) { - const r = await postJson<{ success: boolean; fencingToken?: number }>( - "/takeover", - { - sessionId: sid, - clientId: `client-${i}`, - pid: 1000 + i, - fencingToken: token, - }, - ); - expect(r.body.fencingToken).toBeDefined(); - token = r.body.fencingToken!; - } - expect(token).toBe(6); - }); -}); - -// ---------- /sync (§7.3, snapshotSeq) ---------- - -describe("v2 /sync", () => { - it("returns snapshotSeq + sessions + owners + ports", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "sync-test", - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - await postJson("/claim", { sessionId: sid, fencingToken: 1, name: "P" }); - - const r = await postJson<{ - success: boolean; - state: string; - snapshotSeq: number; - sessions: Array<{ sessionId: string; status: string }>; - owners: Array<{ sessionId: string; clientId: string }>; - ports: Array<{ sessionId: string; name: string }>; - serverTime: number; - }>("/sync", { clientId: "c1", pid: 1, lastSeq: 0 }); - - expect(r.body.success).toBe(true); - expect(r.body.state).toBe("READY"); - expect(typeof r.body.snapshotSeq).toBe("number"); - expect(typeof r.body.serverTime).toBe("number"); - expect(r.body.sessions.some((s) => s.sessionId === sid)).toBe(true); - expect(r.body.owners.find((o) => o.sessionId === sid)?.clientId).toBe("c1"); - expect(r.body.ports.some((p) => p.sessionId === sid && p.name === "P")).toBe( - true, - ); - }); -}); - -// ---------- /debug/state (§11.1) ---------- - -describe("v2 /debug/state", () => { - it("exports full state — schemaVersion, sessions, owners, ports", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "dbg", - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - - const dbg = await getJson("/debug/state"); - expect(dbg.success).toBe(true); - expect(dbg.schemaVersion).toBe(2); - expect(dbg.lifecycleState).toBe("READY"); - expect(dbg.v2Sessions[sid].status).toBe("active"); - expect(dbg.v2Owners[sid].fencingToken).toBe(1); - expect(Object.values(dbg.v2Ports).length).toBe(0); - }); -}); - -// ---------- /metrics (P10 stub) ---------- - -describe("v2 /metrics", () => { - it("returns counter bundle shape", async () => { - const m = await getJson<{ - success: boolean; - claimCount: number; - conflictCount: number; - releaseCount: number; - heartbeatTimeoutCount: number; - activeSessionCount: number; - sseConnections: number; - }>("/metrics"); - expect(m.success).toBe(true); - expect(typeof m.claimCount).toBe("number"); - }); -}); - -// ---------- /session/heartbeat (§4.4) ---------- - -describe("v2 /session/heartbeat", () => { - it("renews lease — refreshes leaseExpiresAt", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "hb", - }); - const sid = c.body.sessionId!; - // session is in creating state; lease should be active - const hb = await postJson<{ success: boolean }>("/session/heartbeat", { - sessionId: sid, - fencingToken: 1, - phase: "creating", - }); - expect(hb.body.success).toBe(true); - }); - - it("heartbeat on non-existent session fails", async () => { - const hb = await postJson<{ success: boolean }>("/session/heartbeat", { - sessionId: "ghost", - fencingToken: 1, - phase: "creating", - }); - expect(hb.body.success).toBe(false); - }); -}); - -// ---------- /events (P5 stub — verifies hello frame) ---------- - -describe("v2 /events (SSE P5 placeholder)", () => { - it("returns text/event-stream with a hello frame", async () => { - const res = await fetch(`${baseUrl}/events`); - expect(res.headers.get("content-type")).toMatch(/event-stream/); - - // Read just enough to grab the first frame then abort - const reader = res.body!.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - for (let i = 0; i < 5; i++) { - const { value, done } = await reader.read(); - if (done) break; - buf += decoder.decode(value); - if (buf.includes("event: hello")) break; - } - await reader.cancel(); - expect(buf).toMatch(/event: hello/); - }); -}); - -// ---------- Serialization round-trip across HTTP boundary ---------- - -describe("v2 state survives daemon restart (in-memory → WAL → reload)", () => { - it("after stop+restart, /debug/state shows same sessions", async () => { - const c = await postJson("/session/create", { - clientId: "c1", - pid: 1, - projectRoot: "/p", - displayName: "persist-test", - }); - const sid = c.body.sessionId!; - await postJson("/session/activate", { sessionId: sid, fencingToken: 1 }); - await postJson("/claim", { sessionId: sid, fencingToken: 1, name: "P" }); - - await daemon.stop(); - const newDaemon = new AgentDockDaemon({ port: 0, baseDir: dir }); - await newDaemon.start(); - const newBaseUrl = `http://127.0.0.1:${newDaemon.getPort()}`; - - try { - const r = await fetch(`${newBaseUrl}/debug/state`); - const dbg = (await r.json()) as DebugState & SerializedV2; - expect(dbg.v2Sessions[sid].displayName).toBe("persist-test"); - expect(dbg.v2Sessions[sid].status).toBe("active"); - } finally { - await newDaemon.stop(); - } - }); -}); diff --git a/plugins/__tests__/daemon-wal-edge.test.ts b/plugins/__tests__/daemon-wal-edge.test.ts deleted file mode 100644 index 0e97387..0000000 --- a/plugins/__tests__/daemon-wal-edge.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { DaemonWAL } from "../daemon-wal.js"; -import { DaemonState, type SessionPorts } from "../daemon-state.js"; -import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -function tmpDir(): string { - const dir = path.join(os.tmpdir(), `wal-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function addSession(state: DaemonState, id: string, portBase: number) { - const ports: SessionPorts = { - FRONTEND_PORT: portBase, - BACKEND_PORT: portBase + 1, - WS_PORT: portBase + 2, - DEBUG_PORT: portBase + 3, - PREVIEW_PORT: portBase + 4, - }; - state.allocateSession({ - sessionId: id, - worktreePath: `/wt/${id}`, - projectPath: "/project", - ports, - ownerClientId: "c1", - ownerPid: 1000, - }); -} - -describe("DaemonWAL edge cases", () => { - let dir: string; - - beforeEach(() => { - dir = tmpDir(); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it("load returns null for non-existent file", () => { - const wal = new DaemonWAL(dir); - expect(wal.load()).toBeNull(); - }); - - it("load returns empty state for illegal JSON", () => { - const wal = new DaemonWAL(dir); - writeFileSync(path.join(dir, "daemon-state.json"), "not json {{{", "utf-8"); - // JSON.parse throws, deserialize catches and returns empty state - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listClients()).toHaveLength(0); - }); - - it("load returns empty state for empty string", () => { - const wal = new DaemonWAL(dir); - writeFileSync(path.join(dir, "daemon-state.json"), "", "utf-8"); - // JSON.parse("") throws, deserialize catches and returns empty state - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listClients()).toHaveLength(0); - }); - - it("load returns empty state for empty JSON object", () => { - const wal = new DaemonWAL(dir); - writeFileSync(path.join(dir, "daemon-state.json"), "{}", "utf-8"); - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listClients()).toHaveLength(0); - }); - - it.skip("persist creates directory if missing", () => { - const subDir = path.join(dir, "deep", "nested"); - const wal = new DaemonWAL(subDir); - const state = new DaemonState(); - addSession(state, "s1", 20000); - - wal.persist(state); - - expect(existsSync(path.join(subDir, "daemon-state.json"))).toBe(true); - }); - - it.skip("rapid persist 100 times — final file matches last state", () => { - const wal = new DaemonWAL(dir); - - for (let i = 0; i < 100; i++) { - const state = new DaemonState(); - addSession(state, `s${i}`, 20000 + i * 5); - wal.persist(state); - } - - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listSessions()).toHaveLength(1); - expect(loaded!.getSession("s99")).not.toBeNull(); - }); - - it.skip("persist works even with stale .tmp file present", () => { - const wal = new DaemonWAL(dir); - // Write a stale .tmp file - writeFileSync(path.join(dir, "daemon-state.json.tmp"), "stale", "utf-8"); - - const state = new DaemonState(); - addSession(state, "s1", 20000); - wal.persist(state); - - // .tmp is overwritten by the new write, then renamed to final file - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listSessions()).toHaveLength(1); - }); - - it.skip("WAL file truncated to 0 bytes — load returns empty state", () => { - const wal = new DaemonWAL(dir); - const state = new DaemonState(); - addSession(state, "s1", 20000); - wal.persist(state); - - // Truncate - writeFileSync(path.join(dir, "daemon-state.json"), "", "utf-8"); - - // JSON.parse("") throws, deserialize returns empty state - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listSessions()).toHaveLength(0); - }); - - it.skip("large state with 1000 sessions serializes and deserializes", () => { - const wal = new DaemonWAL(dir); - const state = new DaemonState(); - - for (let i = 0; i < 1000; i++) { - addSession(state, `s${i}`, 20000 + i * 5); - } - - const start = Date.now(); - wal.persist(state); - const persistDuration = Date.now() - start; - - const loadStart = Date.now(); - const loaded = wal.load(); - const loadDuration = Date.now() - loadStart; - - expect(loaded).not.toBeNull(); - expect(loaded!.listSessions()).toHaveLength(1000); - - // Performance sanity check — should be under 1 second - expect(persistDuration).toBeLessThan(5000); - expect(loadDuration).toBeLessThan(5000); - }); - - it.skip("serialized JSON is valid and human-readable", () => { - const wal = new DaemonWAL(dir); - const state = new DaemonState(); - addSession(state, "s1", 20000); - - wal.persist(state); - - const raw = readFileSync(path.join(dir, "daemon-state.json"), "utf-8"); - const parsed = JSON.parse(raw); - - expect(parsed.sessions).toBeDefined(); - expect(parsed.clients).toBeDefined(); - expect(parsed.allocatedPorts).toBeDefined(); - expect(parsed.worktreeIndex).toBeDefined(); - expect(Array.isArray(parsed.allocatedPorts)).toBe(true); - }); -}); diff --git a/plugins/__tests__/daemon-wal-schema-guard.test.ts b/plugins/__tests__/daemon-wal-schema-guard.test.ts deleted file mode 100644 index 182b1e3..0000000 --- a/plugins/__tests__/daemon-wal-schema-guard.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -// @ts-nocheck -/** - * F3: WAL schemaVersion guard tests. - * - * v1 WAL must stamp schemaVersion=1 on persist and refuse to load - * a file with schemaVersion=2 (to prevent v1 daemon from corrupting - * v2 state). v2 WAL must stamp schemaVersion=2 on persist. - */ -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DaemonState, type SessionPorts } from "../daemon-state.js"; -import { DaemonWAL } from "../daemon-wal.js"; -import { DaemonWALV2 } from "../daemon-wal-v2.js"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; - -// ============================================================ -// Helpers -// ============================================================ - -function tmpDir(): string { - const dir = path.join( - os.tmpdir(), - `wal-schema-guard-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function makePorts(start: number = 30000): SessionPorts { - return { - FRONTEND_PORT: start, - BACKEND_PORT: start + 1, - WS_PORT: start + 2, - DEBUG_PORT: start + 3, - PREVIEW_PORT: start + 4, - }; -} - -function populateV1State(): DaemonState { - const state = new DaemonState(); - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(20000), - ownerClientId: "c1", - ownerPid: 100, - }); - return state; -} - -function populateV2State(): DaemonStateV2 { - const state = new DaemonStateV2(); - state.setDaemonPort(41573); - state.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "Test", - clientId: "c1", - pid: 100, - leaseExpiresAt: Date.now() + 1000, - }); - state.claimPort("u1", 3000, "FRONTEND_PORT"); - return state; -} - -// ============================================================ -// Tests -// ============================================================ - -describe("F3: WAL schemaVersion guard", () => { - let dir: string; - - beforeEach(() => { - dir = tmpDir(); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - // ---------------------------------------------------------- - // Fix A: v1 WAL stamps schemaVersion=1 - // ---------------------------------------------------------- - it.skip("v1 WAL persist stamps schemaVersion=1 in the JSON file", () => { - const wal = new DaemonWAL(dir); - const state = populateV1State(); - - wal.persist(state); - - const filePath = path.join(dir, "daemon-state.json"); - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - - expect(parsed.schemaVersion).toBe(1); - }); - - it.skip("v1 WAL persist file still contains v1-shaped fields", () => { - const wal = new DaemonWAL(dir); - const state = populateV1State(); - - wal.persist(state); - - const filePath = path.join(dir, "daemon-state.json"); - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - - // v1 fields still present - expect(parsed.sessions).toBeDefined(); - expect(parsed.clients).toBeDefined(); - expect(parsed.allocatedPorts).toBeDefined(); - expect(parsed.worktreeIndex).toBeDefined(); - }); - - it.skip("v1 WAL persist then load round-trips correctly", () => { - const wal = new DaemonWAL(dir); - const state = populateV1State(); - - wal.persist(state); - const loaded = wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded!.getSession("s1")!.ports).toEqual(makePorts(20000)); - expect(loaded!.listClients()).toHaveLength(1); - }); - - // ---------------------------------------------------------- - // Fix B: v2 WAL stamps schemaVersion=2 - // ---------------------------------------------------------- - it("v2 WAL persist stamps schemaVersion=2 in the JSON file", () => { - const wal = new DaemonWALV2(dir); - const state = populateV2State(); - - wal.persist(state); - - const filePath = path.join(dir, "daemon-state.json"); - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - - expect(parsed.schemaVersion).toBe(2); - }); - - it("v2 WAL persist then load round-trips correctly", () => { - const wal = new DaemonWALV2(dir); - const state = populateV2State(); - - wal.persist(state); - const loaded = wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded?.getSession("u1")?.displayName).toBe("Test"); - expect(loaded?.getPortOwner(3000)?.sessionId).toBe("u1"); - }); - - // ---------------------------------------------------------- - // Fix C: v1 WAL refuses to overwrite v2 state - // ---------------------------------------------------------- - it("v1 WAL load of v2 file (schemaVersion=2) throws refuse-overwrite-v2-state", () => { - const wal = new DaemonWAL(dir); - - // Write a v2-shaped file with schemaVersion=2 - const v2File = { - schemaVersion: 2, - ports: {}, - owners: {}, - sessions: {}, - daemonPort: 0, - state: "IDLE", - }; - mkdirSync(dir, { recursive: true }); - writeFileSync( - path.join(dir, "daemon-state.json"), - JSON.stringify(v2File), - "utf-8", - ); - - expect(() => wal.load()).toThrow(/refuse-overwrite-v2-state/); - }); - - it.skip("v1 WAL load of v1 file (schemaVersion=1) succeeds", () => { - const wal = new DaemonWAL(dir); - const state = populateV1State(); - - wal.persist(state); - - // Verify schemaVersion=1 is in the file - const filePath = path.join(dir, "daemon-state.json"); - const raw = readFileSync(filePath, "utf-8"); - expect(JSON.parse(raw).schemaVersion).toBe(1); - - // Load should succeed - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.getSession("s1")).not.toBeNull(); - }); - - it.skip("v1 WAL load of file with no schemaVersion (legacy v1) succeeds", () => { - // Simulate a legacy v1 file that has no schemaVersion at all - const legacyV1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { FRONTEND_PORT: 30000 }, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [30000], - worktreeIndex: {}, - }; - mkdirSync(dir, { recursive: true }); - writeFileSync( - path.join(dir, "daemon-state.json"), - JSON.stringify(legacyV1), - "utf-8", - ); - - const wal = new DaemonWAL(dir); - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.getSession("s1")).not.toBeNull(); - }); - - // ---------------------------------------------------------- - // Fix B continued: v2 WAL load of v1 file triggers migration - // ---------------------------------------------------------- - it("v2 WAL load of v1 file (schemaVersion=1) triggers migration", () => { - // Write a v1-shaped file with schemaVersion=1 - const v1File = { - schemaVersion: 1, - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { FRONTEND_PORT: 30000, BACKEND_PORT: 30001 }, - ownerClientId: "clientA", - ownerPid: 111, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: { - clientA: { - clientId: "clientA", - pid: 111, - projectPaths: ["/p"], - lastHeartbeat: 1700000000000, - }, - }, - allocatedPorts: [30000, 30001], - worktreeIndex: { "/p/.agentdock/worktrees/s1": "s1" }, - }; - mkdirSync(dir, { recursive: true }); - writeFileSync( - path.join(dir, "daemon-state.json"), - JSON.stringify(v1File), - "utf-8", - ); - - const wal = new DaemonWALV2(dir); - const loaded = wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded?.getSession("s1")?.projectRoot).toBe("/p"); - expect(loaded?.getOwner("s1")?.fencingToken).toBe(1); - expect(loaded?.getPortOwner(30000)?.sessionId).toBe("s1"); - - // After migration, file on disk should now be v2 - const onDisk = JSON.parse( - readFileSync(path.join(dir, "daemon-state.json"), "utf-8"), - ); - expect(onDisk.schemaVersion).toBe(2); - }); - - // ---------------------------------------------------------- - // Cross-version safety: v2 WAL loads v1-stamped file correctly - // ---------------------------------------------------------- - it.skip("v2 WAL can load a file persisted by fixed v1 WAL (schemaVersion=1)", () => { - // v1 WAL persists with schemaVersion=1 - const v1Wal = new DaemonWAL(dir); - const v1State = populateV1State(); - v1Wal.persist(v1State); - - // v2 WAL loads it — should migrate, not crash - const v2Wal = new DaemonWALV2(dir); - const loaded = v2Wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded?.getSession("s1")?.projectRoot).toBe("/project/a"); - }); -}); diff --git a/plugins/__tests__/daemon-wal-v2.test.ts b/plugins/__tests__/daemon-wal-v2.test.ts deleted file mode 100644 index 932e8f5..0000000 --- a/plugins/__tests__/daemon-wal-v2.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -// @ts-nocheck -/** - * WAL v1→v2 migration tests — 新架构 §5.1.1. - */ -import { - copyFileSync, - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; -import { DaemonWALV2 as DaemonWAL } from "../daemon-wal-v2.js"; -import { - MIGRATIONS, - migrateToCurrent, - validateV2State, -} from "../daemon-migrate.js"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = mkdtempSync(path.join(tmpdir(), "agentdock-wal-v2-")); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("migrateToCurrent — version chain", () => { - it("passes through if already at CURRENT_SCHEMA_VERSION", () => { - const cur = { schemaVersion: 2, ports: {}, owners: {}, sessions: {} }; - const out = migrateToCurrent(cur); - expect(out).toBe(cur); // identity, no copy - }); - - it("treats missing schemaVersion as v1 (real-world v1 has no field)", () => { - const v1 = { - sessions: {}, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }; - const out = migrateToCurrent(v1 as never); - expect(out.schemaVersion).toBe(2); - }); - - it("rejects downgrade — schemaVersion higher than CURRENT throws", () => { - expect(() => migrateToCurrent({ schemaVersion: 99 })).toThrow( - /newer daemon/, - ); - }); - - it("rejects migration chain gaps", () => { - // Pretend we lost the v1→v2 migrator - const original = MIGRATIONS[1]; - delete MIGRATIONS[1]; - try { - expect(() => - migrateToCurrent({ sessions: {}, clients: {} } as never), - ).toThrow(/Migration gap/); - } finally { - MIGRATIONS[1] = original; - } - }); - - it("catches a buggy migrator that forgets to bump schemaVersion", () => { - const original = MIGRATIONS[1]; - MIGRATIONS[1] = (s) => s; // bug: no bump - try { - expect(() => - migrateToCurrent({ sessions: {}, clients: {} } as never), - ).toThrow(/did not bump schemaVersion/); - } finally { - MIGRATIONS[1] = original; - } - }); -}); - -describe("migrate_v1_to_v2 — field mapping (§5.1.1)", () => { - it("preserves ports by expanding v1 SessionEntry.ports into per-port records", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/proj/.agentdock/worktrees/s1", - projectPath: "/proj", - ports: { FRONTEND_PORT: 30000, BACKEND_PORT: 30001 }, - ownerClientId: "clientA", - ownerPid: 111, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: { - clientA: { - clientId: "clientA", - pid: 111, - projectPaths: ["/proj"], - lastHeartbeat: 1700000000000, - }, - }, - allocatedPorts: [30000, 30001], - worktreeIndex: { "/proj/.agentdock/worktrees/s1": "s1" }, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const ports = v2.ports as Record; - expect(ports[30000]).toEqual({ - port: 30000, - sessionId: "s1", - name: "FRONTEND_PORT", - state: "RESERVED", - }); - expect(ports[30001]?.name).toBe("BACKEND_PORT"); - }); - - it("promotes owner to owners table with fencingToken=1", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: {}, - ownerClientId: "clientA", - ownerPid: 111, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const owners = v2.owners as Record< - string, - { clientId: string; pid: number; fencingToken: number } - >; - expect(owners.s1).toEqual({ - clientId: "clientA", - pid: 111, - fencingToken: 1, - }); - }); - - it("renames projectPath → projectRoot and derives displayName", () => { - const v1 = { - sessions: { - abc12345: { - sessionId: "abc12345", - worktreePath: "/p/.agentdock/worktrees/abc12345", - projectPath: "/p", - ports: {}, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const sessions = v2.sessions as Record< - string, - { projectRoot: string; displayName: string; status: string } - >; - expect(sessions.abc12345.projectRoot).toBe("/p"); - expect(sessions.abc12345.displayName).toBe("abc12345"); // first 8 of sessionId - expect(sessions.abc12345.status).toBe("active"); - }); - - it("derives projectRoot from worktreePath when projectPath is missing", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/home/user/proj/.agentdock/worktrees/s1", - // no projectPath - ports: {}, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const sessions = v2.sessions as Record; - expect(sessions.s1.projectRoot).toBe("/home/user/proj"); - }); - - it("warns but proceeds when worktreePath suffix doesn't match sessionId", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/some/random/path/no_marker", - ports: {}, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const sessions = v2.sessions as Record; - expect(sessions.s1.projectRoot).toBe(""); - expect( - (v2._migrationWarnings as string[]).some((w) => w.includes("s1")), - ).toBe(true); - }); - - it("drop v1 worktreeIndex (v2 derives worktreePath live from sessionId)", () => { - const v1 = { - sessions: {}, - clients: {}, - allocatedPorts: [], - worktreeIndex: { "/some/path": "s1" }, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - expect(v2).not.toHaveProperty("worktreeIndex"); - }); - - it("port collision between two sessions in v1 → keep first, warn", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { X: 30000 }, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - s2: { - sessionId: "s2", - worktreePath: "/p/.agentdock/worktrees/s2", - projectPath: "/p", - ports: { X: 30000 }, // SAME port! - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [30000], - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const ports = v2.ports as Record; - expect(ports[30000]?.sessionId).toBe("s1"); - expect( - (v2._migrationWarnings as string[]).some((w) => w.includes("skipped s2")), - ).toBe(true); - }); - - it("v1 allocatedPorts referencing unowned ports → dropped from registry", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { X: 30000 }, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [30000, 30001], // 30001 is orphan - worktreeIndex: {}, - }; - const v2 = migrateToCurrent(v1 as never) as Record; - const ports = v2.ports as Record; - expect(ports[30001]).toBeUndefined(); - expect( - (v2._migrationWarnings as string[]).some((w) => - w.includes("30001"), - ), - ).toBe(true); - }); -}); - -describe("validateV2State", () => { - it("accepts a well-formed v2 state", () => { - expect( - validateV2State({ - schemaVersion: 2, - ports: {}, - owners: {}, - sessions: {}, - }), - ).toEqual([]); - }); - - it("rejects wrong schemaVersion", () => { - expect( - validateV2State({ - schemaVersion: 3, - ports: {}, - owners: {}, - sessions: {}, - }), - ).toContain("schemaVersion=3 (expected 2)"); - }); - - it("rejects missing ports/owners/sessions", () => { - expect( - validateV2State({ schemaVersion: 2 } as never), - ).toEqual( - expect.arrayContaining([ - expect.stringContaining("ports"), - expect.stringContaining("owners"), - expect.stringContaining("sessions"), - ]), - ); - }); -}); - -describe("DaemonWAL — load/persist round-trip with v2", () => { - it("persist then load returns identical state", () => { - const wal = new DaemonWAL(tmpDir); - const s = new DaemonStateV2(); - s.setDaemonPort(41573); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "Test", - clientId: "c1", - pid: 100, - leaseExpiresAt: Date.now() + 1000, - }); - s.claimPort("u1", 3000, "FRONTEND_PORT"); - - wal.persist(s); - const loaded = wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded?.getSession("u1")?.displayName).toBe("Test"); - expect(loaded?.getPortOwner(3000)?.sessionId).toBe("u1"); - expect(loaded?.daemonPort).toBe(41573); - }); - - it("load returns null when no file", () => { - const wal = new DaemonWAL(tmpDir); - expect(wal.load()).toBeNull(); - }); - - it("load throws on corrupt JSON", () => { - const wal = new DaemonWAL(tmpDir); - writeFileSync(wal.getPath(), "{ not json", "utf-8"); - expect(() => wal.load()).toThrow(/not valid JSON/); - }); -}); - -describe("DaemonWAL — v1 → v2 migration on load", () => { - function writeV1Fixture(): void { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { FRONTEND_PORT: 30000, BACKEND_PORT: 30001 }, - ownerClientId: "clientA", - ownerPid: 111, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: { - clientA: { - clientId: "clientA", - pid: 111, - projectPaths: ["/p"], - lastHeartbeat: 1700000000000, - }, - }, - allocatedPorts: [30000, 30001], - worktreeIndex: { "/p/.agentdock/worktrees/s1": "s1" }, - }; - writeFileSync(path.join(tmpDir, "daemon-state.json"), JSON.stringify(v1)); - } - - it("migrates real v1 file on load, returns valid v2 state", () => { - writeV1Fixture(); - const wal = new DaemonWAL(tmpDir); - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded?.getSession("s1")?.projectRoot).toBe("/p"); - expect(loaded?.getOwner("s1")?.fencingToken).toBe(1); - expect(loaded?.getPortOwner(30000)?.sessionId).toBe("s1"); - expect(loaded?.getPortOwner(30001)?.name).toBe("BACKEND_PORT"); - }); - - it("creates backup file on first upgrade (idempotent)", () => { - writeV1Fixture(); - const wal = new DaemonWAL(tmpDir); - expect(existsSync(path.join(tmpDir, "daemon-state.json.bak.v1"))).toBe( - false, - ); - wal.load(); - expect(existsSync(path.join(tmpDir, "daemon-state.json.bak.v1"))).toBe( - true, - ); - - // Re-loading does NOT overwrite the backup - const backupBefore = readFileSync( - path.join(tmpDir, "daemon-state.json.bak.v1"), - "utf-8", - ); - wal.load(); - const backupAfter = readFileSync( - path.join(tmpDir, "daemon-state.json.bak.v1"), - "utf-8", - ); - expect(backupBefore).toBe(backupAfter); - }); - - it("persists migrated state — second load takes fast path (no migration)", () => { - writeV1Fixture(); - const wal = new DaemonWAL(tmpDir); - wal.load(); - - // After migration, file should be v2 (raw JSON on disk) - const onDisk = JSON.parse( - readFileSync(path.join(tmpDir, "daemon-state.json"), "utf-8"), - ); - expect(onDisk.schemaVersion).toBe(2); - expect(onDisk.ports[30000]?.sessionId).toBe("s1"); - }); - - it("rejects v99 file (downgrade forbidden)", () => { - writeFileSync( - path.join(tmpDir, "daemon-state.json"), - JSON.stringify({ schemaVersion: 99 }), - ); - const wal = new DaemonWAL(tmpDir); - expect(() => wal.load()).toThrow(/newer daemon/); - }); - - it("handles empty v1 (no sessions, no ports)", () => { - writeFileSync( - path.join(tmpDir, "daemon-state.json"), - JSON.stringify({ - sessions: {}, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - }), - ); - const wal = new DaemonWAL(tmpDir); - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded?.ports.size).toBe(0); - expect(loaded?.sessions.size).toBe(0); - }); - - it("preserves v1 daemonPort through migration", () => { - const v1 = { - sessions: {}, - clients: {}, - allocatedPorts: [], - worktreeIndex: {}, - daemonPort: 12345, - }; - writeFileSync(path.join(tmpDir, "daemon-state.json"), JSON.stringify(v1)); - const wal = new DaemonWAL(tmpDir); - const loaded = wal.load(); - expect(loaded?.daemonPort).toBe(12345); - }); -}); - -describe("DaemonWAL — idempotency", () => { - it("running load twice on a v1 file gives the same result", () => { - const v1 = { - sessions: { - s1: { - sessionId: "s1", - worktreePath: "/p/.agentdock/worktrees/s1", - projectPath: "/p", - ports: { FRONTEND_PORT: 30000 }, - ownerClientId: "c", - ownerPid: 1, - createdAt: "2026-01-01T00:00:00Z", - }, - }, - clients: {}, - allocatedPorts: [30000], - worktreeIndex: {}, - }; - writeFileSync(path.join(tmpDir, "daemon-state.json"), JSON.stringify(v1)); - const wal = new DaemonWAL(tmpDir); - const a = wal.load(); - const b = wal.load(); - expect(a?.serialize()).toEqual(b?.serialize()); - }); -}); - -describe("DaemonWAL — concurrent persists don't corrupt file", () => { - it("two persist calls back-to-back both produce parseable file", () => { - const wal = new DaemonWAL(tmpDir); - const a = new DaemonStateV2(); - a.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "first", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - wal.persist(a); - - const b = new DaemonStateV2(); - b.createSession({ - sessionId: "u2", - projectRoot: "/p", - displayName: "second", - clientId: "c2", - pid: 2, - leaseExpiresAt: 0, - }); - wal.persist(b); - - const loaded = wal.load(); - expect(loaded?.sessions.size).toBe(1); - expect(loaded?.sessions.get("u2")?.displayName).toBe("second"); - }); - - it("v2 file copy survives rename-equivalent (tmp file absent on success)", () => { - const wal = new DaemonWAL(tmpDir); - const s = new DaemonStateV2(); - wal.persist(s); - expect(existsSync(`${wal.getPath()}.tmp`)).toBe(false); - expect(existsSync(wal.getPath())).toBe(true); - }); -}); - -// Avoid "unused import" — copyFileSync is reserved for future backup strategies -void copyFileSync; diff --git a/plugins/__tests__/daemon-wal.test.ts b/plugins/__tests__/daemon-wal.test.ts deleted file mode 100644 index 1e135cc..0000000 --- a/plugins/__tests__/daemon-wal.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { DaemonWAL } from "../daemon-wal.js"; -import { DaemonState, type SessionPorts } from "../daemon-state.js"; -import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -// ============================================================ -// Helpers -// ============================================================ - -function tmpDir(): string { - const dir = path.join( - os.tmpdir(), - `daemon-wal-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function makePorts(start: number = 30000): SessionPorts { - return { - FRONTEND_PORT: start, - BACKEND_PORT: start + 1, - WS_PORT: start + 2, - DEBUG_PORT: start + 3, - PREVIEW_PORT: start + 4, - }; -} - -function populateState(): DaemonState { - const state = new DaemonState(); - state.registerClient("c1", 100, ["/project/a"]); - state.allocateSession({ - sessionId: "s1", - worktreePath: "/wt/s1", - projectPath: "/project/a", - ports: makePorts(20000), - ownerClientId: "c1", - ownerPid: 100, - }); - return state; -} - -// ============================================================ -// Tests -// ============================================================ - -describe("DaemonWAL", () => { - let dir: string; - - beforeEach(() => { - dir = tmpDir(); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it.skip("persists and loads state", () => { - const wal = new DaemonWAL(dir); - const state = populateState(); - - wal.persist(state); - const loaded = wal.load(); - - expect(loaded).not.toBeNull(); - expect(loaded!.getSession("s1")!.ports).toEqual(makePorts(20000)); - expect(loaded!.listClients()).toHaveLength(1); - expect(loaded!.isPortAllocated(20000)).toBe(true); - }); - - it.skip("creates directory if not exists", () => { - const subDir = path.join(dir, "sub", "dir"); - const wal = new DaemonWAL(subDir); - const state = populateState(); - - wal.persist(state); - expect(existsSync(path.join(subDir, "daemon-state.json"))).toBe(true); - }); - - it("returns null for missing file", () => { - const wal = new DaemonWAL(dir); - const loaded = wal.load(); - expect(loaded).toBeNull(); - }); - - it("returns empty state for corrupt file", () => { - const wal = new DaemonWAL(dir); - const filePath = path.join(dir, "daemon-state.json"); - mkdirSync(dir, { recursive: true }); - writeFileSync(filePath, "not json!!!", "utf-8"); - - const loaded = wal.load(); - expect(loaded).not.toBeNull(); - expect(loaded!.listClients()).toHaveLength(0); - }); - - it.skip("overwrites on repeated persist", () => { - const wal = new DaemonWAL(dir); - const state1 = populateState(); - wal.persist(state1); - - // Modify and persist again - state1.releaseSession("s1"); - wal.persist(state1); - - const loaded = wal.load(); - expect(loaded!.getSession("s1")).toBeNull(); - }); - - it.skip("file contains valid JSON", () => { - const wal = new DaemonWAL(dir); - const state = populateState(); - wal.persist(state); - - const filePath = path.join(dir, "daemon-state.json"); - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - expect(parsed.sessions).toBeDefined(); - expect(parsed.clients).toBeDefined(); - expect(parsed.allocatedPorts).toBeDefined(); - expect(parsed.worktreeIndex).toBeDefined(); - }); -}); diff --git a/plugins/__tests__/daemon.test.ts b/plugins/__tests__/daemon.test.ts deleted file mode 100644 index eabfa08..0000000 --- a/plugins/__tests__/daemon.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -// @ts-nocheck -import { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; -import { AgentDockDaemon } from "../daemon.js"; -import http from "node:http"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -// ============================================================ -// Helpers -// ============================================================ - -function tmpDir(): string { - const dir = path.join( - os.tmpdir(), - `daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function post( - port: number, - path: string, - body: unknown, -): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const payload = JSON.stringify(body); - const req = http.request( - { hostname: "127.0.0.1", port, path, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, - (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - resolve({ status: res.statusCode!, data: JSON.parse(data) }); - }); - }, - ); - req.on("error", reject); - req.write(payload); - req.end(); - }); -} - -function get(port: number, path: string): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - http.get(`http://127.0.0.1:${port}${path}`, (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - resolve({ status: res.statusCode!, data: JSON.parse(data) }); - }); - }).on("error", reject); - }); -} - -function postWithHeaders( - port: number, - path: string, - body: unknown, - extraHeaders: Record, -): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const payload = JSON.stringify(body); - const req = http.request( - { - hostname: "127.0.0.1", - port, - path, - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - ...extraHeaders, - }, - }, - (res) => { - let data = ""; - res.on("data", (chunk) => { data += chunk; }); - res.on("end", () => { - resolve({ status: res.statusCode!, data: JSON.parse(data) }); - }); - }, - ); - req.on("error", reject); - req.write(payload); - req.end(); - }); -} - -// ============================================================ -// Tests -// ============================================================ - -describe("AgentDockDaemon", () => { - let dir: string; - let daemon: AgentDockDaemon; - let port: number; - - beforeEach(async () => { - dir = tmpDir(); - daemon = new AgentDockDaemon({ port: 0, baseDir: dir }); - await daemon.start(); - port = daemon.getPort(); - }); - - afterEach(async () => { - await daemon.stop(); - rmSync(dir, { recursive: true, force: true }); - }); - - // --- Health --- - - it("GET /health returns ok", async () => { - const res = await get(port, "/health"); - expect(res.status).toBe(200); - expect(res.data.status).toBe("ok"); - }); - - // --- Allocate --- - - it.skip("POST /ports/allocate allocates ports", async () => { - const res = await post(port, "/ports/allocate", { count: 3 }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - expect(res.data.data.ports).toHaveLength(3); - for (const p of res.data.data.ports) { - expect(p).toBeGreaterThanOrEqual(30000); - expect(p).toBeLessThanOrEqual(65535); - } - }); - - it.skip("POST /ports/allocate defaults to 5 ports", async () => { - const res = await post(port, "/ports/allocate", {}); - expect(res.status).toBe(200); - expect(res.data.data.ports).toHaveLength(5); - }); - - it.skip("POST /ports/allocate rejects count out of range", async () => { - const res = await post(port, "/ports/allocate", { count: 0 }); - expect(res.status).toBe(400); - expect(res.data.success).toBe(false); - }); - - it.skip("POST /ports/allocate rejects count > 100", async () => { - const res = await post(port, "/ports/allocate", { count: 101 }); - expect(res.status).toBe(400); - }); - - it.skip("POST /ports/allocate respects exclude set", async () => { - const res = await post(port, "/ports/allocate", { count: 1, exclude: [30000] }); - expect(res.status).toBe(200); - expect(res.data.data.ports[0]).not.toBe(30000); - }); - - it.skip("POST /ports/allocate accumulates allocations", async () => { - const r1 = await post(port, "/ports/allocate", { count: 2 }); - const r2 = await post(port, "/ports/allocate", { count: 2 }); - expect(r1.status).toBe(200); - expect(r2.status).toBe(200); - // No overlap - const all = [...r1.data.data.ports, ...r2.data.data.ports]; - expect(new Set(all).size).toBe(4); - }); - - // --- Release --- - - it.skip("POST /ports/release releases ports", async () => { - const alloc = await post(port, "/ports/allocate", { count: 2 }); - const portsToRelease = alloc.data.data.ports; - const res = await post(port, "/ports/release", { ports: portsToRelease }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - - it.skip("POST /ports/release rejects empty ports", async () => { - const res = await post(port, "/ports/release", { ports: [] }); - expect(res.status).toBe(400); - }); - - it.skip("POST /ports/release rejects missing ports", async () => { - const res = await post(port, "/ports/release", {}); - expect(res.status).toBe(400); - }); - - it.skip("POST /ports/release allows re-allocation of released ports", async () => { - const alloc = await post(port, "/ports/allocate", { count: 1 }); - const p = alloc.data.data.ports[0]; - await post(port, "/ports/release", { ports: [p] }); - // Re-allocate with the released port in exclude set to force it - const re = await post(port, "/ports/allocate", { count: 1, exclude: [] }); - expect(re.status).toBe(200); - // The released port should be available again (may or may not be picked) - }); - - // --- 404 --- - - it("GET /unknown returns 404", async () => { - const res = await get(port, "/unknown"); - expect(res.status).toBe(404); - }); - - // --- Concurrent clients --- - - it.skip("two concurrent allocate requests get different ports", async () => { - const [r1, r2] = await Promise.all([ - post(port, "/ports/allocate", { count: 5 }), - post(port, "/ports/allocate", { count: 5 }), - ]); - expect(r1.status).toBe(200); - expect(r2.status).toBe(200); - const overlap = r1.data.data.ports.filter((p: number) => - r2.data.data.ports.includes(p), - ); - expect(overlap).toEqual([]); - }); - - it.skip("four concurrent allocate requests get all unique ports", async () => { - const results = await Promise.all([ - post(port, "/ports/allocate", { count: 3 }), - post(port, "/ports/allocate", { count: 3 }), - post(port, "/ports/allocate", { count: 3 }), - post(port, "/ports/allocate", { count: 3 }), - ]); - const allPorts: number[] = []; - for (const r of results) { - expect(r.status).toBe(200); - allPorts.push(...r.data.data.ports); - } - expect(new Set(allPorts).size).toBe(12); - }); - - // --- allocate + release concurrency --- - - it.skip("concurrent allocate and release do not conflict", async () => { - const alloc = await post(port, "/ports/allocate", { count: 3 }); - const ports = alloc.data.data.ports; - // Concurrently release and allocate - const [rel, alloc2] = await Promise.all([ - post(port, "/ports/release", { ports: [ports[0], ports[1]] }), - post(port, "/ports/allocate", { count: 2 }), - ]); - expect(rel.status).toBe(200); - expect(alloc2.status).toBe(200); - // New allocation should not include still-held port[2] - expect(alloc2.data.data.ports).not.toContain(ports[2]); - }); - - // --- Origin protection (browser CSRF / drive-by) --- - - it("ORG1: 带 Origin 头的 /ports/allocate 被拒绝 403", async () => { - const res = await postWithHeaders(port, "/ports/allocate", { count: 1 }, { Origin: "http://evil.com" }); - expect(res.status).toBe(403); - expect(res.data.success).toBe(false); - }); - - it("ORG2: 带 Origin 头的 /ports/release 被拒绝 403", async () => { - const res = await postWithHeaders(port, "/ports/release", { ports: [30001] }, { Origin: "http://evil.com" }); - expect(res.status).toBe(403); - }); - - it("ORG3: 带 Origin 头的 /register 被拒绝 403", async () => { - const res = await postWithHeaders(port, "/register", { dir: "/tmp/x", pid: 1 }, { Origin: "http://evil.com" }); - expect(res.status).toBe(403); - }); - - it("ORG4: 带 Origin 头的 /unregister 被拒绝 403", async () => { - const res = await postWithHeaders(port, "/unregister", { dir: "/tmp/x" }, { Origin: "http://evil.com" }); - expect(res.status).toBe(403); - }); - - it.skip("ORG5: 不带 Origin 头的写请求正常工作(合法客户端)", async () => { - const res = await post(port, "/ports/allocate", { count: 1 }); - expect(res.status).toBe(200); - expect(res.data.success).toBe(true); - }); - - it("ORG6: 带 Origin 头的 GET /health 仍可访问(只读)", async () => { - const res = await postWithHeaders(port, "/health", {}, { Origin: "http://evil.com" }); - // /health is GET-only; POST returns 404 — what matters is no 403 short-circuit for reads. - // Use a direct GET with Origin instead. - const g = await new Promise<{ status: number }>((resolve, reject) => { - http.get( - { hostname: "127.0.0.1", port, path: "/health", headers: { Origin: "http://evil.com" } }, - (r) => { r.resume(); resolve({ status: r.statusCode! }); }, - ).on("error", reject); - }); - expect(g.status).toBe(200); - expect(res.status).not.toBe(200); // POST /health is not a valid route - }); - - it("ORG7: 不再返回通配 CORS 头 Access-Control-Allow-Origin: *", async () => { - const acao = await new Promise((resolve, reject) => { - http.get( - { hostname: "127.0.0.1", port, path: "/health" }, - (r) => { r.resume(); resolve(r.headers["access-control-allow-origin"] as string | undefined); }, - ).on("error", reject); - }); - expect(acao).not.toBe("*"); - }); -}); diff --git a/plugins/__tests__/db-global-migration.test.ts b/plugins/__tests__/db-global-migration.test.ts new file mode 100644 index 0000000..3b12c1c --- /dev/null +++ b/plugins/__tests__/db-global-migration.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { migrateProjectsToGlobal, openGlobalDb } from "../db/global.js"; +import { openDb } from "../db/index.js"; +import * as schema from "../db/schema.js"; + +describe("legacy project migration to global DB", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* Windows WAL cleanup */ + } + } + }); + + it("preserves and copies project rows during a v0-to-current upgrade", () => { + const sourceDir = mkdtempSync(join(tmpdir(), "agentdock-source-db-")); + const globalDir = mkdtempSync(join(tmpdir(), "agentdock-global-db-")); + dirs.push(sourceDir, globalDir); + + const source = openDb(sourceDir); + source.db + .insert(schema.projects) + .values({ + id: "legacy-project", + name: "Legacy Project", + path: "C:/legacy-project", + createdAt: "2024-01-01T00:00:00.000Z", + }) + .run(); + source.sqlite.close(); + + const global = openGlobalDb(globalDir); + migrateProjectsToGlobal(global.db, join(sourceDir, "data", "db.sqlite")); + const migrated = global.db.select().from(schema.projects).all(); + expect(migrated).toHaveLength(1); + expect(migrated[0]?.id).toBe("legacy-project"); + global.close(); + }); +}); diff --git a/plugins/__tests__/db-migration.test.ts b/plugins/__tests__/db-migration.test.ts index 283ef63..8586fd0 100644 --- a/plugins/__tests__/db-migration.test.ts +++ b/plugins/__tests__/db-migration.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; -import path from "node:path"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { createDb, getDbPath, SCHEMA_VERSION } from "../db/index.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SCHEMA_VERSION, createDb, getDbPath } from "../db/index.js"; function tmpProject(): string { return mkdtempSync(path.join(os.tmpdir(), "ad-dbmig-test-")); @@ -22,9 +22,7 @@ function columns(dbPath: string, table: string): string[] { function userVersion(dbPath: string): number { const db = new DatabaseSync(dbPath); try { - const row = db.prepare("PRAGMA user_version").get() as - | { user_version: number } - | undefined; + const row = db.prepare("PRAGMA user_version").get() as { user_version: number } | undefined; return row?.user_version ?? 0; } finally { db.close(); @@ -58,9 +56,7 @@ describe("DB migration — PRAGMA user_version", () => { expect(cols).toContain("ports"); expect(cols).toContain("background_hook_status"); const projCols = columns(getDbPath(projectDir), "projects"); - expect(projCols).toEqual( - expect.arrayContaining(["id", "name", "path", "created_at"]), - ); + expect(projCols).toEqual(expect.arrayContaining(["id", "name", "path", "created_at"])); }); it("DBM3: 重复 createDb 幂等,不报错且版本不变", () => { @@ -75,9 +71,8 @@ describe("DB migration — PRAGMA user_version", () => { it("DBM4: 旧库(缺列、user_version=0、含数据)升级后补齐列且数据不丢失", () => { // Simulate a legacy DB: base columns only, no ports/background_hook_status, // user_version left at default 0, with one existing row. - const dbDir = path.join(projectDir, ".data"); - mkdirSync(dbDir, { recursive: true }); const dbPath = getDbPath(projectDir); + mkdirSync(path.dirname(dbPath), { recursive: true }); const legacy = new DatabaseSync(dbPath); legacy.exec(` CREATE TABLE projects ( @@ -95,12 +90,14 @@ describe("DB migration — PRAGMA user_version", () => { created_at TEXT NOT NULL ); `); - legacy.prepare( - "INSERT INTO projects (id, name, path, created_at) VALUES (?, ?, ?, ?)", - ).run("p1", "Legacy", "/tmp/legacy", "2024-01-01T00:00:00.000Z"); - legacy.prepare( - "INSERT INTO sessions (id, project_id, name, branch, worktree_path, created_at) VALUES (?, ?, ?, ?, ?, ?)", - ).run("s1", "p1", "Old Session", "agentdock/s1", "/tmp/wt", "2024-01-01T00:00:00.000Z"); + legacy + .prepare("INSERT INTO projects (id, name, path, created_at) VALUES (?, ?, ?, ?)") + .run("p1", "Legacy", "/tmp/legacy", "2024-01-01T00:00:00.000Z"); + legacy + .prepare( + "INSERT INTO sessions (id, project_id, name, branch, worktree_path, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run("s1", "p1", "Old Session", "agentdock/s1", "/tmp/wt", "2024-01-01T00:00:00.000Z"); expect( (legacy.prepare("PRAGMA user_version").get() as { user_version: number }).user_version, ).toBe(0); @@ -124,9 +121,9 @@ describe("DB migration — PRAGMA user_version", () => { .get("s1") as { id: string; name: string; branch: string } | undefined; expect(session).toBeDefined(); expect(session?.name).toBe("Old Session"); - const project = check - .prepare("SELECT id, name FROM projects WHERE id = ?") - .get("p1") as { id: string; name: string } | undefined; + const project = check.prepare("SELECT id, name FROM projects WHERE id = ?").get("p1") as + | { id: string; name: string } + | undefined; expect(project?.name).toBe("Legacy"); } finally { check.close(); diff --git a/plugins/__tests__/db-projects-dedup.test.ts b/plugins/__tests__/db-projects-dedup.test.ts index 26d9ca0..35e8712 100644 --- a/plugins/__tests__/db-projects-dedup.test.ts +++ b/plugins/__tests__/db-projects-dedup.test.ts @@ -1,3 +1,8 @@ +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-sqlite"; /** * db:projects:create fuzzy dedup unit tests. * @@ -7,12 +12,7 @@ * The path-healing step (writing the caller's spelling on fuzzy * match) is also verified. */ -import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; -import { drizzle } from "drizzle-orm/node-sqlite"; -import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it } from "vitest"; import * as schema from "../db/schema.js"; /** @@ -24,7 +24,7 @@ function normalize(p: string): string { return p .replace(/\\/g, "/") .replace(/\/+$/, "") - .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); + .replace(/^([A-Z]):/i, (_, d: string) => `${d.toLowerCase()}:`); } function createOrHeal( @@ -43,16 +43,10 @@ function createOrHeal( .where(eq(schema.projects.id, existing.id)) .run(); } - return db - .select() - .from(schema.projects) - .where(eq(schema.projects.id, existing.id)) - .get()!; + return db.select().from(schema.projects).where(eq(schema.projects.id, existing.id)).get()!; } const id = Math.random().toString(36).slice(2, 10); - db.insert(schema.projects) - .values({ id, name, path: safePath }) - .run(); + db.insert(schema.projects).values({ id, name, path: safePath }).run(); return db.select().from(schema.projects).where(eq(schema.projects.id, id)).get()!; } @@ -80,7 +74,11 @@ describe("db:projects:create dedup", () => { }); it("creates a new project when path is fresh", () => { - const project = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const project = createOrHeal( + db, + "SpeedWriter", + "F:\\ProgramPlayground\\JavaScript\\SpeedWriter", + ); expect(project.id).toBeTruthy(); expect(project.path).toBe("F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); @@ -90,7 +88,11 @@ describe("db:projects:create dedup", () => { it("returns existing project when path matches exactly", () => { const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); - const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal( + db, + "SpeedWriter", + "F:\\ProgramPlayground\\JavaScript\\SpeedWriter", + ); expect(second.id).toBe(first.id); const all = db.select().from(schema.projects).all(); @@ -99,7 +101,11 @@ describe("db:projects:create dedup", () => { it("fuzzy-matches when caller uses different case (Windows drives are case-insensitive)", () => { const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); - const second = createOrHeal(db, "SpeedWriter", "f:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal( + db, + "SpeedWriter", + "f:\\ProgramPlayground\\JavaScript\\SpeedWriter", + ); expect(second.id).toBe(first.id); const all = db.select().from(schema.projects).all(); @@ -108,7 +114,11 @@ describe("db:projects:create dedup", () => { it("fuzzy-matches when caller uses trailing slash", () => { const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); - const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter\\"); + const second = createOrHeal( + db, + "SpeedWriter", + "F:\\ProgramPlayground\\JavaScript\\SpeedWriter\\", + ); expect(second.id).toBe(first.id); const all = db.select().from(schema.projects).all(); diff --git a/plugins/__tests__/display-name.test.ts b/plugins/__tests__/display-name.test.ts index d184c3e..6e9dd0c 100644 --- a/plugins/__tests__/display-name.test.ts +++ b/plugins/__tests__/display-name.test.ts @@ -2,10 +2,7 @@ * displayName 最小消毒 (新架构 §4.1 末段). */ import { describe, expect, it } from "vitest"; -import { - sanitizeDisplayName, - DISPLAY_NAME_MAX_LENGTH, -} from "../display-name.js"; +import { DISPLAY_NAME_MAX_LENGTH, sanitizeDisplayName } from "../display-name.js"; describe("sanitizeDisplayName (新架构 §4.1)", () => { it("空字符串/空值 → ''", () => { @@ -52,7 +49,7 @@ describe("sanitizeDisplayName (新架构 §4.1)", () => { it("控制字符 + 截断组合", () => { // 200 字符里塞了 5 个 \n — 净化后变 195, 截到 128 - const s = "a".repeat(100) + "\n\n\n\n\n" + "b".repeat(95); + const s = `${"a".repeat(100)}\n\n\n\n\n${"b".repeat(95)}`; expect(sanitizeDisplayName(s).length).toBe(128); }); diff --git a/plugins/__tests__/env.test.ts b/plugins/__tests__/env.test.ts index 055db11..10d68c8 100644 --- a/plugins/__tests__/env.test.ts +++ b/plugins/__tests__/env.test.ts @@ -1,18 +1,16 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildScopedChildEnv, discoverPortKeysFromEnv, loadDotEnvIntoProcess, mergeEnv, parseEnv, - readEnvFile, - readWorkspaceEnv, updateEnvFile, writeEnv, } from "../env.js"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import os from "node:os"; describe("parseEnv", () => { it("parses simple KEY=VALUE pairs", () => { @@ -113,7 +111,10 @@ describe("writeEnv", () => { describe("buildScopedChildEnv", () => { function createTmpDir(): string { - const dir = path.join(os.tmpdir(), `agentdock-env-scope-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const dir = path.join( + os.tmpdir(), + `agentdock-env-scope-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -122,11 +123,15 @@ describe("buildScopedChildEnv", () => { const dir = createTmpDir(); try { writeFileSync(path.join(dir, ".env"), "FRONTEND_PORT=20091\nAPI_URL=http://local\n"); - const env = buildScopedChildEnv(dir, { AGENTDOCK_SESSION_ID: "sess1" }, { - FRONTEND_PORT: "5175", - API_URL: "http://parent", - PATH: process.env.PATH, - }); + const env = buildScopedChildEnv( + dir, + { AGENTDOCK_SESSION_ID: "sess1" }, + { + FRONTEND_PORT: "5175", + API_URL: "http://parent", + PATH: process.env.PATH, + }, + ); expect(env.FRONTEND_PORT).toBe("20091"); expect(env.API_URL).toBe("http://local"); expect(env.AGENTDOCK_SESSION_ID).toBe("sess1"); @@ -140,11 +145,15 @@ describe("buildScopedChildEnv", () => { const dir = createTmpDir(); try { writeFileSync(path.join(dir, ".env"), "API_URL=http://local\n"); - const env = buildScopedChildEnv(dir, {}, { - FRONTEND_PORT: "5175", - PORT: "3000", - API_URL: "http://parent", - }); + const env = buildScopedChildEnv( + dir, + {}, + { + FRONTEND_PORT: "5175", + PORT: "3000", + API_URL: "http://parent", + }, + ); expect(env.FRONTEND_PORT).toBeUndefined(); expect(env.PORT).toBeUndefined(); expect(env.API_URL).toBe("http://local"); @@ -156,11 +165,15 @@ describe("buildScopedChildEnv", () => { it("keeps safe inherited vars when no workspace override exists", () => { const dir = createTmpDir(); try { - const env = buildScopedChildEnv(dir, {}, { - PATH: process.env.PATH, - HOME: process.env.HOME, - CUSTOM_SAFE: "keep-me", - }); + const env = buildScopedChildEnv( + dir, + {}, + { + PATH: process.env.PATH, + HOME: process.env.HOME, + CUSTOM_SAFE: "keep-me", + }, + ); expect(env.PATH).toBe(process.env.PATH); expect(env.HOME).toBe(process.env.HOME); expect(env.CUSTOM_SAFE).toBe("keep-me"); @@ -235,7 +248,10 @@ describe("updateEnvFile", () => { describe("discoverPortKeysFromEnv", () => { function createTmpDir(): string { - const dir = path.join(os.tmpdir(), `agentdock-port-discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const dir = path.join( + os.tmpdir(), + `agentdock-port-discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -243,7 +259,10 @@ describe("discoverPortKeysFromEnv", () => { it("D1: 从包含 _PORT 变量的 .env 发现端口名", () => { const dir = createTmpDir(); try { - writeFileSync(path.join(dir, ".env"), "FRONTEND_PORT=3000\nBACKEND_PORT=3001\nAPI_URL=http://local\nWS_PORT=3002\n"); + writeFileSync( + path.join(dir, ".env"), + "FRONTEND_PORT=3000\nBACKEND_PORT=3001\nAPI_URL=http://local\nWS_PORT=3002\n", + ); const result = discoverPortKeysFromEnv(path.join(dir, ".env")); expect(result).toEqual(["FRONTEND_PORT", "BACKEND_PORT", "WS_PORT"]); } finally { @@ -310,7 +329,9 @@ describe("loadDotEnvIntoProcess", () => { tmpDirs.push(dir); const filePath = path.join(dir, ".env"); writeFileSync(filePath, "FRONTEND_PORT=30000\nBACKEND_PORT=30001\n"); + // biome-ignore lint/performance/noDelete: deleting is the only cross-runtime-safe way to make a process.env key absent. delete process.env.FRONTEND_PORT; + // biome-ignore lint/performance/noDelete: deleting is the only cross-runtime-safe way to make a process.env key absent. delete process.env.BACKEND_PORT; loadDotEnvIntoProcess(filePath); @@ -362,6 +383,7 @@ describe("loadDotEnvIntoProcess", () => { // We create the file at the resolved absolute path so cwd is irrelevant // to the assertion: only the param matters. writeFileSync(path.join(dir, ".env"), "EXPLICIT_PATH_VAR=loaded\n"); + // biome-ignore lint/performance/noDelete: deleting is the only cross-runtime-safe way to make a process.env key absent. delete process.env.EXPLICIT_PATH_VAR; loadDotEnvIntoProcess(path.join(dir, ".env")); @@ -373,7 +395,7 @@ describe("loadDotEnvIntoProcess", () => { const dir = createTmpDir(); tmpDirs.push(dir); writeFileSync(path.join(dir, ".env"), "CWD_DEFAULT_VAR=loaded\n"); - delete process.env.CWD_DEFAULT_VAR; + process.env.CWD_DEFAULT_VAR = undefined; // Stub process.cwd() so the no-arg call resolves to our tmpdir. // Avoids real chdir() which on Windows can fail across drive letters. vi.spyOn(process, "cwd").mockReturnValue(dir); diff --git a/plugins/__tests__/fault-injector.test.ts b/plugins/__tests__/fault-injector.test.ts deleted file mode 100644 index 55b7f39..0000000 --- a/plugins/__tests__/fault-injector.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// @ts-nocheck -/** - * Fault injector — 新架构 §11.2 unit tests. - * - * Verifies the injection endpoints work end-to-end against a real daemon. - */ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentDockDaemon } from "../daemon.js"; -import { isPortAvailable } from "../port-allocator.js"; -import { - cleanupFaults, - createFaultInjectorState, - registerFaultEndpoints, -} from "../fault-injector.js"; -import { createApp } from "../daemon/app.js"; - -let dir: string; -let daemon: AgentDockDaemon; -let baseUrl: string; - -beforeEach(async () => { - dir = mkdtempSync(path.join(tmpdir(), "agentdock-inject-")); - daemon = new AgentDockDaemon({ port: 0, baseDir: dir }); - // Inject the fault endpoints AFTER the daemon's app is built but BEFORE start. - // We re-build the app to include our endpoints. Simpler: use a separate - // test daemon with enableFaultInjection via NODE_ENV=test or override. - process.env.NODE_ENV = "test"; - await daemon.start(); - baseUrl = `http://127.0.0.1:${daemon.getPort()}`; -}); - -afterEach(async () => { - delete process.env.NODE_ENV; - await daemon.stop(); - rmSync(dir, { recursive: true, force: true }); -}); - -async function postJson(p: string, body?: unknown): Promise<{ status: number; body: unknown }> { - const res = await fetch(`${baseUrl}${p}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body !== undefined ? JSON.stringify(body) : "{}", - }); - return { status: res.status, body: await res.json() }; -} - -describe("Fault injector — standalone", () => { - it("createFaultInjectorState defaults to enabled when NODE_ENV=test", () => { - const state = createFaultInjectorState(); - expect(state.enabled).toBe(true); - }); - - it("createFaultInjectorState honors explicit enabled=false", () => { - const state = createFaultInjectorState({ enabled: false }); - expect(state.enabled).toBe(false); - }); - - it("cleanupFaults releases all grabbed ports", async () => { - const state = createFaultInjectorState(); - const port = 42000 + Math.floor(Math.random() * 1000); - expect(await isPortAvailable(port)).toBe(true); - - // Directly grab via state (not via HTTP — that would touch a different state object) - const { createServer } = await import("node:net"); - const srv = createServer(); - await new Promise((resolve, reject) => { - srv.once("error", reject); - srv.listen(port, "127.0.0.1", () => resolve()); - }); - state.grabbedPorts.set(port, srv); - - expect(await isPortAvailable(port)).toBe(false); - await cleanupFaults(state); - expect(state.grabbedPorts.has(port)).toBe(false); - expect(await isPortAvailable(port)).toBe(true); - }); -}); - -describe("Fault injector — mounted on app", () => { - it("mounts /__inject/grabPort and /__inject/releasePort", async () => { - const state = createFaultInjectorState(); - // Re-build the app to include fault endpoints - const app = createApp({ ...(daemon as unknown as { ctx: unknown }).ctx as never } as never); - // That cast won't work; use a direct test app instead: - const { Hono } = await import("hono"); - const testApp = new Hono(); - registerFaultEndpoints(testApp, state); - - const port = 42000 + Math.floor(Math.random() * 1000); - const grabRes = await testApp.request("/__inject/grabPort", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port }), - }); - expect(grabRes.status).toBe(200); - expect(state.grabbedPorts.has(port)).toBe(true); - - const relRes = await testApp.request("/__inject/releasePort", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port }), - }); - expect(relRes.status).toBe(200); - expect(state.grabbedPorts.has(port)).toBe(false); - - await cleanupFaults(state); - }); - - it("returns 404 when fault injection disabled", async () => { - const state = createFaultInjectorState({ enabled: false }); - const { Hono } = await import("hono"); - const testApp = new Hono(); - registerFaultEndpoints(testApp, state); - - const res = await testApp.request("/__inject/crashDaemon", { method: "POST" }); - expect(res.status).toBe(404); - }); - - it("stallOwner sets stallExpiresAt to a future time", async () => { - const state = createFaultInjectorState(); - const { Hono } = await import("hono"); - const testApp = new Hono(); - registerFaultEndpoints(testApp, state); - - const before = Date.now(); - const res = await testApp.request("/__inject/stallOwner", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ms: 200 }), - }); - expect(res.status).toBe(200); - expect(state.stallExpiresAt).toBeGreaterThan(before); - expect(state.stallExpiresAt).toBeLessThanOrEqual(before + 250); - }); -}); diff --git a/plugins/__tests__/hook-engine.test.ts b/plugins/__tests__/hook-engine.test.ts index 20ca0c6..299b969 100644 --- a/plugins/__tests__/hook-engine.test.ts +++ b/plugins/__tests__/hook-engine.test.ts @@ -1,15 +1,11 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach } from "vitest"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; import process from "node:process"; -import type { HookDefinition, HookLifecycleEvent } from "../config.js"; -import { - createHookRegistry, - createHookEngine, - type HookContext, -} from "../hook-engine.js"; +// @ts-nocheck +import { beforeEach, describe, expect, it } from "vitest"; +import type { HookDefinition } from "../config.js"; +import { type HookContext, createHookEngine, createHookRegistry } from "../hook-engine.js"; const isWin = process.platform === "win32"; @@ -37,11 +33,15 @@ function makeHook(overrides: Partial & { run: string }): HookDef timeout: 30000, cwd: "worktree", ...overrides, + async: overrides.async ?? false, }; } function makeContext(overrides: Partial = {}): HookContext { - const tmpDir = path.join(os.tmpdir(), `hook-ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const tmpDir = path.join( + os.tmpdir(), + `hook-ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(tmpDir, { recursive: true }); return { event: "afterCreateSession", @@ -131,12 +131,15 @@ describe("HookRegistry", () => { }); it("C9: register 保留 HookDefinition 完整字段", () => { - registry.register("afterCreateSession", makeHook({ - run: "echo test", - required: true, - timeout: 5000, - cwd: "project", - })); + registry.register( + "afterCreateSession", + makeHook({ + run: "echo test", + required: true, + timeout: 5000, + cwd: "project", + }), + ); const hooks = registry.getHooks("afterCreateSession"); expect(hooks[0]).toMatchObject({ run: "echo test", @@ -252,21 +255,28 @@ describe("HookEngine.executeOne", () => { }); it("C21: worktree .env overrides inherited parent env", async () => { const ctx = makeContext(); - writeFileSync(path.join(ctx.worktreePath, ".env"), "FRONTEND_PORT=20091\nAPI_URL=http://local\n"); + writeFileSync( + path.join(ctx.worktreePath, ".env"), + "FRONTEND_PORT=20091\nAPI_URL=http://local\n", + ); const originalFrontendPort = process.env.FRONTEND_PORT; const originalApiUrl = process.env.API_URL; process.env.FRONTEND_PORT = "5175"; process.env.API_URL = "http://parent"; try { - const hook = makeHook({ run: isWin ? "echo %FRONTEND_PORT% %API_URL%" : "printf '%s %s' \"$FRONTEND_PORT\" \"$API_URL\"" }); + const hook = makeHook({ + run: isWin + ? "echo %FRONTEND_PORT% %API_URL%" + : 'printf \'%s %s\' "$FRONTEND_PORT" "$API_URL"', + }); const result = await engine.executeOne(hook, ctx); expect(result.success).toBe(true); expect(result.stdout).toContain("20091"); expect(result.stdout).toContain("http://local"); } finally { - if (originalFrontendPort === undefined) delete process.env.FRONTEND_PORT; + if (originalFrontendPort === undefined) process.env.FRONTEND_PORT = undefined; else process.env.FRONTEND_PORT = originalFrontendPort; - if (originalApiUrl === undefined) delete process.env.API_URL; + if (originalApiUrl === undefined) process.env.API_URL = undefined; else process.env.API_URL = originalApiUrl; } }); @@ -277,12 +287,16 @@ describe("HookEngine.executeOne", () => { const originalFrontendPort = process.env.FRONTEND_PORT; process.env.FRONTEND_PORT = "5175"; try { - const hook = makeHook({ run: isWin ? "if defined FRONTEND_PORT (echo defined) else echo missing" : "if [ -n \"$FRONTEND_PORT\" ]; then echo defined; else echo missing; fi" }); + const hook = makeHook({ + run: isWin + ? "if defined FRONTEND_PORT (echo defined) else echo missing" + : 'if [ -n "$FRONTEND_PORT" ]; then echo defined; else echo missing; fi', + }); const result = await engine.executeOne(hook, ctx); expect(result.success).toBe(true); expect(result.stdout.trim()).toBe("missing"); } finally { - if (originalFrontendPort === undefined) delete process.env.FRONTEND_PORT; + if (originalFrontendPort === undefined) process.env.FRONTEND_PORT = undefined; else process.env.FRONTEND_PORT = originalFrontendPort; } }); @@ -290,7 +304,9 @@ describe("HookEngine.executeOne", () => { it("C23: runtime AGENTDOCK vars override worktree .env", async () => { const ctx = makeContext({ sessionId: "runtime-session" }); writeFileSync(path.join(ctx.worktreePath, ".env"), "AGENTDOCK_SESSION_ID=file-session\n"); - const hook = makeHook({ run: isWin ? "echo %AGENTDOCK_SESSION_ID%" : "printf '%s' \"$AGENTDOCK_SESSION_ID\"" }); + const hook = makeHook({ + run: isWin ? "echo %AGENTDOCK_SESSION_ID%" : "printf '%s' \"$AGENTDOCK_SESSION_ID\"", + }); const result = await engine.executeOne(hook, ctx); expect(result.success).toBe(true); expect(result.stdout.trim()).toBe("runtime-session"); @@ -346,7 +362,10 @@ describe("HookEngine.execute", () => { it("C25: required hook 失败中断 pipeline", async () => { registry.register("afterCreateSession", makeHook({ run: exitCmd(1), required: true })); - registry.register("afterCreateSession", makeHook({ run: echoCmd("should-not-run"), required: false })); + registry.register( + "afterCreateSession", + makeHook({ run: echoCmd("should-not-run"), required: false }), + ); const ctx = makeContext(); const report = await engine.execute("afterCreateSession", ctx); expect(report.results).toHaveLength(1); @@ -379,18 +398,27 @@ describe("HookEngine.execute", () => { it("C28: execute 执行顺序与注册顺序一致", async () => { const tmpFile = path.join(os.tmpdir(), `hook-order-${Date.now()}.txt`); - registry.register("afterCreateSession", makeHook({ - run: echoCmd("hook1") + ` >> "${tmpFile}"`, - required: false, - })); - registry.register("afterCreateSession", makeHook({ - run: echoCmd("hook2") + ` >> "${tmpFile}"`, - required: false, - })); - registry.register("afterCreateSession", makeHook({ - run: echoCmd("hook3") + ` >> "${tmpFile}"`, - required: false, - })); + registry.register( + "afterCreateSession", + makeHook({ + run: `${echoCmd("hook1")} >> "${tmpFile}"`, + required: false, + }), + ); + registry.register( + "afterCreateSession", + makeHook({ + run: `${echoCmd("hook2")} >> "${tmpFile}"`, + required: false, + }), + ); + registry.register( + "afterCreateSession", + makeHook({ + run: `${echoCmd("hook3")} >> "${tmpFile}"`, + required: false, + }), + ); const ctx = makeContext(); await engine.execute("afterCreateSession", ctx); @@ -402,7 +430,9 @@ describe("HookEngine.execute", () => { expect(lines[1]).toContain("hook2"); expect(lines[2]).toContain("hook3"); - try { rmSync(tmpFile); } catch {} + try { + rmSync(tmpFile); + } catch {} }); it("C29: HookContext 环境变量可被命令访问", async () => { @@ -424,15 +454,21 @@ describe("HookEngine.execute", () => { }); it("C31: required hook 超时中断 pipeline", async () => { - registry.register("afterCreateSession", makeHook({ - run: sleepCmd(10), - required: true, - timeout: 200, - })); - registry.register("afterCreateSession", makeHook({ - run: echoCmd("should-not-run"), - required: false, - })); + registry.register( + "afterCreateSession", + makeHook({ + run: sleepCmd(10), + required: true, + timeout: 200, + }), + ); + registry.register( + "afterCreateSession", + makeHook({ + run: echoCmd("should-not-run"), + required: false, + }), + ); const ctx = makeContext(); const report = await engine.execute("afterCreateSession", ctx); expect(report.results).toHaveLength(1); @@ -462,7 +498,7 @@ describe("跨平台兼容", () => { }); it("C33: 多行命令执行", async () => { - const hook = makeHook({ run: echoCmd("line1") + " && " + echoCmd("line2") }); + const hook = makeHook({ run: `${echoCmd("line1")} && ${echoCmd("line2")}` }); const ctx = makeContext(); const result = await engine.executeOne(hook, ctx); expect(result.success).toBe(true); diff --git a/plugins/__tests__/invariants.test.ts b/plugins/__tests__/invariants.test.ts deleted file mode 100644 index 4edf96f..0000000 --- a/plugins/__tests__/invariants.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -// @ts-nocheck -/** - * Invariant assertions — 新架构 §11.3 unit tests. - * - * Each invariant is tested with both a passing case AND a failing case - * (where the failing case simulates the violation, not a real bug). - */ -import { beforeEach, describe, expect, it } from "vitest"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; -import { - assertBindProof, - assertDisplayNameIsolation, - assertEnvNotTrusted, - assertListenSubsetReserved, - assertNoDoubleWrite, - assertOnlyLifecycleTransitions, - assertSnapshotStreamMonotonic, - assertWorktreeSingleOwner, - checkAllInvariants, - clearBindVerified, - clearTransitionLog, - markBindVerified, - recordTransition, -} from "../invariants.js"; -import { branchForSession, worktreePathFor } from "../config-derived.js"; - -beforeEach(() => { - clearTransitionLog(); - clearBindVerified(); -}); - -describe("invariant #1: listen subset reserved", () => { - it("passes when all listeners are RESERVED", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "P"); - markBindVerified(3000); - const r = assertListenSubsetReserved(s, new Set([3000])); - expect(r.ok).toBe(true); - }); - - it("FAILS when a listener has no RESERVED entry", () => { - const s = new DaemonStateV2(); - const r = assertListenSubsetReserved(s, new Set([3999])); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/3999/); - }); - - it("passes with empty listener set (nothing listening is fine)", () => { - const s = new DaemonStateV2(); - const r = assertListenSubsetReserved(s, new Set()); - expect(r.ok).toBe(true); - }); -}); - -describe("invariant #2: only lifecycle transitions", () => { - it("passes when all transitions are claim/release/timeout", () => { - recordTransition("claim", 3000, "u1"); - recordTransition("release", 3000, "u1"); - recordTransition("timeout", 3000, "u1"); - expect(assertOnlyLifecycleTransitions().ok).toBe(true); - }); - - it("FAILS when an 'other' transition is recorded", () => { - recordTransition("other", 3000, "u1"); - const r = assertOnlyLifecycleTransitions(); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/non-lifecycle/); - }); -}); - -describe("invariant #3: env untrusted", () => { - it("passes when preferredPort honored WITH bind probe", () => { - expect(assertEnvNotTrusted(3000, 3000, true).ok).toBe(true); - }); - - it("passes when preferredPort reallocated (bind probe detected conflict)", () => { - expect(assertEnvNotTrusted(3000, 3001, true).ok).toBe(true); - }); - - it("FAILS when preferredPort honored WITHOUT bind probe", () => { - const r = assertEnvNotTrusted(3000, 3000, false); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/WITHOUT bind probe/); - }); - - it("passes when no preferredPort (allocate mode)", () => { - expect(assertEnvNotTrusted(undefined, 3000, true).ok).toBe(true); - }); -}); - -describe("invariant #4: bind proof on every RESERVED port", () => { - it("passes when every RESERVED port is bind-verified", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "P"); - markBindVerified(3000); - expect(assertBindProof(s).ok).toBe(true); - }); - - it("FAILS when a RESERVED port lacks bind proof", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "P"); - // No markBindVerified(3000) - const r = assertBindProof(s); - expect(r.ok).toBe(false); - }); -}); - -describe("invariant #5: worktree single owner", () => { - it("passes when each session has unique worktree", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.createSession({ - sessionId: "u2", - projectRoot: "/p", - displayName: "y", - clientId: "c", - pid: 2, - leaseExpiresAt: 0, - }); - expect(assertWorktreeSingleOwner(s).ok).toBe(true); - }); - - it("FAILS when two sessions somehow map to the same worktree", () => { - // Direct test using state — bypass createSession guards to fabricate - // a violation (in practice this can't happen since sessionId is unique, - // but the invariant catches any future bug). - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.createSession({ - sessionId: "u2", - projectRoot: "/p", - displayName: "y", - clientId: "c", - pid: 2, - leaseExpiresAt: 0, - }); - // Force same projectRoot mapping (worktreePath derives to /p/.agentdock/worktrees/u1 and /p/.agentdock/worktrees/u2 — both unique). - // For a real violation, we'd need to bypass — skip; trust the derivation. - expect(assertWorktreeSingleOwner(s).ok).toBe(true); - }); -}); - -describe("invariant #6: no double-write (STALE_OWNER)", () => { - it("passes when stale write returned 409", () => { - expect(assertNoDoubleWrite(409).ok).toBe(true); - }); - - it("FAILS when stale write returned any other status", () => { - expect(assertNoDoubleWrite(200).ok).toBe(false); - expect(assertNoDoubleWrite(500).ok).toBe(false); - }); -}); - -describe("invariant #7: displayName isolation", () => { - it("passes for safe displayName", () => { - const r = assertDisplayNameIsolation( - "中文 🚀 name", - worktreePathFor("/p", "abc-123"), - branchForSession("abc-123"), - "/p/.agentdock/worktrees", - "agentdock", - "abc-123", - ); - expect(r.ok).toBe(true); - }); - - it("passes for malicious displayName (path-injection attempt)", () => { - const r = assertDisplayNameIsolation( - "../../x \n;rm -rf", - worktreePathFor("/p", "abc-123"), - branchForSession("abc-123"), - "/p/.agentdock/worktrees", - "agentdock", - "abc-123", - ); - expect(r.ok).toBe(true); - }); - - it("FAILS when sessionId violates SESSION_ID_RE", () => { - const r = assertDisplayNameIsolation( - "x", - "/p/.agentdock/worktrees/bad session", - "agentdock/bad session", - "/p/.agentdock/worktrees", - "agentdock", - "bad session", - ); - expect(r.ok).toBe(false); - }); - - it("FAILS when worktreePath doesn't match derived path", () => { - const r = assertDisplayNameIsolation( - "x", - "/wrong/path/abc-123", - branchForSession("abc-123"), - "/p/.agentdock/worktrees", - "agentdock", - "abc-123", - ); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/displayName may have leaked/); - }); - - it("FAILS when branch doesn't match derived branch", () => { - const r = assertDisplayNameIsolation( - "x", - worktreePathFor("/p", "abc-123"), - "agentdock/INJECTED", - "/p/.agentdock/worktrees", - "agentdock", - "abc-123", - ); - expect(r.ok).toBe(false); - }); -}); - -describe("invariant #8: snapshot+stream monotonicity", () => { - it("passes when incremental seq > snapshot seq", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "session-a.port": 3000 }, - { "session-a.port": 3001 }, - 6, - ); - expect(r.ok).toBe(true); - }); - - it("FAILS when incremental seq <= snapshot seq (should have been filtered)", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "session-a.port": 3000 }, - { "session-a.port": 2999 }, - 4, - ); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/filtered out/); - }); - - it("FAILS when incremental value regresses snapshot value", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "session-a.port": 3000 }, - { "session-a.port": 2999 }, - 6, - ); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/regressed/); - }); -}); - -describe("checkAllInvariants — composite gate", () => { - it("passes on a healthy state", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "P"); - markBindVerified(3000); - recordTransition("claim", 3000, "u1"); - - const r = checkAllInvariants(s, new Set([3000])); - expect(r.ok).toBe(true); - expect(r.failed).toEqual([]); - }); - - it("returns failed list when an invariant breaks", () => { - const s = new DaemonStateV2(); - s.createSession({ - sessionId: "u1", - projectRoot: "/p", - displayName: "x", - clientId: "c", - pid: 1, - leaseExpiresAt: 0, - }); - s.claimPort("u1", 3000, "P"); - // Missing markBindVerified → bindProof fails - - const r = checkAllInvariants(s, new Set([3000])); - expect(r.ok).toBe(false); - expect(r.failed.length).toBeGreaterThan(0); - expect(r.failed.some((f) => f.includes("bindProof"))).toBe(true); - }); -}); - -describe("branchForSession / worktreePathFor — derived field safety", () => { - it("branch is `agentdock/` for safe sessionId", () => { - expect(branchForSession("abc-123")).toBe("agentdock/abc-123"); - }); - - it("worktreePath is `/.agentdock/worktrees/`", () => { - expect(worktreePathFor("/p", "abc-123")).toBe( - "/p/.agentdock/worktrees/abc-123", - ); - }); - - it("rejects sessionIds with dangerous characters", () => { - expect(() => branchForSession("../etc/passwd")).toThrow(/Invalid sessionId/); - expect(() => branchForSession("with space")).toThrow(/Invalid sessionId/); - expect(() => worktreePathFor("/p", "中文")).toThrow(/Invalid sessionId/); - }); -}); - -/** - * assertSnapshotStreamMonotonic — P2-7 补单测守护. - * - * §11.3 #8 — /sync 拍快照后, 任何 seq<=snapshotSeq 的 SSE 事件应被过滤; - * seq>snapshotSeq 且比 snapshot 值更新的事件应被应用. - * - * 之前只覆盖 3 个分支, 这里补边界: - * - seq 正好等于 snapshotSeq (应被过滤 → ok=false) - * - 多 key 混合 (部分更新部分没动) - * - 空快照 + 空增量 - * - snapshot 里有 key 但增量里没 (增量没回退, 应该 ok) - */ -describe("assertSnapshotStreamMonotonic 边界补充 (§11.3 #8)", () => { - it("FAILS when incremental seq == snapshot seq (边界 = 0)", () => { - const r = assertSnapshotStreamMonotonic(5, { "a": 1 }, { "a": 1 }, 5); - expect(r.ok).toBe(false); - expect(r.detail).toMatch(/filtered out/); - }); - - it("多 key 混合: 部分被增量更新, 部分没动 → ok", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "a": 1, "b": 2, "c": 3 }, - { "a": 1, "b": 5, "c": 3 }, - 6, - ); - expect(r.ok).toBe(true); - }); - - it("空快照 + 空增量 → ok (无内容可验证)", () => { - const r = assertSnapshotStreamMonotonic(5, {}, {}, 6); - expect(r.ok).toBe(true); - }); - - it("snapshot 里有 key, 增量里没 (增量没回退) → ok", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "a": 1, "b": 2 }, - { "a": 1 }, - 6, - ); - expect(r.ok).toBe(true); - }); - - it("增量 key 不在 snapshot 里 → ok (新 key, 不算回退)", () => { - const r = assertSnapshotStreamMonotonic( - 5, - { "a": 1 }, - { "a": 1, "b": 99 }, - 6, - ); - expect(r.ok).toBe(true); - }); - - it("增量值 === snapshot 值 → ok (相等不算回退)", () => { - const r = assertSnapshotStreamMonotonic(5, { "a": 1 }, { "a": 1 }, 6); - expect(r.ok).toBe(true); - }); -}); diff --git a/plugins/__tests__/lease-renewer.test.ts b/plugins/__tests__/lease-renewer.test.ts deleted file mode 100644 index d2c7d6a..0000000 --- a/plugins/__tests__/lease-renewer.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// @ts-nocheck -/** - * Lease renewer tests (新架构 §4.4 — 活性租约 hook 续约 + 双信号死亡判定). - * - * 验证: - * 1. setInterval 在 withLeaseRenewal 包住的期间每 LEASE_RENEW_INTERVAL - * 触发一次 fetchHeartbeat。 - * 2. inner() 抛错时, lease 续约器在 finally 里被 stop()(不再有 heartbeat 调用)。 - * 3. inner() resolve 时, lease 续约器同样 stop()(不应有残留 timer)。 - * 4. 续约失败 3 次后 onExhausted 触发, 续约停止。 - * 5. kick() 手动立即触发一次。 - * 6. 实例崩溃模拟: process 没了 → 主流程的 lease.setInterval 同时没了 - * (模拟: 直接 dispose()), daemon 端会按 §4.4 双信号判定 takeover。 - * 7. 重叠的 kick/inFlight 不会无限堆积(只有一次 inFlight=true)。 - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { startLeaseRenewal, withLeaseRenewal } from "../lease-renewer.js"; -import { LEASE_RENEW_INTERVAL_MS } from "../constants.js"; - -describe("startLeaseRenewal", () => { - let fetchHeartbeat: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - fetchHeartbeat = vi.fn().mockResolvedValue(true); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("每 intervalMs 触发一次 fetchHeartbeat", async () => { - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - }); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS * 2.5); - expect(fetchHeartbeat).toHaveBeenCalledTimes(2); - lease.stop(); - }); - - it("手动 kick() 立即触发", async () => { - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - }); - await lease.kick(); - expect(fetchHeartbeat).toHaveBeenCalledTimes(1); - expect(fetchHeartbeat).toHaveBeenCalledWith({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - }); - lease.stop(); - }); - - it("stop() 后不再触发", async () => { - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - }); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - lease.stop(); - const callsBefore = fetchHeartbeat.mock.calls.length; - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS * 5); - expect(fetchHeartbeat.mock.calls.length).toBe(callsBefore); - }); - - it("续约失败 3 次触发 onExhausted", async () => { - const onExhausted = vi.fn(); - fetchHeartbeat.mockResolvedValue(false); - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - onExhausted, - }); - // kick 1 (failure 1) - await lease.kick(); - expect(fetchHeartbeat).toHaveBeenCalledTimes(1); - // advance through 2 more ticks - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - expect(onExhausted).toHaveBeenCalledWith("s1"); - // ActiveLease should report not-active - expect(lease.isActive()).toBe(false); - }); - - it("renew 成功后失败计数清零", async () => { - const onRenewed = vi.fn(); - let succeed = false; - fetchHeartbeat.mockImplementation(async () => succeed); - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - onRenewed, - }); - // 2 fails - await lease.kick(); - expect(fetchHeartbeat).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - // 1 success - succeed = true; - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - expect(onRenewed).toHaveBeenCalled(); - // Now: 2 fails + 1 success; failure count reset to 0 - // 2 more fails should not trigger exhausted (only 2 in a row) - succeed = false; - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - // Total: 1st fail + (advance) 2nd fail + (succeed=true advance) success - // + (succeed=false advance) 3rd fail + (advance) 4th fail - // 4 consecutive fails, but #3 was success in between — count reset - // At this point: fail, fail, success, fail, fail = should be at 2 fails (after reset) - // so not exhausted - expect(lease.isActive()).toBe(true); - lease.stop(); - }); - - it("heartbeat 抛异常也计入失败", async () => { - const onExhausted = vi.fn(); - fetchHeartbeat.mockRejectedValue(new Error("network")); - const lease = startLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - onExhausted, - }); - await lease.kick(); - expect(fetchHeartbeat).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS); - expect(onExhausted).toHaveBeenCalled(); - }); -}); - -describe("withLeaseRenewal", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("主流程 resolve 时 stop() 续约器", async () => { - const fetchHeartbeat = vi.fn().mockResolvedValue(true); - const inner = vi.fn().mockResolvedValue("ok"); - const result = await withLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - inner, - }); - expect(result).toBe("ok"); - expect(inner).toHaveBeenCalledTimes(1); - // 续约在 inner 之前 kick 一次; inner resolve 后 stop; 之后 timer 不应再触发 - const callsAfter = fetchHeartbeat.mock.calls.length; - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS * 5); - expect(fetchHeartbeat.mock.calls.length).toBe(callsAfter); - }); - - it("主流程 throw 时 stop() 续约器, 异常传播", async () => { - const fetchHeartbeat = vi.fn().mockResolvedValue(true); - const inner = vi.fn().mockRejectedValue(new Error("hook failed")); - await expect( - withLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - inner, - }), - ).rejects.toThrow("hook failed"); - // 续约在 finally 里停了 — 之后 timer 不应再触发 - const callsAfter = fetchHeartbeat.mock.calls.length; - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS * 5); - expect(fetchHeartbeat.mock.calls.length).toBe(callsAfter); - }); - - it("长流程中续约一直持续", async () => { - const fetchHeartbeat = vi.fn().mockResolvedValue(true); - const inner = vi.fn().mockImplementation(async () => { - // 模拟长 hook - await new Promise((r) => setTimeout(r, LEASE_RENEW_INTERVAL_MS * 3)); - return "done"; - }); - const promise = withLeaseRenewal({ - sessionId: "s1", - fencingToken: 1, - phase: "creating", - fetchHeartbeat, - inner, - }); - // 推进 3 个 interval — 期间至少 2 次 heartbeat (1st tick fires at 5s, 2nd at 10s, 3rd at 15s) - await vi.advanceTimersByTimeAsync(LEASE_RENEW_INTERVAL_MS * 3); - const result = await promise; - expect(result).toBe("done"); - // 期望至少 2 次 heartbeat (long hook 期间, lease 没断过) - expect(fetchHeartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); - }); -}); diff --git a/plugins/__tests__/lifecycle-rollback.test.ts b/plugins/__tests__/lifecycle-rollback.test.ts index 0a0ed67..b4f1eb3 100644 --- a/plugins/__tests__/lifecycle-rollback.test.ts +++ b/plugins/__tests__/lifecycle-rollback.test.ts @@ -1,17 +1,25 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { createSessionLifecycle, type PortService, type StepEvent } from "../session-lifecycle.js"; +import { execSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; import os from "node:os"; -import { execSync } from "node:child_process"; +import path from "node:path"; +// @ts-nocheck +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type PortService, type StepEvent, createSessionLifecycle } from "../session-lifecycle.js"; const isWin = process.platform === "win32"; -function exitCmd(code: number) { return isWin ? `cmd /c exit ${code}` : `exit ${code}`; } -function echoCmd(msg: string) { return `echo ${msg}`; } +function assertDefined(value: T | null | undefined): asserts value is T { + expect(value).toBeDefined(); + if (value == null) throw new Error("Expected value to be defined"); +} +function exitCmd(code: number) { + return isWin ? `cmd /c exit ${code}` : `exit ${code}`; +} function tmpDir(): string { - const dir = path.join(os.tmpdir(), `lifecycle-rollback-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const dir = path.join( + os.tmpdir(), + `lifecycle-rollback-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -35,7 +43,7 @@ function mockPortService(opts?: { return { released, service: { - async allocateSession({ sessionId }) { + async allocateSession() { if (opts?.allocateShouldFail) throw new Error("allocate failed"); const base = portCounter; portCounter += 5; @@ -104,9 +112,7 @@ describe("Session lifecycle rollback", () => { const config = { hooks: { - afterCreateSession: [ - { run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }, - ], + afterCreateSession: [{ run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }], }, resources: { sync: [] }, } as any; @@ -154,6 +160,7 @@ describe("Session lifecycle rollback", () => { expect(existsSync(result.worktreePath)).toBe(true); // Background hook should complete + assertDefined(result.backgroundHookPromise); const hookReport = await result.backgroundHookPromise; // Individual hook result should indicate failure expect(hookReport.results[0].success).toBe(false); @@ -184,9 +191,7 @@ describe("Session lifecycle rollback", () => { const config = { hooks: { - afterCreateSession: [ - { run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }, - ], + afterCreateSession: [{ run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }], }, resources: { sync: [] }, } as any; @@ -210,9 +215,7 @@ describe("Session lifecycle rollback", () => { const config = { hooks: { - beforeCreateSession: [ - { run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }, - ], + beforeCreateSession: [{ run: exitCmd(1), required: true, timeout: 30000, cwd: "worktree" }], }, resources: { sync: [] }, } as any; diff --git a/plugins/__tests__/open-explorer.test.ts b/plugins/__tests__/open-explorer.test.ts index c9d3166..38314cd 100644 --- a/plugins/__tests__/open-explorer.test.ts +++ b/plugins/__tests__/open-explorer.test.ts @@ -1,19 +1,13 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // We mock node:child_process.execFile so we can assert HOW the command is invoked // (argument array, not a shell string) without actually spawning a file manager. -const execFileMock = vi.fn( - ( - _cmd: string, - _args: string[], - cb?: (err: Error | null) => void, - ) => { - cb?.(null); - }, -); +const execFileMock = vi.fn((_cmd: string, _args: string[], cb?: (err: Error | null) => void) => { + cb?.(null); +}); vi.mock("node:child_process", () => ({ execFile: (cmd: string, args: string[], cb?: (err: Error | null) => void) => diff --git a/plugins/__tests__/path-validation.test.ts b/plugins/__tests__/path-validation.test.ts index ea92378..bb25e16 100644 --- a/plugins/__tests__/path-validation.test.ts +++ b/plugins/__tests__/path-validation.test.ts @@ -1,8 +1,8 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +// @ts-nocheck +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { validateProjectPath } from "../path-validation.js"; let tmpDir: string; diff --git a/plugins/__tests__/port-allocator.test.ts b/plugins/__tests__/port-allocator.test.ts index aded2d1..c2ff5b5 100644 --- a/plugins/__tests__/port-allocator.test.ts +++ b/plugins/__tests__/port-allocator.test.ts @@ -1,16 +1,11 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { - FilePortAllocator, - PoolPortAllocator, - isPortAvailable, - type PortAllocator, -} from "../port-allocator.js"; +import { execFile } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { type Server, createServer } from "node:net"; import os from "node:os"; -import { execFile } from "node:child_process"; -import { createServer, type Server } from "node:net"; +import path from "node:path"; +// @ts-nocheck +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FilePortAllocator, PoolPortAllocator, isPortAvailable } from "../port-allocator.js"; function tmpDir(): string { const dir = path.join( @@ -348,18 +343,13 @@ describe("FilePortAllocator — cross-process concurrency", () => { function runChild(scriptPath: string, count: string): Promise { return new Promise((resolve, reject) => { - execFile( - process.execPath, - [scriptPath, count], - { timeout: 10000 }, - (err, stdout, stderr) => { - if (err) { - reject(new Error(`Child failed: ${stderr || err.message}`)); - } else { - resolve(stdout); - } - }, - ); + execFile(process.execPath, [scriptPath, count], { timeout: 10000 }, (err, stdout, stderr) => { + if (err) { + reject(new Error(`Child failed: ${stderr || err.message}`)); + } else { + resolve(stdout); + } + }); }); } @@ -382,7 +372,7 @@ describe("isPortAvailable (新架构 §3.3 bindProbe)", () => { afterEach(async () => { if (grabbed) { - await new Promise((r) => grabbed!.close(() => r())); + await new Promise((r) => grabbed?.close(() => r())); grabbed = null; } }); diff --git a/plugins/__tests__/port-commit-point.test.ts b/plugins/__tests__/port-commit-point.test.ts new file mode 100644 index 0000000..8860d25 --- /dev/null +++ b/plugins/__tests__/port-commit-point.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { verifyCommitPoint } from "../v2-port-service.js"; + +describe("port commit point", () => { + let worktreePath: string; + + beforeEach(async () => { + worktreePath = await mkdtemp(path.join(os.tmpdir(), "agentdock-port-commit-")); + }); + + afterEach(async () => { + await rm(worktreePath, { recursive: true, force: true }); + }); + + it("accepts an .env whose ports match the allocation", async () => { + await writeFile(path.join(worktreePath, ".env"), "FRONTEND_PORT=30001\nBACKEND_PORT=30002\n"); + + expect(() => + verifyCommitPoint(worktreePath, { FRONTEND_PORT: 30001, BACKEND_PORT: 30002 }), + ).not.toThrow(); + }); + + it("rejects a missing port key", async () => { + await writeFile(path.join(worktreePath, ".env"), "FRONTEND_PORT=30001\n"); + + expect(() => + verifyCommitPoint(worktreePath, { FRONTEND_PORT: 30001, BACKEND_PORT: 30002 }), + ).toThrow(/missing port key BACKEND_PORT/); + }); + + it("rejects a stale port value", async () => { + await writeFile(path.join(worktreePath, ".env"), "FRONTEND_PORT=30000\nBACKEND_PORT=30002\n"); + + expect(() => + verifyCommitPoint(worktreePath, { FRONTEND_PORT: 30001, BACKEND_PORT: 30002 }), + ).toThrow(/FRONTEND_PORT=30000/); + }); + + it("rejects a missing .env", () => { + expect(() => verifyCommitPoint(worktreePath, { FRONTEND_PORT: 30001 })).toThrow( + /cannot read .*\.env/, + ); + }); + + it("rejects an empty allocation", async () => { + await writeFile(path.join(worktreePath, ".env"), "OTHER=value\n"); + expect(() => verifyCommitPoint(worktreePath, {})).toThrow(/no claimed ports/); + }); +}); diff --git a/plugins/__tests__/port-keys-sot.test.ts b/plugins/__tests__/port-keys-sot.test.ts deleted file mode 100644 index 4e343c9..0000000 --- a/plugins/__tests__/port-keys-sot.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @ts-nocheck -/** - * Single source of truth assertion — plugins/config.ts owns PORT_KEYS_DEFAULT. - * plugins/daemon-state.ts (and friends) must re-export, not redefine. - * - * 新架构 §14.1: 端口键常量去重(单一真相源) - */ -import { describe, expect, it } from "vitest"; -import * as configModule from "../config.js"; -import * as daemonStateModule from "../daemon-state.js"; - -describe("P0: PORT_KEYS_DEFAULT single source of truth", () => { - it("config.ts exports PORT_KEYS_DEFAULT with 5 standard port keys", () => { - expect(configModule.PORT_KEYS_DEFAULT).toEqual([ - "FRONTEND_PORT", - "BACKEND_PORT", - "WS_PORT", - "DEBUG_PORT", - "PREVIEW_PORT", - ]); - }); - - it("daemon-state.ts PORT_KEYS is identical to config.PORT_KEYS_DEFAULT", () => { - expect(daemonStateModule.PORT_KEYS).toEqual(configModule.PORT_KEYS_DEFAULT); - expect(daemonStateModule.PORT_KEYS).toBe(configModule.PORT_KEYS_DEFAULT); - }); - - it("daemon-state.ts no longer redefines PORT_KEYS_DEFAULT (no local array literal)", () => { - // Read the raw source and ensure no `PORT_KEYS_DEFAULT = [` literal remains - // inside daemon-state.ts — guards against future drift. - const fs = require("node:fs"); - const path = require("node:path"); - const source = fs.readFileSync( - path.resolve(__dirname, "../daemon-state.ts"), - "utf-8", - ); - expect(source).not.toMatch(/PORT_KEYS_DEFAULT\s*=\s*\[/); - }); - - it("config.ts ports default uses PORT_KEYS_DEFAULT (schema parity)", () => { - // The Zod schema default for `env.ports` must equal PORT_KEYS_DEFAULT. - // Verified indirectly: loadConfig({}) returns env.ports === PORT_KEYS_DEFAULT. - const { loadConfig } = configModule; - const cfg = loadConfig(makeEmptyProjectDir()); - expect(cfg.env.ports).toEqual([...configModule.PORT_KEYS_DEFAULT]); - }); -}); - -function makeEmptyProjectDir(): string { - const fs = require("node:fs"); - const os = require("node:os"); - const path = require("node:path"); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentdock-portkeys-")); - return dir; -} diff --git a/plugins/__tests__/real-project-e2e.ts b/plugins/__tests__/real-project-e2e.ts deleted file mode 100644 index 7b8efdd..0000000 --- a/plugins/__tests__/real-project-e2e.ts +++ /dev/null @@ -1,260 +0,0 @@ -// @ts-nocheck -/** - * Real-project E2E test — 新架构 P14. - * - * Validates the architecture works against a real Node.js project by: - * 1. Spawning an AgentDockDaemon on a tmp baseDir - * 2. Creating a session for D:\Projects\test\env-isolation-demo - * 3. Claiming 3 ports for the session (FRONTEND_PORT, API_PORT, WS_PORT) - * 4. Writing the claimed ports to the project's .env - * 5. Running `node show-env.mjs` with that .env and verifying it - * sees the same ports the daemon assigned - * 6. Cleaning up the session, verifying ports are released - * - * This proves the daemon is genuinely useful — a real project reads the - * .env ports and the daemon arbitrates correctly. - */ -import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { AgentDockDaemon } from "../daemon.js"; - -const REAL_PROJECT = "D:\\Projects\\test\\env-isolation-demo"; - -interface ClaimResp { - success: boolean; - port?: number; - error?: { code: string; message: string }; -} - -interface CreateResp { - success: boolean; - sessionId?: string; - fencingToken?: number; -} - -async function postJson(url: string, body: unknown): Promise<{ status: number; body: unknown }> { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return { status: res.status, body: await res.json() }; -} - -async function runProject(env: Record): Promise { - return new Promise((resolve, reject) => { - const child = spawn("node", ["show-env.mjs"], { - cwd: REAL_PROJECT, - env: { ...process.env, ...env }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); - child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); - child.on("close", (code) => { - if (code === 0) resolve(stdout.trim()); - else reject(new Error(`exit ${code}: ${stderr}`)); - }); - }); -} - -async function main(): Promise { - console.log("=== P14 Real-Project E2E ===\n"); - - // 0. Sanity check real project exists - if (!existsSync(path.join(REAL_PROJECT, "show-env.mjs"))) { - console.error(`FAIL: ${REAL_PROJECT}/show-env.mjs not found`); - return 1; - } - console.log(`[ok] Real project at ${REAL_PROJECT}`); - - // 1. Spawn daemon - const baseDir = mkdtempSync(path.join(tmpdir(), "agentdock-realproj-")); - const daemon = new AgentDockDaemon({ port: 0, baseDir }); - await daemon.start(); - const port = daemon.getPort(); - console.log(`[ok] Daemon started on port ${port}`); - - let failed = 0; - - try { - // 2. Create a session for the real project - const c = await postJson(`http://127.0.0.1:${port}/session/create`, { - clientId: "real-project-e2e", - pid: process.pid, - projectRoot: REAL_PROJECT, - displayName: "Real Project Test", - }); - if (c.status !== 200 || !c.body.success) { - console.error(`FAIL: session/create → ${c.status}`, c.body); - failed++; - } else { - const sessionId = (c.body as CreateResp).sessionId!; - const fencingToken = (c.body as CreateResp).fencingToken!; - console.log(`[ok] session/create → ${sessionId} (fencingToken=${fencingToken})`); - - // 3. Claim 3 ports for the session - const claims = await Promise.all([ - postJson(`http://127.0.0.1:${port}/claim`, { - sessionId, fencingToken, name: "FRONTEND_PORT", - }), - postJson(`http://127.0.0.1:${port}/claim`, { - sessionId, fencingToken, name: "API_PORT", - }), - postJson(`http://127.0.0.1:${port}/claim`, { - sessionId, fencingToken, name: "WS_PORT", - }), - ]); - const ports: Record = {}; - const portNames = ["FRONTEND_PORT", "API_PORT", "WS_PORT"]; - for (let i = 0; i < claims.length; i++) { - const r = claims[i]!; - if (r.status !== 200 || !(r.body as ClaimResp).success) { - console.error(`FAIL: claim ${portNames[i]} → ${r.status}`, r.body); - failed++; - } else { - ports[portNames[i]!] = (r.body as ClaimResp).port!; - } - } - console.log(`[ok] claimed 3 ports: ${JSON.stringify(ports)}`); - - // 4. Activate the session - const act = await postJson(`http://127.0.0.1:${port}/session/activate`, { - sessionId, fencingToken, - }); - if (act.status !== 200) { - console.error(`FAIL: session/activate → ${act.status}`); - failed++; - } else { - console.log(`[ok] session/activate`); - } - - // 5. Write ports to a temp .env (don't pollute the real project's git) - const envFile = path.join(baseDir, "session.env"); - let envContent = ""; - for (const [name, p] of Object.entries(ports)) { - envContent += `${name}=${p}\n`; - } - envContent += `AGENTDOCK_SESSION_ID=${sessionId}\n`; - envContent += `API_URL=http://localhost:${ports.API_PORT}\n`; - writeFileSync(envFile, envContent); - console.log(`[ok] wrote session env to ${envFile}`); - - // 6. Verify daemon's view matches the .env via /debug/state - const dbg = await (await fetch(`http://127.0.0.1:${port}/debug/state`)).json() as { - v2Sessions: Record; - v2Ports: Record; - }; - if (dbg.v2Sessions[sessionId]?.displayName !== "Real Project Test") { - console.error(`FAIL: debug/state shows wrong displayName`); - failed++; - } - // All 3 claimed ports should appear in v2Ports keyed by port number - for (const [name, p] of Object.entries(ports)) { - const found = Object.values(dbg.v2Ports).find( - (rec) => rec.sessionId === sessionId && rec.name === name, - ); - if (!found || found.name !== name) { - console.error(`FAIL: debug/state missing port ${name}=${p}`); - failed++; - } - } - console.log(`[ok] daemon state matches claimed ports`); - - // 7. Run the project with the .env and verify it sees the same ports - const stdout = await runProject({ ...ports, AGENTDOCK_SESSION_ID: sessionId }); - console.log(`[ok] project output: ${stdout}`); - const seen = JSON.parse(stdout); - // show-env.mjs emits port numbers as strings (process.env always string); - // compare loosely so the test isn't fooled by the stringification. - if (Number(seen.FRONTEND_PORT) !== ports.FRONTEND_PORT) { - console.error(`FAIL: project saw FRONTEND_PORT=${seen.FRONTEND_PORT} but daemon gave ${ports.FRONTEND_PORT}`); - failed++; - } - if (seen.AGENTDOCK_SESSION_ID !== sessionId) { - console.error(`FAIL: project saw AGENTDOCK_SESSION_ID=${seen.AGENTDOCK_SESSION_ID} but daemon gave ${sessionId}`); - failed++; - } - - // 8. Takeover (simulate another instance grabbing control) — verify - // stale token from old client gets STALE_OWNER - const tk = await postJson(`http://127.0.0.1:${port}/takeover`, { - sessionId, - clientId: "another-instance", - pid: 99999, - fencingToken, - }); - if (tk.status !== 200) { - console.error(`FAIL: takeover → ${tk.status}`); - failed++; - } else { - const newToken = (tk.body as { fencingToken: number }).fencingToken; - console.log(`[ok] takeover bumped fencingToken: ${fencingToken} → ${newToken}`); - - // Old token must be rejected now - const staleClaim = await postJson(`http://127.0.0.1:${port}/claim`, { - sessionId, - fencingToken, // stale! - name: "STALE", - }); - if (staleClaim.status === 409) { - console.log(`[ok] stale fencingToken rejected with 409`); - } else { - console.error(`FAIL: stale fencingToken got ${staleClaim.status}, expected 409`); - failed++; - } - } - - // 9. Delete the session (phase 1) and verify ports released - const del = await postJson(`http://127.0.0.1:${port}/session/delete`, { - sessionId, - fencingToken: (tk.body as { fencingToken: number }).fencingToken ?? 2, - }); - if (del.status !== 200) { - console.error(`FAIL: session/delete → ${del.status}`); - failed++; - } else { - console.log(`[ok] session/delete`); - } - - // After delete, all 3 ports should be free again - const dbgAfter = await (await fetch(`http://127.0.0.1:${port}/debug/state`)).json() as { - v2Ports: Record; - }; - const remainingPorts = Object.keys(dbgAfter.v2Ports).length; - if (remainingPorts !== 0) { - console.error(`FAIL: expected 0 RESERVED ports after delete, got ${remainingPorts}`); - failed++; - } else { - console.log(`[ok] all 3 ports released back to FREE`); - } - - // 10. Purge (phase 2) — drops the session entry - const purge = await postJson(`http://127.0.0.1:${port}/session/purge`, { - sessionId, - fencingToken: (tk.body as { fencingToken: number }).fencingToken ?? 2, - }); - if (purge.status !== 200) { - console.error(`FAIL: session/purge → ${purge.status}`); - failed++; - } else { - console.log(`[ok] session/purge`); - } - } - } finally { - await daemon.stop(); - rmSync(baseDir, { recursive: true, force: true }); - } - - if (failed === 0) { - console.log("\n=== P14 PASS ==="); - return 0; - } - console.log(`\n=== P14 FAIL (${failed} failures) ===`); - return 1; -} - -main().then((code) => process.exit(code)); diff --git a/plugins/__tests__/reconciler-prune.test.ts b/plugins/__tests__/reconciler-prune.test.ts deleted file mode 100644 index 0c970ca..0000000 --- a/plugins/__tests__/reconciler-prune.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// @ts-nocheck -/** - * Reconciler C5 prune — 命令注入防护 (新架构 §11.5 H5, F8). - * - * 背景: - * - C5 路径里 `git worktree prune` 之前通过 `exec()` + 字符串拼接 - * (`git -C "${projectRoot}" worktree prune`) 调用. - * - projectRoot 是客户端可控字段, 直接拼进 shell 字符串存在命令注入 - * 漏洞 (例如 projectRoot = `/tmp/foo"; rm -rf /`). - * - 修复: 改用 `execFile("git", ["worktree", "prune", "-d"], { cwd }, cb)` - * 把 projectRoot 作为 `cwd` 而非 shell 参数, 让 Node 走 fork/execve - * 通道, 不经 shell parse. - * - * TDD 验证: - * 1. 注入恶意 projectRoot → 仍能正常调用, 不触发 shell 解析. - * 2. 调用走 execFile (而非 exec), 且参数 EXACTLY - * ["worktree", "prune", "-d"] (不再有 `git -C "..." worktree prune` - * 这种 cmd string). - * 3. projectRoot 出现在 { cwd } 字段而非 args 数组. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { exec, execFile } from "node:child_process"; -import { - createReconciler, - type ReconcileAction, - type ReconcileDeps, - triggerC5PruneForTest, -} from "../reconciler.js"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; - -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - exec: vi.fn((_cmd: string, _opts: unknown, _cb: unknown) => { - // mock 永不调用 cb — 如果测试走到 exec, 视为命令注入防护失败. - return {} as ReturnType; - }), - execFile: vi.fn( - ( - _cmd: string, - _args: readonly string[] | undefined, - _opts: unknown, - _cb?: unknown, - ) => { - const cb = (_opts as { cb?: unknown })?.cb ?? _cb; - if (typeof cb === "function") { - (cb as (e: null, out: { stdout: string; stderr: string }) => void)( - null, - { stdout: "", stderr: "" }, - ); - } - return {} as ReturnType; - }, - ), - }; -}); - -function makeStateV2(): DaemonStateV2 { - const s = new DaemonStateV2(); - s.setState("READY"); - return s; -} - -function makeDeps(overrides: Partial = {}): ReconcileDeps { - return { - stateV2: makeStateV2(), - getOwnerLastHeartbeat: () => null, - isProcessAlive: () => false, - existsSync: () => false, - readFileSync: () => "", - now: () => 1_000_000, - ...overrides, - }; -} - -describe("reconciler — C5 git worktree prune 命令注入防护 (F8)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("恶意 projectRoot 走 execFile, 参数 EXACTLY ['worktree', 'prune', '-d'], projectRoot 作 cwd", async () => { - const maliciousProjectRoot = '/tmp/foo"; rm -rf /'; - const action: ReconcileAction = { - kind: "C5-prune-then-C3", - sessionId: "s1", - projectRoot: maliciousProjectRoot, - }; - const deps = makeDeps(); - await triggerC5PruneForTest(action, deps); - - // 1. exec 一定不能被调用 (避免 shell parse) - expect(exec).not.toHaveBeenCalled(); - - // 2. execFile 必须被调用一次, 命令名是 "git" - expect(execFile).toHaveBeenCalledTimes(1); - const call = vi.mocked(execFile).mock.calls[0]; - expect(call?.[0]).toBe("git"); - - // 3. args 必须是 EXACTLY ["worktree", "prune", "-d"] — 不包含 - // projectRoot, 也不包含任何 shell metachar. - const args = call?.[1] as readonly string[] | undefined; - expect(args).toEqual(["worktree", "prune", "-d"]); - - // 4. opts.cwd 必须是 projectRoot (而非拼到 args 里) - const opts = call?.[2] as { cwd?: string } | undefined; - expect(opts?.cwd).toBe(maliciousProjectRoot); - - // 5. 防御性断言: 任何 args 元素中都不应出现 " 或 ; (shell metachar) - for (const a of args ?? []) { - expect(a).not.toMatch(/["`$;|&<>(){}!\\]/); - } - }); - - it("正常 projectRoot 同样走 execFile with EXACT args", async () => { - const action: ReconcileAction = { - kind: "C5-prune-then-C3", - sessionId: "s2", - projectRoot: "/home/user/project", - }; - const deps = makeDeps(); - await triggerC5PruneForTest(action, deps); - - expect(exec).not.toHaveBeenCalled(); - expect(execFile).toHaveBeenCalledTimes(1); - const call = vi.mocked(execFile).mock.calls[0]; - expect(call?.[0]).toBe("git"); - expect(call?.[1]).toEqual(["worktree", "prune", "-d"]); - const opts = call?.[2] as { cwd?: string } | undefined; - expect(opts?.cwd).toBe("/home/user/project"); - }); - - it("reconciler C5 路径不再持有 child_process.exec 引用", async () => { - // 静态断言: reconciler 模块不应再 import exec (仅 execFile). - // 这是结构性 RED 提示, 防止后续误用 exec. - const mod = await import("../reconciler.js"); - expect(typeof mod.triggerC5PruneForTest).toBe("function"); - }); - - it("createReconciler 仍可正常构造且 dispatchAction C5 走 execFile (集成)", async () => { - // 集成校验: 通过 createReconciler + emitOrphan 路径, 验证 reconciler - // 实例的 dispatchAction 也会走 execFile 而非 exec. 由于 dispatchAction - // 当前不自动触发 C5 (classifyActive 未实现 git 探测), 我们手动通过 - // reconciler 的 emitOrphan 钩子确认: emit 不会被错误触发, 但 reconciler - // 自身构造无副作用. - const emitted: ReconcileAction[] = []; - const deps = makeDeps({ emitOrphan: (a) => emitted.push(a) }); - const r = createReconciler(deps); - expect(typeof r.tick).toBe("function"); - expect(typeof r.setReady).toBe("function"); - expect(emitted).toHaveLength(0); - }); -}); \ No newline at end of file diff --git a/plugins/__tests__/reconciler.test.ts b/plugins/__tests__/reconciler.test.ts deleted file mode 100644 index dc6f195..0000000 --- a/plugins/__tests__/reconciler.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -// @ts-nocheck -/** - * Reconciler tests — C1-C5 残缺态分类 (新架构 §4.3 + §4.4 双信号死亡). - * - * 验证: - * 1. RECOVERING 期完全跳过对账 (§4.4 末段). - * 2. READY 后 LEASE_TTL 宽限窗口内跳过卡死判定. - * 3. C1 (creating, lease dead) — 通过 commit point 检查 → retain; 不通过 → rollback. - * 4. C2 (deleting, lease dead) — 接管续删. - * 5. C3 (active, worktree 不存在) — 标记 orphan, 不静默删. - * 6. C4 (无记录, dir 存在) — UI 提示永不自动删. - * 7. C5 (active, git 悬挂) — git worktree prune → C3. - * 8. 正常 active + worktree 存在 — noop. - */ -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { createReconciler, type ReconcileDeps, type ReconcileAction } from "../reconciler.js"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; -import { LEASE_TTL_MS } from "../constants.js"; - -function makeStateV2(): DaemonStateV2 { - const s = new DaemonStateV2(); - s.setState("READY"); - return s; -} - -function makeDeps(overrides: Partial = {}): ReconcileDeps { - return { - stateV2: makeStateV2(), - getOwnerLastHeartbeat: () => null, - isProcessAlive: () => false, - existsSync: () => false, - readFileSync: () => "", - execImpl: async () => ({ stdout: "", stderr: "" }), - now: () => 1_000_000, - ...overrides, - }; -} - -describe("reconciler — RECOVERING 期跳过", () => { - it("RECOVERING 时 tick 返回空 actions", async () => { - const stateV2 = makeStateV2(); - stateV2.setState("RECOVERING"); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: Date.now() - 10_000, // 已过期 - }); - const deps = makeDeps({ stateV2 }); - const r = createReconciler(deps); - const report = await r.tick(); - expect(report.actions).toHaveLength(0); - }); -}); - -describe("reconciler — READY 宽限窗口", () => { - it("READY 后 LEASE_TTL 宽限窗口内跳过", async () => { - const stateV2 = makeStateV2(); - let nowVal = 1_000_000; - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: nowVal - 10_000, // 过期, 但在宽限窗内 - }); - const r = createReconciler( - makeDeps({ stateV2, now: () => nowVal }), - ); - r.setReady(nowVal); - // Within grace: 1s after ready - nowVal += 1_000; - const report = await r.tick(); - expect(report.actions.filter((a) => a.kind !== "noop")).toHaveLength(0); - expect(r.isInGraceWindow()).toBe(true); - }); - - it("超过宽限窗口后开始判定", async () => { - const stateV2 = makeStateV2(); - let nowVal = 1_000_000; - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: nowVal - 10_000, - }); - const r = createReconciler( - makeDeps({ stateV2, now: () => nowVal }), - ); - r.setReady(nowVal); - // Past grace: LEASE_TTL + 1s - nowVal += LEASE_TTL_MS + 1_000; - const report = await r.tick(); - expect(r.isInGraceWindow()).toBe(false); - // Should have classified this abandoned session - const meaningful = report.actions.filter((a) => a.kind !== "noop"); - expect(meaningful.length).toBeGreaterThan(0); - }); -}); - -describe("reconciler — C1 (creating + lease dead)", () => { - it(".env 缺端口键 → C1-rollback missing-env-ports", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, // 已过期 - }); - stateV2.claimPort("s1", 3000, "FOO"); - const rollback = vi.fn().mockResolvedValue(undefined); - const deps = makeDeps({ - stateV2, - existsSync: () => false, // .env 不存在 - rollbackCreate: rollback, - }); - const r = createReconciler(deps); - const report = await r.tick(); - const c1 = report.actions.find((a) => a.kind === "C1-rollback"); - expect(c1).toBeDefined(); - if (c1?.kind === "C1-rollback") { - expect(c1.reason).toBe("missing-env-ports"); - } - expect(rollback).toHaveBeenCalledWith("s1"); - }); - - it(".env 端口值不匹配 → C1-rollback env-values-mismatch", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - stateV2.claimPort("s1", 3000, "FOO"); - const envFileContent = "FOO=2999\n"; // 不一致 - const deps = makeDeps({ - stateV2, - existsSync: () => true, - readFileSync: () => envFileContent, - }); - const r = createReconciler(deps); - const report = await r.tick(); - const c1 = report.actions.find((a) => a.kind === "C1-rollback"); - expect(c1).toBeDefined(); - if (c1?.kind === "C1-rollback") { - expect(c1.reason).toBe("env-values-mismatch"); - } - }); - - it(".env 端口值匹配 → C1-retain passes-commit-point", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - stateV2.claimPort("s1", 3000, "FOO"); - const deps = makeDeps({ - stateV2, - existsSync: () => true, - readFileSync: () => "FOO=3000\n", - }); - const r = createReconciler(deps); - const report = await r.tick(); - const c1 = report.actions.find((a) => a.kind === "C1-retain"); - expect(c1).toBeDefined(); - }); -}); - -describe("reconciler — C2 (deleting + lease dead)", () => { - it("接管续删", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - stateV2.activateSession("s1"); - stateV2.beginDelete("s1", 0); - const takeOver = vi.fn().mockResolvedValue(undefined); - const deps = makeDeps({ - stateV2, - takeOverDelete: takeOver, - }); - const r = createReconciler(deps); - const report = await r.tick(); - const c2 = report.actions.find((a) => a.kind === "C2-takeover-delete"); - expect(c2).toBeDefined(); - if (c2?.kind === "C2-takeover-delete") { - expect(c2.sessionId).toBe("s1"); - expect(c2.projectRoot).toBe("/p"); - } - expect(takeOver).toHaveBeenCalledWith("s1", "/p", expect.stringContaining(`${path.sep}.agentdock${path.sep}worktrees${path.sep}s1`)); - }); -}); - -describe("reconciler — C3 (active + no worktree)", () => { - it("active 但 worktree 不存在 → 标记 orphan, 不自动删", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - stateV2.activateSession("s1"); - const emitted: ReconcileAction[] = []; - const deps = makeDeps({ - stateV2, - existsSync: () => false, // worktree 不存在 - emitOrphan: (a) => emitted.push(a), - }); - const r = createReconciler(deps); - const report = await r.tick(); - const c3 = report.actions.find((a) => a.kind === "C3-orphan"); - expect(c3).toBeDefined(); - expect(emitted.some((a) => a.kind === "C3-orphan")).toBe(true); - }); - - it("active + worktree 存在 → noop", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: 0, - }); - stateV2.activateSession("s1"); - const deps = makeDeps({ - stateV2, - existsSync: () => true, // worktree 存在 - }); - const r = createReconciler(deps); - const report = await r.tick(); - // no C1/C2/C3 actions for this active session - const meaningful = report.actions.filter((a) => a.kind !== "noop"); - expect(meaningful).toHaveLength(0); - }); -}); - -describe("reconciler — creating 状态但 lease 仍 alive", () => { - it("不应被对账器打扰", async () => { - const stateV2 = makeStateV2(); - stateV2.createSession({ - sessionId: "s1", - projectRoot: "/p", - displayName: "x", - clientId: "c1", - pid: 1, - leaseExpiresAt: Date.now() + 60_000, // 远未到期 - }); - const deps = makeDeps({ stateV2 }); - const r = createReconciler(deps); - const report = await r.tick(); - // All creating sessions in progress → noop - const meaningful = report.actions.filter((a) => a.kind !== "noop"); - expect(meaningful).toHaveLength(0); - }); -}); - -describe("reconciler — RECONCILER_TUNING 常量", () => { - it("TICK_INTERVAL_MS = RECOVERING_HARD_MAX/2", async () => { - const { RECONCILER_TUNING } = await import("../reconciler.js"); - expect(RECONCILER_TUNING.TICK_INTERVAL_MS).toBeGreaterThan(0); - expect(RECONCILER_TUNING.LEASE_TTL_MS).toBe(LEASE_TTL_MS); - }); -}); - -describe("reconciler — C4 orphan-dir 扫描 (§4.3)", () => { - it("emits C4-orphan-dir for dirs with no DB record (永不自动删)", async () => { - const stateV2 = makeStateV2(); - // DB has session "known-id" but NOT "ghost-id" or "unknown-id" - stateV2.createSession({ - sessionId: "known-id", - projectRoot: "/p", - displayName: "known", - clientId: "c1", - pid: 1, - leaseExpiresAt: Date.now() + 60_000, - }); - stateV2.activateSession("known-id"); - - const emitted: ReconcileAction[] = []; - const deps = makeDeps({ - stateV2, - emitOrphan: (a) => emitted.push(a), - scanWorktreeDirs: (projectRoot) => [ - { sessionIdGuess: "known-id", worktreePath: `${projectRoot}/.agentdock/worktrees/known-id` }, - { sessionIdGuess: "ghost-id", worktreePath: `${projectRoot}/.agentdock/worktrees/ghost-id` }, - { sessionIdGuess: null, worktreePath: `${projectRoot}/.agentdock/worktrees/random-stuff` }, - { sessionIdGuess: "unknown-id", worktreePath: `${projectRoot}/.agentdock/worktrees/unknown-id` }, - ], - }); - const r = createReconciler(deps); - const report = await r.tick(); - - const c4s = report.actions.filter((a) => a.kind === "C4-orphan-dir"); - expect(c4s).toHaveLength(2); - const ids = c4s.map((a) => (a as { sessionIdGuess: string | null }).sessionIdGuess).sort(); - expect(ids).toEqual(["ghost-id", "unknown-id"]); - // known-id 不应被报 (DB 里有) - expect(ids).not.toContain("known-id"); - // 推断不到 sessionId 的目录不报 - expect(c4s.every((a) => (a as { sessionIdGuess: string | null }).sessionIdGuess !== null)).toBe(true); - }); - - it("C4 永不触发 takeOverDelete/rollbackCreate (永不自动删, §4.3 原则)", async () => { - const stateV2 = makeStateV2(); - const takeOverDelete = vi.fn(); - const rollbackCreate = vi.fn(); - const deps = makeDeps({ - stateV2, - takeOverDelete, - rollbackCreate, - scanWorktreeDirs: (projectRoot) => [ - { sessionIdGuess: "orphan", worktreePath: `${projectRoot}/.agentdock/worktrees/orphan` }, - ], - }); - const r = createReconciler(deps); - await r.tick(); - expect(takeOverDelete).not.toHaveBeenCalled(); - expect(rollbackCreate).not.toHaveBeenCalled(); - }); - - it("scanWorktreeDirs 未注入时不报 C4 (保持旧行为, 无副作用)", async () => { - const stateV2 = makeStateV2(); - const deps = makeDeps({ stateV2 }); - const r = createReconciler(deps); - const report = await r.tick(); - expect(report.actions.filter((a) => a.kind === "C4-orphan-dir")).toHaveLength(0); - }); -}); diff --git a/plugins/__tests__/recovering-controller.test.ts b/plugins/__tests__/recovering-controller.test.ts deleted file mode 100644 index afac068..0000000 --- a/plugins/__tests__/recovering-controller.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// @ts-nocheck -/** - * RECOVERING state machine — 新架构 §5.2 unit tests. - */ -import { describe, expect, it } from "vitest"; -import { DaemonStateV2 } from "../daemon-state-v2.js"; -import { - createRecoveringController, - gateClaimInRecovering, -} from "../recovering-controller.js"; - -describe("createRecoveringController — early-exit + hard cap", () => { - it("starts in RECOVERING", () => { - const s = new DaemonStateV2(); - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1", "u2"]), - now: () => 0, - }); - expect(c.isRecovering()).toBe(true); - expect(s.state).toBe("RECOVERING"); - }); - - it("early-exits when all expected reports received after soft_min", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1"]), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - }); - c.recordReport("u1"); - - // Before soft_min: still RECOVERING even if all reported - clock = 50; - expect(c.tick()).toBe("RECOVERING"); - - // After soft_min + all reported → READY - clock = 150; - expect(c.tick()).toBe("READY"); - expect(s.state).toBe("READY"); - }); - - it("does NOT early-exit before soft_min even with all reports in", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1"]), - softMinMs: 1000, - hardMaxMs: 5000, - now: () => clock, - }); - c.recordReport("u1"); - - clock = 500; - expect(c.tick()).toBe("RECOVERING"); - - clock = 1100; - expect(c.tick()).toBe("READY"); - }); - - it("hard-caps at RECOVERING_HARD_MAX regardless of reports", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1", "u2", "u3"]), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - }); - // Only 1 of 3 reported, but past hard_max - c.recordReport("u1"); - - clock = 999; - expect(c.tick()).toBe("RECOVERING"); - - clock = 1001; - expect(c.tick()).toBe("READY"); - }); - - it("early-exits immediately after soft_min when no expected (fresh install)", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - }); - clock = 50; - expect(c.tick()).toBe("RECOVERING"); - - clock = 101; - expect(c.tick()).toBe("READY"); - }); - - it("records only expected reports (stray sessionIds ignored)", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1"]), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - }); - c.recordReport("u1"); // expected - c.recordReport("u99"); // not expected — should not count - c.recordReport("u1"); // duplicate — still 1 - - clock = 150; - expect(c.tick()).toBe("READY"); - }); - - it("forceReady bypasses the timer", () => { - const s = new DaemonStateV2(); - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1"]), - now: () => 0, - }); - c.forceReady("admin override"); - expect(s.state).toBe("READY"); - }); - - it("snapshot reports current state + counters", () => { - const s = new DaemonStateV2(); - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(["u1", "u2"]), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - }); - c.recordReport("u1"); - clock = 50; - c.tick(); - - const snap = c.snapshot(); - expect(snap.state).toBe("RECOVERING"); - expect(snap.elapsedMs).toBe(50); - expect(snap.expected).toBe(2); - expect(snap.reported).toBe(1); - expect(snap.softMinMs).toBe(100); - expect(snap.hardMaxMs).toBe(1000); - }); - - it("onTransition callback fires on state change", () => { - const s = new DaemonStateV2(); - const transitions: Array<{ to: string; reason: string }> = []; - let clock = 0; - const c = createRecoveringController(s, { - expectedSessionIds: new Set(), - softMinMs: 100, - hardMaxMs: 1000, - now: () => clock, - onTransition: (next, reason) => { - transitions.push({ to: next, reason }); - }, - }); - clock = 150; - c.tick(); - expect(transitions).toHaveLength(1); - expect(transitions[0]).toEqual({ - to: "READY", - reason: expect.stringMatching(/early-exit/), - }); - }); -}); - -describe("gateClaimInRecovering — recovery claim whitelisting", () => { - it("allows all claims when READY", () => { - const s = new DaemonStateV2(); - s.setState("READY"); - const r = gateClaimInRecovering(s, "u1", new Set(), new Set()); - expect(r.allow).toBe(true); - }); - - it("RECOVERING rejects claims for unknown sessionIds", () => { - const s = new DaemonStateV2(); - s.setState("RECOVERING"); - const r = gateClaimInRecovering(s, "u1", new Set(["u2"]), new Set()); - expect(r.allow).toBe(false); - if (!r.allow) expect(r.code).toBe("RECOVERING"); - }); - - it("RECOVERING allows claims for expected sessionIds (recovery re-registration)", () => { - const s = new DaemonStateV2(); - s.setState("RECOVERING"); - const r = gateClaimInRecovering(s, "u1", new Set(["u1"]), new Set()); - expect(r.allow).toBe(true); - }); - - it("RECOVERING allows claims for sessionIds already reported this window", () => { - const s = new DaemonStateV2(); - s.setState("RECOVERING"); - const r = gateClaimInRecovering(s, "u1", new Set(), new Set(["u1"])); - expect(r.allow).toBe(true); - }); - - it("RECOVERING rejects claims for sessionIds NOT in expected set AND not yet reported", () => { - const s = new DaemonStateV2(); - s.setState("RECOVERING"); - const r = gateClaimInRecovering(s, "u99", new Set(["u1"]), new Set()); - expect(r.allow).toBe(false); - }); -}); diff --git a/plugins/__tests__/resource-sync-e2e.test.ts b/plugins/__tests__/resource-sync-e2e.test.ts index 768fee5..aca4d6a 100644 --- a/plugins/__tests__/resource-sync-e2e.test.ts +++ b/plugins/__tests__/resource-sync-e2e.test.ts @@ -1,3 +1,7 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; // @ts-nocheck /** * E2E tests for resource sync — verifies directory sync through the @@ -7,19 +11,8 @@ * sync resources, then exercise lifecycle.create() to verify directories are * correctly synced to the worktree. */ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { execSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { createSessionLifecycle, type PortService } from "../session-lifecycle.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type PortService, createSessionLifecycle } from "../session-lifecycle.js"; // --- Mock PortService --- let nextPort = 30000; @@ -73,27 +66,15 @@ function createSourceDir(projectDir: string): void { writeFileSync(path.join(nestedDir, "nested.txt"), "nested-content"); } -function createConfig(projectDir: string): void { - const config = `version: "1" -resources: - sync: - - source: "uploads/" - strategy: "overwrite" - - source: "uploads/" - strategy: "skip" - skipIfMissing: true -hooks: {} -`; - writeFileSync(path.join(projectDir, "agentdock.config.yaml"), config, "utf-8"); -} - function cleanup(fixture: Fixture) { // Remove worktree paths that were created const wtBase = path.join(fixture.projectDir, ".agentdock", "worktrees"); if (existsSync(wtBase)) { for (const entry of readdirSync(wtBase)) { const wtPath = path.join(wtBase, entry); - try { rmSync(wtPath, { recursive: true, force: true }); } catch {} + try { + rmSync(wtPath, { recursive: true, force: true }); + } catch {} } } // Remove branches created by test @@ -181,7 +162,7 @@ hooks: {} expect(existsSync(path.join(wtUploads, "sub"))).toBe(true); expect(readFileSync(path.join(wtUploads, "sub", "nested.txt"), "utf-8")).toBe("nested-content"); - // Cleanup git worktree (lifecycle.remove also works but needs daemon) + // Cleanup the git worktree directly; lifecycle deletion is covered elsewhere. gitWorktreeRemove(projectDir, result.worktreePath); }); @@ -299,7 +280,9 @@ hooks: {} expect(readFileSync(path.join(wtUploads, "a.txt"), "utf-8")).toBe("file-a"); // Verify file - expect(readFileSync(path.join(result.worktreePath, ".env"), "utf-8")).toContain("NODE_ENV=development"); + expect(readFileSync(path.join(result.worktreePath, ".env"), "utf-8")).toContain( + "NODE_ENV=development", + ); gitWorktreeRemove(projectDir, result.worktreePath); }); diff --git a/plugins/__tests__/resource-sync.test.ts b/plugins/__tests__/resource-sync.test.ts index fde1d13..5887148 100644 --- a/plugins/__tests__/resource-sync.test.ts +++ b/plugins/__tests__/resource-sync.test.ts @@ -1,17 +1,10 @@ -// @ts-nocheck -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { - mkdirSync, - writeFileSync, - rmSync, - existsSync, - readFileSync, - readdirSync, -} from "node:fs"; -import path from "node:path"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; +// @ts-nocheck +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ResourceDefinition } from "../config.js"; -import { createResourceSyncService, SyncError } from "../resource-sync.js"; +import { SyncError, createResourceSyncService } from "../resource-sync.js"; let projectDir: string; let worktreeDir: string; @@ -65,11 +58,7 @@ describe("单文件同步 syncOne", () => { it("B3: skip 策略跳过已有文件", async () => { writeFileSync(path.join(projectDir, ".env"), "A=new\n"); writeFileSync(path.join(worktreeDir, ".env"), "A=old\n"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource(".env", "skip"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource(".env", "skip")); expect(result.success).toBe(true); expect(result.action).toBe("skipped"); expect(readFileSync(path.join(worktreeDir, ".env"), "utf-8")).toBe("A=old\n"); @@ -77,11 +66,7 @@ describe("单文件同步 syncOne", () => { it("B4: skip 策略复制不存在的文件", async () => { writeFileSync(path.join(projectDir, ".env"), "A=1\n"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource(".env", "skip"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource(".env", "skip")); expect(result.success).toBe(true); expect(result.action).toBe("copied"); expect(readFileSync(path.join(worktreeDir, ".env"), "utf-8")).toBe("A=1\n"); @@ -90,11 +75,7 @@ describe("单文件同步 syncOne", () => { it("B5: merge 策略合并 .env 文件 — source 覆盖同名 key, target 独有 key 保留", async () => { writeFileSync(path.join(projectDir, ".env"), "A=1\nB=2\n"); writeFileSync(path.join(worktreeDir, ".env"), "B=old\nC=3\n"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource(".env", "merge"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource(".env", "merge")); expect(result.success).toBe(true); expect(result.action).toBe("merged"); const merged = readFileSync(path.join(worktreeDir, ".env"), "utf-8"); @@ -106,11 +87,7 @@ describe("单文件同步 syncOne", () => { it("B6: merge 策略对目标不存在的文件等同于复制", async () => { writeFileSync(path.join(projectDir, ".env"), "A=1\n"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource(".env", "merge"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource(".env", "merge")); expect(result.success).toBe(true); expect(result.action).toBe("copied"); expect(readFileSync(path.join(worktreeDir, ".env"), "utf-8")).toBe("A=1\n"); @@ -222,11 +199,7 @@ describe("目录同步 syncOne", () => { mkdirSync(wtUploads, { recursive: true }); writeFileSync(path.join(wtUploads, "old.txt"), "old"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource("uploads/", "skip"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource("uploads/", "skip")); expect(result.success).toBe(true); expect(result.action).toBe("skipped"); // worktree unchanged @@ -265,11 +238,7 @@ describe("目录同步 syncOne", () => { writeFileSync(path.join(uploadsDir, "a.txt"), "file-a"); writeFileSync(path.join(uploadsDir, "b.txt"), "file-b"); - const result = await service.syncOne( - projectDir, - worktreeDir, - makeResource("uploads/", "skip"), - ); + const result = await service.syncOne(projectDir, worktreeDir, makeResource("uploads/", "skip")); expect(result.success).toBe(true); expect(result.action).toBe("copied"); // Directory should have been copied @@ -340,9 +309,7 @@ describe("syncAll 批量同步", () => { it("B19: 同步记录耗时", async () => { writeFileSync(path.join(projectDir, ".env"), "A=1\n"); - const report = await service.syncAll(projectDir, worktreeDir, [ - makeResource(".env"), - ]); + const report = await service.syncAll(projectDir, worktreeDir, [makeResource(".env")]); expect(report.duration).toBeGreaterThanOrEqual(0); expect(typeof report.duration).toBe("number"); }); @@ -356,9 +323,7 @@ describe("边界情况", () => { writeFileSync(path.join(projectDir, ".env"), "A=1\n"); const badWorktree = path.join(os.tmpdir(), `nonexistent-${Date.now()}`); // Don't create the directory - await expect( - service.syncOne(projectDir, badWorktree, makeResource(".env")), - ).rejects.toThrow(); + await expect(service.syncOne(projectDir, badWorktree, makeResource(".env"))).rejects.toThrow(); }); it("B21: project 路径不存在时 — 源文件 missing, skipIfMissing=true", async () => { @@ -384,9 +349,9 @@ describe("边界情况", () => { ); expect(result.success).toBe(true); expect(result.action).toBe("copied"); - expect( - readFileSync(path.join(worktreeDir, "my files", "config.json"), "utf-8"), - ).toBe('{"ok":true}'); + expect(readFileSync(path.join(worktreeDir, "my files", "config.json"), "utf-8")).toBe( + '{"ok":true}', + ); }); it("B23: 嵌套目录同步", async () => { @@ -402,10 +367,7 @@ describe("边界情况", () => { ); expect(result.success).toBe(true); expect( - readFileSync( - path.join(worktreeDir, "src", "components", "ui", "Button.tsx"), - "utf-8", - ), + readFileSync(path.join(worktreeDir, "src", "components", "ui", "Button.tsx"), "utf-8"), ).toBe(" -

- {t("resourceSyncDesc")} -

+

{t("resourceSyncDesc")}

{expandedSections.resources && (
{config.resources.sync.map((res, i) => ( -
+
-
- +
-
- + + ? + +