251148c3ff
## Summary - add repository-wide quality tooling and verification scaffolding, including CI workflows, pnpm workspace setup, ESLint/Prettier/markdown checks, and generated-output verification helpers - reorganize skill sources and generation flow by introducing canonical `_source` variants, generator/manifests, reusable helper abstractions, and shared web-automation/browser utilities - clean up and expand documentation so the root README flows into docs and skill docs, with clearer development, reviewer, installer, and workflow guidance ## Notable changes - docs flow and consistency cleanup across `README.md`, `docs/README.md`, and related docs - new scripts for `check`, docs verification, generated-file verification, shell portability, and safe directory replacement - refactors in Atlassian and web-automation skill runtimes to reduce duplication and centralize reusable code - changelog, development documentation, and CI surface updates ## Test Plan - [ ] `pnpm run check` - [ ] review generated/manifests and skill sync outputs - [ ] smoke-check docs flow from `README.md` to `docs/README.md` to skill docs ## Notes - this branch currently includes tracked `skills/web-automation/shared/node_modules` content that should be reviewed carefully as potentially noisy/accidental committed artifacts Co-authored-by: Stefano Fiorini <stefano.fiorini@firsthorizon.com> Reviewed-on: #1
366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
/**
|
|
* Unit tests for generate-skills.mjs — RED phase of TDD.
|
|
*
|
|
* Tests cover:
|
|
* - detectFileType: classification of files by extension
|
|
* - applyHeader: insertion per file-type-aware policy
|
|
* - makePackageJsonContent: unique name + private:true
|
|
* - getGeneratedRoots: returns the canonical generated-root list
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
|
import crypto from "node:crypto";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPTS_DIR = path.resolve(__dirname, "..");
|
|
|
|
const {
|
|
detectFileType,
|
|
applyHeader,
|
|
makePackageJsonContent,
|
|
getGeneratedRoots,
|
|
buildManifest,
|
|
generateSkills,
|
|
} = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
|
|
|
|
// ── detectFileType ────────────────────────────────────────────────────────
|
|
|
|
test("detectFileType: .md files → markdown", () => {
|
|
assert.equal(detectFileType("SKILL.md"), "markdown");
|
|
assert.equal(detectFileType("templates/milestone-plan.md"), "markdown");
|
|
assert.equal(detectFileType("README.md"), "markdown");
|
|
});
|
|
|
|
test("detectFileType: .sh files → shell", () => {
|
|
assert.equal(detectFileType("run-review.sh"), "shell");
|
|
assert.equal(detectFileType("scripts/install.sh"), "shell");
|
|
});
|
|
|
|
test("detectFileType: .ts and .d.ts files → ts", () => {
|
|
assert.equal(detectFileType("src/cli.ts"), "ts");
|
|
assert.equal(detectFileType("turndown-plugin-gfm.d.ts"), "ts");
|
|
assert.equal(detectFileType("auth.ts"), "ts");
|
|
});
|
|
|
|
test("detectFileType: .js files → js", () => {
|
|
assert.equal(detectFileType("check-install.js"), "js");
|
|
assert.equal(detectFileType("extract.js"), "js");
|
|
});
|
|
|
|
test("detectFileType: .json files → json", () => {
|
|
assert.equal(detectFileType("package.json"), "json");
|
|
assert.equal(detectFileType("tsconfig.json"), "json");
|
|
});
|
|
|
|
test("detectFileType: .yaml and .yml files → yaml", () => {
|
|
assert.equal(detectFileType("pnpm-lock.yaml"), "yaml");
|
|
assert.equal(detectFileType("other.yml"), "yaml");
|
|
});
|
|
|
|
test("detectFileType: .jsonc files → jsonc", () => {
|
|
assert.equal(detectFileType(".markdownlint.jsonc"), "jsonc");
|
|
});
|
|
|
|
test("detectFileType: unknown extension → unknown", () => {
|
|
assert.equal(detectFileType("Makefile"), "unknown");
|
|
assert.equal(detectFileType("somefile"), "unknown");
|
|
});
|
|
|
|
// ── applyHeader ───────────────────────────────────────────────────────────
|
|
|
|
test("applyHeader: markdown with YAML front matter inserts HTML comment after closing ---", () => {
|
|
const content = "---\nname: create-plan\n---\n\n# Create Plan\n\nBody.\n";
|
|
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/SKILL.md");
|
|
|
|
// Front matter block preserved verbatim at start
|
|
assert.ok(result.startsWith("---\nname: create-plan\n---\n"), "front matter at start");
|
|
|
|
// HTML comment present
|
|
assert.ok(result.includes("<!-- ⚠️"), "HTML comment present");
|
|
|
|
// Comment comes after front matter closer and before the title
|
|
const commentIdx = result.indexOf("<!-- ⚠️");
|
|
const titleIdx = result.indexOf("# Create Plan");
|
|
assert.ok(commentIdx !== -1 && titleIdx !== -1, "both positions found");
|
|
assert.ok(commentIdx < titleIdx, "comment before title");
|
|
|
|
// Comment must NOT appear before the first ---
|
|
assert.ok(commentIdx > 3, "comment not before front matter");
|
|
});
|
|
|
|
test("applyHeader: markdown without front matter inserts HTML comment at top", () => {
|
|
const content = "# Heading\n\nContent\n";
|
|
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/templates/plan.md");
|
|
assert.ok(result.startsWith("<!-- ⚠️"), "comment at very top");
|
|
assert.ok(result.includes("# Heading"), "original content preserved");
|
|
});
|
|
|
|
test("applyHeader: shell with shebang inserts # comment after shebang", () => {
|
|
const content = "#!/usr/bin/env bash\nset -euo pipefail\necho hello\n";
|
|
const result = applyHeader(content, "shell", "skills/reviewer-runtime/run-review.sh");
|
|
|
|
assert.ok(result.startsWith("#!/usr/bin/env bash\n"), "shebang preserved at top");
|
|
assert.ok(result.includes("# ⚠️"), "hash comment present");
|
|
|
|
const shebangEnd = result.indexOf("\n") + 1;
|
|
const commentStart = result.indexOf("# ⚠️");
|
|
assert.ok(commentStart === shebangEnd, "comment immediately after shebang line");
|
|
});
|
|
|
|
test("applyHeader: ts file inserts // comment at top", () => {
|
|
const content = "import path from 'path';\nexport const x = 1;\n";
|
|
const result = applyHeader(content, "ts", "skills/atlassian/shared/scripts/src/cli.ts");
|
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
|
assert.ok(result.includes("import path from 'path';"), "original content preserved");
|
|
});
|
|
|
|
test("applyHeader: ts file with shebang inserts // comment after shebang", () => {
|
|
const content = "#!/usr/bin/env npx tsx\nimport foo from 'foo';\n";
|
|
const result = applyHeader(content, "ts", "auth.ts");
|
|
assert.ok(result.startsWith("#!/usr/bin/env npx tsx\n"), "shebang preserved at top");
|
|
assert.ok(result.includes("// ⚠️"), "// comment present");
|
|
const shebangEnd = result.indexOf("\n") + 1;
|
|
const commentStart = result.indexOf("// ⚠️");
|
|
assert.ok(commentStart === shebangEnd, "// comment immediately after shebang");
|
|
});
|
|
|
|
test("applyHeader: js file with shebang inserts // comment after shebang", () => {
|
|
const content = "#!/usr/bin/env node\nconst x = 1;\n";
|
|
const result = applyHeader(content, "js", "check-install.js");
|
|
assert.ok(result.startsWith("#!/usr/bin/env node\n"), "shebang preserved at top");
|
|
assert.ok(result.includes("// ⚠️"), "// comment present");
|
|
});
|
|
|
|
test("applyHeader: js file inserts // comment at top", () => {
|
|
const content = "const x = require('x');\n";
|
|
const result = applyHeader(content, "js", "check-install.js");
|
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
|
});
|
|
|
|
test("applyHeader: yaml file inserts # comment at top", () => {
|
|
const content = "lockfileVersion: '9.0'\n\npackages:\n";
|
|
const result = applyHeader(content, "yaml", "pnpm-lock.yaml");
|
|
assert.ok(result.startsWith("# ⚠️"), "# comment at top");
|
|
});
|
|
|
|
test("applyHeader: jsonc file inserts // comment at top", () => {
|
|
const content = "// existing comment\n{\n \"key\": true\n}\n";
|
|
const result = applyHeader(content, "jsonc", ".markdownlint.jsonc");
|
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
|
});
|
|
|
|
test("applyHeader: json file returns content unchanged (no header)", () => {
|
|
const content = '{\n "name": "test"\n}\n';
|
|
const result = applyHeader(content, "json", "package.json");
|
|
assert.equal(result, content, "JSON file must not be modified");
|
|
});
|
|
|
|
test("applyHeader: unknown type returns content unchanged", () => {
|
|
const content = "raw content\n";
|
|
const result = applyHeader(content, "unknown", "Makefile");
|
|
assert.equal(result, content);
|
|
});
|
|
|
|
test("applyHeader: header contains canonical source hint", () => {
|
|
const hint = "skills/create-plan/_source/pi/SKILL.md";
|
|
const content = "---\nname: create-plan\n---\n\n# Title\n";
|
|
const result = applyHeader(content, "markdown", hint);
|
|
assert.ok(result.includes(hint), "canonical hint appears in header");
|
|
});
|
|
|
|
test("applyHeader: never inserts header before shebang", () => {
|
|
const content = "#!/usr/bin/env bash\nset -euo pipefail\n";
|
|
const result = applyHeader(content, "shell", "run.sh");
|
|
assert.ok(result.startsWith("#!/"), "shebang still first");
|
|
});
|
|
|
|
// ── makePackageJsonContent ────────────────────────────────────────────────
|
|
|
|
test("makePackageJsonContent: renames name to scoped unique form", () => {
|
|
const src = { name: "atlassian-skill-scripts", version: "1.0.0" };
|
|
const result = makePackageJsonContent(src, "atlassian", "claude-code");
|
|
assert.equal(result.name, "@ai-coding-skills/atlassian-claude-code");
|
|
});
|
|
|
|
test("makePackageJsonContent: adds private:true", () => {
|
|
const src = { name: "web-automation-scripts", version: "1.0.0" };
|
|
const result = makePackageJsonContent(src, "web-automation", "codex");
|
|
assert.equal(result.private, true);
|
|
});
|
|
|
|
test("makePackageJsonContent: preserves all other top-level fields", () => {
|
|
const src = {
|
|
name: "atlassian-skill-scripts",
|
|
version: "1.0.0",
|
|
type: "module",
|
|
scripts: { typecheck: "tsc --noEmit" },
|
|
dependencies: { commander: "^13.1.0" },
|
|
devDependencies: { tsx: "^4.20.5" },
|
|
packageManager: "pnpm@10.18.1",
|
|
};
|
|
const result = makePackageJsonContent(src, "atlassian", "cursor");
|
|
assert.equal(result.version, "1.0.0");
|
|
assert.equal(result.type, "module");
|
|
assert.deepEqual(result.scripts, { typecheck: "tsc --noEmit" });
|
|
assert.deepEqual(result.dependencies, { commander: "^13.1.0" });
|
|
assert.equal(result.packageManager, "pnpm@10.18.1");
|
|
});
|
|
|
|
test("makePackageJsonContent: pi-package mirror uses -pi agent suffix", () => {
|
|
const src = { name: "atlassian-skill-scripts" };
|
|
const result = makePackageJsonContent(src, "atlassian", "pi");
|
|
assert.equal(result.name, "@ai-coding-skills/atlassian-pi");
|
|
});
|
|
|
|
test("makePackageJsonContent: does not mutate the source object", () => {
|
|
const src = { name: "original", version: "1.0.0" };
|
|
makePackageJsonContent(src, "atlassian", "codex");
|
|
assert.equal(src.name, "original", "source object not mutated");
|
|
});
|
|
|
|
// ── getGeneratedRoots ─────────────────────────────────────────────────────
|
|
|
|
test("getGeneratedRoots: returns at least one root per agent per skills skill", () => {
|
|
const roots = getGeneratedRoots();
|
|
assert.ok(Array.isArray(roots), "returns array");
|
|
assert.ok(roots.length > 0, "non-empty");
|
|
|
|
const agents = ["claude-code", "codex", "cursor", "opencode", "pi"];
|
|
const skillsWithAgents = ["create-plan", "do-task", "implement-plan", "atlassian", "web-automation"];
|
|
for (const skill of skillsWithAgents) {
|
|
for (const agent of agents) {
|
|
const expected = `skills/${skill}/${agent}`;
|
|
assert.ok(roots.includes(expected), `missing generated root: ${expected}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("getGeneratedRoots: includes pi-package mirrors for all skills", () => {
|
|
const roots = getGeneratedRoots();
|
|
const piPackageSkills = ["atlassian", "create-plan", "do-task", "implement-plan", "web-automation"];
|
|
for (const skill of piPackageSkills) {
|
|
const expected = `pi-package/skills/${skill}`;
|
|
assert.ok(roots.includes(expected), `missing pi-package mirror root: ${expected}`);
|
|
}
|
|
});
|
|
|
|
test("getGeneratedRoots: includes reviewer-runtime/pi", () => {
|
|
const roots = getGeneratedRoots();
|
|
assert.ok(roots.includes("skills/reviewer-runtime/pi"), "reviewer-runtime/pi missing");
|
|
});
|
|
|
|
test("getGeneratedRoots: does not include _source or shared directories", () => {
|
|
const roots = getGeneratedRoots();
|
|
for (const r of roots) {
|
|
assert.ok(!r.includes("_source"), `root should not contain _source: ${r}`);
|
|
assert.ok(!r.endsWith("/shared"), `root should not be shared: ${r}`);
|
|
assert.ok(!r.includes("shared/scripts"), `root should not contain shared/scripts: ${r}`);
|
|
}
|
|
});
|
|
|
|
// ── buildManifest ─────────────────────────────────────────────────────────
|
|
|
|
test("buildManifest: returns object with $schema and generator markers", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-test-"));
|
|
try {
|
|
await writeFile(path.join(dir, "SKILL.md"), "---\nname: test\n---\n# Test\n");
|
|
await mkdir(path.join(dir, "templates"), { recursive: true });
|
|
await writeFile(path.join(dir, "templates", "plan.md"), "# Plan\n");
|
|
// Write .generated-manifest.json itself (should be excluded from listing)
|
|
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
|
|
|
|
const manifest = await buildManifest(dir, "skills/test/claude-code");
|
|
|
|
assert.ok(manifest.$schema, "$schema field present");
|
|
assert.ok(manifest.generator, "generator field present");
|
|
assert.equal(manifest.generatedRoot, "skills/test/claude-code");
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildManifest: does not include .generated-manifest.json in files list", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-self-"));
|
|
try {
|
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
|
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
|
|
|
|
const manifest = await buildManifest(dir, "skills/test/pi");
|
|
|
|
const paths = manifest.files.map((f) => f.path);
|
|
assert.ok(!paths.includes(".generated-manifest.json"), "manifest must not list itself");
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildManifest: files list includes path, kind, mode, sha256", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-fields-"));
|
|
try {
|
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
|
|
|
const manifest = await buildManifest(dir, "skills/test/codex");
|
|
|
|
assert.equal(manifest.files.length, 1);
|
|
const entry = manifest.files[0];
|
|
assert.equal(entry.path, "SKILL.md");
|
|
assert.equal(entry.kind, "file");
|
|
assert.ok(typeof entry.mode === "string" && entry.mode.match(/^\d{3}$/), "mode is 3-digit octal string");
|
|
assert.ok(typeof entry.sha256 === "string" && entry.sha256.length === 64, "sha256 is 64-char hex");
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildManifest: files are sorted by path", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-sorted-"));
|
|
try {
|
|
await mkdir(path.join(dir, "templates"), { recursive: true });
|
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
|
await writeFile(path.join(dir, "templates", "z.md"), "z\n");
|
|
await writeFile(path.join(dir, "templates", "a.md"), "a\n");
|
|
|
|
const manifest = await buildManifest(dir, "skills/test/cursor");
|
|
|
|
const paths = manifest.files.map((f) => f.path);
|
|
const sorted = [...paths].sort();
|
|
assert.deepEqual(paths, sorted, "files must be sorted by path");
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildManifest: sha256 matches actual file content", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-hash-"));
|
|
try {
|
|
const content = "---\nname: test\n---\n# Title\n";
|
|
await writeFile(path.join(dir, "SKILL.md"), content);
|
|
|
|
const manifest = await buildManifest(dir, "skills/test/opencode");
|
|
|
|
const expected = crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
assert.equal(manifest.files[0].sha256, expected, "sha256 matches file content");
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("generateSkills: clears pre-existing empty generated directories without EISDIR", async () => {
|
|
const targetRoot = await mkdtemp(path.join(tmpdir(), "generate-skills-target-"));
|
|
try {
|
|
await mkdir(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates"), { recursive: true });
|
|
|
|
await generateSkills(path.resolve(SCRIPTS_DIR, ".."), { targetRoot });
|
|
|
|
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "SKILL.md"), "utf8");
|
|
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates", "milestone-plan.md"), "utf8");
|
|
} finally {
|
|
await rm(targetRoot, { recursive: true, force: true });
|
|
}
|
|
});
|