refactor(test): replace try/finally with t.after() in src/tests (a-n) (#2394)

This commit is contained in:
Tom Boucher 2026-03-24 23:30:00 -04:00 committed by GitHub
parent ea0b1e4444
commit 99af6b0315
11 changed files with 854 additions and 1038 deletions

View file

@ -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");
});

View file

@ -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(), [])
})

View file

@ -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);
});

View file

@ -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)
})

View file

@ -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')
})
})

View file

@ -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");
});

View file

@ -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");
});

View file

@ -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}`);
});
});

View file

@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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 ───

View file

@ -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");
});

View file

@ -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'
)
})
})