Files
pbakaus_impeccable/tests/validate-plugin-manifest.test.js
T
6d2af3f800 Guard the plugin loader contract that PR #494 exposed (#499)
* test: guard the plugin loader contract that PR #494 exposed

The agents manifest key shipped for months and silently loaded zero of
the four subagents; no validator looked at the generated plugin
manifest's shape and claude plugin validate never checks it. Three
layers now do:

- scripts/lib/validate-plugin-manifest.js pins the verified loader
  contract (KNOWN_LOADER_KEYS allowlist, no agents key, trailing-slash
  skills path from issue #86, every skill/agents/*.md shipped in
  plugin/agents/), unit-tested in tests/validate-plugin-manifest.test.js
  including a check of the real committed subtree.
- The same check gates bun run build next to the version-drift guard.
- tests/plugin-e2e.test.mjs installs the committed ./plugin subtree into
  a real Claude Code (sandboxed via CLAUDE_CONFIG_DIR in a temp dir) and
  asserts the component inventory: skill parses, all agents visible,
  hooks discovered. In the default suite; runs in about a second and
  skips cleanly when the claude CLI is absent, so CI is unaffected.

All three failed against the pre-#494 tree for the shipped reason
(Agents 0 of 4) and pass against current main.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: address PR review bot findings

- Copilot: guard collectPluginManifestFindings against valid JSON that is
  not an object (null, string, number, array) so a broken manifest is a
  finding instead of a build crash; unit test added
- Copilot: update the plugin-e2e header comment, the suite is in the
  default lineup rather than opt-in

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: harden plugin E2E sandbox isolation

Bugbot: create the sandbox CLAUDE_CONFIG_DIR up front and redirect HOME
and USERPROFILE into the temp workDir too, so a CLI code path that
derives config or cache locations from the home directory instead of
CLAUDE_CONFIG_DIR still cannot touch the developer's real Claude config
when the default suite runs.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: agent parity check mirrors the build's emit rules

Bugbot: the shipped filename is `${claude-name || name}.md` and a
providers: list may exclude claude-code, so comparing raw source
basenames could fail the build on a renamed or provider-scoped agent
with a build:release hint that cannot fix it. The validator now derives
expected filenames the same way the transformer factory does (shared
parseFrontmatter, same providers gate) with unit coverage for renames,
name overrides, and provider-scoped agents.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: run the plugin E2E through a shell on Windows

Bugbot: the claude CLI is a .cmd shim on Windows and Node refuses to
spawn those via execFile without a shell, so the availability probe
always failed and the suite silently skipped there. Windows now invokes
through a shell with every argument double-quoted (temp paths routinely
contain spaces); the POSIX path is unchanged.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-03 13:13:37 -07:00

195 lines
7.8 KiB
JavaScript

