From d1d0ddda0f3f8380befe64264087abf534eebaff Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:55:01 -0300 Subject: [PATCH] test(agent-skills): unit tests for generator + scripts smoke 20 tests covering: - dry-run produces report without writing (filesystem unchanged) - apply writes SKILL.md for API and CLI skills with correct sections - apply writes all 42 skills when no filter - idempotency: second run reports 0 generated, all unchanged - prune dry-run detects orphans without deleting - prune apply deletes orphan dirs, preserves catalog dirs - marker preservation: custom block survives regeneration - buildSkillMarkdown: valid shape, no escape errors, correct sections per category - onlyIds filter limits generation - report shape validation Also adds gray-matter as dependency (per plan prerequisite check). --- package-lock.json | 109 +++++ package.json | 5 +- tests/unit/agentSkills-generator.test.ts | 539 +++++++++++++++++++++++ 3 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 tests/unit/agentSkills-generator.test.ts diff --git a/package-lock.json b/package-lock.json index 87c663e68f..d1864445fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "fumadocs-core": "^16.9.0", "fumadocs-mdx": "^15.0.7", "fumadocs-ui": "^16.9.0", + "gray-matter": "^4.0.3", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -10631,6 +10632,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -10903,6 +10917,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -11861,6 +11887,43 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -13023,6 +13086,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -13874,6 +13946,15 @@ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/koffi": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", @@ -18294,6 +18375,19 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -18794,6 +18888,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/sql.js": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", @@ -19101,6 +19201,15 @@ "node": ">=4" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", diff --git a/package.json b/package.json index cb90e3cc95..891225c01c 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "fumadocs-core": "^16.9.0", "fumadocs-mdx": "^15.0.7", "fumadocs-ui": "^16.9.0", + "gray-matter": "^4.0.3", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -215,6 +216,7 @@ "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^3.0.0", "@types/better-sqlite3": "^7.6.13", + "@types/bun": "latest", "@types/keytar": "^4.4.2", "@types/node": "^25.9.1", "@types/react": "^19.2.15", @@ -237,8 +239,7 @@ "typescript-eslint": "^8.59.4", "vitest": "^4.1.7", "wait-on": "^9.0.10", - "wtfnode": "^0.10.1", - "@types/bun": "latest" + "wtfnode": "^0.10.1" }, "lint-staged": { "*.{js,jsx,ts,tsx,mjs}": [ diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts new file mode 100644 index 0000000000..c8437a92a4 --- /dev/null +++ b/tests/unit/agentSkills-generator.test.ts @@ -0,0 +1,539 @@ +/** + * agentSkills-generator.test.ts + * + * Tests for src/lib/agentSkills/generator.ts + * + * All tests use a temporary directory so they never touch the real `skills/` folder. + * Uses Node.js native test runner. + */ + +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 imports (tsx/esm resolves TS imports) ──────────────────────────── + +const { generateAgentSkills, buildSkillMarkdown } = await import( + "../../src/lib/agentSkills/generator.ts" +); + +const { getCatalog, refreshCatalog } = await import( + "../../src/lib/agentSkills/catalog.ts" +); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Creates an isolated tmp directory and returns its path. */ +function mkTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "agent-skills-gen-test-")); +} + +/** Cleanup a tmp directory. */ +function rmTmpDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +/** + * Builds a minimal sources object for tests that don't need real parsers. + */ +function emptySources() { + return { + openapi: { paths: new Map(), areas: new Map() }, + cliRegistry: { commands: new Map(), families: new Map() }, + }; +} + +// ── Dry-run: no writes ──────────────────────────────────────────────────────── + +test("dry-run (default) returns report without writing any files", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + const report = await generateAgentSkills({ + dryRun: true, + prune: false, + outputDir: tmpDir, + }); + + // All 42 skills should appear as generated (would-write) since dir is empty + assert.equal( + report.generated.length + report.unchanged.length, + 42, + `Expected 42 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + ); + assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); + + // No files written + const entries = fs.readdirSync(tmpDir); + assert.equal( + entries.length, + 0, + `Dry-run wrote ${entries.length} entries to ${tmpDir}: ${entries.join(", ")}`, + ); + } finally { + rmTmpDir(tmpDir); + } +}); + +test("dry-run generates report with 42 total (generated+unchanged)", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + const report = await generateAgentSkills({ + dryRun: true, + prune: false, + outputDir: tmpDir, + }); + const total = report.generated.length + report.unchanged.length; + assert.equal(total, 42); + } finally { + rmTmpDir(tmpDir); + } +}); + +// ── Apply: writes SKILL.md ───────────────────────────────────────────────────── + +test("apply mode writes SKILL.md with valid frontmatter for omni-providers", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + const report = await generateAgentSkills({ + dryRun: false, + prune: false, + outputDir: tmpDir, + onlyIds: ["omni-providers"], + }); + + assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`); + assert.equal(report.generated.length, 1); + assert.equal(report.generated[0], "omni-providers"); + + const skillFile = path.join(tmpDir, "omni-providers", "SKILL.md"); + assert.ok(fs.existsSync(skillFile), `SKILL.md not found at ${skillFile}`); + + const content = fs.readFileSync(skillFile, "utf-8"); + + // Frontmatter present + assert.ok(content.startsWith("---\n"), "Missing frontmatter start"); + assert.ok(content.includes("name: omni-providers"), "Missing name in frontmatter"); + assert.ok(content.includes("---\n"), "Missing frontmatter end"); + + // Generated comment present + assert.ok( + content.includes("\nMy custom content here.\n"; + const contentWithCustom = originalContent + "\n" + customBlock + "\n"; + fs.writeFileSync(skillFile, contentWithCustom, "utf-8"); + + // Re-run generator — file is now different (custom block changed content) + const report2 = await generateAgentSkills({ + dryRun: false, + prune: false, + outputDir: tmpDir, + onlyIds: ["omni-providers"], + }); + + assert.equal( + report2.errors.length, + 0, + `Errors: ${JSON.stringify(report2.errors)}`, + ); + + const newContent = fs.readFileSync(skillFile, "utf-8"); + + // Custom block should still be present + assert.ok( + newContent.includes("My custom content here."), + "Custom content was lost during regeneration", + ); + assert.ok( + newContent.includes(""), + "Custom start marker missing", + ); + assert.ok( + newContent.includes(""), + "Custom end marker missing", + ); + } finally { + rmTmpDir(tmpDir); + } +}); + +// ── buildSkillMarkdown ───────────────────────────────────────────────────────── + +test("buildSkillMarkdown returns valid frontmatter + body for omni-providers", () => { + refreshCatalog(); + const sources = emptySources(); + const result = buildSkillMarkdown("omni-providers", sources); + + assert.ok(typeof result.frontmatter === "object", "frontmatter must be an object"); + assert.equal(result.frontmatter.name, "omni-providers"); + assert.ok(result.frontmatter.description.length > 0, "description must be non-empty"); + assert.ok(typeof result.body === "string" && result.body.length > 0, "body must be a non-empty string"); +}); + +test("buildSkillMarkdown body has no erroneously escaped characters", () => { + refreshCatalog(); + const sources = emptySources(); + + // Test several skills + const testIds = ["omni-providers", "cli-serve", "omni-auth", "cli-health"]; + for (const id of testIds) { + const result = buildSkillMarkdown(id, sources); + + // Check for common escape errors: \\n in rendered text, &, <, > + assert.ok( + !result.body.includes("\\\\n"), + `Skill ${id}: body contains \\\\n (double-escaped newline)`, + ); + assert.ok( + !result.body.includes("&"), + `Skill ${id}: body contains HTML entity &`, + ); + assert.ok( + !result.body.includes("<"), + `Skill ${id}: body contains HTML entity <`, + ); + assert.ok( + !result.body.includes(">"), + `Skill ${id}: body contains HTML entity >`, + ); + } +}); + +test("buildSkillMarkdown throws for unknown skillId", () => { + refreshCatalog(); + const sources = emptySources(); + + assert.throws( + () => buildSkillMarkdown("non-existent-skill", sources), + /non-existent-skill/, + "Should throw with skill ID in message", + ); +}); + +test("buildSkillMarkdown API skill body contains expected sections", () => { + refreshCatalog(); + const sources = emptySources(); + const result = buildSkillMarkdown("omni-cache", sources); + + assert.ok(result.body.includes("## Visão geral"), "Missing Visão geral section"); + assert.ok(result.body.includes("## Autenticação"), "Missing Autenticação section"); + assert.ok(result.body.includes("## Endpoints"), "Missing Endpoints section"); + assert.ok(result.body.includes("## Payloads"), "Missing Payloads section"); +}); + +test("buildSkillMarkdown CLI skill body contains expected sections", () => { + refreshCatalog(); + const sources = emptySources(); + const result = buildSkillMarkdown("cli-health", sources); + + assert.ok(result.body.includes("## Visão geral"), "Missing Visão geral section"); + assert.ok(result.body.includes("## Instalação rápida"), "Missing Instalação rápida section"); + assert.ok(result.body.includes("## Subcomandos"), "Missing Subcomandos section"); +}); + +test("buildSkillMarkdown description is at most 2000 chars", () => { + refreshCatalog(); + const catalog = getCatalog(); + const sources = emptySources(); + + for (const skill of catalog) { + const result = buildSkillMarkdown(skill.id, sources); + assert.ok( + result.frontmatter.description.length <= 2000, + `Skill ${skill.id}: description too long (${result.frontmatter.description.length} > 2000)`, + ); + } +}); + +// ── onlyIds filter ──────────────────────────────────────────────────────────── + +test("onlyIds filter limits generation to specified IDs", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + const report = await generateAgentSkills({ + dryRun: false, + prune: false, + outputDir: tmpDir, + onlyIds: ["omni-providers", "cli-serve"], + }); + + assert.equal(report.generated.length, 2); + assert.ok(report.generated.includes("omni-providers")); + assert.ok(report.generated.includes("cli-serve")); + + // Only 2 dirs created + const entries = fs.readdirSync(tmpDir); + assert.equal(entries.length, 2, `Expected 2 dirs, got: ${entries.join(", ")}`); + } finally { + rmTmpDir(tmpDir); + } +}); + +// ── Generated comment ───────────────────────────────────────────────────────── + +test("generated SKILL.md contains the mandatory generated comment", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + await generateAgentSkills({ + dryRun: false, + prune: false, + outputDir: tmpDir, + onlyIds: ["omni-providers"], + }); + + const content = fs.readFileSync(path.join(tmpDir, "omni-providers", "SKILL.md"), "utf-8"); + assert.ok( + content.includes(""), + "Missing mandatory generated comment", + ); + } finally { + rmTmpDir(tmpDir); + } +}); + +// ── Report shape ────────────────────────────────────────────────────────────── + +test("report has all required fields with correct types", async () => { + const tmpDir = mkTmpDir(); + try { + refreshCatalog(); + const report = await generateAgentSkills({ + dryRun: true, + prune: false, + outputDir: tmpDir, + }); + + assert.ok(Array.isArray(report.generated), "generated must be an array"); + assert.ok(Array.isArray(report.unchanged), "unchanged must be an array"); + assert.ok(Array.isArray(report.pruned), "pruned must be an array"); + assert.ok(Array.isArray(report.orphansDetected), "orphansDetected must be an array"); + assert.ok(Array.isArray(report.errors), "errors must be an array"); + } finally { + rmTmpDir(tmpDir); + } +});