diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000..282ae09 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,45 @@ +name: Smoke tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + smoke-tests: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Install root smoke-test dependencies + run: bun install --frozen-lockfile + + - name: Install codex-plugin dependencies + working-directory: packages/codex-plugin + run: bun install --frozen-lockfile + + - name: Build codex-plugin + working-directory: packages/codex-plugin + run: bun run build + + - name: Run smoke tests with pinned skills CLI + run: bun test + + - name: Run smoke tests with skills@latest + env: + SMOKE_SKILLS_CLI_CHANNEL: latest + run: bun test diff --git a/README.md b/README.md index a833bae..75349eb 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,28 @@ Contributions welcome! Skills should be: When adding or editing skills, follow the [agentskills.io specification](https://agentskills.io/specification) and [Claude Code best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices). The maintainer checklist lives in [AGENTS.md](./AGENTS.md), with supporting details in [docs/skill-conventions.md](./docs/skill-conventions.md). +### Validation + +Before opening a PR, run the local validation checks that match the current repository setup: + +```bash +bun install +bun test +``` + +This repository's smoke suite covers: + +- `SKILL.md` frontmatter parsing +- marketplace drift between shipped skills/plugins and metadata +- Codex plugin installation in project and pseudo-global modes +- Claude Code layout checks +- standalone skill installation checks + +The GitHub Actions smoke workflow runs the same suite twice: + +- once with the pinned local `skills` CLI version from `package.json` +- once with `skills@latest` as a lightweight forward-compatibility signal + ## Roadmap / Work in Progress This is just the start! The following features are planned or in progress. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..92fe178 --- /dev/null +++ b/bun.lock @@ -0,0 +1,38 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "agent-skills-smoke-tests", + "devDependencies": { + "gray-matter": "^4.0.3", + "skills": "1.4.9", + }, + }, + }, + "packages": { + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "skills": ["skills@1.4.9", "", { "dependencies": { "yaml": "^2.8.3" }, "bin": { "skills": "bin/cli.mjs", "add-skill": "bin/cli.mjs" } }, "sha512-BTh7kfSkGPirsLgvg5vvALjDlgNImm9HRn937yAfESFzmShQEZWWTYJQbN34qjlwxOBO7Me4E9Lh6Ot5AE29zA=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b2558a1 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "agent-skills-smoke-tests", + "private": true, + "packageManager": "bun@1.3.11", + "devDependencies": { + "gray-matter": "^4.0.3", + "skills": "1.4.9" + } +} diff --git a/tests/claude-layout-smoke.test.ts b/tests/claude-layout-smoke.test.ts new file mode 100644 index 0000000..6e6a9fd --- /dev/null +++ b/tests/claude-layout-smoke.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { cpSync, existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + createTempDir, + cleanupTempDir, + parseFrontmatter, + readJsonFile, + repoRoot +} from "./helpers/smoke-test-helpers"; + +type ClaudeMarketplacePlugin = { + name: string; + source: string; + skills: string[]; +}; + +type ClaudeMarketplaceManifest = { + name: string; + plugins: ClaudeMarketplacePlugin[]; +}; + +function getClaudeMarketplace(): ClaudeMarketplaceManifest { + return readJsonFile( + join(repoRoot, ".claude-plugin", "marketplace.json") + ); +} + +describe("claude layout smoke", () => { + test("parses the Claude marketplace and resolves plugin skills under the plugin source", () => { + const manifest = getClaudeMarketplace(); + + expect(manifest.name).toBeString(); + expect(manifest.plugins.length).toBeGreaterThan(0); + + for (const plugin of manifest.plugins) { + const pluginSourceRoot = resolve(repoRoot, plugin.source); + + expect(existsSync(pluginSourceRoot)).toBe(true); + expect(plugin.skills.length).toBeGreaterThan(0); + + for (const skillPath of plugin.skills) { + const absoluteSkillPath = resolve(pluginSourceRoot, skillPath); + + expect(absoluteSkillPath.startsWith(join(repoRoot, "skills"))).toBe(true); + expect(existsSync(absoluteSkillPath)).toBe(true); + expect(existsSync(join(absoluteSkillPath, "SKILL.md"))).toBe(true); + + const frontmatter = parseFrontmatter( + readFileSync(join(absoluteSkillPath, "SKILL.md"), "utf8") + ); + + expect(frontmatter.name).toBeString(); + expect(frontmatter.name).toBe(plugin.name); + } + } + }); + + test("copies skills into the documented personal Claude layout", () => { + const tempRoot = createTempDir("claude-personal-layout-smoke-"); + + try { + const manifest = getClaudeMarketplace(); + const personalSkillsRoot = join(tempRoot, ".claude", "skills"); + + for (const plugin of manifest.plugins) { + const sourceSkillRoot = resolve(repoRoot, plugin.skills[0]!); + const targetSkillRoot = join(personalSkillsRoot, plugin.name); + + cpSync(sourceSkillRoot, targetSkillRoot, { recursive: true, dereference: true }); + + expect(existsSync(join(targetSkillRoot, "SKILL.md"))).toBe(true); + expect( + readFileSync(join(targetSkillRoot, "SKILL.md"), "utf8") + ).toContain(`name: ${plugin.name}`); + } + } finally { + cleanupTempDir(tempRoot); + } + }); + + test("copies skills into the documented project Claude layout", () => { + const tempRoot = createTempDir("claude-project-layout-smoke-"); + + try { + const manifest = getClaudeMarketplace(); + const projectRoot = join(tempRoot, "project"); + const projectSkillsRoot = join(projectRoot, ".claude", "skills"); + + for (const plugin of manifest.plugins) { + const sourceSkillRoot = resolve(repoRoot, plugin.skills[0]!); + const targetSkillRoot = join(projectSkillsRoot, plugin.name); + + cpSync(sourceSkillRoot, targetSkillRoot, { recursive: true, dereference: true }); + + expect(existsSync(join(targetSkillRoot, "SKILL.md"))).toBe(true); + expect( + readFileSync(join(targetSkillRoot, "SKILL.md"), "utf8") + ).toContain(`name: ${plugin.name}`); + } + } finally { + cleanupTempDir(tempRoot); + } + }); +}); diff --git a/tests/codex-plugin-install-smoke.test.ts b/tests/codex-plugin-install-smoke.test.ts new file mode 100644 index 0000000..8a7be2b --- /dev/null +++ b/tests/codex-plugin-install-smoke.test.ts @@ -0,0 +1,206 @@ +import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + cleanupTempDir, + createCodexPluginFixtureRepo, + createTempDir, + ensureBuiltCodexPluginCli, + ensureDir, + readJsonFile, + runCodexPluginInstaller, + writeGitUrlRewrite, + type MarketplaceManifest +} from "./helpers/smoke-test-helpers"; + +const repoRef = "callstackincubator/agent-skills"; + +let cliPath = ""; + +setDefaultTimeout(20_000); + +beforeAll(() => { + cliPath = ensureBuiltCodexPluginCli(); +}); + +describe("codex-plugin install smoke", () => { + test("installs project marketplace into the working directory", () => { + const tempRoot = createTempDir("codex-plugin-project-smoke-"); + + try { + const { sourceRepoRoot, sourceManifest } = createCodexPluginFixtureRepo(tempRoot); + const workspaceRoot = join(tempRoot, "workspace"); + const homeDir = join(tempRoot, "home"); + + ensureDir(workspaceRoot); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + runCodexPluginInstaller({ + cliPath, + cwd: workspaceRoot, + homeDir, + repoRef, + scope: "project" + }); + + const marketplacePath = join(workspaceRoot, ".agents", "plugins", "marketplace.json"); + const installedMarketplace = readJsonFile(marketplacePath); + + expect(installedMarketplace.plugins.map((plugin) => plugin.name).sort()).toEqual( + sourceManifest.plugins.map((plugin) => plugin.name).sort() + ); + + for (const plugin of sourceManifest.plugins) { + const installedPluginRoot = join(workspaceRoot, ".codex", "plugins", plugin.name); + const installedPluginEntry = installedMarketplace.plugins.find( + (entry) => entry.name === plugin.name + ); + + expect(existsSync(installedPluginRoot)).toBe(true); + expect(existsSync(join(installedPluginRoot, ".codex-plugin", "plugin.json"))).toBe(true); + expect(existsSync(join(installedPluginRoot, "README.md"))).toBe(true); + expect(installedPluginEntry?.source.path).toBe(`./.codex/plugins/${plugin.name}`); + } + + expect(existsSync(join(homeDir, ".agents", "plugins", "marketplace.json"))).toBe(false); + expect(existsSync(join(homeDir, ".codex", "plugins"))).toBe(false); + } finally { + cleanupTempDir(tempRoot); + } + }); + + test("installs global marketplace into a fake HOME without touching the workspace", () => { + const tempRoot = createTempDir("codex-plugin-global-smoke-"); + + try { + const { sourceRepoRoot, sourceManifest } = createCodexPluginFixtureRepo(tempRoot); + const workspaceRoot = join(tempRoot, "workspace"); + const homeDir = join(tempRoot, "home"); + + ensureDir(workspaceRoot); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + runCodexPluginInstaller({ + cliPath, + cwd: workspaceRoot, + homeDir, + repoRef, + scope: "global" + }); + + const marketplacePath = join(homeDir, ".agents", "plugins", "marketplace.json"); + const installedMarketplace = readJsonFile(marketplacePath); + + expect(installedMarketplace.plugins.map((plugin) => plugin.name).sort()).toEqual( + sourceManifest.plugins.map((plugin) => plugin.name).sort() + ); + + for (const plugin of sourceManifest.plugins) { + const installedPluginRoot = join(homeDir, ".codex", "plugins", plugin.name); + const installedPluginEntry = installedMarketplace.plugins.find( + (entry) => entry.name === plugin.name + ); + + expect(existsSync(installedPluginRoot)).toBe(true); + expect(existsSync(join(installedPluginRoot, ".codex-plugin", "plugin.json"))).toBe(true); + expect(installedPluginEntry?.source.path).toBe(`./.codex/plugins/${plugin.name}`); + } + + expect(existsSync(join(workspaceRoot, ".agents"))).toBe(false); + expect(existsSync(join(workspaceRoot, ".codex"))).toBe(false); + } finally { + cleanupTempDir(tempRoot); + } + }); + + test("merges into an existing marketplace and stays stable across duplicate installs", () => { + const tempRoot = createTempDir("codex-plugin-merge-smoke-"); + + try { + const { sourceRepoRoot, sourceManifest } = createCodexPluginFixtureRepo(tempRoot); + const workspaceRoot = join(tempRoot, "workspace"); + const homeDir = join(tempRoot, "home"); + const marketplacePath = join(workspaceRoot, ".agents", "plugins", "marketplace.json"); + + ensureDir(join(workspaceRoot, ".agents", "plugins")); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + writeFileSync( + marketplacePath, + `${JSON.stringify( + { + name: "custom-marketplace", + interface: { + displayName: "Existing Marketplace" + }, + plugins: [ + { + name: "existing-plugin", + source: { + source: "local", + path: "./.codex/plugins/existing-plugin" + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_INSTALL" + }, + category: "Utility" + }, + { + name: sourceManifest.plugins[0]!.name, + source: { + source: "local", + path: "./.codex/plugins/stale-copy" + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_INSTALL" + }, + category: "Stale" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + runCodexPluginInstaller({ + cliPath, + cwd: workspaceRoot, + homeDir, + repoRef, + scope: "project" + }); + runCodexPluginInstaller({ + cliPath, + cwd: workspaceRoot, + homeDir, + repoRef, + scope: "project" + }); + + const mergedMarketplace = readJsonFile(marketplacePath); + const mergedNames = mergedMarketplace.plugins.map((plugin) => plugin.name); + + expect(mergedMarketplace.name).toBe("custom-marketplace"); + expect(mergedMarketplace.interface.displayName).toBe("Existing Marketplace"); + expect(mergedNames.filter((name) => name === "existing-plugin")).toHaveLength(1); + expect(mergedNames.filter((name) => name === sourceManifest.plugins[0]!.name)).toHaveLength(1); + expect(mergedNames.sort()).toEqual( + ["existing-plugin", ...sourceManifest.plugins.map((plugin) => plugin.name)].sort() + ); + + for (const plugin of sourceManifest.plugins) { + const installedPluginEntry = mergedMarketplace.plugins.find( + (entry) => entry.name === plugin.name + ); + + expect(installedPluginEntry?.source.path).toBe(`./.codex/plugins/${plugin.name}`); + } + } finally { + cleanupTempDir(tempRoot); + } + }); +}); diff --git a/tests/helpers/smoke-test-helpers.ts b/tests/helpers/smoke-test-helpers.ts new file mode 100644 index 0000000..534219b --- /dev/null +++ b/tests/helpers/smoke-test-helpers.ts @@ -0,0 +1,179 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import matter from "gray-matter"; + +export type MarketplacePluginEntry = { + name: string; + source: { + source: "local"; + path: string; + }; + policy: { + installation: "AVAILABLE" | "NOT_AVAILABLE" | "INSTALLED_BY_DEFAULT"; + authentication: "ON_INSTALL" | "ON_USE"; + }; + category: string; +}; + +export type MarketplaceManifest = { + name: string; + interface: { + displayName: string; + }; + plugins: MarketplacePluginEntry[]; +}; + +export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export function createTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function cleanupTempDir(path: string): void { + rmSync(path, { recursive: true, force: true }); +} + +export function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function readJsonFile(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function parseFrontmatter(content: string): Record { + if (!matter.test(content)) { + throw new Error("Missing YAML frontmatter block."); + } + + const { data } = matter(content); + + return data as Record; +} + +export function getSourceCodexMarketplace(): MarketplaceManifest { + return readJsonFile( + join(repoRoot, ".agents", "plugins", "marketplace.json") + ); +} + +export function createCodexPluginFixtureRepo(tempRoot: string): { + sourceRepoRoot: string; + sourceManifest: MarketplaceManifest; +} { + const sourceManifest = getSourceCodexMarketplace(); + const sourceRepoRoot = join(tempRoot, "source-repo"); + + ensureDir(join(sourceRepoRoot, ".agents", "plugins")); + writeFileSync( + join(sourceRepoRoot, ".agents", "plugins", "marketplace.json"), + `${JSON.stringify(sourceManifest, null, 2)}\n`, + "utf8" + ); + + for (const plugin of sourceManifest.plugins) { + const sourcePluginDir = resolve(repoRoot, plugin.source.path); + const targetPluginDir = join(sourceRepoRoot, "plugins", plugin.name); + cpSync(sourcePluginDir, targetPluginDir, { recursive: true, dereference: true }); + } + + execFileSync("git", ["init"], { cwd: sourceRepoRoot, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Smoke Tests"], { + cwd: sourceRepoRoot, + stdio: "pipe" + }); + execFileSync("git", ["config", "user.email", "smoke-tests@example.com"], { + cwd: sourceRepoRoot, + stdio: "pipe" + }); + execFileSync("git", ["add", "."], { cwd: sourceRepoRoot, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd: sourceRepoRoot, stdio: "pipe" }); + + return { sourceRepoRoot, sourceManifest }; +} + +export function createSkillsFixtureRepo(tempRoot: string): string { + const sourceRepoRoot = join(tempRoot, "skills-source-repo"); + const entriesToCopy = [ + ".agents", + ".claude", + ".claude-plugin", + "skills" + ]; + + for (const entry of entriesToCopy) { + cpSync(join(repoRoot, entry), join(sourceRepoRoot, entry), { + recursive: true, + dereference: true + }); + } + + execFileSync("git", ["init"], { cwd: sourceRepoRoot, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Smoke Tests"], { + cwd: sourceRepoRoot, + stdio: "pipe" + }); + execFileSync("git", ["config", "user.email", "smoke-tests@example.com"], { + cwd: sourceRepoRoot, + stdio: "pipe" + }); + execFileSync("git", ["add", "."], { cwd: sourceRepoRoot, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd: sourceRepoRoot, stdio: "pipe" }); + + return sourceRepoRoot; +} + +export function writeGitUrlRewrite(homeDir: string, repoRef: string, sourceRepoRoot: string): void { + ensureDir(homeDir); + writeFileSync( + join(homeDir, ".gitconfig"), + [ + `[url "${pathToFileURL(sourceRepoRoot).href}"]`, + ` insteadOf = https://github.com/${repoRef}.git`, + "" + ].join("\n"), + "utf8" + ); +} + +export function ensureBuiltCodexPluginCli(): string { + const packageRoot = join(repoRoot, "packages", "codex-plugin"); + const cliPath = join(packageRoot, "dist", "index.js"); + + execFileSync("bun", ["run", "build"], { + cwd: packageRoot, + stdio: "pipe" + }); + + return cliPath; +} + +export function runCodexPluginInstaller({ + cliPath, + cwd, + homeDir, + repoRef, + scope +}: { + cliPath: string; + cwd: string; + homeDir: string; + repoRef: string; + scope: "global" | "project"; +}): void { + execFileSync( + "bun", + [cliPath, "add", repoRef, `--${scope}`, "--yes"], + { + cwd, + env: { + ...process.env, + HOME: homeDir + }, + stdio: "pipe" + } + ); +} diff --git a/tests/manifest-smoke.test.ts b/tests/manifest-smoke.test.ts new file mode 100644 index 0000000..c2fbd21 --- /dev/null +++ b/tests/manifest-smoke.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + parseFrontmatter, + readJsonFile, + repoRoot, + type MarketplaceManifest +} from "./helpers/smoke-test-helpers"; + +type ClaudeMarketplaceManifest = { + plugins: Array<{ + name: string; + source: string; + skills: string[]; + }>; +}; + +describe("manifest smoke", () => { + test("parses SKILL.md frontmatter for shipped skills", () => { + const skillsRoot = join(repoRoot, "skills"); + const skillDirectories = readdirSync(skillsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + expect(skillDirectories.length).toBeGreaterThan(0); + + for (const skillDirectory of skillDirectories) { + const skillPath = join(skillsRoot, skillDirectory, "SKILL.md"); + const frontmatter = parseFrontmatter(readFileSync(skillPath, "utf8")); + + expect(frontmatter.name).toBeString(); + expect(frontmatter.description).toBeString(); + } + }); + + test("every Claude marketplace skill path exists under skills", () => { + const manifest = readJsonFile( + join(repoRoot, ".claude-plugin", "marketplace.json") + ); + + for (const plugin of manifest.plugins) { + expect(plugin.skills.length).toBeGreaterThan(0); + + for (const skillPath of plugin.skills) { + const absoluteSkillPath = resolve(repoRoot, skillPath); + expect(absoluteSkillPath.startsWith(join(repoRoot, "skills"))).toBe(true); + expect(existsSync(absoluteSkillPath)).toBe(true); + expect(existsSync(join(absoluteSkillPath, "SKILL.md"))).toBe(true); + } + } + }); + + test("every Codex marketplace plugin path exists under plugins", () => { + const manifest = readJsonFile( + join(repoRoot, ".agents", "plugins", "marketplace.json") + ); + + for (const plugin of manifest.plugins) { + const absolutePluginPath = resolve(repoRoot, plugin.source.path); + expect(absolutePluginPath.startsWith(join(repoRoot, "plugins"))).toBe(true); + expect(existsSync(absolutePluginPath)).toBe(true); + expect(existsSync(join(absolutePluginPath, ".codex-plugin", "plugin.json"))).toBe(true); + } + }); +}); diff --git a/tests/skill-install-smoke.test.ts b/tests/skill-install-smoke.test.ts new file mode 100644 index 0000000..9ee5a39 --- /dev/null +++ b/tests/skill-install-smoke.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + cleanupTempDir, + createSkillsFixtureRepo, + createTempDir, + repoRoot, + writeGitUrlRewrite +} from "./helpers/smoke-test-helpers"; + +const repoRef = "callstackincubator/agent-skills"; +const skillsCliPath = resolve(repoRoot, "node_modules", ".bin", "skills"); +const useLatestSkillsCli = process.env.SMOKE_SKILLS_CLI_CHANNEL === "latest"; + +setDefaultTimeout(20_000); + +function runSkillsAdd( + source: string, + args: string[], + { + cwd, + homeDir + }: { + cwd: string; + homeDir: string; + } +): string { + const command = useLatestSkillsCli ? "npx" : skillsCliPath; + const commandArgs = useLatestSkillsCli + ? ["-y", "skills@latest", "add", source, ...args] + : ["add", source, ...args]; + + return execFileSync(command, commandArgs, { + cwd, + env: { + ...process.env, + HOME: homeDir, + FORCE_COLOR: "0", + NO_COLOR: "1", + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_update_notifier: "false" + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); +} + +describe("skill install smoke", () => { + test(`uses the expected skills CLI channel (${useLatestSkillsCli ? "latest" : "pinned"})`, () => { + expect(useLatestSkillsCli ? "latest" : "pinned").toBe( + process.env.SMOKE_SKILLS_CLI_CHANNEL === "latest" ? "latest" : "pinned" + ); + }); + + test("lists skills from a remote repository source without creating install output", () => { + const tempRoot = createTempDir("skill-list-smoke-"); + const homeDir = createTempDir("skill-list-home-"); + + try { + const sourceRepoRoot = createSkillsFixtureRepo(tempRoot); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + const output = runSkillsAdd(repoRef, ["--list"], { + cwd: tempRoot, + homeDir + }); + + expect(output).toContain("github"); + expect(output).toContain("react-native-best-practices"); + expect(output).toContain("upgrading-react-native"); + + expect(existsSync(join(tempRoot, ".claude"))).toBe(false); + expect(existsSync(join(tempRoot, ".agents"))).toBe(false); + expect(existsSync(join(tempRoot, "skills-lock.json"))).toBe(false); + } finally { + cleanupTempDir(tempRoot); + cleanupTempDir(homeDir); + } + }); + + test("installs a selected skill from a remote repository source into the project Codex layout", () => { + const tempRoot = createTempDir("skill-project-codex-smoke-"); + const homeDir = createTempDir("skill-project-codex-home-"); + + try { + const sourceRepoRoot = createSkillsFixtureRepo(tempRoot); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + runSkillsAdd(repoRef, ["--skill", "github", "--agent", "codex", "-y", "--copy"], { + cwd: tempRoot, + homeDir + }); + + const installedSkillPath = join(tempRoot, ".agents", "skills", "github", "SKILL.md"); + + expect(existsSync(installedSkillPath)).toBe(true); + expect(existsSync(join(tempRoot, ".agents", "skills", "github", "agents", "openai.yaml"))).toBe( + true + ); + expect(existsSync(join(tempRoot, "skills-lock.json"))).toBe(true); + expect(readFileSync(installedSkillPath, "utf8")).toContain("name: github"); + + expect(existsSync(join(tempRoot, ".claude"))).toBe(false); + expect(existsSync(join(homeDir, ".agents"))).toBe(false); + expect(existsSync(join(homeDir, ".claude"))).toBe(false); + } finally { + cleanupTempDir(tempRoot); + cleanupTempDir(homeDir); + } + }); + + test("installs a selected skill from a remote repository source into the global Claude layout using fake HOME", () => { + const tempRoot = createTempDir("skill-global-claude-smoke-"); + const homeDir = createTempDir("skill-global-claude-home-"); + + try { + const sourceRepoRoot = createSkillsFixtureRepo(tempRoot); + writeGitUrlRewrite(homeDir, repoRef, sourceRepoRoot); + + runSkillsAdd(repoRef, ["--skill", "github", "--agent", "claude-code", "--global", "-y", "--copy"], { + cwd: tempRoot, + homeDir + }); + + const installedSkillPath = join(homeDir, ".claude", "skills", "github", "SKILL.md"); + + expect(existsSync(installedSkillPath)).toBe(true); + expect( + existsSync(join(homeDir, ".claude", "skills", "github", "references", "stacked-pr-workflow.md")) + ).toBe(true); + expect(readFileSync(installedSkillPath, "utf8")).toContain("name: github"); + + expect(existsSync(join(tempRoot, ".claude"))).toBe(false); + expect(existsSync(join(tempRoot, ".agents"))).toBe(false); + expect(existsSync(join(tempRoot, "skills-lock.json"))).toBe(false); + expect(existsSync(join(homeDir, ".agents", "skills"))).toBe(false); + } finally { + cleanupTempDir(tempRoot); + cleanupTempDir(homeDir); + } + }); +});