/**
* Unit coverage for the plugin manifest shape guard (PR #494).
*
* The bug this exists to catch: the build emitted an `agents` array into the
* generated plugin manifest, and that key made Claude Code load zero of the
* four shipped subagents. No validator looked at the manifest's shape, so the
* defect shipped in every release since the subagents were added. The guard
* pins the verified loader contract (KNOWN_LOADER_KEYS) so any new key fails
* the build until it has been confirmed against a real Claude Code install.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
collectPluginManifestFindings,
KNOWN_LOADER_KEYS,
} from '../scripts/lib/validate-plugin-manifest.js';
const REPO_ROOT = path.resolve(import.meta.dir, '..');
const GOOD_MANIFEST = {
name: 'impeccable',
description: 'Test plugin',
version: '4.0.4',
author: { name: 'Paul Bakaus' },
homepage: 'https://impeccable.style',
repository: 'https://github.com/pbakaus/impeccable',
skills: './skills/',
};
function writeFixture(root, { manifest = GOOD_MANIFEST, sourceAgents = [], shippedAgents } = {}) {
const write = (rel, contents) => {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, contents);
};
if (manifest !== undefined) {
write(
'plugin/.claude-plugin/plugin.json',
typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 2),
);
}
// Source agents may be plain filenames or { file, frontmatter } for tests
// that exercise the build's emit rules (claude-name, name, providers).
for (const agent of sourceAgents) {
const { file, frontmatter = '' } = typeof agent === 'string' ? { file: agent } : agent;
write(`skill/agents/${file}`, `---\n${frontmatter}${frontmatter ? '\n' : ''}description: t\n---\nBody.\n`);
}
const shipped = shippedAgents ?? sourceAgents.map((a) => (typeof a === 'string' ? a : a.file));
for (const file of shipped) write(`plugin/agents/${file}`, '---\ndescription: t\n---\nBody.\n');
}
describe('collectPluginManifestFindings', () => {
let root;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-manifest-'));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
test('a clean manifest with shipped agents produces no findings', () => {
writeFixture(root, { sourceAgents: ['reviewer.md', 'producer.md'] });
expect(collectPluginManifestFindings(root)).toEqual([]);
});
test('flags an agents key given as an array of file paths (the shipped bug)', () => {
writeFixture(root, {
manifest: { ...GOOD_MANIFEST, agents: ['./agents/reviewer.md', './agents/producer.md'] },
sourceAgents: ['reviewer.md', 'producer.md'],
});
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].relPath).toBe('plugin/.claude-plugin/plugin.json');
expect(findings[0].reason).toMatch(/"agents" key/);
expect(findings[0].reason).toMatch(/zero agents/);
});
test('flags an agents key of any other shape too', () => {
writeFixture(root, { manifest: { ...GOOD_MANIFEST, agents: './agents' } });
const findings = collectPluginManifestFindings(root);
expect(findings.some((f) => f.reason.match(/"agents" key/))).toBe(true);
});
test('flags a manifest key outside the verified loader contract', () => {
writeFixture(root, { manifest: { ...GOOD_MANIFEST, mcpServers: './mcp.json' } });
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].reason).toMatch(/unverified manifest key "mcpServers"/);
expect(findings[0].reason).toMatch(/KNOWN_LOADER_KEYS/);
});
test('flags a skills path without the trailing slash (issue #86)', () => {
writeFixture(root, { manifest: { ...GOOD_MANIFEST, skills: './skills' } });
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].reason).toMatch(/trailing-slash/);
});
test('flags a source agent missing from the shipped subtree', () => {
writeFixture(root, {
sourceAgents: ['reviewer.md', 'producer.md'],
shippedAgents: ['reviewer.md'],
});
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].relPath).toBe('plugin/agents/producer.md');
expect(findings[0].reason).toMatch(/never load/);
expect(findings[0].reason).toMatch(/skill\/agents\/producer\.md/);
});
test('a claude-name rename expects the emitted filename, not the source basename', () => {
writeFixture(root, {
sourceAgents: [{ file: 'reviewer.md', frontmatter: 'claude-name: impeccable-reviewer' }],
shippedAgents: ['impeccable-reviewer.md'],
});
expect(collectPluginManifestFindings(root)).toEqual([]);
});
test('a claude-name rename that is not shipped is reported under the emitted filename', () => {
writeFixture(root, {
sourceAgents: [{ file: 'reviewer.md', frontmatter: 'claude-name: impeccable-reviewer' }],
shippedAgents: [],
});
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].relPath).toBe('plugin/agents/impeccable-reviewer.md');
expect(findings[0].reason).toMatch(/skill\/agents\/reviewer\.md/);
});
test('a frontmatter name overrides the source basename like the build does', () => {
writeFixture(root, {
sourceAgents: [{ file: 'reviewer.md', frontmatter: 'name: custom-reviewer' }],
shippedAgents: ['custom-reviewer.md'],
});
expect(collectPluginManifestFindings(root)).toEqual([]);
});
test('an agent whose providers list excludes claude-code owes no shipped copy', () => {
writeFixture(root, {
sourceAgents: [
{ file: 'codex-only.md', frontmatter: 'providers: codex' },
'reviewer.md',
],
shippedAgents: ['reviewer.md'],
});
expect(collectPluginManifestFindings(root)).toEqual([]);
});
test('reports every problem at once', () => {
writeFixture(root, {
manifest: { ...GOOD_MANIFEST, agents: ['./agents/reviewer.md'], skills: './skills', commands: './commands/' },
sourceAgents: ['reviewer.md'],
shippedAgents: [],
});
const reasons = collectPluginManifestFindings(root).map((f) => f.reason);
expect(reasons).toHaveLength(4);
});
test('an absent plugin subtree produces no findings', () => {
expect(collectPluginManifestFindings(root)).toEqual([]);
});
test('a malformed manifest is a finding, not a thrown stack', () => {
writeFixture(root, { manifest: '{ not json' });
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].reason).toMatch(/parse/);
});
test('valid JSON that is not an object is a finding, not a thrown stack', () => {
for (const raw of ['null', '"impeccable"', '42', '["./agents/reviewer.md"]']) {
writeFixture(root, { manifest: raw });
const findings = collectPluginManifestFindings(root);
expect(findings).toHaveLength(1);
expect(findings[0].reason).toMatch(/not a JSON object/);
}
});
test('KNOWN_LOADER_KEYS never re-admits agents', () => {
expect(KNOWN_LOADER_KEYS).not.toContain('agents');
});
});
describe('committed plugin subtree', () => {
// The test that was missing when the agents key shipped: validate the real
// artifact the marketplace installs, not a fixture. If this fails, the
// committed ./plugin subtree carries a manifest shape Claude Code will not
// load; regenerate it with `bun run build:release` after fixing build.js.
test('the shipped manifest honors the verified loader contract', () => {
expect(collectPluginManifestFindings(REPO_ROOT)).toEqual([]);
});
});