diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index abf1b582e..ef19def8d 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -46,7 +46,7 @@ test("app-paths resolve to ~/.gsd/", async () => { // 2. loader env vars // ═══════════════════════════════════════════════════════════════════════════ -test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { +test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async (t) => { // Run loader in a subprocess that prints env vars and exits before TUI starts const script = ` import { fileURLToPath } from 'url'; @@ -75,17 +75,18 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { const scriptPath = join(tmp, "check-env.ts"); writeFileSync(scriptPath, script); + t.after(() => rmSync(tmp, { recursive: true, force: true })); try { - const output = execSync( - `node --experimental-strip-types -e " - process.chdir('${projectRoot}'); - await import('./src/app-paths.ts'); - " 2>&1`, - { encoding: "utf-8", cwd: projectRoot }, - ); - // If we got here without error, the import works + const output = execSync( + `node --experimental-strip-types -e " + process.chdir('${projectRoot}'); + await import('./src/app-paths.ts'); + " 2>&1`, + { encoding: "utf-8", cwd: projectRoot }, + ); + // If we got here without error, the import works } catch { - // Fine — we test the logic inline below + // Fine — we test the logic inline below } // Direct logic verification (no subprocess needed) @@ -112,17 +113,17 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { // extensions directory has discoverable entry points const { discoverExtensionEntryPaths } = await import("../extension-discovery.ts"); const bundledExtensionsDir = join(projectRoot, existsSync(join(projectRoot, "dist", "resources")) - ? "dist" : "src", "resources", "extensions"); + ? "dist" : "src", "resources", "extensions"); const discovered = discoverExtensionEntryPaths(bundledExtensionsDir); assert.ok(discovered.length >= 10, `expected >=10 extensions, found ${discovered.length}`); // Spot-check that core extensions are discoverable const discoveredNames = discovered.map(p => { - const rel = p.slice(bundledExtensionsDir.length + 1); - return rel.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, ""); + const rel = p.slice(bundledExtensionsDir.length + 1); + return rel.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, ""); }); for (const core of ["gsd", "bg-shell", "browser-tools", "subagent", "search-the-web"]) { - assert.ok(discoveredNames.includes(core), `core extension '${core}' is discoverable`); + assert.ok(discoveredNames.includes(core), `core extension '${core}' is discoverable`); } rmSync(tmp, { recursive: true, force: true }); @@ -132,79 +133,72 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { // 3. resource-loader syncs bundled resources // ═══════════════════════════════════════════════════════════════════════════ -test("initResources syncs extensions, agents, and skills to target dir", async () => { +test("initResources syncs extensions, agents, and skills to target dir", async (t) => { const { initResources, readManagedResourceVersion } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-test-")); const fakeAgentDir = join(tmp, "agent"); - try { - initResources(fakeAgentDir); + initResources(fakeAgentDir); - // Extensions synced - assertExtensionIndexExists(fakeAgentDir, "gsd"); - assertExtensionIndexExists(fakeAgentDir, "browser-tools"); - assertExtensionIndexExists(fakeAgentDir, "search-the-web"); - assertExtensionIndexExists(fakeAgentDir, "context7"); - assertExtensionIndexExists(fakeAgentDir, "subagent"); + // Extensions synced + assertExtensionIndexExists(fakeAgentDir, "gsd"); + assertExtensionIndexExists(fakeAgentDir, "browser-tools"); + assertExtensionIndexExists(fakeAgentDir, "search-the-web"); + assertExtensionIndexExists(fakeAgentDir, "context7"); + assertExtensionIndexExists(fakeAgentDir, "subagent"); - // Agents synced - assert.ok(existsSync(join(fakeAgentDir, "agents", "scout.md")), "scout agent synced"); + // Agents synced + assert.ok(existsSync(join(fakeAgentDir, "agents", "scout.md")), "scout agent synced"); - // Skills synced - assert.ok(existsSync(join(fakeAgentDir, "skills")), "skills directory synced"); + // Skills synced + assert.ok(existsSync(join(fakeAgentDir, "skills")), "skills directory synced"); - // Version manifest synced - const managedVersion = readManagedResourceVersion(fakeAgentDir); - assert.ok(managedVersion, "managed resource version written"); + // Version manifest synced + const managedVersion = readManagedResourceVersion(fakeAgentDir); + assert.ok(managedVersion, "managed resource version written"); - // Idempotent: run again, no crash - initResources(fakeAgentDir); - assertExtensionIndexExists(fakeAgentDir, "gsd"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + // Idempotent: run again, no crash + initResources(fakeAgentDir); + assertExtensionIndexExists(fakeAgentDir, "gsd"); }); -test("initResources skips copy when managed version matches current version", async () => { +test("initResources skips copy when managed version matches current version", async (t) => { const { initResources, readManagedResourceVersion } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-skip-")); const fakeAgentDir = join(tmp, "agent"); - try { - // First run: full sync (no manifest yet) - initResources(fakeAgentDir); - const version = readManagedResourceVersion(fakeAgentDir); - assert.ok(version, "manifest written after first sync"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + // First run: full sync (no manifest yet) + initResources(fakeAgentDir); + const version = readManagedResourceVersion(fakeAgentDir); + assert.ok(version, "manifest written after first sync"); - // Add a marker file to detect whether sync runs again - const markerPath = join(fakeAgentDir, "extensions", "gsd", "_marker.txt"); - writeFileSync(markerPath, "test-marker"); + // Add a marker file to detect whether sync runs again + const markerPath = join(fakeAgentDir, "extensions", "gsd", "_marker.txt"); + writeFileSync(markerPath, "test-marker"); - // Second run: version matches — should skip, marker survives - initResources(fakeAgentDir); - assert.ok(existsSync(markerPath), "marker file survives when version matches (sync skipped)"); + // Second run: version matches — should skip, marker survives + initResources(fakeAgentDir); + assert.ok(existsSync(markerPath), "marker file survives when version matches (sync skipped)"); - // Simulate version mismatch by writing older version to manifest - const manifestPath = join(fakeAgentDir, "managed-resources.json"); - writeFileSync(manifestPath, JSON.stringify({ gsdVersion: "0.0.1", syncedAt: Date.now() })); + // Simulate version mismatch by writing older version to manifest + const manifestPath = join(fakeAgentDir, "managed-resources.json"); + writeFileSync(manifestPath, JSON.stringify({ gsdVersion: "0.0.1", syncedAt: Date.now() })); - // Third run: version mismatch — full sync, marker removed - initResources(fakeAgentDir); - assert.ok(!existsSync(markerPath), "marker file removed after version-mismatch sync"); + // Third run: version mismatch — full sync, marker removed + initResources(fakeAgentDir); + assert.ok(!existsSync(markerPath), "marker file removed after version-mismatch sync"); - // Manifest updated to current version - const updatedVersion = readManagedResourceVersion(fakeAgentDir); - assert.strictEqual(updatedVersion, version, "manifest updated to current version after sync"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + // Manifest updated to current version + const updatedVersion = readManagedResourceVersion(fakeAgentDir); + assert.strictEqual(updatedVersion, version, "manifest updated to current version after sync"); }); // ═══════════════════════════════════════════════════════════════════════════ // 4. wizard loadStoredEnvKeys hydration // ═══════════════════════════════════════════════════════════════════════════ -test("loadStoredEnvKeys hydrates process.env from auth.json", async () => { +test("loadStoredEnvKeys hydrates process.env from auth.json", async (t) => { const { loadStoredEnvKeys } = await import("../wizard.ts"); const { AuthStorage } = await import("@gsd/pi-coding-agent"); @@ -231,30 +225,29 @@ test("loadStoredEnvKeys hydrates process.env from auth.json", async () => { delete process.env[v]; } - try { - const auth = AuthStorage.create(authPath); - loadStoredEnvKeys(auth); - - assert.equal(process.env.BRAVE_API_KEY, "test-brave-key", "BRAVE_API_KEY hydrated"); - assert.equal(process.env.BRAVE_ANSWERS_KEY, "test-answers-key", "BRAVE_ANSWERS_KEY hydrated"); - assert.equal(process.env.CONTEXT7_API_KEY, "test-ctx7-key", "CONTEXT7_API_KEY hydrated"); - assert.equal(process.env.JINA_API_KEY, undefined, "JINA_API_KEY not set (not in auth)"); - assert.equal(process.env.TAVILY_API_KEY, "test-tavily-key", "TAVILY_API_KEY hydrated"); - assert.equal(process.env.TELEGRAM_BOT_TOKEN, "test-telegram-key", "TELEGRAM_BOT_TOKEN hydrated"); - assert.equal(process.env.CUSTOM_OPENAI_API_KEY, "test-custom-openai-key", "CUSTOM_OPENAI_API_KEY hydrated"); - } finally { + t.after(() => { for (const v of envVarsToRestore) { - if (origValues[v]) process.env[v] = origValues[v]; else delete process.env[v]; + if (origValues[v]) process.env[v] = origValues[v]; else delete process.env[v]; } rmSync(tmp, { recursive: true, force: true }); - } + }); + const auth = AuthStorage.create(authPath); + loadStoredEnvKeys(auth); + + assert.equal(process.env.BRAVE_API_KEY, "test-brave-key", "BRAVE_API_KEY hydrated"); + assert.equal(process.env.BRAVE_ANSWERS_KEY, "test-answers-key", "BRAVE_ANSWERS_KEY hydrated"); + assert.equal(process.env.CONTEXT7_API_KEY, "test-ctx7-key", "CONTEXT7_API_KEY hydrated"); + assert.equal(process.env.JINA_API_KEY, undefined, "JINA_API_KEY not set (not in auth)"); + assert.equal(process.env.TAVILY_API_KEY, "test-tavily-key", "TAVILY_API_KEY hydrated"); + assert.equal(process.env.TELEGRAM_BOT_TOKEN, "test-telegram-key", "TELEGRAM_BOT_TOKEN hydrated"); + assert.equal(process.env.CUSTOM_OPENAI_API_KEY, "test-custom-openai-key", "CUSTOM_OPENAI_API_KEY hydrated"); }); // ═══════════════════════════════════════════════════════════════════════════ // 5. loadStoredEnvKeys does NOT overwrite existing env vars // ═══════════════════════════════════════════════════════════════════════════ -test("loadStoredEnvKeys does not overwrite existing env vars", async () => { +test("loadStoredEnvKeys does not overwrite existing env vars", async (t) => { const { loadStoredEnvKeys } = await import("../wizard.ts"); const { AuthStorage } = await import("@gsd/pi-coding-agent"); @@ -267,122 +260,109 @@ test("loadStoredEnvKeys does not overwrite existing env vars", async () => { const origBrave = process.env.BRAVE_API_KEY; process.env.BRAVE_API_KEY = "existing-env-key"; - try { - const auth = AuthStorage.create(authPath); - loadStoredEnvKeys(auth); - - assert.equal(process.env.BRAVE_API_KEY, "existing-env-key", "existing env var not overwritten"); - } finally { + t.after(() => { if (origBrave) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; rmSync(tmp, { recursive: true, force: true }); - } + }); + const auth = AuthStorage.create(authPath); + loadStoredEnvKeys(auth); + + assert.equal(process.env.BRAVE_API_KEY, "existing-env-key", "existing env var not overwritten"); }); // ═══════════════════════════════════════════════════════════════════════════ // 6. State derivation — Gap 2 // ═══════════════════════════════════════════════════════════════════════════ -test("deriveState returns pre-planning phase for empty .gsd/ directory", async () => { +test("deriveState returns pre-planning phase for empty .gsd/ directory", async (t) => { const { deriveState } = await import("../resources/extensions/gsd/state.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-state-smoke-")); // Create minimal .gsd/ structure with no milestones mkdirSync(join(tmp, ".gsd"), { recursive: true }); - try { - const state = await deriveState(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + const state = await deriveState(tmp); - assert.equal(state.phase, "pre-planning", - `expected pre-planning phase for empty .gsd/, got: ${state.phase}`); - assert.equal(state.activeMilestone, null, "no active milestone"); - assert.equal(state.activeSlice, null, "no active slice"); - assert.equal(state.activeTask, null, "no active task"); - assert.ok(Array.isArray(state.blockers), "blockers is an array"); - assert.ok(Array.isArray(state.registry), "registry is an array"); - assert.equal(state.registry.length, 0, "empty registry"); - assert.ok(typeof state.nextAction === "string", "nextAction is a string"); - assert.ok(state.nextAction.length > 0, "nextAction is non-empty"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + assert.equal(state.phase, "pre-planning", + `expected pre-planning phase for empty .gsd/, got: ${state.phase}`); + assert.equal(state.activeMilestone, null, "no active milestone"); + assert.equal(state.activeSlice, null, "no active slice"); + assert.equal(state.activeTask, null, "no active task"); + assert.ok(Array.isArray(state.blockers), "blockers is an array"); + assert.ok(Array.isArray(state.registry), "registry is an array"); + assert.equal(state.registry.length, 0, "empty registry"); + assert.ok(typeof state.nextAction === "string", "nextAction is a string"); + assert.ok(state.nextAction.length > 0, "nextAction is non-empty"); }); -test("deriveState returns pre-planning phase when no .gsd/ directory exists", async () => { +test("deriveState returns pre-planning phase when no .gsd/ directory exists", async (t) => { const { deriveState } = await import("../resources/extensions/gsd/state.ts"); // Use a temp dir with no .gsd/ subdirectory at all const tmp = mkdtempSync(join(tmpdir(), "gsd-state-nogsd-")); - try { - // Should not throw — missing .gsd/ is a valid "no project" state - const state = await deriveState(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + // Should not throw — missing .gsd/ is a valid "no project" state + const state = await deriveState(tmp); - assert.equal(state.phase, "pre-planning", - `expected pre-planning phase when .gsd/ absent, got: ${state.phase}`); - assert.equal(state.activeMilestone, null, "no active milestone"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + assert.equal(state.phase, "pre-planning", + `expected pre-planning phase when .gsd/ absent, got: ${state.phase}`); + assert.equal(state.activeMilestone, null, "no active milestone"); }); -test("deriveState shape is structurally complete", async () => { +test("deriveState shape is structurally complete", async (t) => { const { deriveState } = await import("../resources/extensions/gsd/state.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-state-shape-")); mkdirSync(join(tmp, ".gsd"), { recursive: true }); - try { - const state = await deriveState(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + const state = await deriveState(tmp); - // All required fields present - const requiredFields = [ - "phase", "activeMilestone", "activeSlice", "activeTask", - "recentDecisions", "blockers", "nextAction", "registry", - ] as const; - for (const field of requiredFields) { - assert.ok(field in state, `state.${field} should be present`); - } - - // phase is a known string value - const validPhases = [ - "pre-planning", "needs-discussion", "researching", "planning", - "executing", "summarizing", "replanning-slice", "validating-milestone", - "completing-milestone", "complete", "blocked", - ]; - assert.ok(validPhases.includes(state.phase), - `state.phase '${state.phase}' should be a known phase`); - } finally { - rmSync(tmp, { recursive: true, force: true }); + // All required fields present + const requiredFields = [ + "phase", "activeMilestone", "activeSlice", "activeTask", + "recentDecisions", "blockers", "nextAction", "registry", + ] as const; + for (const field of requiredFields) { + assert.ok(field in state, `state.${field} should be present`); } + + // phase is a known string value + const validPhases = [ + "pre-planning", "needs-discussion", "researching", "planning", + "executing", "summarizing", "replanning-slice", "validating-milestone", + "completing-milestone", "complete", "blocked", + ]; + assert.ok(validPhases.includes(state.phase), + `state.phase '${state.phase}' should be a known phase`); }); // ═══════════════════════════════════════════════════════════════════════════ // 7. Doctor health checks — Gap 3 // ═══════════════════════════════════════════════════════════════════════════ -test("runGSDDoctor completes without throwing on empty .gsd/ directory", async () => { +test("runGSDDoctor completes without throwing on empty .gsd/ directory", async (t) => { const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-smoke-")); mkdirSync(join(tmp, ".gsd"), { recursive: true }); - try { - // audit-only mode (fix: false) — should never throw - const report = await runGSDDoctor(tmp, { fix: false }); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + // audit-only mode (fix: false) — should never throw + const report = await runGSDDoctor(tmp, { fix: false }); - // Structural assertions on the DoctorReport - assert.ok(typeof report === "object" && report !== null, "report is an object"); - assert.ok("ok" in report, "report has ok field"); - assert.ok("issues" in report, "report has issues field"); - assert.ok("fixesApplied" in report, "report has fixesApplied field"); - assert.ok("basePath" in report, "report has basePath field"); - assert.ok(Array.isArray(report.issues), "report.issues is an array"); - assert.ok(Array.isArray(report.fixesApplied), "report.fixesApplied is an array"); - assert.equal(typeof report.ok, "boolean", "report.ok is a boolean"); - assert.equal(report.fixesApplied.length, 0, "no fixes applied in audit mode"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + // Structural assertions on the DoctorReport + assert.ok(typeof report === "object" && report !== null, "report is an object"); + assert.ok("ok" in report, "report has ok field"); + assert.ok("issues" in report, "report has issues field"); + assert.ok("fixesApplied" in report, "report has fixesApplied field"); + assert.ok("basePath" in report, "report has basePath field"); + assert.ok(Array.isArray(report.issues), "report.issues is an array"); + assert.ok(Array.isArray(report.fixesApplied), "report.fixesApplied is an array"); + assert.equal(typeof report.ok, "boolean", "report.ok is a boolean"); + assert.equal(report.fixesApplied.length, 0, "no fixes applied in audit mode"); }); -test("runGSDDoctor issue objects have required fields", async () => { +test("runGSDDoctor issue objects have required fields", async (t) => { const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-fields-")); mkdirSync(join(tmp, ".gsd"), { recursive: true }); @@ -392,28 +372,25 @@ test("runGSDDoctor issue objects have required fields", async () => { mkdirSync(mDir, { recursive: true }); writeFileSync(join(mDir, "M001-CONTEXT.md"), "# Context\n"); - try { - const report = await runGSDDoctor(tmp, { fix: false }); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + const report = await runGSDDoctor(tmp, { fix: false }); - // Should find at least one issue (missing roadmap for M001) - assert.ok(report.issues.length > 0, "expected at least one issue for milestone missing ROADMAP.md"); + // Should find at least one issue (missing roadmap for M001) + assert.ok(report.issues.length > 0, "expected at least one issue for milestone missing ROADMAP.md"); - // Verify structure of each issue - for (const issue of report.issues) { - assert.ok(typeof issue.severity === "string", "issue.severity is a string"); - assert.ok(["info", "warning", "error"].includes(issue.severity), - `issue.severity '${issue.severity}' should be info|warning|error`); - assert.ok(typeof issue.code === "string", "issue.code is a string"); - assert.ok(typeof issue.message === "string", "issue.message is a string"); - assert.ok(issue.message.length > 0, "issue.message is non-empty"); - assert.ok(typeof issue.fixable === "boolean", "issue.fixable is a boolean"); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); + // Verify structure of each issue + for (const issue of report.issues) { + assert.ok(typeof issue.severity === "string", "issue.severity is a string"); + assert.ok(["info", "warning", "error"].includes(issue.severity), + `issue.severity '${issue.severity}' should be info|warning|error`); + assert.ok(typeof issue.code === "string", "issue.code is a string"); + assert.ok(typeof issue.message === "string", "issue.message is a string"); + assert.ok(issue.message.length > 0, "issue.message is non-empty"); + assert.ok(typeof issue.fixable === "boolean", "issue.fixable is a boolean"); } }); -test("runGSDDoctor with fix:false never modifies the filesystem", async () => { +test("runGSDDoctor with fix:false never modifies the filesystem", async (t) => { const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-readonly-")); const gsdDir = join(tmp, ".gsd"); @@ -423,13 +400,10 @@ test("runGSDDoctor with fix:false never modifies the filesystem", async () => { const sentinelPath = join(gsdDir, "SENTINEL.md"); writeFileSync(sentinelPath, "# sentinel\n"); - try { - await runGSDDoctor(tmp, { fix: false }); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + await runGSDDoctor(tmp, { fix: false }); - assert.ok(existsSync(sentinelPath), "sentinel file still exists after audit-only run"); - const content = readFileSync(sentinelPath, "utf-8"); - assert.equal(content, "# sentinel\n", "sentinel file content unchanged"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + assert.ok(existsSync(sentinelPath), "sentinel file still exists after audit-only run"); + const content = readFileSync(sentinelPath, "utf-8"); + assert.equal(content, "# sentinel\n", "sentinel file content unchanged"); }); diff --git a/src/tests/artifact-manager.test.ts b/src/tests/artifact-manager.test.ts index 426dbbf74..8fd89bcaa 100644 --- a/src/tests/artifact-manager.test.ts +++ b/src/tests/artifact-manager.test.ts @@ -23,144 +23,117 @@ function makeTmpSession(): { sessionFile: string; cleanup: () => void } { // save / getPath // ═══════════════════════════════════════════════════════════════════════════ -test('save creates artifact file with sequential ID', () => { +test('save creates artifact file with sequential ID', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - const id0 = mgr.save('output 0', 'bash') - const id1 = mgr.save('output 1', 'bash') + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + const id0 = mgr.save('output 0', 'bash') + const id1 = mgr.save('output 1', 'bash') - assert.equal(id0, '0') - assert.equal(id1, '1') + assert.equal(id0, '0') + assert.equal(id1, '1') - const path0 = mgr.getPath('0') - assert.ok(path0) - assert.equal(readFileSync(path0, 'utf-8'), 'output 0') + const path0 = mgr.getPath('0') + assert.ok(path0) + assert.equal(readFileSync(path0, 'utf-8'), 'output 0') - const path1 = mgr.getPath('1') - assert.ok(path1) - assert.equal(readFileSync(path1, 'utf-8'), 'output 1') - } finally { - cleanup() - } + const path1 = mgr.getPath('1') + assert.ok(path1) + assert.equal(readFileSync(path1, 'utf-8'), 'output 1') }) -test('artifact directory is named after session file without .jsonl', () => { +test('artifact directory is named after session file without .jsonl', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - const expectedDir = sessionFile.slice(0, -6) // strip .jsonl - assert.equal(mgr.dir, expectedDir) - } finally { - cleanup() - } + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + const expectedDir = sessionFile.slice(0, -6) // strip .jsonl + assert.equal(mgr.dir, expectedDir) }) -test('artifact directory is created lazily on first write', () => { +test('artifact directory is created lazily on first write', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - const artifactDir = mgr.dir + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + const artifactDir = mgr.dir - assert.equal(existsSync(artifactDir), false) - mgr.save('trigger creation', 'bash') - assert.ok(existsSync(artifactDir)) - } finally { - cleanup() - } + assert.equal(existsSync(artifactDir), false) + mgr.save('trigger creation', 'bash') + assert.ok(existsSync(artifactDir)) }) // ═══════════════════════════════════════════════════════════════════════════ // exists // ═══════════════════════════════════════════════════════════════════════════ -test('exists returns true for saved artifact', () => { +test('exists returns true for saved artifact', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - const id = mgr.save('content', 'bash') - assert.ok(mgr.exists(id)) - } finally { - cleanup() - } + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + const id = mgr.save('content', 'bash') + assert.ok(mgr.exists(id)) }) -test('exists returns false for missing artifact', () => { +test('exists returns false for missing artifact', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - assert.equal(mgr.exists('999'), false) - } finally { - cleanup() - } + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + assert.equal(mgr.exists('999'), false) }) // ═══════════════════════════════════════════════════════════════════════════ // allocatePath // ═══════════════════════════════════════════════════════════════════════════ -test('allocatePath returns path without writing', () => { +test('allocatePath returns path without writing', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - const { id, path } = mgr.allocatePath('fetch') + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + const { id, path } = mgr.allocatePath('fetch') - assert.equal(id, '0') - assert.ok(path.endsWith('0.fetch.log')) - // File should not exist yet — allocatePath doesn't write - assert.equal(existsSync(path), false) - } finally { - cleanup() - } + assert.equal(id, '0') + assert.ok(path.endsWith('0.fetch.log')) + // File should not exist yet — allocatePath doesn't write + assert.equal(existsSync(path), false) }) // ═══════════════════════════════════════════════════════════════════════════ // Session resume — ID continuity // ═══════════════════════════════════════════════════════════════════════════ -test('new manager picks up where previous left off', () => { +test('new manager picks up where previous left off', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr1 = new ArtifactManager(sessionFile) - mgr1.save('first', 'bash') - mgr1.save('second', 'bash') + t.after(cleanup); + const mgr1 = new ArtifactManager(sessionFile) + mgr1.save('first', 'bash') + mgr1.save('second', 'bash') - // Simulate session resume — new manager for same session file - const mgr2 = new ArtifactManager(sessionFile) - const id = mgr2.save('third', 'bash') + // Simulate session resume — new manager for same session file + const mgr2 = new ArtifactManager(sessionFile) + const id = mgr2.save('third', 'bash') - assert.equal(id, '2') // continues from 0, 1 → next is 2 - } finally { - cleanup() - } + assert.equal(id, '2') // continues from 0, 1 → next is 2 }) // ═══════════════════════════════════════════════════════════════════════════ // listFiles // ═══════════════════════════════════════════════════════════════════════════ -test('listFiles returns all artifact filenames', () => { +test('listFiles returns all artifact filenames', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - mgr.save('a', 'bash') - mgr.save('b', 'fetch') + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + mgr.save('a', 'bash') + mgr.save('b', 'fetch') - const files = mgr.listFiles() - assert.equal(files.length, 2) - assert.ok(files.some(f => f === '0.bash.log')) - assert.ok(files.some(f => f === '1.fetch.log')) - } finally { - cleanup() - } + const files = mgr.listFiles() + assert.equal(files.length, 2) + assert.ok(files.some(f => f === '0.bash.log')) + assert.ok(files.some(f => f === '1.fetch.log')) }) -test('listFiles returns empty for nonexistent dir', () => { +test('listFiles returns empty for nonexistent dir', (t) => { const { sessionFile, cleanup } = makeTmpSession() - try { - const mgr = new ArtifactManager(sessionFile) - assert.deepEqual(mgr.listFiles(), []) - } finally { - cleanup() - } + t.after(cleanup); + const mgr = new ArtifactManager(sessionFile) + assert.deepEqual(mgr.listFiles(), []) }) diff --git a/src/tests/bg-shell-session-cleanup.test.ts b/src/tests/bg-shell-session-cleanup.test.ts index 6ac74f7f1..9e3a51893 100644 --- a/src/tests/bg-shell-session-cleanup.test.ts +++ b/src/tests/bg-shell-session-cleanup.test.ts @@ -22,7 +22,8 @@ function isPidAlive(pid: number | undefined): boolean { // without relying on platform-specific quoting for `node -e "..."` const sleeperCommand = "sleep 30"; -test("cleanupSessionProcesses reaps only session-scoped processes from the previous session", async () => { +test("cleanupSessionProcesses reaps only session-scoped processes from the previous session", async (t) => { + t.after(cleanupAll); const owned = startProcess({ command: sleeperCommand, cwd: process.cwd(), @@ -40,22 +41,18 @@ test("cleanupSessionProcesses reaps only session-scoped processes from the previ ownerSessionFile: "session-b", }); - try { - await new Promise((resolve) => setTimeout(resolve, 150)); - assert.equal(isPidAlive(owned.proc.pid), true, "owned process should be alive before cleanup"); - assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should be alive before cleanup"); - assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should be alive before cleanup"); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(isPidAlive(owned.proc.pid), true, "owned process should be alive before cleanup"); + assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should be alive before cleanup"); + assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should be alive before cleanup"); - const removed = await cleanupSessionProcesses("session-a", { graceMs: 200 }); - assert.deepEqual(removed.sort(), [owned.id], "only the session-scoped process should be reaped"); + const removed = await cleanupSessionProcesses("session-a", { graceMs: 200 }); + assert.deepEqual(removed.sort(), [owned.id], "only the session-scoped process should be reaped"); - await new Promise((resolve) => setTimeout(resolve, 150)); - assert.equal(isPidAlive(owned.proc.pid), false, "owned process should be terminated"); - assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should survive cleanup"); - assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should survive cleanup"); - assert.equal(processes.get(owned.id)?.persistAcrossSessions, false); - assert.equal(processes.get(persistent.id)?.persistAcrossSessions, true); - } finally { - cleanupAll(); - } + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(isPidAlive(owned.proc.pid), false, "owned process should be terminated"); + assert.equal(isPidAlive(persistent.proc.pid), true, "persistent process should survive cleanup"); + assert.equal(isPidAlive(foreign.proc.pid), true, "foreign process should survive cleanup"); + assert.equal(processes.get(owned.id)?.persistAcrossSessions, false); + assert.equal(processes.get(persistent.id)?.persistAcrossSessions, true); }); diff --git a/src/tests/blob-store.test.ts b/src/tests/blob-store.test.ts index d5ad2cf41..6f2922b81 100644 --- a/src/tests/blob-store.test.ts +++ b/src/tests/blob-store.test.ts @@ -33,131 +33,101 @@ function sha256(data: Buffer): string { // BlobStore.put / get / has // ═══════════════════════════════════════════════════════════════════════════ -test('put stores data and returns correct hash', () => { +test('put stores data and returns correct hash', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const data = Buffer.from('hello world') - const result = store.put(data) + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('hello world') + const result = store.put(data) - assert.equal(result.hash, sha256(data)) - assert.ok(existsSync(result.path)) - assert.deepEqual(readFileSync(result.path), data) - } finally { - cleanup() - } + assert.equal(result.hash, sha256(data)) + assert.ok(existsSync(result.path)) + assert.deepEqual(readFileSync(result.path), data) }) -test('put is idempotent — same data returns same hash, no duplicate write', () => { +test('put is idempotent — same data returns same hash, no duplicate write', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const data = Buffer.from('duplicate test') - const r1 = store.put(data) - const r2 = store.put(data) + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('duplicate test') + const r1 = store.put(data) + const r2 = store.put(data) - assert.equal(r1.hash, r2.hash) - assert.equal(r1.path, r2.path) - } finally { - cleanup() - } + assert.equal(r1.hash, r2.hash) + assert.equal(r1.path, r2.path) }) -test('get retrieves stored data', () => { +test('get retrieves stored data', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const data = Buffer.from('retrieve me') - const { hash } = store.put(data) - const retrieved = store.get(hash) + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('retrieve me') + const { hash } = store.put(data) + const retrieved = store.get(hash) - assert.deepEqual(retrieved, data) - } finally { - cleanup() - } + assert.deepEqual(retrieved, data) }) -test('get returns null for nonexistent hash', () => { +test('get returns null for nonexistent hash', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const fakeHash = 'a'.repeat(64) - assert.equal(store.get(fakeHash), null) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const fakeHash = 'a'.repeat(64) + assert.equal(store.get(fakeHash), null) }) -test('has returns true for stored blob', () => { +test('has returns true for stored blob', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const { hash } = store.put(Buffer.from('exists')) - assert.ok(store.has(hash)) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const { hash } = store.put(Buffer.from('exists')) + assert.ok(store.has(hash)) }) -test('has returns false for missing blob', () => { +test('has returns false for missing blob', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - assert.equal(store.has('b'.repeat(64)), false) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.has('b'.repeat(64)), false) }) -test('ref property returns correct blob: URI', () => { +test('ref property returns correct blob: URI', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const data = Buffer.from('ref test') - const result = store.put(data) - assert.equal(result.ref, `blob:sha256:${result.hash}`) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const data = Buffer.from('ref test') + const result = store.put(data) + assert.equal(result.ref, `blob:sha256:${result.hash}`) }) // ═══════════════════════════════════════════════════════════════════════════ // Path traversal protection // ═══════════════════════════════════════════════════════════════════════════ -test('get rejects non-hex hash (path traversal attempt)', () => { +test('get rejects non-hex hash (path traversal attempt)', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - assert.equal(store.get('../../etc/passwd'), null) - assert.equal(store.get('../../../foo'), null) - assert.equal(store.get('not-a-valid-hash'), null) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.get('../../etc/passwd'), null) + assert.equal(store.get('../../../foo'), null) + assert.equal(store.get('not-a-valid-hash'), null) }) -test('has rejects non-hex hash', () => { +test('has rejects non-hex hash', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - assert.equal(store.has('../../etc/passwd'), false) - assert.equal(store.has('short'), false) - assert.equal(store.has('Z'.repeat(64)), false) // uppercase not valid - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.has('../../etc/passwd'), false) + assert.equal(store.has('short'), false) + assert.equal(store.has('Z'.repeat(64)), false) // uppercase not valid }) -test('get rejects hash with wrong length', () => { +test('get rejects hash with wrong length', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - assert.equal(store.get('a'.repeat(63)), null) // too short - assert.equal(store.get('a'.repeat(65)), null) // too long - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(store.get('a'.repeat(63)), null) // too short + assert.equal(store.get('a'.repeat(65)), null) // too long }) // ═══════════════════════════════════════════════════════════════════════════ @@ -190,62 +160,47 @@ test('parseBlobRef rejects invalid hash format', () => { // externalizeImageData / resolveImageData // ═══════════════════════════════════════════════════════════════════════════ -test('externalizeImageData stores base64 and returns blob ref', () => { +test('externalizeImageData stores base64 and returns blob ref', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const base64 = Buffer.from('image bytes').toString('base64') - const ref = externalizeImageData(store, base64) + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const base64 = Buffer.from('image bytes').toString('base64') + const ref = externalizeImageData(store, base64) - assert.ok(ref.startsWith('blob:sha256:')) - assert.ok(store.has(parseBlobRef(ref)!)) - } finally { - cleanup() - } + assert.ok(ref.startsWith('blob:sha256:')) + assert.ok(store.has(parseBlobRef(ref)!)) }) -test('externalizeImageData passes through existing blob refs', () => { +test('externalizeImageData passes through existing blob refs', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const existingRef = `blob:sha256:${'c'.repeat(64)}` - assert.equal(externalizeImageData(store, existingRef), existingRef) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const existingRef = `blob:sha256:${'c'.repeat(64)}` + assert.equal(externalizeImageData(store, existingRef), existingRef) }) -test('resolveImageData round-trips with externalizeImageData', () => { +test('resolveImageData round-trips with externalizeImageData', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const base64 = Buffer.from('round trip test').toString('base64') - const ref = externalizeImageData(store, base64) - const resolved = resolveImageData(store, ref) + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const base64 = Buffer.from('round trip test').toString('base64') + const ref = externalizeImageData(store, base64) + const resolved = resolveImageData(store, ref) - assert.equal(resolved, base64) - } finally { - cleanup() - } + assert.equal(resolved, base64) }) -test('resolveImageData returns non-ref strings unchanged', () => { +test('resolveImageData returns non-ref strings unchanged', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - assert.equal(resolveImageData(store, 'plain text'), 'plain text') - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + assert.equal(resolveImageData(store, 'plain text'), 'plain text') }) -test('resolveImageData returns ref unchanged when blob is missing', () => { +test('resolveImageData returns ref unchanged when blob is missing', (t) => { const { dir, cleanup } = makeTmpDir() - try { - const store = new BlobStore(join(dir, 'blobs')) - const missingRef = `blob:sha256:${'d'.repeat(64)}` - assert.equal(resolveImageData(store, missingRef), missingRef) - } finally { - cleanup() - } + t.after(cleanup); + const store = new BlobStore(join(dir, 'blobs')) + const missingRef = `blob:sha256:${'d'.repeat(64)}` + assert.equal(resolveImageData(store, missingRef), missingRef) }) diff --git a/src/tests/extension-discovery.test.ts b/src/tests/extension-discovery.test.ts index b3744c5ba..03bc8bdd8 100644 --- a/src/tests/extension-discovery.test.ts +++ b/src/tests/extension-discovery.test.ts @@ -12,110 +12,89 @@ function makeTempDir(): string { } describe('resolveExtensionEntries', () => { - test('returns index.ts when no package.json exists', () => { + test('returns index.ts when no package.json exists', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'index.ts'), 'export default function() {}') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 1) - assert.ok(entries[0].endsWith('index.ts')) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'index.ts'), 'export default function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.ts')) }) - test('returns index.js when no package.json and no index.ts', () => { + test('returns index.js when no package.json and no index.ts', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'index.js'), 'module.exports = function() {}') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 1) - assert.ok(entries[0].endsWith('index.js')) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'index.js'), 'module.exports = function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.js')) }) - test('returns declared extensions from pi.extensions array', () => { + test('returns declared extensions from pi.extensions array', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ - pi: { extensions: ['main.js'] } - })) - writeFileSync(join(dir, 'main.js'), 'module.exports = function() {}') - writeFileSync(join(dir, 'index.js'), 'should not be returned') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 1) - assert.ok(entries[0].endsWith('main.js')) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + pi: { extensions: ['main.js'] } + })) + writeFileSync(join(dir, 'main.js'), 'module.exports = function() {}') + writeFileSync(join(dir, 'index.js'), 'should not be returned') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('main.js')) }) - test('returns empty array when pi manifest has no extensions (library opt-out)', () => { + test('returns empty array when pi manifest has no extensions (library opt-out)', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ - name: '@gsd/cmux', - pi: {} - })) - writeFileSync(join(dir, 'index.js'), 'export function utility() {}') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 0, 'pi: {} should opt out of extension discovery') - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: '@gsd/cmux', + pi: {} + })) + writeFileSync(join(dir, 'index.js'), 'export function utility() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 0, 'pi: {} should opt out of extension discovery') }) - test('returns empty array when pi.extensions is an empty array', () => { + test('returns empty array when pi.extensions is an empty array', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ - pi: { extensions: [] } - })) - writeFileSync(join(dir, 'index.js'), 'should not be returned') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 0) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + pi: { extensions: [] } + })) + writeFileSync(join(dir, 'index.js'), 'should not be returned') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 0) }) - test('falls back to index.ts when package.json has no pi field', () => { + test('falls back to index.ts when package.json has no pi field', (t) => { const dir = makeTempDir() - try { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'some-pkg' })) - writeFileSync(join(dir, 'index.ts'), 'export default function() {}') - const entries = resolveExtensionEntries(dir) - assert.equal(entries.length, 1) - assert.ok(entries[0].endsWith('index.ts')) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'some-pkg' })) + writeFileSync(join(dir, 'index.ts'), 'export default function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.ts')) }) }) describe('discoverExtensionEntryPaths', () => { - test('skips library directories with pi: {} opt-out', () => { + test('skips library directories with pi: {} opt-out', (t) => { const root = makeTempDir() - try { - // Real extension - const extDir = join(root, 'my-ext') - mkdirSync(extDir) - writeFileSync(join(extDir, 'index.js'), 'module.exports = function() {}') + t.after(() => rmSync(root, { recursive: true, force: true })); + // Real extension + const extDir = join(root, 'my-ext') + mkdirSync(extDir) + writeFileSync(join(extDir, 'index.js'), 'module.exports = function() {}') - // Library with opt-out (like cmux) - const libDir = join(root, 'cmux') - mkdirSync(libDir) - writeFileSync(join(libDir, 'package.json'), JSON.stringify({ pi: {} })) - writeFileSync(join(libDir, 'index.js'), 'export function utility() {}') + // Library with opt-out (like cmux) + const libDir = join(root, 'cmux') + mkdirSync(libDir) + writeFileSync(join(libDir, 'package.json'), JSON.stringify({ pi: {} })) + writeFileSync(join(libDir, 'index.js'), 'export function utility() {}') - const paths = discoverExtensionEntryPaths(root) - assert.equal(paths.length, 1, 'should discover my-ext but skip cmux') - assert.ok(paths[0].includes('my-ext')) - assert.ok(!paths.some(p => p.includes('cmux')), 'cmux should not be discovered') - } finally { - rmSync(root, { recursive: true, force: true }) - } + const paths = discoverExtensionEntryPaths(root) + assert.equal(paths.length, 1, 'should discover my-ext but skip cmux') + assert.ok(paths[0].includes('my-ext')) + assert.ok(!paths.some(p => p.includes('cmux')), 'cmux should not be discovered') }) }) diff --git a/src/tests/google-search-auth.repro.test.ts b/src/tests/google-search-auth.repro.test.ts index 69198845b..309bbb72b 100644 --- a/src/tests/google-search-auth.repro.test.ts +++ b/src/tests/google-search-auth.repro.test.ts @@ -38,7 +38,7 @@ function mockModelRegistry(oauthJson?: string) { }; } -test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async () => { +test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async (t) => { const originalKey = process.env.GEMINI_API_KEY; delete process.env.GEMINI_API_KEY; @@ -61,71 +61,64 @@ test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async () => { }; }; - try { - const pi = createMockPI(); - googleSearchExtension(pi as any); - - const oauthJson = JSON.stringify({ token: "mock-token", projectId: "mock-project" }); - const mockCtx = { - ui: { notify() {} }, - modelRegistry: mockModelRegistry(oauthJson), - }; - - await pi.fire("session_start", {}, mockCtx); - const registeredTool = (pi as any).registeredTool; - const result = await registeredTool.execute("call-1", { query: "test" }, new AbortController().signal, () => {}, mockCtx); - - assert.equal(result.isError, undefined); - assert.ok(result.content[0].text.includes("Mocked AI Answer")); - } finally { + t.after(() => { global.fetch = originalFetch; process.env.GEMINI_API_KEY = originalKey; - } + }); + const pi = createMockPI(); + googleSearchExtension(pi as any); + + const oauthJson = JSON.stringify({ token: "mock-token", projectId: "mock-project" }); + const mockCtx = { + ui: { notify() {} }, + modelRegistry: mockModelRegistry(oauthJson), + }; + + await pi.fire("session_start", {}, mockCtx); + const registeredTool = (pi as any).registeredTool; + const result = await registeredTool.execute("call-1", { query: "test" }, new AbortController().signal, () => {}, mockCtx); + + assert.equal(result.isError, undefined); + assert.ok(result.content[0].text.includes("Mocked AI Answer")); }); -test("google-search warns if NO authentication is present", async () => { +test("google-search warns if NO authentication is present", async (t) => { const originalKey = process.env.GEMINI_API_KEY; delete process.env.GEMINI_API_KEY; - try { - const pi = createMockPI(); - googleSearchExtension(pi as any); + t.after(() => process.env.GEMINI_API_KEY = originalKey); + const pi = createMockPI(); + googleSearchExtension(pi as any); - const notifications: any[] = []; - const mockCtx = { - ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, - modelRegistry: mockModelRegistry(undefined), - }; + const notifications: any[] = []; + const mockCtx = { + ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, + modelRegistry: mockModelRegistry(undefined), + }; - await pi.fire("session_start", {}, mockCtx); - assert.equal(notifications.length, 1); - assert.ok(notifications[0].msg.includes("No authentication set")); + await pi.fire("session_start", {}, mockCtx); + assert.equal(notifications.length, 1); + assert.ok(notifications[0].msg.includes("No authentication set")); - const registeredTool = (pi as any).registeredTool; - const result = await registeredTool.execute("call-2", { query: "test" }, new AbortController().signal, () => {}, mockCtx); - assert.equal(result.isError, true); - assert.ok(result.content[0].text.includes("No authentication found")); - } finally { - process.env.GEMINI_API_KEY = originalKey; - } + const registeredTool = (pi as any).registeredTool; + const result = await registeredTool.execute("call-2", { query: "test" }, new AbortController().signal, () => {}, mockCtx); + assert.equal(result.isError, true); + assert.ok(result.content[0].text.includes("No authentication found")); }); -test("google-search uses GEMINI_API_KEY if present (precedence)", async () => { +test("google-search uses GEMINI_API_KEY if present (precedence)", async (t) => { process.env.GEMINI_API_KEY = "mock-api-key"; - try { - const pi = createMockPI(); - googleSearchExtension(pi as any); + t.after(() => delete process.env.GEMINI_API_KEY); + const pi = createMockPI(); + googleSearchExtension(pi as any); - const notifications: any[] = []; - const mockCtx = { - ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, - modelRegistry: mockModelRegistry(JSON.stringify({ token: "should-not-be-used", projectId: "mock-project" })), - }; + const notifications: any[] = []; + const mockCtx = { + ui: { notify(msg: string, level: string) { notifications.push({ msg, level }); } }, + modelRegistry: mockModelRegistry(JSON.stringify({ token: "should-not-be-used", projectId: "mock-project" })), + }; - await pi.fire("session_start", {}, mockCtx); - assert.equal(notifications.length, 0, "Should NOT notify if API Key is present"); - } finally { - delete process.env.GEMINI_API_KEY; - } + await pi.fire("session_start", {}, mockCtx); + assert.equal(notifications.length, 0, "Should NOT notify if API Key is present"); }); diff --git a/src/tests/llm-context-tavily.test.ts b/src/tests/llm-context-tavily.test.ts index 3e62093f7..e4a14ce3e 100644 --- a/src/tests/llm-context-tavily.test.ts +++ b/src/tests/llm-context-tavily.test.ts @@ -306,7 +306,7 @@ test("no-key error message mentions both TAVILY_API_KEY and BRAVE_API_KEY", () = assert.ok(errorMessage.includes("secure_env_collect"), "Error must mention secure_env_collect"); }); -test("Tavily LLM context request uses POST with Bearer auth and advanced search depth", async () => { +test("Tavily LLM context request uses POST with Bearer auth and advanced search depth", async (t) => { const apiKey = "tvly-test-key-abc123"; const query = "typescript handbook"; @@ -318,43 +318,40 @@ test("Tavily LLM context request uses POST with Bearer auth and advanced search const { captured, restore } = mockFetch(tavilyResponse); - try { - // Simulate what the Tavily LLM context path will build - const requestBody = { - query, - max_results: 20, - search_depth: "advanced", - include_raw_content: true, - }; + t.after(restore); + // Simulate what the Tavily LLM context path will build + const requestBody = { + query, + max_results: 20, + search_depth: "advanced", + include_raw_content: true, + }; - await globalThis.fetch("https://api.tavily.com/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey}`, - }, - body: JSON.stringify(requestBody), - }); + await globalThis.fetch("https://api.tavily.com/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + }); - // Verify POST method - assert.equal(captured.method, "POST", "Tavily uses POST"); + // Verify POST method + assert.equal(captured.method, "POST", "Tavily uses POST"); - // Verify Bearer auth header - assert.equal( - captured.headers?.["Authorization"], - "Bearer tvly-test-key-abc123", - "Authorization header uses Bearer scheme", - ); + // Verify Bearer auth header + assert.equal( + captured.headers?.["Authorization"], + "Bearer tvly-test-key-abc123", + "Authorization header uses Bearer scheme", + ); - // Verify advanced search depth for LLM context (richer content) - assert.equal(captured.body?.search_depth, "advanced", "LLM context uses advanced search depth"); + // Verify advanced search depth for LLM context (richer content) + assert.equal(captured.body?.search_depth, "advanced", "LLM context uses advanced search depth"); - // Verify include_raw_content for full page text - assert.equal(captured.body?.include_raw_content, true, "LLM context requests raw_content"); + // Verify include_raw_content for full page text + assert.equal(captured.body?.include_raw_content, true, "LLM context requests raw_content"); - // Verify POST target URL - assert.equal(captured.url, "https://api.tavily.com/search", "Posts to Tavily search endpoint"); - } finally { - restore(); - } + // Verify POST target URL + assert.equal(captured.url, "https://api.tavily.com/search", "Posts to Tavily search endpoint"); }); diff --git a/src/tests/marketplace-discovery.test.ts b/src/tests/marketplace-discovery.test.ts index 538497b88..80e61f443 100644 --- a/src/tests/marketplace-discovery.test.ts +++ b/src/tests/marketplace-discovery.test.ts @@ -257,60 +257,51 @@ describe('Marketplace Discovery Contract Tests', { skip: skipReason }, () => { assert.strictEqual(result.summary.error, 0); }); - it('should return error for directory without marketplace.json', () => { + it('should return error for directory without marketplace.json', (t) => { // Create a temp directory without marketplace.json const tmpDir = '/tmp/test-no-marketplace-' + Date.now(); fs.mkdirSync(tmpDir, { recursive: true }); - try { - const result = discoverMarketplace(tmpDir); - - assert.strictEqual(result.status, 'error'); - assert.ok(result.error, 'Error message should be present'); - assert.ok(result.error.includes('not found'), - `Error should mention 'not found', got: ${result.error}`); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const result = discoverMarketplace(tmpDir); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error, 'Error message should be present'); + assert.ok(result.error.includes('not found'), + `Error should mention 'not found', got: ${result.error}`); }); - it('should return error for malformed marketplace.json', () => { + it('should return error for malformed marketplace.json', (t) => { const tmpDir = '/tmp/test-malformed-marketplace-' + Date.now(); fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', '{ this is not valid json }'); - try { - const result = discoverMarketplace(tmpDir); - - assert.strictEqual(result.status, 'error'); - assert.ok(result.error, 'Error message should be present'); - assert.ok(result.error.includes('Failed to parse'), - `Error should mention 'Failed to parse', got: ${result.error}`); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const result = discoverMarketplace(tmpDir); + + assert.strictEqual(result.status, 'error'); + assert.ok(result.error, 'Error message should be present'); + assert.ok(result.error.includes('Failed to parse'), + `Error should mention 'Failed to parse', got: ${result.error}`); }); - it('should return error for marketplace.json missing required fields', () => { + it('should return error for marketplace.json missing required fields', (t) => { const tmpDir = '/tmp/test-invalid-marketplace-' + Date.now(); fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); // Valid JSON but missing required 'name' and 'plugins' fields fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', JSON.stringify({ description: 'test' })); - try { - const parseResult = parseMarketplaceJson(tmpDir); - - assert.strictEqual(parseResult.success, false); - if (!parseResult.success) { - assert.ok(parseResult.error.includes('missing'), - `Error should mention missing field, got: ${parseResult.error}`); - } - } finally { - fs.rmSync(tmpDir, { recursive: true }); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const parseResult = parseMarketplaceJson(tmpDir); + + assert.strictEqual(parseResult.success, false); + if (!parseResult.success) { + assert.ok(parseResult.error.includes('missing'), + `Error should mention missing field, got: ${parseResult.error}`); } }); - it('should handle missing plugin directory gracefully', () => { + it('should handle missing plugin directory gracefully', (t) => { const tmpDir = '/tmp/test-missing-plugin-' + Date.now(); fs.mkdirSync(tmpDir + '/.claude-plugin', { recursive: true }); fs.writeFileSync(tmpDir + '/.claude-plugin/marketplace.json', JSON.stringify({ @@ -320,21 +311,18 @@ describe('Marketplace Discovery Contract Tests', { skip: skipReason }, () => { ] })); - try { - const result = discoverMarketplace(tmpDir); - - // Marketplace should parse ok, but the missing plugin should have error status - assert.strictEqual(result.status, 'error'); // Because one plugin has error - - const missingPlugin = result.plugins.find(p => p.name === 'missing-plugin'); - assert.ok(missingPlugin, 'Missing plugin should be in results'); - assert.strictEqual(missingPlugin.status, 'error'); - assert.ok(missingPlugin.error, 'Missing plugin should have error message'); - assert.ok(missingPlugin.error.includes('not found'), - `Error should mention 'not found', got: ${missingPlugin.error}`); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const result = discoverMarketplace(tmpDir); + + // Marketplace should parse ok, but the missing plugin should have error status + assert.strictEqual(result.status, 'error'); // Because one plugin has error + + const missingPlugin = result.plugins.find(p => p.name === 'missing-plugin'); + assert.ok(missingPlugin, 'Missing plugin should be in results'); + assert.strictEqual(missingPlugin.status, 'error'); + assert.ok(missingPlugin.error, 'Missing plugin should have error message'); + assert.ok(missingPlugin.error.includes('not found'), + `Error should mention 'not found', got: ${missingPlugin.error}`); }); }); diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index 725c28f66..55c964f79 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -295,94 +295,91 @@ test("before_provider_request skips when payload is falsy", async () => { assert.equal(result, undefined, "Should return undefined for null payload"); }); -test("model_select disables Brave tools when Anthropic + no BRAVE_API_KEY", async () => { +test("model_select disables Brave tools when Anthropic + no BRAVE_API_KEY", async (t) => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - - const active = pi.getActiveTools(); - assert.ok(!active.includes("search-the-web"), "search-the-web should be disabled"); - assert.ok(!active.includes("search_and_read"), "search_and_read should be disabled"); - assert.ok(!active.includes("google_search"), "google_search should be disabled"); - assert.ok(active.includes("fetch_page"), "fetch_page should remain active"); - assert.ok(active.includes("bash"), "Other tools should remain active"); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const active = pi.getActiveTools(); + assert.ok(!active.includes("search-the-web"), "search-the-web should be disabled"); + assert.ok(!active.includes("search_and_read"), "search_and_read should be disabled"); + assert.ok(!active.includes("google_search"), "google_search should be disabled"); + assert.ok(active.includes("fetch_page"), "fetch_page should remain active"); + assert.ok(active.includes("bash"), "Other tools should remain active"); }); -test("model_select disables all custom search tools when Anthropic even with BRAVE_API_KEY", async () => { +test("model_select disables all custom search tools when Anthropic even with BRAVE_API_KEY", async (t) => { const originalKey = process.env.BRAVE_API_KEY; process.env.BRAVE_API_KEY = "test-key"; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - - const active = pi.getActiveTools(); - assert.ok(!active.includes("search-the-web"), "search-the-web should be disabled for Anthropic"); - assert.ok(!active.includes("search_and_read"), "search_and_read should be disabled for Anthropic"); - assert.ok(!active.includes("google_search"), "google_search should be disabled for Anthropic"); - assert.ok(active.includes("fetch_page"), "fetch_page should remain active"); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const active = pi.getActiveTools(); + assert.ok(!active.includes("search-the-web"), "search-the-web should be disabled for Anthropic"); + assert.ok(!active.includes("search_and_read"), "search_and_read should be disabled for Anthropic"); + assert.ok(!active.includes("google_search"), "google_search should be disabled for Anthropic"); + assert.ok(active.includes("fetch_page"), "fetch_page should remain active"); }); -test("model_select re-enables Brave tools when switching away from Anthropic", async () => { +test("model_select re-enables Brave tools when switching away from Anthropic", async (t) => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - // First: select Anthropic — disables Brave tools - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - - let active = pi.getActiveTools(); - assert.ok(!active.includes("search-the-web"), "Should disable after Anthropic select"); - - // Second: switch to non-Anthropic — re-enables - await pi.fire("model_select", { - type: "model_select", - model: { provider: "openai", name: "gpt-4o" }, - previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, - source: "set", - }); - - active = pi.getActiveTools(); - assert.ok(active.includes("search-the-web"), "search-the-web should be re-enabled"); - assert.ok(active.includes("search_and_read"), "search_and_read should be re-enabled"); - assert.ok(active.includes("google_search"), "google_search should be re-enabled"); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // First: select Anthropic — disables Brave tools + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + let active = pi.getActiveTools(); + assert.ok(!active.includes("search-the-web"), "Should disable after Anthropic select"); + + // Second: switch to non-Anthropic — re-enables + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + + active = pi.getActiveTools(); + assert.ok(active.includes("search-the-web"), "search-the-web should be re-enabled"); + assert.ok(active.includes("search_and_read"), "search_and_read should be re-enabled"); + assert.ok(active.includes("google_search"), "google_search should be re-enabled"); }); test("model_select shows 'Native Anthropic web search active' for Anthropic provider", async () => { @@ -406,31 +403,30 @@ test("model_select shows 'Native Anthropic web search active' for Anthropic prov ); }); -test("model_select shows warning for non-Anthropic without Brave key", async () => { +test("model_select shows warning for non-Anthropic without Brave key", async (t) => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - await pi.fire("model_select", { - type: "model_select", - model: { provider: "openai", name: "gpt-4o" }, - previousModel: undefined, - source: "set", - }); - - const warning = pi.notifications.find((n) => n.level === "warning"); - assert.ok(warning, "Should show warning for non-Anthropic without Brave key"); - assert.ok( - warning!.message.includes("Anthropic"), - `Warning should mention Anthropic — got: ${warning!.message}` - ); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: undefined, + source: "set", + }); + + const warning = pi.notifications.find((n) => n.level === "warning"); + assert.ok(warning, "Should show warning for non-Anthropic without Brave key"); + assert.ok( + warning!.message.includes("Anthropic"), + `Warning should mention Anthropic — got: ${warning!.message}` + ); }); test("session_start resets search count and shows no startup notification", async () => { @@ -454,160 +450,157 @@ test("CUSTOM_SEARCH_TOOL_NAMES contains all custom search tools", () => { assert.deepEqual(CUSTOM_SEARCH_TOOL_NAMES, ["search-the-web", "search_and_read", "google_search"]); }); -test("before_provider_request removes Brave tools from payload when no BRAVE_API_KEY", async () => { +test("before_provider_request removes Brave tools from payload when no BRAVE_API_KEY", async (t) => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - - const payload: Record = { - model: "claude-sonnet-4-6-20250514", - tools: [ - { name: "bash", type: "function" }, - { name: "search-the-web", type: "function" }, - { name: "search_and_read", type: "function" }, - { name: "google_search", type: "function" }, - { name: "fetch_page", type: "function" }, - ], - }; - - const result = await pi.fire("before_provider_request", { - type: "before_provider_request", - payload, - }); - - const tools = ((result as any)?.tools ?? payload.tools) as any[]; - const names = tools.map((t: any) => t.name); - - assert.ok(!names.includes("search-the-web"), "search-the-web should be removed from payload"); - assert.ok(!names.includes("search_and_read"), "search_and_read should be removed from payload"); - assert.ok(!names.includes("google_search"), "google_search should be removed from payload"); - assert.ok(names.includes("bash"), "bash should remain"); - assert.ok(names.includes("fetch_page"), "fetch_page should remain"); - assert.ok(names.includes("web_search"), "native web_search should be injected"); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [ + { name: "bash", type: "function" }, + { name: "search-the-web", type: "function" }, + { name: "search_and_read", type: "function" }, + { name: "google_search", type: "function" }, + { name: "fetch_page", type: "function" }, + ], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = ((result as any)?.tools ?? payload.tools) as any[]; + const names = tools.map((t: any) => t.name); + + assert.ok(!names.includes("search-the-web"), "search-the-web should be removed from payload"); + assert.ok(!names.includes("search_and_read"), "search_and_read should be removed from payload"); + assert.ok(!names.includes("google_search"), "google_search should be removed from payload"); + assert.ok(names.includes("bash"), "bash should remain"); + assert.ok(names.includes("fetch_page"), "fetch_page should remain"); + assert.ok(names.includes("web_search"), "native web_search should be injected"); }); -test("before_provider_request removes all custom search tools from payload even with BRAVE_API_KEY", async () => { +test("before_provider_request removes all custom search tools from payload even with BRAVE_API_KEY", async (t) => { const originalKey = process.env.BRAVE_API_KEY; process.env.BRAVE_API_KEY = "test-key"; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - - const payload: Record = { - model: "claude-sonnet-4-6-20250514", - tools: [ - { name: "search-the-web", type: "function" }, - { name: "search_and_read", type: "function" }, - { name: "google_search", type: "function" }, - { name: "fetch_page", type: "function" }, - ], - }; - - const result = await pi.fire("before_provider_request", { - type: "before_provider_request", - payload, - }); - - const tools = ((result as any)?.tools ?? payload.tools) as any[]; - const names = tools.map((t: any) => t.name); - - assert.ok(!names.includes("search-the-web"), "search-the-web should be removed for Anthropic"); - assert.ok(!names.includes("search_and_read"), "search_and_read should be removed for Anthropic"); - assert.ok(!names.includes("google_search"), "google_search should be removed for Anthropic"); - assert.ok(names.includes("fetch_page"), "fetch_page should remain"); - assert.ok(names.includes("web_search"), "native web_search should be injected"); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [ + { name: "search-the-web", type: "function" }, + { name: "search_and_read", type: "function" }, + { name: "google_search", type: "function" }, + { name: "fetch_page", type: "function" }, + ], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = ((result as any)?.tools ?? payload.tools) as any[]; + const names = tools.map((t: any) => t.name); + + assert.ok(!names.includes("search-the-web"), "search-the-web should be removed for Anthropic"); + assert.ok(!names.includes("search_and_read"), "search_and_read should be removed for Anthropic"); + assert.ok(!names.includes("google_search"), "google_search should be removed for Anthropic"); + assert.ok(names.includes("fetch_page"), "fetch_page should remain"); + assert.ok(names.includes("web_search"), "native web_search should be injected"); }); // ─── BUG-1 regression: duplicate Brave tools on repeated provider toggle ──── -test("model_select re-enable does not duplicate Brave tools across toggle cycles", async () => { +test("model_select re-enable does not duplicate Brave tools across toggle cycles", async (t) => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; - try { - const pi = createMockPI(); - registerNativeSearchHooks(pi); - - // Cycle 1: Anthropic disables Brave tools - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: undefined, - source: "set", - }); - assert.ok(!pi.getActiveTools().includes("search-the-web"), "Disabled after 1st Anthropic select"); - - // Cycle 1: switch away re-enables - await pi.fire("model_select", { - type: "model_select", - model: { provider: "openai", name: "gpt-4o" }, - previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, - source: "set", - }); - let active = pi.getActiveTools(); - assert.equal( - active.filter((t) => t === "search-the-web").length, 1, - "search-the-web exactly once after first re-enable" - ); - - // Cycle 2: Anthropic again - await pi.fire("model_select", { - type: "model_select", - model: { provider: "anthropic", name: "claude-sonnet-4-6" }, - previousModel: { provider: "openai", name: "gpt-4o" }, - source: "set", - }); - - // Cycle 2: switch away again — must NOT accumulate duplicates - await pi.fire("model_select", { - type: "model_select", - model: { provider: "openai", name: "gpt-4o" }, - previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, - source: "set", - }); - active = pi.getActiveTools(); - assert.equal( - active.filter((t) => t === "search-the-web").length, 1, - "search-the-web exactly once after second re-enable (no duplicates)" - ); - assert.equal( - active.filter((t) => t === "search_and_read").length, 1, - "search_and_read exactly once (no duplicates)" - ); - assert.equal( - active.filter((t) => t === "google_search").length, 1, - "google_search exactly once (no duplicates)" - ); - } finally { + t.after(() => { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; - } + }); + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // Cycle 1: Anthropic disables Brave tools + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + assert.ok(!pi.getActiveTools().includes("search-the-web"), "Disabled after 1st Anthropic select"); + + // Cycle 1: switch away re-enables + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + let active = pi.getActiveTools(); + assert.equal( + active.filter((t) => t === "search-the-web").length, 1, + "search-the-web exactly once after first re-enable" + ); + + // Cycle 2: Anthropic again + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: { provider: "openai", name: "gpt-4o" }, + source: "set", + }); + + // Cycle 2: switch away again — must NOT accumulate duplicates + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + active = pi.getActiveTools(); + assert.equal( + active.filter((t) => t === "search-the-web").length, 1, + "search-the-web exactly once after second re-enable (no duplicates)" + ); + assert.equal( + active.filter((t) => t === "search_and_read").length, 1, + "search_and_read exactly once (no duplicates)" + ); + assert.equal( + active.filter((t) => t === "google_search").length, 1, + "google_search exactly once (no duplicates)" + ); }); // ─── BUG-3 regression: mock fire() must call all handlers, not just first ─── diff --git a/src/tests/node-modules-symlink.test.ts b/src/tests/node-modules-symlink.test.ts index 4f2f2230e..ef0bdf724 100644 --- a/src/tests/node-modules-symlink.test.ts +++ b/src/tests/node-modules-symlink.test.ts @@ -4,113 +4,101 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, sy import { join } from "node:path"; import { tmpdir } from "node:os"; -test("initResources creates node_modules symlink in agent dir", async () => { +test("initResources creates node_modules symlink in agent dir", async (t) => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-symlink-")); const fakeAgentDir = join(tmp, "agent"); - try { - initResources(fakeAgentDir); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + initResources(fakeAgentDir); - const nodeModulesPath = join(fakeAgentDir, "node_modules"); - // Use lstatSync instead of existsSync — existsSync follows the symlink and - // returns false for dangling symlinks (e.g. in worktrees without node_modules) - let stat; - try { - stat = lstatSync(nodeModulesPath); - } catch { - assert.fail("node_modules symlink should exist after initResources"); - } - assert.equal(stat.isSymbolicLink(), true, "node_modules should be a symlink, not a real directory"); - } finally { - rmSync(tmp, { recursive: true, force: true }); + const nodeModulesPath = join(fakeAgentDir, "node_modules"); + // Use lstatSync instead of existsSync — existsSync follows the symlink and + // returns false for dangling symlinks (e.g. in worktrees without node_modules) + let stat; + try { + stat = lstatSync(nodeModulesPath); + } catch { + assert.fail("node_modules symlink should exist after initResources"); } + assert.equal(stat.isSymbolicLink(), true, "node_modules should be a symlink, not a real directory"); }); -test("initResources replaces a real directory blocking node_modules with a symlink", async () => { +test("initResources replaces a real directory blocking node_modules with a symlink", async (t) => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-symlink-realdir-")); const fakeAgentDir = join(tmp, "agent"); - try { - // First call to set up agent dir structure - initResources(fakeAgentDir); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + // First call to set up agent dir structure + initResources(fakeAgentDir); - const nodeModulesPath = join(fakeAgentDir, "node_modules"); + const nodeModulesPath = join(fakeAgentDir, "node_modules"); - // Remove the symlink and replace with a real directory - rmSync(nodeModulesPath, { recursive: true, force: true }); - mkdirSync(nodeModulesPath, { recursive: true }); + // Remove the symlink and replace with a real directory + rmSync(nodeModulesPath, { recursive: true, force: true }); + mkdirSync(nodeModulesPath, { recursive: true }); - const statBefore = lstatSync(nodeModulesPath); - assert.equal(statBefore.isSymbolicLink(), false, "should be a real directory before fix"); - assert.equal(statBefore.isDirectory(), true, "should be a real directory before fix"); + const statBefore = lstatSync(nodeModulesPath); + assert.equal(statBefore.isSymbolicLink(), false, "should be a real directory before fix"); + assert.equal(statBefore.isDirectory(), true, "should be a real directory before fix"); - // Second call should replace the real directory with a symlink - initResources(fakeAgentDir); + // Second call should replace the real directory with a symlink + initResources(fakeAgentDir); - const statAfter = lstatSync(nodeModulesPath); - assert.equal(statAfter.isSymbolicLink(), true, "real directory should be replaced with symlink"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const statAfter = lstatSync(nodeModulesPath); + assert.equal(statAfter.isSymbolicLink(), true, "real directory should be replaced with symlink"); }); -test("initResources replaces a stale symlink with a correct one", async () => { +test("initResources replaces a stale symlink with a correct one", async (t) => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-symlink-stale-")); const fakeAgentDir = join(tmp, "agent"); - try { - // First call to set up agent dir structure - initResources(fakeAgentDir); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + // First call to set up agent dir structure + initResources(fakeAgentDir); - const nodeModulesPath = join(fakeAgentDir, "node_modules"); - const correctTarget = readlinkSync(nodeModulesPath); + const nodeModulesPath = join(fakeAgentDir, "node_modules"); + const correctTarget = readlinkSync(nodeModulesPath); - // Remove and replace with a stale symlink pointing to a non-existent path - unlinkSync(nodeModulesPath); - symlinkSync("/tmp/nonexistent-gsd-node-modules-" + Date.now(), nodeModulesPath); + // Remove and replace with a stale symlink pointing to a non-existent path + unlinkSync(nodeModulesPath); + symlinkSync("/tmp/nonexistent-gsd-node-modules-" + Date.now(), nodeModulesPath); - const staleTarget = readlinkSync(nodeModulesPath); - assert.notEqual(staleTarget, correctTarget, "stale symlink should point elsewhere"); + const staleTarget = readlinkSync(nodeModulesPath); + assert.notEqual(staleTarget, correctTarget, "stale symlink should point elsewhere"); - // Second call should fix the stale symlink - initResources(fakeAgentDir); + // Second call should fix the stale symlink + initResources(fakeAgentDir); - const fixedTarget = readlinkSync(nodeModulesPath); - assert.equal(fixedTarget, correctTarget, "stale symlink should be replaced with correct target"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const fixedTarget = readlinkSync(nodeModulesPath); + assert.equal(fixedTarget, correctTarget, "stale symlink should be replaced with correct target"); }); -test("initResources replaces symlink whose target was deleted", async () => { +test("initResources replaces symlink whose target was deleted", async (t) => { const { initResources } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-symlink-missing-")); const fakeAgentDir = join(tmp, "agent"); - try { - initResources(fakeAgentDir); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + initResources(fakeAgentDir); - const nodeModulesPath = join(fakeAgentDir, "node_modules"); - const correctTarget = readlinkSync(nodeModulesPath); + const nodeModulesPath = join(fakeAgentDir, "node_modules"); + const correctTarget = readlinkSync(nodeModulesPath); - // Create a symlink that points to a path that doesn't exist - // (simulates the case where npm upgrade moved the package location) - unlinkSync(nodeModulesPath); - const deadTarget = join(tmp, "old-install", "node_modules"); - symlinkSync(deadTarget, nodeModulesPath); + // Create a symlink that points to a path that doesn't exist + // (simulates the case where npm upgrade moved the package location) + unlinkSync(nodeModulesPath); + const deadTarget = join(tmp, "old-install", "node_modules"); + symlinkSync(deadTarget, nodeModulesPath); - // The symlink itself exists but its target doesn't - assert.equal(lstatSync(nodeModulesPath).isSymbolicLink(), true); - assert.equal(existsSync(deadTarget), false, "dead target should not exist"); + // The symlink itself exists but its target doesn't + assert.equal(lstatSync(nodeModulesPath).isSymbolicLink(), true); + assert.equal(existsSync(deadTarget), false, "dead target should not exist"); - initResources(fakeAgentDir); + initResources(fakeAgentDir); - const fixedTarget = readlinkSync(nodeModulesPath); - assert.equal(fixedTarget, correctTarget, "broken symlink should be replaced with correct target"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const fixedTarget = readlinkSync(nodeModulesPath); + assert.equal(fixedTarget, correctTarget, "broken symlink should be replaced with correct target"); }); diff --git a/src/tests/non-extension-library.test.ts b/src/tests/non-extension-library.test.ts index 70e1bcd4a..e263468b8 100644 --- a/src/tests/non-extension-library.test.ts +++ b/src/tests/non-extension-library.test.ts @@ -51,145 +51,124 @@ function isNonExtensionLibrary(resolvedPath: string): boolean { } describe('isNonExtensionLibrary — defense-in-depth for #1709', () => { - test('returns true for a file inside a directory with pi: {} (cmux pattern)', () => { + test('returns true for a file inside a directory with pi: {} (cmux pattern)', (t) => { const root = makeTempDir() - try { - const libDir = join(root, 'cmux') - mkdirSync(libDir) - writeFileSync(join(libDir, 'package.json'), JSON.stringify({ - name: '@gsd/cmux', - description: 'cmux integration library — used by other extensions, not an extension itself', - pi: {} - })) - writeFileSync(join(libDir, 'index.js'), 'module.exports.utility = function() {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const libDir = join(root, 'cmux') + mkdirSync(libDir) + writeFileSync(join(libDir, 'package.json'), JSON.stringify({ + name: '@gsd/cmux', + description: 'cmux integration library — used by other extensions, not an extension itself', + pi: {} + })) + writeFileSync(join(libDir, 'index.js'), 'module.exports.utility = function() {};') - assert.equal( - isNonExtensionLibrary(join(libDir, 'index.js')), - true, - 'cmux with pi: {} should be identified as a non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(libDir, 'index.js')), + true, + 'cmux with pi: {} should be identified as a non-extension library' + ) }) - test('returns true for pi.extensions as empty array', () => { + test('returns true for pi.extensions as empty array', (t) => { const root = makeTempDir() - try { - const libDir = join(root, 'lib-empty') - mkdirSync(libDir) - writeFileSync(join(libDir, 'package.json'), JSON.stringify({ - name: 'lib-empty', - pi: { extensions: [] } - })) - writeFileSync(join(libDir, 'index.js'), 'module.exports.helper = function() {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const libDir = join(root, 'lib-empty') + mkdirSync(libDir) + writeFileSync(join(libDir, 'package.json'), JSON.stringify({ + name: 'lib-empty', + pi: { extensions: [] } + })) + writeFileSync(join(libDir, 'index.js'), 'module.exports.helper = function() {};') - assert.equal( - isNonExtensionLibrary(join(libDir, 'index.js')), - true, - 'pi: { extensions: [] } should be identified as non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(libDir, 'index.js')), + true, + 'pi: { extensions: [] } should be identified as non-extension library' + ) }) - test('returns false for a directory without pi manifest (broken extension)', () => { + test('returns false for a directory without pi manifest (broken extension)', (t) => { const root = makeTempDir() - try { - const extDir = join(root, 'broken-ext') - mkdirSync(extDir) - writeFileSync(join(extDir, 'package.json'), JSON.stringify({ - name: 'broken-ext' - })) - writeFileSync(join(extDir, 'index.js'), 'module.exports.notAFactory = function() {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const extDir = join(root, 'broken-ext') + mkdirSync(extDir) + writeFileSync(join(extDir, 'package.json'), JSON.stringify({ + name: 'broken-ext' + })) + writeFileSync(join(extDir, 'index.js'), 'module.exports.notAFactory = function() {};') - assert.equal( - isNonExtensionLibrary(join(extDir, 'index.js')), - false, - 'directory without pi manifest should NOT be identified as non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(extDir, 'index.js')), + false, + 'directory without pi manifest should NOT be identified as non-extension library' + ) }) - test('returns false when pi.extensions declares actual entries', () => { + test('returns false when pi.extensions declares actual entries', (t) => { const root = makeTempDir() - try { - const extDir = join(root, 'declared-ext') - mkdirSync(extDir) - writeFileSync(join(extDir, 'package.json'), JSON.stringify({ - name: 'declared-ext', - pi: { extensions: ['./index.js'] } - })) - writeFileSync(join(extDir, 'index.js'), 'module.exports.notAFactory = function() {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const extDir = join(root, 'declared-ext') + mkdirSync(extDir) + writeFileSync(join(extDir, 'package.json'), JSON.stringify({ + name: 'declared-ext', + pi: { extensions: ['./index.js'] } + })) + writeFileSync(join(extDir, 'index.js'), 'module.exports.notAFactory = function() {};') - assert.equal( - isNonExtensionLibrary(join(extDir, 'index.js')), - false, - 'directory with declared extensions should NOT be identified as non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(extDir, 'index.js')), + false, + 'directory with declared extensions should NOT be identified as non-extension library' + ) }) - test('returns false when no package.json exists at all', () => { + test('returns false when no package.json exists at all', (t) => { const root = makeTempDir() - try { - const noManifest = join(root, 'no-manifest') - mkdirSync(noManifest) - writeFileSync(join(noManifest, 'index.js'), 'module.exports = {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const noManifest = join(root, 'no-manifest') + mkdirSync(noManifest) + writeFileSync(join(noManifest, 'index.js'), 'module.exports = {};') - // Should return false since there is no package.json with pi manifest - // (it will find the temp dir's absence of package.json and return false) - assert.equal( - isNonExtensionLibrary(join(noManifest, 'index.js')), - false, - 'directory without any package.json should NOT be identified as non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + // Should return false since there is no package.json with pi manifest + // (it will find the temp dir's absence of package.json and return false) + assert.equal( + isNonExtensionLibrary(join(noManifest, 'index.js')), + false, + 'directory without any package.json should NOT be identified as non-extension library' + ) }) - test('handles malformed package.json gracefully', () => { + test('handles malformed package.json gracefully', (t) => { const root = makeTempDir() - try { - const badDir = join(root, 'bad-json') - mkdirSync(badDir) - writeFileSync(join(badDir, 'package.json'), 'not valid json {{{') - writeFileSync(join(badDir, 'index.js'), 'module.exports = {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const badDir = join(root, 'bad-json') + mkdirSync(badDir) + writeFileSync(join(badDir, 'package.json'), 'not valid json {{{') + writeFileSync(join(badDir, 'index.js'), 'module.exports = {};') - assert.equal( - isNonExtensionLibrary(join(badDir, 'index.js')), - false, - 'malformed package.json should not cause a crash and should return false' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(badDir, 'index.js')), + false, + 'malformed package.json should not cause a crash and should return false' + ) }) - test('pi manifest with other fields but no extensions still opts out', () => { + test('pi manifest with other fields but no extensions still opts out', (t) => { const root = makeTempDir() - try { - const libDir = join(root, 'lib-with-skills') - mkdirSync(libDir) - writeFileSync(join(libDir, 'package.json'), JSON.stringify({ - name: 'lib-with-skills', - pi: { skills: ['./my-skill.md'] } - })) - writeFileSync(join(libDir, 'index.js'), 'module.exports.helper = function() {};') + t.after(() => rmSync(root, { recursive: true, force: true })); + const libDir = join(root, 'lib-with-skills') + mkdirSync(libDir) + writeFileSync(join(libDir, 'package.json'), JSON.stringify({ + name: 'lib-with-skills', + pi: { skills: ['./my-skill.md'] } + })) + writeFileSync(join(libDir, 'index.js'), 'module.exports.helper = function() {};') - assert.equal( - isNonExtensionLibrary(join(libDir, 'index.js')), - true, - 'pi manifest with skills but no extensions should be identified as non-extension library' - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } + assert.equal( + isNonExtensionLibrary(join(libDir, 'index.js')), + true, + 'pi manifest with skills but no extensions should be identified as non-extension library' + ) }) })