mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* 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>
144 lines
5.8 KiB
JavaScript
144 lines
5.8 KiB
JavaScript
/**
|
|
* Plugin loader E2E: install the committed ./plugin subtree into a real
|
|
* Claude Code and assert every shipped component actually loads. Part of the
|
|
* default suite; the only external requirement is the claude CLI, and the
|
|
* suite skips cleanly when it is not on PATH.
|
|
*
|
|
* The unit-level shape guard (tests/validate-plugin-manifest.test.js) pins the
|
|
* loader contract we KNOW about. This suite is the only thing that catches the
|
|
* contract being wrong: the `agents` manifest key shipped for months and loaded
|
|
* zero of the four subagents (PR #494), `claude plugin validate` never flagged
|
|
* it, and earlier releases had SKILL.md frontmatter that failed to parse. All
|
|
* of those are invisible until the real loader reports its component
|
|
* inventory, which is exactly what this suite asserts on.
|
|
*
|
|
* Isolation: every claude invocation runs with CLAUDE_CONFIG_DIR, HOME, and
|
|
* USERPROFILE all pointed into a fresh temp dir, so the developer's real
|
|
* config, marketplaces, and installed plugins are never touched even if the
|
|
* CLI derives a path from the home directory rather than CLAUDE_CONFIG_DIR.
|
|
* Requires the `claude` CLI on PATH; skips cleanly otherwise.
|
|
*
|
|
* Run with: bun run test:plugin-e2e
|
|
*/
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PLUGIN_DIR = path.join(REPO_ROOT, 'plugin');
|
|
const MARKETPLACE_NAME = 'impeccable-e2e';
|
|
const PLUGIN_REF = `impeccable@${MARKETPLACE_NAME}`;
|
|
|
|
// On Windows the claude CLI is a .cmd shim, which Node refuses to spawn
|
|
// without a shell, so commands there go through one with every argument
|
|
// double-quoted (paths under %TEMP% routinely contain spaces). Elsewhere
|
|
// execFileSync runs the binary directly with no quoting concerns.
|
|
const IS_WINDOWS = process.platform === 'win32';
|
|
const quoteForCmd = (arg) => `"${String(arg).replace(/"/g, '""')}"`;
|
|
const runClaude = (args, opts) =>
|
|
IS_WINDOWS
|
|
? execSync(['claude', ...args.map(quoteForCmd)].join(' '), opts)
|
|
: execFileSync('claude', args, opts);
|
|
|
|
const claudeAvailable = (() => {
|
|
try {
|
|
runClaude(['--version'], { stdio: 'ignore', timeout: 30000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const skip = claudeAvailable ? false : 'claude CLI not on PATH; install Claude Code to run the plugin E2E suite';
|
|
|
|
describe('committed plugin subtree loads in a real Claude Code', { skip }, () => {
|
|
let workDir;
|
|
let configDir;
|
|
let detailsOutput;
|
|
|
|
const claude = (...args) =>
|
|
runClaude(args, {
|
|
encoding: 'utf-8',
|
|
timeout: 120000,
|
|
// Neutral cwd so the repo's own .claude/ project config cannot leak in.
|
|
cwd: workDir,
|
|
// Belt and suspenders: CLAUDE_CONFIG_DIR is the documented isolation
|
|
// switch, but any path the CLI derives from the home directory instead
|
|
// must also land in the sandbox, so HOME/USERPROFILE point there too.
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_CONFIG_DIR: configDir,
|
|
HOME: workDir,
|
|
USERPROFILE: workDir,
|
|
},
|
|
});
|
|
|
|
before(() => {
|
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-plugin-e2e-'));
|
|
configDir = path.join(workDir, 'claude-config');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
const marketplaceDir = path.join(workDir, 'marketplace');
|
|
fs.mkdirSync(path.join(marketplaceDir, '.claude-plugin'), { recursive: true });
|
|
fs.cpSync(PLUGIN_DIR, path.join(marketplaceDir, 'impeccable'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(marketplaceDir, '.claude-plugin', 'marketplace.json'),
|
|
JSON.stringify({
|
|
name: MARKETPLACE_NAME,
|
|
owner: { name: 'impeccable plugin E2E' },
|
|
plugins: [
|
|
{ name: 'impeccable', source: './impeccable', description: 'committed ./plugin subtree' },
|
|
],
|
|
}, null, 2),
|
|
);
|
|
|
|
claude('plugin', 'marketplace', 'add', marketplaceDir);
|
|
claude('plugin', 'install', PLUGIN_REF);
|
|
detailsOutput = claude('plugin', 'details', PLUGIN_REF);
|
|
});
|
|
|
|
after(() => {
|
|
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// `claude plugin details` prints "Skills (N) name, name" lines; long name
|
|
// lists wrap, so counts come from the header and names are matched anywhere.
|
|
const componentCount = (component) => {
|
|
const match = detailsOutput.match(new RegExp(`${component}\\s+\\((\\d+)\\)`));
|
|
assert.ok(match, `component inventory has no "${component} (N)" line:\n${detailsOutput}`);
|
|
return Number(match[1]);
|
|
};
|
|
|
|
it('reports the plugin as installed rather than silently absent', () => {
|
|
// A manifest the loader rejects surfaces as "Plugin not found" here, with
|
|
// no validation error anywhere else. Reaching this assertion at all means
|
|
// the details call above did not throw.
|
|
assert.match(detailsOutput, /Component inventory/);
|
|
});
|
|
|
|
it('parses and loads the impeccable skill', () => {
|
|
assert.equal(componentCount('Skills'), 1);
|
|
assert.match(detailsOutput, /Skills\s+\(1\)\s+impeccable/);
|
|
});
|
|
|
|
it('loads every shipped agent via auto-discovery (PR #494 regression)', () => {
|
|
const shipped = fs.readdirSync(path.join(PLUGIN_DIR, 'agents'))
|
|
.filter((file) => file.endsWith('.md'))
|
|
.map((file) => file.replace(/\.md$/, ''));
|
|
assert.ok(shipped.length > 0, 'plugin/agents/ ships no agent files');
|
|
assert.equal(componentCount('Agents'), shipped.length);
|
|
for (const name of shipped) {
|
|
assert.ok(detailsOutput.includes(name), `agent "${name}" missing from inventory`);
|
|
}
|
|
});
|
|
|
|
it('discovers the packaged hooks', () => {
|
|
assert.equal(componentCount('Hooks'), 2);
|
|
assert.match(detailsOutput, /PostToolUse/);
|
|
assert.match(detailsOutput, /Stop/);
|
|
});
|
|
});
|