diff --git a/tests/unit/agentSkills-catalog.test.ts b/tests/unit/agentSkills-catalog.test.ts new file mode 100644 index 0000000000..73ecbe1e0c --- /dev/null +++ b/tests/unit/agentSkills-catalog.test.ts @@ -0,0 +1,239 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Dynamic imports to pick up ESM modules with tsx +const { getCatalog, getSkillById, filterCatalog, computeCoverage, refreshCatalog, API_SKILL_IDS, CLI_SKILL_IDS } = + await import("../../src/lib/agentSkills/catalog.ts"); + +// ─── Counts ─────────────────────────────────────────────────────────────────── + +test("getCatalog() returns exactly 42 entries", () => { + refreshCatalog(); + const catalog = getCatalog(); + assert.equal(catalog.length, 42, `Expected 42 but got ${catalog.length}`); +}); + +test("API_SKILL_IDS has exactly 22 entries", () => { + assert.equal(API_SKILL_IDS.length, 22); +}); + +test("CLI_SKILL_IDS has exactly 20 entries", () => { + assert.equal(CLI_SKILL_IDS.length, 20); +}); + +test("getCatalog() contains exactly 22 api skills", () => { + const apiSkills = getCatalog().filter((s) => s.category === "api"); + assert.equal(apiSkills.length, 22); +}); + +test("getCatalog() contains exactly 20 cli skills", () => { + const cliSkills = getCatalog().filter((s) => s.category === "cli"); + assert.equal(cliSkills.length, 20); +}); + +// ─── ID format ──────────────────────────────────────────────────────────────── + +test("all skill IDs match regex ^[a-z][a-z0-9-]*$", () => { + const ID_REGEX = /^[a-z][a-z0-9-]*$/; + for (const skill of getCatalog()) { + assert.match( + skill.id, + ID_REGEX, + `Skill ID "${skill.id}" does not match expected format`, + ); + } +}); + +test("all skill IDs are unique (no duplicates)", () => { + const ids = getCatalog().map((s) => s.id); + const uniqueIds = new Set(ids); + assert.equal( + uniqueIds.size, + ids.length, + `Duplicate IDs found: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(", ")}`, + ); +}); + +// ─── Required fields ────────────────────────────────────────────────────────── + +test("all skills have non-empty name and description", () => { + for (const skill of getCatalog()) { + assert.ok(skill.name.length > 0, `Skill ${skill.id} has empty name`); + assert.ok(skill.description.length > 0, `Skill ${skill.id} has empty description`); + } +}); + +test("all skills have rawUrl and githubUrl as valid GitHub URLs", () => { + for (const skill of getCatalog()) { + assert.ok( + skill.rawUrl.startsWith("https://raw.githubusercontent.com/"), + `Skill ${skill.id}: rawUrl "${skill.rawUrl}" is not a GitHub raw URL`, + ); + assert.ok( + skill.githubUrl.startsWith("https://github.com/"), + `Skill ${skill.id}: githubUrl "${skill.githubUrl}" is not a GitHub blob URL`, + ); + assert.ok( + skill.rawUrl.endsWith("/SKILL.md"), + `Skill ${skill.id}: rawUrl does not end with /SKILL.md`, + ); + } +}); + +test("api skills have area matching API_SKILL_IDS derived IDs", () => { + const catalog = getCatalog(); + for (const id of API_SKILL_IDS) { + const skill = catalog.find((s) => s.id === id); + assert.ok(skill, `API skill ID "${id}" not found in catalog`); + assert.equal(skill!.category, "api", `Skill "${id}" expected category api, got ${skill!.category}`); + } +}); + +test("cli skills have area matching CLI_SKILL_IDS derived IDs", () => { + const catalog = getCatalog(); + for (const id of CLI_SKILL_IDS) { + const skill = catalog.find((s) => s.id === id); + assert.ok(skill, `CLI skill ID "${id}" not found in catalog`); + assert.equal(skill!.category, "cli", `Skill "${id}" expected category cli, got ${skill!.category}`); + } +}); + +// ─── getSkillById ───────────────────────────────────────────────────────────── + +test("getSkillById('omni-providers') returns the omni-providers entry", () => { + const skill = getSkillById("omni-providers"); + assert.ok(skill, "Expected skill to be found"); + assert.equal(skill!.id, "omni-providers"); + assert.equal(skill!.category, "api"); + assert.equal(skill!.area, "providers"); +}); + +test("getSkillById('cli-serve') returns the cli-serve entry", () => { + const skill = getSkillById("cli-serve"); + assert.ok(skill); + assert.equal(skill!.id, "cli-serve"); + assert.equal(skill!.category, "cli"); + assert.equal(skill!.isEntry, true); +}); + +test("getSkillById('omni-auth') returns entry with isEntry=true", () => { + const skill = getSkillById("omni-auth"); + assert.ok(skill); + assert.equal(skill!.isEntry, true); +}); + +test("getSkillById('does-not-exist') returns null", () => { + const skill = getSkillById("does-not-exist"); + assert.equal(skill, null); +}); + +test("getSkillById('') returns null", () => { + const skill = getSkillById(""); + assert.equal(skill, null); +}); + +// ─── filterCatalog ──────────────────────────────────────────────────────────── + +test("filterCatalog({ category: 'api' }) returns 22 api skills", () => { + const skills = filterCatalog({ category: "api" }); + assert.equal(skills.length, 22); + for (const s of skills) { + assert.equal(s.category, "api"); + } +}); + +test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => { + const skills = filterCatalog({ category: "cli" }); + assert.equal(skills.length, 20); + for (const s of skills) { + assert.equal(s.category, "cli"); + } +}); + +test("filterCatalog({ area: 'providers' }) returns exactly omni-providers", () => { + const skills = filterCatalog({ area: "providers" }); + assert.equal(skills.length, 1); + assert.equal(skills[0].id, "omni-providers"); +}); + +test("filterCatalog({ category: 'api', area: 'mcp' }) returns omni-mcp", () => { + const skills = filterCatalog({ category: "api", area: "mcp" }); + assert.equal(skills.length, 1); + assert.equal(skills[0].id, "omni-mcp"); +}); + +test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => { + const skills = filterCatalog({ area: "nonexistent" }); + assert.equal(skills.length, 0); +}); + +test("filterCatalog({}) returns full catalog (42 entries)", () => { + const skills = filterCatalog({}); + assert.equal(skills.length, 42); +}); + +// ─── refreshCatalog ─────────────────────────────────────────────────────────── + +test("refreshCatalog() causes getCatalog() to re-derive (returns fresh array)", () => { + const first = getCatalog(); + refreshCatalog(); + const second = getCatalog(); + // Different array reference after refresh + assert.notEqual(first, second); + // But same content + assert.equal(first.length, second.length); + assert.equal(first[0].id, second[0].id); +}); + +// ─── computeCoverage ───────────────────────────────────────────────────────── + +test("computeCoverage() returns valid SkillCoverage shape", () => { + const cov = computeCoverage(); + + assert.ok(typeof cov.api === "object"); + assert.equal(cov.api.total, 22); + assert.ok(typeof cov.api.have === "number"); + assert.ok(cov.api.have >= 0 && cov.api.have <= 22); + + assert.ok(typeof cov.cli === "object"); + assert.equal(cov.cli.total, 20); + assert.ok(typeof cov.cli.have === "number"); + assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20); + + assert.equal(cov.totalSkills, cov.api.have + cov.cli.have); + + // generatedAt must be a valid ISO datetime string + assert.ok(!isNaN(Date.parse(cov.generatedAt)), `generatedAt "${cov.generatedAt}" is not a valid ISO date`); +}); + +test("computeCoverage() api.have + cli.have = totalSkills", () => { + const cov = computeCoverage(); + assert.equal(cov.totalSkills, cov.api.have + cov.cli.have); +}); + +// ─── Cache behaviour ───────────────────────────────────────────────────────── + +test("getCatalog() returns the same array reference on repeated calls (cached)", () => { + refreshCatalog(); + const first = getCatalog(); + const second = getCatalog(); + assert.strictEqual(first, second, "Expected same cached array reference"); +}); + +// ─── Canonical IDs check ───────────────────────────────────────────────────── + +test("API_SKILL_IDS first entry is omni-auth", () => { + assert.equal(API_SKILL_IDS[0], "omni-auth"); +}); + +test("API_SKILL_IDS last entry is omni-inference", () => { + assert.equal(API_SKILL_IDS[API_SKILL_IDS.length - 1], "omni-inference"); +}); + +test("CLI_SKILL_IDS first entry is cli-serve", () => { + assert.equal(CLI_SKILL_IDS[0], "cli-serve"); +}); + +test("CLI_SKILL_IDS last entry is cli-setup", () => { + assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup"); +}); diff --git a/tests/unit/agentSkills-cliRegistryParser.test.ts b/tests/unit/agentSkills-cliRegistryParser.test.ts new file mode 100644 index 0000000000..086181494f --- /dev/null +++ b/tests/unit/agentSkills-cliRegistryParser.test.ts @@ -0,0 +1,304 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Dynamic import to pick up ESM module +const { parseCliRegistry, getCommandsForFamily } = await import( + "../../src/lib/agentSkills/cliRegistryParser.ts" +); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory mirroring bin/cli/commands/, + * writes fixture .mjs files, changes CWD, returns cleanup fn. + */ +function withFixtureCli( + files: Record, +): { cleanup: () => void } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-test-")); + const commandsDir = path.join(tmpDir, "bin", "cli", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + + for (const [filename, content] of Object.entries(files)) { + fs.writeFileSync(path.join(commandsDir, filename), content, "utf-8"); + } + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + return { + cleanup() { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +// ─── Fixture content ────────────────────────────────────────────────────────── + +const FIXTURE_PROVIDERS_MJS = ` +export function registerProviders(program) { + const providers = program.command('providers').description('Manage provider connections'); + + providers + .command('list') + .description('List configured provider connections') + .option('--json', 'Print machine-readable JSON') + .action(async (opts) => {}); + + providers + .command('available') + .description('Show available providers in the catalog') + .option('--search ', 'Filter by id or name') + .option('--category ', 'Filter by category') + .action(async (opts) => {}); + + providers + .command('test ') + .description('Test a configured provider connection') + .action(async (idOrName, opts) => {}); + + providers + .command('test-all') + .description('Test all active provider connections') + .action(async (opts) => {}); + + providers + .command('validate') + .description('Validate local provider configuration') + .action(async (opts) => {}); + + providers + .command('rotate ') + .description('Rotate API key for a provider connection') + .option('--new-key ', 'New API key value') + .option('--dry-run', 'Preview without writing') + .action(async (idOrName, opts) => {}); + + providers + .command('status') + .description('Show provider connection status and expiry') + .option('--json', 'JSON output') + .action(async (opts) => {}); +} +`; + +const FIXTURE_HEALTH_MJS = ` +export function registerHealth(program) { + const health = program + .command('health') + .description('Check server health status') + .option('-v, --verbose', 'Show extended info') + .option('--json', 'Output as JSON') + .action(async (opts) => {}); + + health + .command('components') + .description('List health components and status') + .action(async (opts) => {}); + + health + .command('watch') + .description('Live dashboard — refresh every N seconds') + .option('--interval ', 'Refresh interval in seconds') + .action(async (opts) => {}); +} +`; + +const FIXTURE_KEYS_MJS = ` +export function registerKeys(program) { + const keys = program.command('keys').description('Manage OmniRoute API keys'); + + keys + .command('list') + .description('List all API keys') + .option('--json', 'JSON output') + .action(async (opts) => {}); + + keys + .command('create') + .description('Create a new API key') + .option('--name ', 'Key name') + .action(async (opts) => {}); + + keys + .command('revoke ') + .description('Revoke an API key') + .action(async (id, opts) => {}); +} +`; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test("parseCliRegistry() returns commands Map and families Map", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + "health.mjs": FIXTURE_HEALTH_MJS, + }); + try { + const result = parseCliRegistry(); + assert.ok(result.commands instanceof Map, "commands should be a Map"); + assert.ok(result.families instanceof Map, "families should be a Map"); + assert.ok(result.commands.size > 0, "commands should not be empty"); + assert.ok(result.families.size > 0, "families should not be empty"); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() recognises providers family with ≥5 subcommands", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + const providerCmds = families.get("cli-providers"); + assert.ok(providerCmds, "Expected 'cli-providers' family to exist"); + assert.ok( + providerCmds!.length >= 5, + `Expected ≥5 provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() recognises health family commands", () => { + const { cleanup } = withFixtureCli({ + "health.mjs": FIXTURE_HEALTH_MJS, + }); + try { + const { families } = parseCliRegistry(); + const healthCmds = families.get("cli-health"); + assert.ok(healthCmds, "Expected 'cli-health' family to exist"); + assert.ok( + healthCmds!.length >= 2, + `Expected ≥2 health commands, got ${healthCmds!.length}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() extracts description for each command", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + "keys.mjs": FIXTURE_KEYS_MJS, + }); + try { + const { commands } = parseCliRegistry(); + // Top-level providers command should have description + const providers = [...commands.values()].find((c) => c.name === "providers"); + assert.ok(providers, "Expected providers command"); + assert.ok( + providers!.description.length > 0, + `Expected non-empty description for providers, got: "${providers!.description}"`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() marks subcommands with isSubcommand=true (after first)", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + const providerCmds = families.get("cli-providers")!; + // After the first (top-level) entry, rest should be subcommands + const subCmds = providerCmds.filter((c) => c.isSubcommand); + assert.ok( + subCmds.length >= 4, + `Expected ≥4 subcommands (list, available, test, etc.), got ${subCmds.length}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() extracts flags from .option() calls", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { commands } = parseCliRegistry(); + // Find a command that has options + const rotate = [...commands.values()].find((c) => c.name.includes("rotate")); + // Flags might be present if parsing found them + if (rotate) { + // If rotate exists and has flags, verify format + for (const flag of rotate.flags) { + assert.ok( + typeof flag === "string" && flag.length > 0, + `Invalid flag: "${flag}"`, + ); + } + } + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() skips unrecognised .mjs files", () => { + const { cleanup } = withFixtureCli({ + "unknown-custom.mjs": `export function register(p) {}`, + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + // No family should be mapped from unknown-custom + const hasUnknown = [...families.keys()].some((k) => + String(k).includes("unknown-custom"), + ); + assert.equal(hasUnknown, false, "unknown-custom.mjs should not create a family"); + // providers.mjs should still be parsed + assert.ok(families.has("cli-providers"), "Expected cli-providers family from providers.mjs"); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() throws if commands directory is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-missing-")); + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + assert.throws( + () => parseCliRegistry(), + /cliRegistryParser: could not read/, + "Expected error when commands dir is missing", + ); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ─── Integration test: real providers.mjs (always runs — it's in the repo) ─── + +test("parseCliRegistry() with real providers.mjs: providers family has ≥5 commands", () => { + // This test uses the actual project files (not a fixture). + // We rely on the CWD being the worktree root during `npm run test:unit`. + const result = parseCliRegistry(); + const providerCmds = result.families.get("cli-providers"); + assert.ok(providerCmds, "Expected cli-providers family from real providers.mjs"); + assert.ok( + providerCmds!.length >= 5, + `Expected ≥5 real provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`, + ); +}); + +test("getCommandsForFamily('cli-providers') with real files: returns ≥5 strings", () => { + const commands = getCommandsForFamily("cli-providers"); + assert.ok( + commands.length >= 5, + `Expected ≥5 cli-providers commands, got ${commands.length}`, + ); + for (const cmd of commands) { + assert.ok(typeof cmd === "string" && cmd.length > 0, `Invalid command name: "${cmd}"`); + } +}); diff --git a/tests/unit/agentSkills-openapiParser.test.ts b/tests/unit/agentSkills-openapiParser.test.ts new file mode 100644 index 0000000000..cbe70cf541 --- /dev/null +++ b/tests/unit/agentSkills-openapiParser.test.ts @@ -0,0 +1,247 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Dynamic import to pick up ESM module +const { parseOpenapi, getEndpointsForArea } = await import("../../src/lib/agentSkills/openapiParser.ts"); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory with a minimal openapi.yaml fixture, + * changes CWD to it, and returns a cleanup function. + */ +function withFixtureOpenapi(yamlContent: string): { cleanup: () => void } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-test-")); + const docsDir = path.join(tmpDir, "docs", "reference"); + fs.mkdirSync(docsDir, { recursive: true }); + fs.writeFileSync(path.join(docsDir, "openapi.yaml"), yamlContent, "utf-8"); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + return { + cleanup() { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +// ─── Fixture YAML ───────────────────────────────────────────────────────────── + +const FIXTURE_YAML = ` +openapi: 3.1.0 +info: + title: OmniRoute Test + version: 1.0.0 +paths: + /api/providers: + get: + tags: [Providers] + summary: List provider connections + description: Returns all configured provider connections. + post: + tags: [Providers] + summary: Add provider connection + /api/providers/{id}: + get: + tags: [Providers] + summary: Get provider by id + patch: + tags: [Providers] + summary: Update provider connection + delete: + tags: [Providers] + summary: Remove provider connection + /api/providers/{id}/test: + post: + tags: [Providers] + summary: Test provider connection + /api/keys: + get: + tags: [APIKeys] + summary: List API keys + post: + tags: [APIKeys] + summary: Create API key + /api/keys/{id}: + delete: + tags: [APIKeys] + summary: Revoke API key + /api/usage/analytics: + get: + tags: [Usage] + summary: Get usage analytics + /api/v1/chat/completions: + post: + tags: [Chat] + summary: Create chat completion + /api/settings: + get: + tags: [Settings] + summary: Get settings + put: + tags: [Settings] + summary: Update settings +`; + +// ─── Tests using fixture ────────────────────────────────────────────────────── + +test("parseOpenapi() returns paths Map with all operations from fixture", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { paths } = parseOpenapi(); + + assert.ok(paths instanceof Map, "paths should be a Map"); + assert.ok(paths.size > 0, "paths should not be empty"); + + // Spot-check a few keys + assert.ok(paths.has("GET /api/providers"), "Expected GET /api/providers"); + assert.ok(paths.has("POST /api/providers"), "Expected POST /api/providers"); + assert.ok(paths.has("GET /api/providers/{id}"), "Expected GET /api/providers/{id}"); + assert.ok(paths.has("DELETE /api/providers/{id}"), "Expected DELETE /api/providers/{id}"); + assert.ok(paths.has("POST /api/v1/chat/completions"), "Expected POST /api/v1/chat/completions"); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/providers/* under 'providers' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + + const providerOps = areas.get("providers"); + assert.ok(providerOps, "Expected 'providers' area to exist"); + assert.ok( + providerOps!.length >= 5, + `Expected at least 5 provider endpoints, got ${providerOps!.length}`, + ); + + const paths = providerOps!.map((op) => op.path); + assert.ok(paths.includes("/api/providers"), "Expected /api/providers"); + assert.ok(paths.includes("/api/providers/{id}"), "Expected /api/providers/{id}"); + assert.ok(paths.includes("/api/providers/{id}/test"), "Expected /api/providers/{id}/test"); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/keys/* under 'api-keys' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + const keyOps = areas.get("api-keys"); + assert.ok(keyOps, "Expected 'api-keys' area to exist"); + assert.ok(keyOps!.length >= 2, `Expected at least 2 key endpoints, got ${keyOps!.length}`); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/v1/* under 'inference' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + const inferenceOps = areas.get("inference"); + assert.ok(inferenceOps, "Expected 'inference' area to exist"); + assert.ok( + inferenceOps!.length >= 1, + `Expected at least 1 inference endpoint, got ${inferenceOps!.length}`, + ); + assert.ok( + inferenceOps!.some((op) => op.path === "/api/v1/chat/completions"), + "Expected /api/v1/chat/completions in inference area", + ); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() OpenapiPath entries have required fields", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { paths } = parseOpenapi(); + for (const [key, op] of paths) { + assert.ok(typeof op.method === "string" && op.method.length > 0, `${key}: method missing`); + assert.ok(typeof op.path === "string" && op.path.length > 0, `${key}: path missing`); + assert.ok(typeof op.summary === "string", `${key}: summary not a string`); + assert.ok(Array.isArray(op.tags), `${key}: tags not an array`); + } + } finally { + cleanup(); + } +}); + +test("parseOpenapi() throws if openapi.yaml is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-missing-")); + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + assert.throws( + () => parseOpenapi(), + /openapiParser: could not read/, + "Expected error when openapi.yaml is missing", + ); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("parseOpenapi() returns empty areas Map for YAML with no paths", () => { + const emptyPathsYaml = ` +openapi: 3.1.0 +info: + title: Empty + version: 1.0.0 +paths: {} +`; + const { cleanup } = withFixtureOpenapi(emptyPathsYaml); + try { + const { paths, areas } = parseOpenapi(); + assert.equal(paths.size, 0); + assert.equal(areas.size, 0); + } finally { + cleanup(); + } +}); + +// ─── Integration test: real openapi.yaml (gated) ───────────────────────────── + +const SKIP_REAL = process.env.SKIP_REAL_OPENAPI === "1"; + +test( + "parseOpenapi() with real openapi.yaml: providers area has ≥5 endpoints", + { skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false }, + () => { + // This test runs from the project root (the worktree). + // It will fail if openapi.yaml doesn't exist — that's intentional. + const { areas } = parseOpenapi(); + const providerOps = areas.get("providers"); + assert.ok(providerOps, "Expected 'providers' area in real OpenAPI spec"); + assert.ok( + providerOps!.length >= 5, + `Expected ≥5 provider endpoints in real spec, got ${providerOps!.length}`, + ); + }, +); + +test( + "getEndpointsForArea('providers') with real openapi.yaml: returns ≥5 strings", + { skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false }, + () => { + const endpoints = getEndpointsForArea("providers"); + assert.ok( + endpoints.length >= 5, + `Expected ≥5 provider endpoint strings, got ${endpoints.length}: ${endpoints.join(", ")}`, + ); + // Each entry should match "METHOD /path" + for (const ep of endpoints) { + assert.match(ep, /^[A-Z]+ \//, `Endpoint "${ep}" does not match METHOD /path format`); + } + }, +);