refactor(test): replace try/finally with t.after() in src/tests (a-n) (#2394)
This commit is contained in:
parent
ea0b1e4444
commit
99af6b0315
11 changed files with 854 additions and 1038 deletions
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(), [])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue