mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
3972 lines
153 KiB
TypeScript
3972 lines
153 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { readdir } from "node:fs/promises";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
const SKILLS_DIR = join(ROOT, "skills");
|
|
const TEMP_HOOK_RUNTIME_MODULES = [
|
|
"pretooluse-skill-inject.mjs",
|
|
"skill-map-frontmatter.mjs",
|
|
"patterns.mjs",
|
|
"vercel-config.mjs",
|
|
"logger.mjs",
|
|
"hook-env.mjs",
|
|
"compat.mjs",
|
|
"telemetry.mjs",
|
|
] as const;
|
|
|
|
function copyTempHookRuntime(
|
|
tempRoot: string,
|
|
tempHooksDir: string,
|
|
overrides: Partial<Record<(typeof TEMP_HOOK_RUNTIME_MODULES)[number], string>> = {},
|
|
): void {
|
|
mkdirSync(tempHooksDir, { recursive: true });
|
|
for (const mod of TEMP_HOOK_RUNTIME_MODULES) {
|
|
writeFileSync(
|
|
join(tempHooksDir, mod),
|
|
overrides[mod] ?? readFileSync(join(ROOT, "hooks", mod), "utf-8"),
|
|
);
|
|
}
|
|
symlinkSync(join(ROOT, "node_modules"), join(tempRoot, "node_modules"));
|
|
}
|
|
|
|
/** Derive expected skill count from disk so tests don't break on skill add/remove */
|
|
function countSkillDirs(): number {
|
|
return readdirSync(SKILLS_DIR).filter((d) => {
|
|
try {
|
|
return existsSync(join(SKILLS_DIR, d, "SKILL.md"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}).length;
|
|
}
|
|
|
|
// Unique session ID per test run to avoid cross-test dedup conflicts
|
|
let testSession: string;
|
|
|
|
/**
|
|
* Pre-seed the file-based dedup state so the hook thinks these skills
|
|
* were already injected. Writes to the session file at
|
|
* <tmpdir>/vercel-plugin-<sessionId>-seen-skills.txt
|
|
*/
|
|
function seedSeenSkills(skills: string[]): void {
|
|
const seenFile = join(tmpdir(), `vercel-plugin-${testSession}-seen-skills.txt`);
|
|
writeFileSync(seenFile, skills.join(","), "utf-8");
|
|
}
|
|
|
|
function cleanupSessionDedup(): void {
|
|
const prefix = `vercel-plugin-${testSession}-`;
|
|
try {
|
|
for (const entry of readdirSync(tmpdir())) {
|
|
if (entry.startsWith(prefix)) {
|
|
const full = join(tmpdir(), entry);
|
|
rmSync(full, { recursive: true, force: true });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testSession = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupSessionDedup();
|
|
});
|
|
|
|
// High budget disables budget-based limiting so existing cap tests are unaffected
|
|
const UNLIMITED_BUDGET = "999999";
|
|
|
|
/**
|
|
* Extract skillInjection metadata from additionalContext.
|
|
* The metadata is embedded as an HTML comment to comply with Claude Code's
|
|
* strict hookSpecificOutput schema (unknown keys cause validation failure).
|
|
*/
|
|
function extractSkillInjection(hookSpecificOutput: any): any {
|
|
const ctx = hookSpecificOutput?.additionalContext || "";
|
|
const match = ctx.match(/<!-- skillInjection: ({.*?}) -->/);
|
|
if (!match) return undefined;
|
|
try { return JSON.parse(match[1]); } catch { return undefined; }
|
|
}
|
|
|
|
function getInjectedSkills(hookSpecificOutput: any): string[] {
|
|
const metadata = extractSkillInjection(hookSpecificOutput);
|
|
return Array.isArray(metadata?.injectedSkills) ? metadata.injectedSkills : [];
|
|
}
|
|
|
|
async function runHook(input: object): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const payload = JSON.stringify({ ...input, session_id: testSession });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
return { code, stdout, stderr };
|
|
}
|
|
|
|
describe("pretooluse-skill-inject.mjs", () => {
|
|
test("hook script exists", () => {
|
|
expect(existsSync(HOOK_SCRIPT)).toBe(true);
|
|
});
|
|
|
|
test("outputs empty JSON for unmatched file path", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
|
|
test("outputs empty JSON for empty stdin", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
|
|
test("outputs empty JSON for unmatched tool name", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Glob",
|
|
tool_input: { pattern: "**/*.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
|
|
test("matches next.config.ts to nextjs skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("matches app/ path to nextjs skill via Edit", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/Users/me/project/app/page.tsx" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("matches middleware.ts to routing-middleware skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: { file_path: "/Users/me/project/middleware.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(routing-middleware)");
|
|
});
|
|
|
|
test("matches proxy.ts to routing-middleware skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/src/proxy.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(routing-middleware)");
|
|
});
|
|
|
|
test("matches vercel.json to vercel-functions skill (highest priority)", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/vercel.json" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// vercel.json now matches multiple skills; vercel-functions (priority 8) is highest
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-functions)");
|
|
});
|
|
|
|
test("matches turbo.json to turborepo skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/Users/me/project/turbo.json" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(turborepo)");
|
|
});
|
|
|
|
test("matches flags.ts to vercel-flags skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/flags.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-flags)");
|
|
});
|
|
|
|
test("plain .env file does NOT trigger ai-gateway via file path", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.env" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// .env was removed from ai-gateway pathPatterns to avoid false positives
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(ai-gateway)");
|
|
}
|
|
});
|
|
|
|
test(".env.local does NOT trigger ai-gateway via file path", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.env.local" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(ai-gateway)");
|
|
}
|
|
});
|
|
|
|
test("ai-gateway still triggers via bash (vercel env pull)", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel env pull .env.local" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-gateway)");
|
|
});
|
|
|
|
test("matches npm install ai to ai-sdk skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm install ai" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-sdk)");
|
|
});
|
|
|
|
test("matches vercel deploy to vercel-cli skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel deploy" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-cli)");
|
|
});
|
|
|
|
test("matches turbo run build to turborepo skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "turbo run build" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(turborepo)");
|
|
});
|
|
|
|
test("matches npx v0 to v0-dev skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npx v0 generate" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(v0-dev)");
|
|
});
|
|
|
|
test("matches vercel integration to marketplace skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel integration add neon" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(marketplace)");
|
|
});
|
|
|
|
test("deduplicates across invocations via file-based dedup", async () => {
|
|
// First call: no seen skills — skill should inject
|
|
const { stdout: first } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/app/page.tsx" } },
|
|
{},
|
|
);
|
|
const r1 = JSON.parse(first);
|
|
expect(r1.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
|
|
// Second call: pre-seed file-based dedup with skills from first call
|
|
seedSeenSkills(["nextjs"]);
|
|
const { stdout: second } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/app/page.tsx" } },
|
|
{},
|
|
);
|
|
const r2 = JSON.parse(second);
|
|
expect(r2).toEqual({});
|
|
});
|
|
|
|
test("caps at 3 skills when bash command matches 5+ skills", async () => {
|
|
// This command matches 5 distinct skills:
|
|
// vercel-cli (vercel deploy)
|
|
// turborepo (turbo run build)
|
|
// v0-dev (npx v0)
|
|
// ai-sdk (npm install ai)
|
|
// marketplace (vercel integration)
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: {
|
|
command:
|
|
"vercel deploy && turbo run build && npx v0 generate && npm install ai && vercel integration add neon",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toBeDefined();
|
|
expect(getInjectedSkills(result.hookSpecificOutput).length).toBe(3);
|
|
});
|
|
|
|
test("large multi-skill output is valid JSON with correct structure", async () => {
|
|
// Trigger 3 skills via bash and verify the full output structure
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: {
|
|
command: "vercel deploy && turbo run build && npx v0 generate",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
|
|
// Must be parseable JSON
|
|
let result: any;
|
|
expect(() => {
|
|
result = JSON.parse(stdout);
|
|
}).not.toThrow();
|
|
|
|
// Must have hookSpecificOutput.additionalContext string
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(typeof result.hookSpecificOutput.additionalContext).toBe("string");
|
|
expect(result.hookSpecificOutput.additionalContext.length).toBeGreaterThan(0);
|
|
|
|
const ctx = result.hookSpecificOutput.additionalContext;
|
|
const injectedSkills = getInjectedSkills(result.hookSpecificOutput);
|
|
expect(injectedSkills.length).toBeGreaterThanOrEqual(1);
|
|
expect(injectedSkills.length).toBeLessThanOrEqual(5);
|
|
for (const skill of injectedSkills) {
|
|
expect(ctx).toContain(`Skill(${skill})`);
|
|
}
|
|
});
|
|
|
|
test("returns {} when skills directory is empty (no SKILL.md files)", async () => {
|
|
// Create a temporary plugin-like directory with an empty skills/ dir
|
|
const tempRoot = join(tmpdir(), `vp-test-empty-skills-${Date.now()}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
mkdirSync(tempSkillsDir, { recursive: true });
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
const tempHookPath = join(tempHooksDir, "pretooluse-skill-inject.mjs");
|
|
|
|
// Run the hook from the temp location
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
session_id: testSession,
|
|
});
|
|
const proc = Bun.spawn(["node", tempHookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
|
|
// Cleanup
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("globToRegex escapes regex metacharacters in path patterns", async () => {
|
|
// Paths containing ( ) [ ] { } + | ^ $ should match literally
|
|
// We test by reading a file whose path contains metacharacters
|
|
const metaCharPaths = [
|
|
"/project/src/components/(auth)/login.tsx",
|
|
"/project/src/[id]/page.tsx",
|
|
"/project/src/[[...slug]]/page.tsx",
|
|
"/project/app/(group)/layout.tsx",
|
|
];
|
|
for (const filePath of metaCharPaths) {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: filePath },
|
|
});
|
|
expect(code).toBe(0);
|
|
// These should parse without throwing, even if they don't match a skill
|
|
expect(() => JSON.parse(stdout)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("exit code is always 0", async () => {
|
|
// Even with malformed JSON input
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.write("not-json");
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
test("output is always valid JSON", async () => {
|
|
const inputs = [
|
|
{ tool_name: "Read", tool_input: { file_path: "/nothing/here.txt" } },
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ tool_name: "Bash", tool_input: { command: "echo hello" } },
|
|
{ tool_name: "Bash", tool_input: { command: "vercel deploy" } },
|
|
];
|
|
for (const input of inputs) {
|
|
const { stdout } = await runHook(input);
|
|
expect(() => JSON.parse(stdout)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("match output uses correct hookSpecificOutput schema", async () => {
|
|
const { stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const result = JSON.parse(stdout);
|
|
// Must use hookSpecificOutput wrapper per Claude Code hook spec
|
|
expect(result).toHaveProperty("hookSpecificOutput");
|
|
expect(result.hookSpecificOutput).toHaveProperty("additionalContext");
|
|
expect(typeof result.hookSpecificOutput.additionalContext).toBe("string");
|
|
// Must NOT have top-level additionalContext
|
|
expect(result).not.toHaveProperty("additionalContext");
|
|
// No other top-level keys
|
|
expect(Object.keys(result)).toEqual(["hookSpecificOutput"]);
|
|
expect(Object.keys(result.hookSpecificOutput)).toContain("additionalContext");
|
|
expect(extractSkillInjection(result.hookSpecificOutput)).toBeDefined();
|
|
});
|
|
|
|
test("no-match output is empty object", async () => {
|
|
const { stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toEqual({});
|
|
expect(Object.keys(result).length).toBe(0);
|
|
});
|
|
|
|
test("completes in under 200ms", async () => {
|
|
const start = performance.now();
|
|
await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const elapsed = performance.now() - start;
|
|
// Allow some slack for CI — 500ms
|
|
expect(elapsed).toBeLessThan(500);
|
|
});
|
|
});
|
|
|
|
describe("skill-map from frontmatter", () => {
|
|
test("buildSkillMap produces a valid skill map from SKILL.md files", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const map = buildSkillMap(SKILLS_DIR);
|
|
expect(typeof map.skills).toBe("object");
|
|
expect(Object.keys(map.skills).length).toBe(countSkillDirs());
|
|
});
|
|
|
|
test("every skill has at least one trigger pattern", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const map = buildSkillMap(SKILLS_DIR);
|
|
const noTriggers: string[] = [];
|
|
for (const [skill, config] of Object.entries(map.skills) as [string, any][]) {
|
|
const pathCount = (config.pathPatterns || []).length;
|
|
const bashCount = (config.bashPatterns || []).length;
|
|
const importCount = (config.importPatterns || []).length;
|
|
if (pathCount === 0 && bashCount === 0 && importCount === 0) noTriggers.push(skill);
|
|
}
|
|
expect(noTriggers).toEqual([]);
|
|
});
|
|
|
|
test("covers all skills directories with SKILL.md", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const map = buildSkillMap(SKILLS_DIR);
|
|
const mapSkills = new Set(Object.keys(map.skills));
|
|
|
|
const skillDirs = (await readdir(SKILLS_DIR)).filter((d) =>
|
|
existsSync(join(SKILLS_DIR, d, "SKILL.md")),
|
|
);
|
|
|
|
const uncovered: string[] = [];
|
|
for (const dir of skillDirs) {
|
|
if (!mapSkills.has(dir)) uncovered.push(dir);
|
|
}
|
|
expect(uncovered).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// Helper to run hook with debug mode enabled
|
|
async function runHookDebug(input: object): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const payload = JSON.stringify({ ...input, session_id: `dbg-${Date.now()}-${Math.random().toString(36).slice(2)}` });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
return { code, stdout, stderr };
|
|
}
|
|
|
|
describe("debug logging (VERCEL_PLUGIN_HOOK_DEBUG=1)", () => {
|
|
test("emits no stderr when debug is off (default)", async () => {
|
|
const { stderr } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
test("emits JSON-lines to stderr when debug is on", async () => {
|
|
const { code, stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(stderr.trim().length).toBeGreaterThan(0);
|
|
const lines = stderr.trim().split("\n");
|
|
for (const line of lines) {
|
|
expect(() => JSON.parse(line)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("each debug line has invocationId, event, and timestamp", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
for (const obj of lines) {
|
|
expect(typeof obj.invocationId).toBe("string");
|
|
expect(obj.invocationId.length).toBe(8); // 4 random bytes = 8 hex chars
|
|
expect(typeof obj.event).toBe("string");
|
|
expect(typeof obj.timestamp).toBe("string");
|
|
}
|
|
});
|
|
|
|
test("all invocationIds are the same within one invocation", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const ids = new Set(lines.map((l: any) => l.invocationId));
|
|
expect(ids.size).toBe(1);
|
|
});
|
|
|
|
test("emits expected events for a matching invocation", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const events = stderr.trim().split("\n").map((l: string) => JSON.parse(l).event);
|
|
expect(events).toContain("input-parsed");
|
|
expect(events).toContain("skillmap-loaded");
|
|
expect(events).toContain("matches-found");
|
|
expect(events).toContain("dedup-filtered");
|
|
expect(events).toContain("skills-injected");
|
|
expect(events).toContain("complete");
|
|
});
|
|
|
|
test("emits expected events for a non-matching invocation", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
const events = stderr.trim().split("\n").map((l: string) => JSON.parse(l).event);
|
|
expect(events).toContain("input-parsed");
|
|
expect(events).toContain("skillmap-loaded");
|
|
expect(events).toContain("matches-found");
|
|
expect(events).toContain("dedup-filtered");
|
|
expect(events).toContain("complete");
|
|
// skills-injected should NOT appear since nothing matched
|
|
expect(events).not.toContain("skills-injected");
|
|
});
|
|
|
|
test("complete event includes elapsed_ms", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const complete = lines.find((l: any) => l.event === "complete");
|
|
expect(complete).toBeDefined();
|
|
expect(typeof complete.elapsed_ms).toBe("number");
|
|
expect(complete.elapsed_ms).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test("stdout remains valid JSON when debug is on", async () => {
|
|
const { stdout } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
});
|
|
|
|
describe("issue events in debug mode", () => {
|
|
test("STDIN_EMPTY issue emitted for empty stdin", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const issue = lines.find((l: any) => l.event === "issue");
|
|
expect(issue).toBeDefined();
|
|
expect(issue.code).toBe("STDIN_EMPTY");
|
|
expect(typeof issue.message).toBe("string");
|
|
expect(typeof issue.hint).toBe("string");
|
|
});
|
|
|
|
test("STDIN_PARSE_FAIL issue emitted for invalid JSON", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write("not-json");
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const issue = lines.find((l: any) => l.event === "issue");
|
|
expect(issue).toBeDefined();
|
|
expect(issue.code).toBe("STDIN_PARSE_FAIL");
|
|
expect(typeof issue.context.error).toBe("string");
|
|
});
|
|
|
|
test("SKILLMAP_EMPTY issue emitted when skills directory has no SKILL.md files", async () => {
|
|
const tempRoot = join(tmpdir(), `vp-test-noskills-${Date.now()}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
mkdirSync(tempSkillsDir, { recursive: true });
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
const tempHookPath = join(tempHooksDir, "pretooluse-skill-inject.mjs");
|
|
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
session_id: testSession,
|
|
});
|
|
const proc = Bun.spawn(["node", tempHookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const issue = lines.find((l: any) => l.event === "issue");
|
|
expect(issue).toBeDefined();
|
|
expect(issue.code).toBe("SKILLMAP_EMPTY");
|
|
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("no issue events emitted when debug is off", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
test("issue events have required fields: code, message, hint, context", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write("not-json");
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const issues = lines.filter((l: any) => l.event === "issue");
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
for (const issue of issues) {
|
|
expect(typeof issue.code).toBe("string");
|
|
expect(typeof issue.message).toBe("string");
|
|
expect(typeof issue.hint).toBe("string");
|
|
expect(issue.context).toBeDefined();
|
|
// Also has standard debug fields
|
|
expect(typeof issue.invocationId).toBe("string");
|
|
expect(typeof issue.timestamp).toBe("string");
|
|
}
|
|
});
|
|
|
|
test("SKILLMD_PARSE_FAIL issue emitted for malformed YAML frontmatter", async () => {
|
|
// Set up a temp plugin root with one valid skill and one malformed SKILL.md
|
|
const tempRoot = join(tmpdir(), `vp-test-malformed-${Date.now()}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
|
|
// Create a valid skill so the skill map isn't empty
|
|
const validSkillDir = join(tempSkillsDir, "valid-skill");
|
|
mkdirSync(validSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(validSkillDir, "SKILL.md"),
|
|
`---\nname: valid-skill\nmetadata:\n pathPatterns:\n - '**/*.valid'\n---\n# Valid Skill\n`,
|
|
);
|
|
|
|
// Create a malformed skill with invalid YAML frontmatter (tab indentation triggers parse error)
|
|
const badSkillDir = join(tempSkillsDir, "bad-skill");
|
|
mkdirSync(badSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(badSkillDir, "SKILL.md"),
|
|
`---\nname: bad-skill\n\tmetadata: foo\n---\n# Bad Skill\n`,
|
|
);
|
|
|
|
// Copy hook files and symlink node_modules
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/foo.valid" },
|
|
session_id: testSession,
|
|
});
|
|
const proc = Bun.spawn(["node", join(tempHooksDir, "pretooluse-skill-inject.mjs")], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
// Hook should still produce output (valid skill matches)
|
|
expect(stdout.length).toBeGreaterThan(0);
|
|
|
|
// Parse stderr debug lines and find SKILLMD_PARSE_FAIL issues
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const parseFailIssues = lines.filter(
|
|
(l: any) => l.event === "issue" && l.code === "SKILLMD_PARSE_FAIL",
|
|
);
|
|
expect(parseFailIssues.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const issue = parseFailIssues[0];
|
|
expect(issue.message).toContain("Failed to parse SKILL.md");
|
|
expect(typeof issue.hint).toBe("string");
|
|
expect(issue.hint).toContain("bad-skill");
|
|
expect(issue.context.file).toContain("bad-skill");
|
|
expect(typeof issue.context.error).toBe("string");
|
|
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("SKILLMD_PARSE_FAIL not emitted when debug is off", async () => {
|
|
// Same malformed setup but without debug mode
|
|
const tempRoot = join(tmpdir(), `vp-test-malformed-nodebug-${Date.now()}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
|
|
const badSkillDir = join(tempSkillsDir, "bad-skill");
|
|
mkdirSync(badSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(badSkillDir, "SKILL.md"),
|
|
`---\nname: bad-skill\n\tmetadata: foo\n---\n# Bad Skill\n`,
|
|
);
|
|
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/foo.txt" },
|
|
session_id: `nodebug-${Date.now()}`,
|
|
});
|
|
const proc = Bun.spawn(["node", join(tempHooksDir, "pretooluse-skill-inject.mjs")], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
// No debug env var
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
// No stderr output when debug is off
|
|
expect(stderr).toBe("");
|
|
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// Helper to run hook with custom env vars and optional session_id override
|
|
async function runHookEnv(
|
|
input: object,
|
|
env: Record<string, string | undefined>,
|
|
opts?: { omitSessionId?: boolean },
|
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const payload = opts?.omitSessionId
|
|
? JSON.stringify(input)
|
|
: JSON.stringify({ ...input, session_id: testSession });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET, ...env },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
return { code, stdout, stderr };
|
|
}
|
|
|
|
describe("setup mode bootstrap routing", () => {
|
|
test("injects bootstrap on unmatched paths when setup mode is active", async () => {
|
|
const { code, stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/random-not-matched.txt" } },
|
|
{ VERCEL_PLUGIN_SETUP_MODE: "1" },
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(bootstrap)");
|
|
expect(extractSkillInjection(result.hookSpecificOutput).matchedSkills).toContain("bootstrap");
|
|
expect(extractSkillInjection(result.hookSpecificOutput).injectedSkills[0]).toBe("bootstrap");
|
|
});
|
|
|
|
test("boosts bootstrap ahead of other skills in setup mode", async () => {
|
|
const { code, stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/vercel.json" } },
|
|
{ VERCEL_PLUGIN_SETUP_MODE: "1" },
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const injectedSkills = extractSkillInjection(result.hookSpecificOutput).injectedSkills;
|
|
expect(injectedSkills[0]).toBe("bootstrap");
|
|
});
|
|
|
|
test("skips synthetic bootstrap when bootstrap was already injected", async () => {
|
|
seedSeenSkills(["bootstrap"]);
|
|
const { code, stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/random-not-matched.txt" } },
|
|
{ VERCEL_PLUGIN_SETUP_MODE: "1" },
|
|
);
|
|
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("seen-skills env file and dedup controls", () => {
|
|
const nextjsOnlyPath = "/project/app/page.tsx";
|
|
|
|
test("file-based dedup persists across invocations with same session_id", async () => {
|
|
const { stdout: first } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
const r1 = JSON.parse(first);
|
|
expect(r1.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
|
|
const { stdout: second } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
const r2 = JSON.parse(second);
|
|
expect(r2).toEqual({});
|
|
});
|
|
|
|
test("file-based dedup skips across invocations", async () => {
|
|
// First call — skill should inject
|
|
const { stdout: first } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
const r1 = JSON.parse(first);
|
|
expect(r1.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
|
|
// Second call with skill pre-seeded in file-based dedup — should be deduped
|
|
seedSeenSkills(["nextjs"]);
|
|
const { stdout: second } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
const r2 = JSON.parse(second);
|
|
expect(r2).toEqual({});
|
|
});
|
|
|
|
test("pre-seeded file dedup skips matching injection", async () => {
|
|
seedSeenSkills(["nextjs"]);
|
|
const { stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
|
|
test("VERCEL_PLUGIN_HOOK_DEDUP=off injects every call even with pre-seeded dedup", async () => {
|
|
seedSeenSkills(["nextjs"]);
|
|
|
|
const { stdout: first } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const r1 = JSON.parse(first);
|
|
expect(r1.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
|
|
const { stdout: second } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const r2 = JSON.parse(second);
|
|
expect(r2.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("clearing file dedup state re-enables injection", async () => {
|
|
// With pre-seeded skills, injection is skipped
|
|
seedSeenSkills(["nextjs"]);
|
|
const { stdout: skipped } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
expect(JSON.parse(skipped)).toEqual({});
|
|
|
|
// Clear dedup state — injection happens again
|
|
cleanupSessionDedup();
|
|
const { stdout: injected } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
expect(JSON.parse(injected).hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("debug mode logs dedup strategy for file, memory-only, and disabled", async () => {
|
|
const { stderr: fileStderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
const fileLines = fileStderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const fileStrategy = fileLines.find((l: any) => l.event === "dedup-strategy");
|
|
expect(fileStrategy).toBeDefined();
|
|
expect(fileStrategy.strategy).toBe("file");
|
|
|
|
const { stderr: memoryOnlyStderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
{ omitSessionId: true },
|
|
);
|
|
const memoryOnlyLines = memoryOnlyStderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const memoryOnlyStrategy = memoryOnlyLines.find((l: any) => l.event === "dedup-strategy");
|
|
expect(memoryOnlyStrategy).toBeDefined();
|
|
expect(memoryOnlyStrategy.strategy).toBe("memory-only");
|
|
|
|
const { stderr: disabledStderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const disabledLines = disabledStderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const disabledStrategy = disabledLines.find((l: any) => l.event === "dedup-strategy");
|
|
expect(disabledStrategy).toBeDefined();
|
|
expect(disabledStrategy.strategy).toBe("disabled");
|
|
});
|
|
|
|
test("file-based dedup uses comma-delimited format", async () => {
|
|
// Pre-seed with multiple skills via file-based dedup
|
|
seedSeenSkills(["nextjs", "turbopack"]);
|
|
const { stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: nextjsOnlyPath } },
|
|
{},
|
|
);
|
|
// nextjs is in the seen list, so it should be deduped
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("new pattern coverage", () => {
|
|
test("matches .vercelignore to vercel-cli skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.vercelignore" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-cli)");
|
|
});
|
|
|
|
test("matches lib/cache.ts to runtime-cache skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/lib/cache.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(runtime-cache)");
|
|
});
|
|
|
|
test("matches lib/blob.ts to vercel-storage skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/lib/blob.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-storage)");
|
|
});
|
|
|
|
test("matches lib/queues.ts to vercel-queues skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/lib/queues.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-queues)");
|
|
});
|
|
|
|
test("matches workflow.ts to workflow skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/workflow.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(workflow)");
|
|
});
|
|
|
|
test("matches workflows/async-request-reply.ts to workflow skill via Write", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/workflows/async-request-reply.ts",
|
|
content: [
|
|
"import { createWebhook, getWritable, sleep } from \"workflow\";",
|
|
"",
|
|
"export async function asyncRequestReply(documentId: string) {",
|
|
" \"use workflow\";",
|
|
" const webhook = createWebhook({ respondWith: \"manual\" });",
|
|
" await sleep(\"5s\");",
|
|
" return { documentId, token: webhook.token, writer: getWritable() };",
|
|
"}",
|
|
].join("\n"),
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(workflow)");
|
|
});
|
|
|
|
test("matches app/health/route.ts to vercel-functions skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/health/route.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-functions)");
|
|
});
|
|
|
|
test("matches npm install @neondatabase/serverless to vercel-storage skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm install @neondatabase/serverless" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-storage)");
|
|
});
|
|
|
|
test("matches npm install @vercel/workflow to workflow skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm install @vercel/workflow @workflow/ai" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(workflow)");
|
|
});
|
|
|
|
test("matches src/middleware.mjs to routing-middleware skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/src/middleware.mjs" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(routing-middleware)");
|
|
});
|
|
|
|
test("matches src/middleware.mts to routing-middleware skill via Edit", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/Users/me/project/src/middleware.mts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(routing-middleware)");
|
|
});
|
|
|
|
test("matches app/layout.tsx to observability skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/layout.tsx" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(observability)");
|
|
});
|
|
|
|
test("matches pages/_app.tsx to observability skill via Edit", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/Users/me/project/pages/_app.tsx" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(observability)");
|
|
});
|
|
|
|
test("matches pages/api/chat.ts to ai-sdk skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/pages/api/chat.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-sdk)");
|
|
});
|
|
|
|
test("matches pages/api/completion.ts to ai-sdk skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/pages/api/completion.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-sdk)");
|
|
});
|
|
|
|
test("matches claude mcp add vercel to vercel-api skill via Bash", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "claude mcp add vercel mcp.vercel.com" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-api)");
|
|
});
|
|
});
|
|
|
|
describe("glob regression", () => {
|
|
test("app/foobarroute.ts does NOT trigger vercel-functions", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/foobarroute.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// Should match nextjs (app/**) but NOT vercel-functions (app/**/route.*)
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(vercel-functions)");
|
|
}
|
|
});
|
|
|
|
test("non-Vercel workflow file does NOT trigger vercel-agent", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.github/workflows/ci.yml" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(vercel-agent)");
|
|
}
|
|
});
|
|
|
|
test("generic test.yml workflow does NOT trigger vercel-agent", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.github/workflows/test.yml" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(vercel-agent)");
|
|
}
|
|
});
|
|
|
|
test("vercel-deploy.yml workflow DOES trigger vercel-agent", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.github/workflows/vercel-deploy.yml" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-agent)");
|
|
});
|
|
|
|
test("deploy-preview.yaml workflow DOES trigger vercel-agent", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/.github/workflows/deploy-preview.yaml" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-agent)");
|
|
});
|
|
|
|
test("bare api/ directory path does NOT trigger vercel-functions", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/api/health" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
if (result.hookSpecificOutput) {
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("Skill(vercel-functions)");
|
|
}
|
|
});
|
|
|
|
test("api/hello.ts DOES trigger vercel-functions", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/api/hello.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-functions)");
|
|
});
|
|
});
|
|
|
|
describe("vercel.ts pattern", () => {
|
|
test("matches vercel.ts to vercel-cli skill via Read", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/vercel.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(vercel-cli)");
|
|
});
|
|
});
|
|
|
|
describe("? wildcard in glob patterns", () => {
|
|
test("tsconfig.?.json matches single-char extensions", async () => {
|
|
// This simulates a pattern like "tsconfig.?.json" — we test that
|
|
// the existing "tsconfig.*.json" pattern works with single chars
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/tsconfig.e.json" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// tsconfig.*.json is in nextjs pathPatterns and * matches single char too
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("? wildcard does not match slash", async () => {
|
|
// next.config.* uses * which should not match across slashes
|
|
// A path like next.config.sub/file should NOT match next.config.*
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts/nested" },
|
|
});
|
|
expect(code).toBe(0);
|
|
// This path should not match next.config.* because * doesn't cross slashes
|
|
// It might match app/** via suffix matching though, so just verify no error
|
|
expect(() => JSON.parse(stdout)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("priority ordering for file-path matches", () => {
|
|
test("app/api/chat/route.ts matches multiple skills; highest-priority ones win", async () => {
|
|
// This path matches:
|
|
// chat-sdk (priority 8): app/api/chat/**
|
|
// ai-sdk (priority 8): app/api/chat/**
|
|
// vercel-functions (priority 8): app/**/route.*
|
|
// nextjs (priority 5): app/**
|
|
// With cap 3, top 3 by priority inject; nextjs (5) gets dropped
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/api/chat/route.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const ctx = result.hookSpecificOutput.additionalContext;
|
|
expect(ctx).toContain("Skill(ai-sdk)");
|
|
expect(ctx).toContain("Skill(chat-sdk)");
|
|
expect(ctx).toContain("Skill(vercel-functions)");
|
|
// nextjs (priority 5) dropped by cap
|
|
expect(ctx).not.toContain("Skill(nextjs)");
|
|
});
|
|
|
|
test("skills appear in priority order (highest first) in additionalContext", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/api/chat/route.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const ctx = result.hookSpecificOutput.additionalContext;
|
|
|
|
const aiSdkPos = ctx.indexOf("Skill(ai-sdk)");
|
|
const chatSdkPos = ctx.indexOf("Skill(chat-sdk)");
|
|
const funcPos = ctx.indexOf("Skill(vercel-functions)");
|
|
|
|
// All three priority-8 skills should be present and ordered consistently
|
|
expect(aiSdkPos).toBeGreaterThan(-1);
|
|
expect(chatSdkPos).toBeGreaterThan(-1);
|
|
expect(funcPos).toBeGreaterThan(-1);
|
|
});
|
|
});
|
|
|
|
describe("priority ordering", () => {
|
|
test("when 5 skills match, top 3 inject within cap", async () => {
|
|
// Craft a bash command that matches 5 skills with known priorities:
|
|
// ai-sdk (priority 8): "npm install ai"
|
|
// vercel-storage (priority 7): "npm install @vercel/blob"
|
|
// turborepo (priority 5): "turbo run build"
|
|
// vercel-cli (priority 4): "vercel deploy"
|
|
// v0-dev (priority 5): "npx v0 generate"
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: {
|
|
command:
|
|
"vercel deploy && npm install ai && npm install @vercel/blob && turbo run build && npx v0 generate",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const ctx = result.hookSpecificOutput.additionalContext;
|
|
expect(ctx).toBeDefined();
|
|
|
|
// With cap 3, at most 3 skills inject
|
|
expect(getInjectedSkills(result.hookSpecificOutput).length).toBeLessThanOrEqual(3);
|
|
|
|
// Highest priority skills should be present
|
|
expect(ctx).toContain("Skill(ai-sdk)");
|
|
expect(ctx).toContain("Skill(vercel-storage)");
|
|
});
|
|
});
|
|
|
|
describe("VERCEL_PLUGIN_DEBUG alias", () => {
|
|
test("VERCEL_PLUGIN_DEBUG=1 activates debug output (stderr)", async () => {
|
|
const { code, stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_DEBUG: "1" },
|
|
);
|
|
expect(code).toBe(0);
|
|
expect(stderr.trim().length).toBeGreaterThan(0);
|
|
const lines = stderr.trim().split("\n");
|
|
for (const line of lines) {
|
|
expect(() => JSON.parse(line)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("VERCEL_PLUGIN_DEBUG=1 produces identical event types as VERCEL_PLUGIN_HOOK_DEBUG=1", async () => {
|
|
const input = { tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } };
|
|
// Use VERCEL_PLUGIN_HOOK_DEDUP=off so dedup doesn't cause divergence between runs
|
|
const { stderr: stderrNew } = await runHookEnv(input, { VERCEL_PLUGIN_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" });
|
|
const { stderr: stderrOld } = await runHookEnv(input, { VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" });
|
|
const eventsNew = stderrNew.trim().split("\n").map((l: string) => JSON.parse(l).event);
|
|
const eventsOld = stderrOld.trim().split("\n").map((l: string) => JSON.parse(l).event);
|
|
expect(eventsNew).toEqual(eventsOld);
|
|
});
|
|
|
|
test("neither env var set produces no stderr", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{},
|
|
);
|
|
expect(stderr).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("match-reason logging", () => {
|
|
test("matches-found event includes reasons with pattern and matchType for path match", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_DEBUG: "1" },
|
|
);
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const matchEvent = lines.find((l: any) => l.event === "matches-found");
|
|
expect(matchEvent).toBeDefined();
|
|
expect(matchEvent.reasons).toBeDefined();
|
|
expect(typeof matchEvent.reasons).toBe("object");
|
|
// nextjs skill should match next.config.ts
|
|
const nextjsReason = matchEvent.reasons["nextjs"];
|
|
expect(nextjsReason).toBeDefined();
|
|
expect(nextjsReason.pattern).toBeDefined();
|
|
expect(typeof nextjsReason.pattern).toBe("string");
|
|
expect(["full", "basename", "suffix"]).toContain(nextjsReason.matchType);
|
|
});
|
|
|
|
test("matches-found event includes reasons for bash command match", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Bash", tool_input: { command: "npx next build" } },
|
|
{ VERCEL_PLUGIN_DEBUG: "1" },
|
|
);
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const matchEvent = lines.find((l: any) => l.event === "matches-found");
|
|
expect(matchEvent).toBeDefined();
|
|
expect(matchEvent.reasons).toBeDefined();
|
|
// Should have at least one matched skill with a reason
|
|
const skills = Object.keys(matchEvent.reasons);
|
|
if (skills.length > 0) {
|
|
const reason = matchEvent.reasons[skills[0]];
|
|
expect(reason.pattern).toBeDefined();
|
|
expect(reason.matchType).toBe("full");
|
|
}
|
|
});
|
|
|
|
test("matches-found reasons is empty object when no skills match", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/totally-unrelated-file.xyz" } },
|
|
{ VERCEL_PLUGIN_DEBUG: "1" },
|
|
);
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const matchEvent = lines.find((l: any) => l.event === "matches-found");
|
|
expect(matchEvent).toBeDefined();
|
|
expect(matchEvent.reasons).toEqual({});
|
|
expect(matchEvent.matched).toEqual([]);
|
|
});
|
|
|
|
test("matchType is basename when only basename matches", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/some/deep/path/next.config.js" } },
|
|
{ VERCEL_PLUGIN_DEBUG: "1" },
|
|
);
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const matchEvent = lines.find((l: any) => l.event === "matches-found");
|
|
expect(matchEvent).toBeDefined();
|
|
if (matchEvent.reasons["nextjs"]) {
|
|
expect(["full", "basename", "suffix"]).toContain(matchEvent.reasons["nextjs"].matchType);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("cap observability (debug mode)", () => {
|
|
test("emits cap-applied event with selected and dropped arrays when >3 skills match", async () => {
|
|
// This command matches 5+ distinct skills:
|
|
// vercel-cli (vercel deploy)
|
|
// turborepo (turbo run build)
|
|
// v0-dev (npx v0)
|
|
// ai-sdk (npm install ai)
|
|
// marketplace (vercel integration)
|
|
const { code, stderr } = await runHookDebug({
|
|
tool_name: "Bash",
|
|
tool_input: {
|
|
command:
|
|
"vercel deploy && turbo run build && npx v0 generate && npm install ai && vercel integration add neon",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const capEvent = lines.find((l: any) => l.event === "cap-applied");
|
|
expect(capEvent).toBeDefined();
|
|
expect(capEvent.max).toBe(3);
|
|
expect(capEvent.totalCandidates).toBeGreaterThanOrEqual(5);
|
|
expect(typeof capEvent.budgetBytes).toBe("number");
|
|
expect(typeof capEvent.usedBytes).toBe("number");
|
|
|
|
// selected array has exactly 3 entries with skill name
|
|
expect(Array.isArray(capEvent.selected)).toBe(true);
|
|
expect(capEvent.selected.length).toBe(3);
|
|
for (const entry of capEvent.selected) {
|
|
expect(typeof entry.skill).toBe("string");
|
|
}
|
|
|
|
// droppedByCap should have remaining skills
|
|
expect(Array.isArray(capEvent.droppedByCap)).toBe(true);
|
|
expect(capEvent.droppedByCap.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("does NOT emit cap-applied when <=3 skills match", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const capEvent = lines.find((l: any) => l.event === "cap-applied");
|
|
expect(capEvent).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("injection byte budget", () => {
|
|
test("6000-byte budget still injects up to MAX_SKILLS for app/api/chat route", async () => {
|
|
// app/api/chat/route.ts matches ai-sdk, chat-sdk, vercel-functions, and nextjs
|
|
// Current hook output still fits within budget, so cap decides the final set
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/api/chat/route.ts" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBe(3);
|
|
expect(si.droppedByCap.length).toBe(1);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
});
|
|
|
|
test("large budget allows all matching skills up to MAX_SKILLS", async () => {
|
|
// Same path, unlimited budget — but cap is 3 so only 3 of 4 matches inject
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/api/chat/route.ts" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "999999" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBe(3);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
});
|
|
|
|
test("VERCEL_PLUGIN_INJECTION_BUDGET env var overrides default", async () => {
|
|
// next.config.ts still injects both matched skills under the current hook output
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "100" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si.injectedSkills.length).toBe(2);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
});
|
|
|
|
test("small skills can fill more than typical slots under generous budget", async () => {
|
|
// vercel.json matches 5 skills; some are small (cron-jobs ~2KB, vercel-functions ~5.7KB)
|
|
// With 15000-byte budget, should fit more small skills
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/vercel.json" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "15000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
// MAX_SKILLS=3 ceiling still applies
|
|
expect(si.injectedSkills.length).toBeLessThanOrEqual(5);
|
|
});
|
|
|
|
test("invalid VERCEL_PLUGIN_INJECTION_BUDGET falls back to default", async () => {
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "not-a-number" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// Should still work with default budget
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
});
|
|
|
|
test("droppedByBudget appears in skillInjection metadata", async () => {
|
|
// Use a tight budget that forces budget drops
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/app/api/chat/route.ts" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(Array.isArray(si.droppedByBudget)).toBe(true);
|
|
expect(Array.isArray(si.droppedByCap)).toBe(true);
|
|
// Total should account for all matched skills
|
|
expect(si.injectedSkills.length + si.droppedByCap.length + si.droppedByBudget.length).toBe(
|
|
si.matchedSkills.length,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("sectional injection (summary fallback)", () => {
|
|
function createTempPlugin(skills: Array<{ name: string; summary?: string; body: string; patterns: string[] }>) {
|
|
const tempRoot = join(tmpdir(), `vp-test-sectional-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
mkdirSync(tempSkillsDir, { recursive: true });
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
|
|
// Create skills
|
|
for (const skill of skills) {
|
|
const skillDir = join(tempSkillsDir, skill.name);
|
|
mkdirSync(skillDir, { recursive: true });
|
|
const summaryLine = skill.summary ? `summary: '${skill.summary}'\n` : "";
|
|
const patterns = skill.patterns.map((p) => ` - '${p}'`).join("\n");
|
|
writeFileSync(
|
|
join(skillDir, "SKILL.md"),
|
|
`---\nname: ${skill.name}\n${summaryLine}description: Test skill\nmetadata:\n priority: 5\n pathPatterns:\n${patterns}\n bashPatterns: []\n---\n${skill.body}`,
|
|
);
|
|
}
|
|
|
|
return { tempRoot, tempHooksDir, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) };
|
|
}
|
|
|
|
async function runTempHook(
|
|
tempHooksDir: string,
|
|
input: object,
|
|
env: Record<string, string>,
|
|
) {
|
|
const payload = JSON.stringify({ ...input, session_id: `test-${Date.now()}` });
|
|
const proc = Bun.spawn(["node", join(tempHooksDir, "pretooluse-skill-inject.mjs")], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, ...env },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
return { code, stdout };
|
|
}
|
|
|
|
test("skills without summary still inject when context stays within budget", async () => {
|
|
// Create 2 skills with large bodies, no summaries
|
|
const bigBody = "X".repeat(5000);
|
|
const { tempHooksDir, cleanup } = createTempPlugin([
|
|
{ name: "skill-a", body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-b", body: bigBody, patterns: ["src/**"] },
|
|
]);
|
|
|
|
const { code, stdout } = await runTempHook(
|
|
tempHooksDir,
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/src/index.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBe(2);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
expect(si.summaryOnly).toEqual([]);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test("full injection is kept when a summarized skill still fits within budget", async () => {
|
|
// skill-a: large body, no summary
|
|
// skill-b: large body + short summary
|
|
const bigBody = "X".repeat(5000);
|
|
const { tempHooksDir, cleanup } = createTempPlugin([
|
|
{ name: "skill-a", body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-b", summary: "Short summary for skill-b.", body: bigBody, patterns: ["src/**"] },
|
|
]);
|
|
|
|
const { code, stdout } = await runTempHook(
|
|
tempHooksDir,
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/src/index.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBe(2);
|
|
expect(si.summaryOnly).toEqual([]);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
expect(result.hookSpecificOutput.additionalContext).not.toContain("mode:summary");
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(skill-b)");
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test("large summaries are not dropped when the current hook output stays within budget", async () => {
|
|
// skill-a: large body
|
|
// skill-b: large body + large summary
|
|
const bigBody = "X".repeat(5000);
|
|
const bigSummary = "Y".repeat(5000);
|
|
const { tempHooksDir, cleanup } = createTempPlugin([
|
|
{ name: "skill-a", body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-b", summary: bigSummary, body: bigBody, patterns: ["src/**"] },
|
|
]);
|
|
|
|
const { code, stdout } = await runTempHook(
|
|
tempHooksDir,
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/src/index.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBe(2);
|
|
expect(si.droppedByBudget.length).toBe(0);
|
|
expect(si.summaryOnly).toEqual([]);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test("total injected bytes stay within budget with summary fallback", async () => {
|
|
const bigBody = "X".repeat(4000);
|
|
const shortSummary = "Brief help for this skill.";
|
|
const { tempHooksDir, cleanup } = createTempPlugin([
|
|
{ name: "skill-a", body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-b", summary: shortSummary, body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-c", summary: shortSummary, body: bigBody, patterns: ["src/**"] },
|
|
]);
|
|
|
|
const budget = 5000;
|
|
const { code, stdout } = await runTempHook(
|
|
tempHooksDir,
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/src/index.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: String(budget) },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const ctx = result.hookSpecificOutput?.additionalContext || "";
|
|
// Total bytes must not exceed budget (first skill exempted, but subsequent ones
|
|
// including summaries should keep total within budget)
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
// All injected + summaryOnly + dropped should account for all matched
|
|
expect(si.injectedSkills.length + si.droppedByCap.length + si.droppedByBudget.length).toBe(
|
|
si.matchedSkills.length,
|
|
);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test("summaryOnly array in skillInjection metadata", async () => {
|
|
const bigBody = "X".repeat(5000);
|
|
const { tempHooksDir, cleanup } = createTempPlugin([
|
|
{ name: "skill-a", body: bigBody, patterns: ["src/**"] },
|
|
{ name: "skill-b", summary: "A brief summary.", body: bigBody, patterns: ["src/**"] },
|
|
]);
|
|
|
|
const { code, stdout } = await runTempHook(
|
|
tempHooksDir,
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/src/index.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: "6000" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(Array.isArray(si.summaryOnly)).toBe(true);
|
|
|
|
cleanup();
|
|
});
|
|
});
|
|
|
|
describe("per-phase timing_ms (debug mode)", () => {
|
|
test("complete event includes timing_ms with required phase keys", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const complete = lines.find((l: any) => l.event === "complete");
|
|
expect(complete).toBeDefined();
|
|
expect(complete.timing_ms).toBeDefined();
|
|
|
|
// Required keys
|
|
for (const key of ["stdin_parse", "skillmap_load", "match", "skill_read", "total"]) {
|
|
expect(typeof complete.timing_ms[key]).toBe("number");
|
|
expect(complete.timing_ms[key]).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
test("timing_ms.total >= 0 for non-matching invocation", async () => {
|
|
const { stderr } = await runHookDebug({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const complete = lines.find((l: any) => l.event === "complete");
|
|
expect(complete).toBeDefined();
|
|
expect(complete.timing_ms).toBeDefined();
|
|
expect(complete.timing_ms.total).toBeGreaterThanOrEqual(0);
|
|
expect(complete.timing_ms.stdin_parse).toBeGreaterThanOrEqual(0);
|
|
expect(complete.timing_ms.skillmap_load).toBeGreaterThanOrEqual(0);
|
|
expect(complete.timing_ms.match).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test("timing_ms not present when debug is off", async () => {
|
|
const { stderr, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
// No stderr in non-debug mode
|
|
expect(stderr).toBe("");
|
|
// stdout should not contain timing_ms
|
|
expect(stdout).not.toContain("timing_ms");
|
|
});
|
|
});
|
|
|
|
describe("invalid bash regex handling", () => {
|
|
// Helper: create a temp plugin dir with a single skill containing an invalid bash regex
|
|
function createTempSkillWithRegex(bashPatterns: string[]): { hookPath: string; root: string } {
|
|
const tempRoot = join(tmpdir(), `vp-test-regex-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillDir = join(tempRoot, "skills", "test-skill");
|
|
mkdirSync(tempSkillDir, { recursive: true });
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
|
|
// Write a SKILL.md with the given bashPatterns
|
|
const bashYaml = bashPatterns.map(p => ` - '${p.replace(/'/g, "''")}'`).join("\n");
|
|
writeFileSync(
|
|
join(tempSkillDir, "SKILL.md"),
|
|
`---\nname: test-skill\ndescription: Test skill\nmetadata:\n priority: 10\n pathPatterns: []\n bashPatterns:\n${bashYaml}\n---\n# Test Skill\nContent here.`,
|
|
);
|
|
|
|
return { hookPath: join(tempHooksDir, "pretooluse-skill-inject.mjs"), root: tempRoot };
|
|
}
|
|
|
|
test("emits BASH_REGEX_INVALID for broken regex, still exits 0 with valid JSON, and valid patterns still match", async () => {
|
|
const { hookPath, root } = createTempSkillWithRegex(["(unclosed-group", "\\bvalid-command\\b"]);
|
|
|
|
try {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "valid-command --flag" },
|
|
session_id: `invalid-regex-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", hookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
// Hook exits 0
|
|
expect(code).toBe(0);
|
|
|
|
// stdout is valid JSON
|
|
expect(() => JSON.parse(stdout)).not.toThrow();
|
|
|
|
// stderr contains BASH_REGEX_INVALID issue
|
|
expect(stderr).toContain("BASH_REGEX_INVALID");
|
|
const issueLines = stderr.split("\n").filter(l => l.includes("BASH_REGEX_INVALID"));
|
|
expect(issueLines.length).toBeGreaterThanOrEqual(1);
|
|
const issueEvent = JSON.parse(issueLines[0]);
|
|
expect(issueEvent.event).toBe("issue");
|
|
expect(issueEvent.code).toBe("BASH_REGEX_INVALID");
|
|
expect(issueEvent.context.pattern).toBe("(unclosed-group");
|
|
|
|
// Valid pattern still matched — skill was found in debug output
|
|
expect(stderr).toContain("matches-found");
|
|
const matchLine = stderr.split("\n").find(l => l.includes("matches-found"));
|
|
const matchEvent = JSON.parse(matchLine!);
|
|
expect(matchEvent.matched).toContain("test-skill");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("does not emit BASH_REGEX_INVALID when debug is off", async () => {
|
|
const { hookPath, root } = createTempSkillWithRegex(["(unclosed-group"]);
|
|
|
|
try {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "some-command" },
|
|
session_id: `no-debug-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", hookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
// No stderr when debug is off
|
|
expect(stderr).toBe("");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("invalid glob pattern handling", () => {
|
|
/**
|
|
* Helper: create a temp plugin dir with a patched patterns.mjs that throws
|
|
* on a sentinel pattern "__THROW__", simulating a broken glob at compile time.
|
|
* Also creates two skills: bad-glob-skill (with __THROW__ pattern) and good-skill.
|
|
*/
|
|
function createTempSkillWithBadGlob(): { hookPath: string; root: string } {
|
|
const tempRoot = join(tmpdir(), `vp-test-glob-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
|
|
// Copy hook + frontmatter parser
|
|
// Patched patterns.mjs: throws on "__THROW__" sentinel, delegates otherwise
|
|
const realPatterns = readFileSync(join(ROOT, "hooks", "patterns.mjs"), "utf-8");
|
|
// Match both tsc output ("export function globToRegex(pattern)") and
|
|
// tsup output ("function globToRegex(pattern)") since bundlers may strip export keywords
|
|
const patchedPatterns = realPatterns.replace(
|
|
/^(\s*(?:export\s+)?function globToRegex\(pattern\)\s*\{)/m,
|
|
'$1\n if (pattern === "__THROW__") throw new Error("simulated glob compile failure");',
|
|
);
|
|
copyTempHookRuntime(tempRoot, tempHooksDir, { "patterns.mjs": patchedPatterns });
|
|
|
|
// Skill with a __THROW__ pathPattern that will trigger the patched globToRegex to throw
|
|
const badSkillDir = join(tempRoot, "skills", "bad-glob-skill");
|
|
mkdirSync(badSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(badSkillDir, "SKILL.md"),
|
|
`---\nname: bad-glob-skill\ndescription: Skill with bad glob\nmetadata:\n priority: 10\n pathPatterns:\n - '__THROW__'\n - '**/*.validext'\n---\n# Bad Glob Skill\nContent here.`,
|
|
);
|
|
|
|
// Valid skill that should still match
|
|
const goodSkillDir = join(tempRoot, "skills", "good-skill");
|
|
mkdirSync(goodSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(goodSkillDir, "SKILL.md"),
|
|
`---\nname: good-skill\ndescription: Valid skill\nmetadata:\n priority: 5\n pathPatterns:\n - '**/*.validext'\n---\n# Good Skill\nGood content.`,
|
|
);
|
|
|
|
return { hookPath: join(tempHooksDir, "pretooluse-skill-inject.mjs"), root: tempRoot };
|
|
}
|
|
|
|
test("emits PATH_GLOB_INVALID for broken glob, still exits 0 and injects valid skills", async () => {
|
|
const { hookPath, root } = createTempSkillWithBadGlob();
|
|
|
|
try {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/src/app.validext" },
|
|
session_id: `invalid-glob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", hookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
// Hook exits 0
|
|
expect(code).toBe(0);
|
|
|
|
// stdout is valid JSON
|
|
expect(() => JSON.parse(stdout)).not.toThrow();
|
|
|
|
// stderr contains PATH_GLOB_INVALID issue
|
|
expect(stderr).toContain("PATH_GLOB_INVALID");
|
|
const issueLines = stderr.split("\n").filter(l => l.includes("PATH_GLOB_INVALID"));
|
|
expect(issueLines.length).toBeGreaterThanOrEqual(1);
|
|
const issueEvent = JSON.parse(issueLines[0]);
|
|
expect(issueEvent.event).toBe("issue");
|
|
expect(issueEvent.code).toBe("PATH_GLOB_INVALID");
|
|
expect(issueEvent.context.skill).toBe("bad-glob-skill");
|
|
expect(issueEvent.context.pattern).toBe("__THROW__");
|
|
|
|
// Valid skill (good-skill) still injected despite bad-glob-skill having a broken pattern
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(good-skill)");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("bad-glob-skill with mixed valid/invalid patterns still matches via valid pattern", async () => {
|
|
const { hookPath, root } = createTempSkillWithBadGlob();
|
|
|
|
try {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/foo.validext" },
|
|
session_id: `mixed-glob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", hookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
// PATH_GLOB_INVALID emitted for the __THROW__ pattern
|
|
expect(stderr).toContain("PATH_GLOB_INVALID");
|
|
|
|
// bad-glob-skill still matched via its valid "**/*.validext" pattern
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(bad-glob-skill)");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("does not emit PATH_GLOB_INVALID when debug is off", async () => {
|
|
const { hookPath, root } = createTempSkillWithBadGlob();
|
|
|
|
try {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/foo.txt" },
|
|
session_id: `no-debug-glob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", hookPath], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
// No debug env var
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(stderr).toBe("");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Coverage matrix: representative file paths (20+)
|
|
// ---------------------------------------------------------------------------
|
|
describe("coverage matrix — file paths", () => {
|
|
// Helper: run hook with dedup disabled so each test is independent
|
|
async function matchFile(filePath: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: filePath },
|
|
session_id: `matrix-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
// 1. Next.js app dir page
|
|
test("app/page.tsx → nextjs", async () => {
|
|
const skills = await matchFile("/project/app/page.tsx");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 2. Next.js pages dir
|
|
test("pages/index.tsx → nextjs", async () => {
|
|
const skills = await matchFile("/project/pages/index.tsx");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 3. src/app layout
|
|
test("src/app/layout.tsx → nextjs + observability", async () => {
|
|
const skills = await matchFile("/project/src/app/layout.tsx");
|
|
expect(skills).toContain("nextjs");
|
|
expect(skills).toContain("observability");
|
|
});
|
|
|
|
// 4. Monorepo: apps/web/app/page.tsx → nextjs
|
|
test("apps/web/app/page.tsx → nextjs (monorepo)", async () => {
|
|
const skills = await matchFile("/project/apps/web/app/page.tsx");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 5. Monorepo: apps/docs/next.config.ts → nextjs + turbopack
|
|
test("apps/docs/next.config.ts → nextjs + turbopack (monorepo)", async () => {
|
|
const skills = await matchFile("/project/apps/docs/next.config.ts");
|
|
expect(skills).toContain("nextjs");
|
|
expect(skills).toContain("turbopack");
|
|
});
|
|
|
|
// 6. AI SDK chat route
|
|
test("app/api/chat/route.ts → ai-sdk + chat-sdk + vercel-functions (cap 3 drops nextjs)", async () => {
|
|
const skills = await matchFile("/project/app/api/chat/route.ts");
|
|
// All three priority-8 skills make the cap; nextjs (5) gets dropped
|
|
expect(skills).toContain("ai-sdk");
|
|
expect(skills).toContain("chat-sdk");
|
|
expect(skills).toContain("vercel-functions");
|
|
expect(skills.length).toBe(3);
|
|
});
|
|
|
|
// 7. Monorepo AI SDK
|
|
test("apps/web/app/api/chat/route.ts → ai-sdk (monorepo)", async () => {
|
|
const skills = await matchFile("/project/apps/web/app/api/chat/route.ts");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
|
|
test("src/app/api/chat/route.ts → chat-sdk via src app route pattern", async () => {
|
|
const skills = await matchFile("/project/src/app/api/chat/route.ts");
|
|
expect(skills).toContain("chat-sdk");
|
|
});
|
|
|
|
// 8. Auth route → sign-in-with-vercel
|
|
test("app/api/auth/callback/route.ts → sign-in-with-vercel", async () => {
|
|
const skills = await matchFile("/project/app/api/auth/callback/route.ts");
|
|
expect(skills).toContain("sign-in-with-vercel");
|
|
});
|
|
|
|
// 9. .env.local → env-vars
|
|
test(".env.local → env-vars", async () => {
|
|
const skills = await matchFile("/project/.env.local");
|
|
expect(skills).toContain("env-vars");
|
|
});
|
|
|
|
// 10. .env.production → env-vars
|
|
test(".env.production → env-vars", async () => {
|
|
const skills = await matchFile("/project/.env.production");
|
|
expect(skills).toContain("env-vars");
|
|
});
|
|
|
|
// 11. .env → env-vars
|
|
test(".env → env-vars", async () => {
|
|
const skills = await matchFile("/project/.env");
|
|
expect(skills).toContain("env-vars");
|
|
});
|
|
|
|
// 12. middleware.ts → routing-middleware
|
|
test("middleware.ts → routing-middleware", async () => {
|
|
const skills = await matchFile("/project/middleware.ts");
|
|
expect(skills).toContain("routing-middleware");
|
|
});
|
|
|
|
// 13. src/proxy.mts → routing-middleware
|
|
test("src/proxy.mts → routing-middleware", async () => {
|
|
const skills = await matchFile("/project/src/proxy.mts");
|
|
expect(skills).toContain("routing-middleware");
|
|
});
|
|
|
|
// 14. vercel.json → vercel-functions + cron-jobs + deployments-cicd (capped at 3)
|
|
test("vercel.json → multiple control-plane skills (capped at 3)", async () => {
|
|
const skills = await matchFile("/project/vercel.json");
|
|
// vercel.json triggers 5 skills; MAX_SKILLS=3 caps to top 3
|
|
expect(skills.length).toBe(3);
|
|
// vercel-functions (priority 8) must be included
|
|
expect(skills).toContain("vercel-functions");
|
|
});
|
|
|
|
// 15. CI workflow → deployments-cicd
|
|
test(".github/workflows/deploy.yml → deployments-cicd", async () => {
|
|
const skills = await matchFile("/project/.github/workflows/deploy.yml");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// 16. GitLab CI → deployments-cicd
|
|
test(".gitlab-ci.yml → deployments-cicd", async () => {
|
|
const skills = await matchFile("/project/.gitlab-ci.yml");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// 17. shadcn components
|
|
test("components/ui/button.tsx → shadcn", async () => {
|
|
const skills = await matchFile("/project/components/ui/button.tsx");
|
|
expect(skills).toContain("shadcn");
|
|
});
|
|
|
|
// 18. Monorepo shadcn
|
|
test("apps/web/src/components/ui/dialog.tsx → shadcn (monorepo)", async () => {
|
|
const skills = await matchFile("/project/apps/web/src/components/ui/dialog.tsx");
|
|
expect(skills).toContain("shadcn");
|
|
});
|
|
|
|
// 19. instrumentation.ts → observability
|
|
test("instrumentation.ts → observability", async () => {
|
|
const skills = await matchFile("/project/instrumentation.ts");
|
|
expect(skills).toContain("observability");
|
|
});
|
|
|
|
// 20. lib/ai/providers.ts → ai-sdk
|
|
test("lib/ai/providers.ts → ai-sdk", async () => {
|
|
const skills = await matchFile("/project/lib/ai/providers.ts");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
|
|
// 21. integration.json → marketplace
|
|
test("integration.json → marketplace", async () => {
|
|
const skills = await matchFile("/project/integration.json");
|
|
expect(skills).toContain("marketplace");
|
|
});
|
|
|
|
// 22. .mcp.json → vercel-api
|
|
test(".mcp.json → vercel-api", async () => {
|
|
const skills = await matchFile("/project/.mcp.json");
|
|
expect(skills).toContain("vercel-api");
|
|
});
|
|
|
|
// 23. components/chat/message-list.tsx → json-render
|
|
test("components/chat/message-list.tsx → json-render", async () => {
|
|
const skills = await matchFile("/project/components/chat/message-list.tsx");
|
|
expect(skills).toContain("json-render");
|
|
});
|
|
|
|
// 24. lib/edge-config.ts → vercel-storage
|
|
test("lib/edge-config.ts → vercel-storage", async () => {
|
|
const skills = await matchFile("/project/lib/edge-config.ts");
|
|
expect(skills).toContain("vercel-storage");
|
|
});
|
|
|
|
// 25. flags.ts → vercel-flags
|
|
test("flags.ts → vercel-flags", async () => {
|
|
const skills = await matchFile("/project/flags.ts");
|
|
expect(skills).toContain("vercel-flags");
|
|
});
|
|
|
|
// Negative cases
|
|
test("random/file.txt → no skills", async () => {
|
|
const skills = await matchFile("/project/random/file.txt");
|
|
expect(skills).toEqual([]);
|
|
});
|
|
|
|
test("package.json → bootstrap only", async () => {
|
|
const skills = await matchFile("/project/package.json");
|
|
expect(skills).toEqual(["bootstrap"]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Coverage matrix: representative bash commands (15+)
|
|
// ---------------------------------------------------------------------------
|
|
describe("coverage matrix — bash commands", () => {
|
|
async function matchBash(command: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Bash",
|
|
tool_input: { command },
|
|
session_id: `matrix-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
// 1. vercel deploy --prod → deployments-cicd + vercel-cli
|
|
test("vercel deploy --prod → deployments-cicd + vercel-cli", async () => {
|
|
const skills = await matchBash("vercel deploy --prod");
|
|
expect(skills).toContain("deployments-cicd");
|
|
expect(skills).toContain("vercel-cli");
|
|
});
|
|
|
|
// 2. vercel promote → deployments-cicd
|
|
test("vercel promote → deployments-cicd", async () => {
|
|
const skills = await matchBash("vercel promote");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// 3. vercel rollback → deployments-cicd
|
|
test("vercel rollback → deployments-cicd", async () => {
|
|
const skills = await matchBash("vercel rollback");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// 4. vercel build → deployments-cicd
|
|
test("vercel build → deployments-cicd", async () => {
|
|
const skills = await matchBash("vercel build");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// 5. vercel env pull → ai-gateway + env-vars
|
|
test("vercel env pull → ai-gateway + env-vars", async () => {
|
|
const skills = await matchBash("vercel env pull .env.local");
|
|
expect(skills).toContain("ai-gateway");
|
|
expect(skills).toContain("env-vars");
|
|
});
|
|
|
|
// 6. vercel env add → env-vars
|
|
test("vercel env add → env-vars", async () => {
|
|
const skills = await matchBash("vercel env add SECRET_KEY");
|
|
expect(skills).toContain("env-vars");
|
|
});
|
|
|
|
// 7. pnpm dlx vercel deploy → vercel-cli
|
|
test("pnpm dlx vercel deploy → vercel-cli", async () => {
|
|
const skills = await matchBash("pnpm dlx vercel deploy");
|
|
expect(skills).toContain("vercel-cli");
|
|
});
|
|
|
|
// 8. bunx vercel → vercel-cli
|
|
test("bunx vercel → vercel-cli", async () => {
|
|
const skills = await matchBash("bunx vercel");
|
|
expect(skills).toContain("vercel-cli");
|
|
});
|
|
|
|
// 9. next dev --turbopack → dev-server verification takes priority, turbopack may be dropped by cap
|
|
test("next dev --turbopack → agent-browser-verify + verification + nextjs (cap 3 drops turbopack)", async () => {
|
|
const skills = await matchBash("next dev --turbopack");
|
|
// Dev server detection boosts agent-browser-verify (45) and verification (45)
|
|
// nextjs (5) fills last slot; turbopack (4) dropped by cap
|
|
expect(skills).toContain("agent-browser-verify");
|
|
expect(skills).toContain("verification");
|
|
expect(skills).toContain("nextjs");
|
|
expect(skills.length).toBe(3);
|
|
});
|
|
|
|
// 10. npm install @vercel/blob → vercel-storage
|
|
test("npm install @vercel/blob → vercel-storage", async () => {
|
|
const skills = await matchBash("npm install @vercel/blob");
|
|
expect(skills).toContain("vercel-storage");
|
|
});
|
|
|
|
// 11. pnpm add @vercel/analytics → observability
|
|
test("pnpm add @vercel/analytics → observability", async () => {
|
|
const skills = await matchBash("pnpm add @vercel/analytics");
|
|
expect(skills).toContain("observability");
|
|
});
|
|
|
|
// 12. npm install @vercel/flags → vercel-flags
|
|
test("npm install @vercel/flags → vercel-flags", async () => {
|
|
const skills = await matchBash("npm install @vercel/flags");
|
|
expect(skills).toContain("vercel-flags");
|
|
});
|
|
|
|
// 13. npx shadcn@latest add button → shadcn
|
|
test("npx shadcn@latest add button → shadcn", async () => {
|
|
const skills = await matchBash("npx shadcn@latest add button");
|
|
expect(skills).toContain("shadcn");
|
|
});
|
|
|
|
// 14. npm run dev → nextjs
|
|
test("npm run dev → nextjs", async () => {
|
|
const skills = await matchBash("npm run dev");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 15. pnpm build → nextjs
|
|
test("pnpm build → nextjs", async () => {
|
|
const skills = await matchBash("pnpm build");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 16. bun run dev → nextjs
|
|
test("bun run dev → nextjs", async () => {
|
|
const skills = await matchBash("bun run dev");
|
|
expect(skills).toContain("nextjs");
|
|
});
|
|
|
|
// 17. vercel firewall → vercel-firewall
|
|
test("vercel firewall → vercel-firewall", async () => {
|
|
const skills = await matchBash("vercel firewall");
|
|
expect(skills).toContain("vercel-firewall");
|
|
});
|
|
|
|
// 18. npm install @vercel/sandbox → vercel-sandbox
|
|
test("npm install @vercel/sandbox → vercel-sandbox", async () => {
|
|
const skills = await matchBash("npm install @vercel/sandbox");
|
|
expect(skills).toContain("vercel-sandbox");
|
|
});
|
|
|
|
// 19. yarn dlx vercel deploy → vercel-cli
|
|
test("yarn dlx vercel deploy → vercel-cli", async () => {
|
|
const skills = await matchBash("yarn dlx vercel deploy");
|
|
expect(skills).toContain("vercel-cli");
|
|
});
|
|
|
|
// 20. vercel inspect → deployments-cicd
|
|
test("vercel inspect → deployments-cicd", async () => {
|
|
const skills = await matchBash("vercel inspect https://my-app.vercel.app");
|
|
expect(skills).toContain("deployments-cicd");
|
|
});
|
|
|
|
// Negative cases
|
|
test("echo hello → no skills", async () => {
|
|
const skills = await matchBash("echo hello");
|
|
expect(skills).toEqual([]);
|
|
});
|
|
|
|
test("git status → no skills", async () => {
|
|
const skills = await matchBash("git status");
|
|
expect(skills).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Specialist-over-generalist overlap scenarios
|
|
// ---------------------------------------------------------------------------
|
|
describe("specialist wins over generalist in overlap", () => {
|
|
async function matchFileOrdered(filePath: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: filePath },
|
|
session_id: `overlap-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
async function matchBashOrdered(command: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Bash",
|
|
tool_input: { command },
|
|
session_id: `overlap-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
test("app/api/chat/route.ts: all 3 injected skills are priority 8 (nextjs dropped by cap)", async () => {
|
|
const skills = await matchFileOrdered("/project/app/api/chat/route.ts");
|
|
// With cap 3, only the three priority-8 skills inject
|
|
expect(skills.length).toBe(3);
|
|
expect(skills).toContain("ai-sdk");
|
|
expect(skills).toContain("chat-sdk");
|
|
expect(skills).toContain("vercel-functions");
|
|
expect(skills).not.toContain("nextjs");
|
|
});
|
|
|
|
test("app/api/auth/route.ts: sign-in-with-vercel (6) appears before nextjs (5)", async () => {
|
|
const skills = await matchFileOrdered("/project/app/api/auth/route.ts");
|
|
const authIdx = skills.indexOf("sign-in-with-vercel");
|
|
const nextIdx = skills.indexOf("nextjs");
|
|
expect(authIdx).toBeGreaterThanOrEqual(0);
|
|
expect(nextIdx).toBeGreaterThanOrEqual(0);
|
|
expect(authIdx).toBeLessThan(nextIdx);
|
|
});
|
|
|
|
test("app/layout.tsx: observability (6) appears before nextjs (5)", async () => {
|
|
const skills = await matchFileOrdered("/project/app/layout.tsx");
|
|
const obsIdx = skills.indexOf("observability");
|
|
const nextIdx = skills.indexOf("nextjs");
|
|
expect(obsIdx).toBeGreaterThanOrEqual(0);
|
|
expect(nextIdx).toBeGreaterThanOrEqual(0);
|
|
expect(obsIdx).toBeLessThan(nextIdx);
|
|
});
|
|
|
|
test("vercel env pull: env-vars (7) and ai-gateway (7) appear before vercel-cli (4)", async () => {
|
|
const skills = await matchBashOrdered("vercel env pull .env.local");
|
|
expect(skills).toContain("env-vars");
|
|
expect(skills).toContain("ai-gateway");
|
|
// vercel-cli should either not appear (capped) or appear after specialists
|
|
const cliIdx = skills.indexOf("vercel-cli");
|
|
if (cliIdx >= 0) {
|
|
expect(skills.indexOf("env-vars")).toBeLessThan(cliIdx);
|
|
expect(skills.indexOf("ai-gateway")).toBeLessThan(cliIdx);
|
|
}
|
|
});
|
|
|
|
test("vercel deploy --prod: deployments-cicd (6) appears before vercel-cli (4)", async () => {
|
|
const skills = await matchBashOrdered("vercel deploy --prod");
|
|
const cicdIdx = skills.indexOf("deployments-cicd");
|
|
const cliIdx = skills.indexOf("vercel-cli");
|
|
expect(cicdIdx).toBeGreaterThanOrEqual(0);
|
|
expect(cliIdx).toBeGreaterThanOrEqual(0);
|
|
expect(cicdIdx).toBeLessThan(cliIdx);
|
|
});
|
|
|
|
test("monorepo apps/web/app/api/chat/route.ts: ai-sdk before nextjs", async () => {
|
|
const skills = await matchFileOrdered("/project/apps/web/app/api/chat/route.ts");
|
|
const aiIdx = skills.indexOf("ai-sdk");
|
|
const nextIdx = skills.indexOf("nextjs");
|
|
expect(aiIdx).toBeGreaterThanOrEqual(0);
|
|
// nextjs may or may not match monorepo paths for app/**
|
|
if (nextIdx >= 0) {
|
|
expect(aiIdx).toBeLessThan(nextIdx);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("vercel-firewall priority ranks above vercel-cli", () => {
|
|
async function matchFileOrdered(filePath: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: filePath },
|
|
session_id: `firewall-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
test(".vercel/firewall/config.json: vercel-firewall appears before vercel-cli", async () => {
|
|
const skills = await matchFileOrdered("/project/.vercel/firewall/config.json");
|
|
const firewallIdx = skills.indexOf("vercel-firewall");
|
|
const cliIdx = skills.indexOf("vercel-cli");
|
|
expect(firewallIdx).toBeGreaterThanOrEqual(0);
|
|
expect(cliIdx).toBeGreaterThanOrEqual(0);
|
|
expect(firewallIdx).toBeLessThan(cliIdx);
|
|
});
|
|
|
|
test("vercel-firewall priority is higher than vercel-cli priority in skill-map", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const map = buildSkillMap(SKILLS_DIR);
|
|
expect(map.skills["vercel-firewall"].priority).toBeGreaterThan(
|
|
map.skills["vercel-cli"].priority,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ai-sdk bash patterns match @ai-sdk/ scoped packages", () => {
|
|
async function matchBash(command: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Bash",
|
|
tool_input: { command },
|
|
session_id: `aisdk-bash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
test("npm install @ai-sdk/react → ai-sdk", async () => {
|
|
const skills = await matchBash("npm install @ai-sdk/react");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
|
|
test("pnpm add @ai-sdk/openai → ai-sdk", async () => {
|
|
const skills = await matchBash("pnpm add @ai-sdk/openai");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
|
|
test("bun add @ai-sdk/anthropic → ai-sdk", async () => {
|
|
const skills = await matchBash("bun add @ai-sdk/anthropic");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
|
|
test("yarn add @ai-sdk/google → ai-sdk", async () => {
|
|
const skills = await matchBash("yarn add @ai-sdk/google");
|
|
expect(skills).toContain("ai-sdk");
|
|
});
|
|
});
|
|
|
|
describe("hooks.json PreToolUse config", () => {
|
|
test("has PreToolUse matcher for Read|Edit|Write|Bash", () => {
|
|
const hooks = JSON.parse(readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"));
|
|
expect(hooks.hooks.PreToolUse).toBeDefined();
|
|
expect(hooks.hooks.PreToolUse.length).toBeGreaterThan(0);
|
|
|
|
const matcher = hooks.hooks.PreToolUse[0].matcher;
|
|
expect(matcher).toContain("Read");
|
|
expect(matcher).toContain("Edit");
|
|
expect(matcher).toContain("Write");
|
|
expect(matcher).toContain("Bash");
|
|
});
|
|
|
|
test("references the correct hook script", () => {
|
|
const hooks = JSON.parse(readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"));
|
|
const hookCmd = hooks.hooks.PreToolUse[0].hooks[0].command;
|
|
expect(hookCmd).toContain("pretooluse-skill-inject.mjs");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateSkillMap tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { validateSkillMap } from "../hooks/pretooluse-skill-inject.mjs";
|
|
|
|
describe("validateSkillMap", () => {
|
|
test("returns error when input is null", () => {
|
|
const result = validateSkillMap(null);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.errors).toContain("skill-map must be a non-null object");
|
|
});
|
|
|
|
test("returns error when skills key is missing", () => {
|
|
const result = validateSkillMap({});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.errors).toContain("skill-map is missing required 'skills' key");
|
|
});
|
|
|
|
test("returns error when skills is not an object", () => {
|
|
const result = validateSkillMap({ skills: "bad" });
|
|
expect(result.ok).toBe(false);
|
|
expect(result.errors[0]).toContain("'skills' must be a non-null object");
|
|
});
|
|
|
|
test("returns error when skills is an array", () => {
|
|
const result = validateSkillMap({ skills: [] });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
test("normalizes missing pathPatterns to empty array", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 5, bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].pathPatterns).toEqual([]);
|
|
});
|
|
|
|
test("normalizes missing bashPatterns to empty array", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 5, pathPatterns: ["*.ts"] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].bashPatterns).toEqual([]);
|
|
});
|
|
|
|
test("normalizes missing priority to 5", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { pathPatterns: [], bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].priority).toBe(5);
|
|
});
|
|
|
|
test("warns and defaults NaN priority to 5", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: NaN, pathPatterns: [], bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].priority).toBe(5);
|
|
expect(result.warnings.some((w: string) => w.includes("priority") && w.includes("not a valid number"))).toBe(true);
|
|
});
|
|
|
|
test("warns and defaults non-number priority to 5", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: "high", pathPatterns: [], bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].priority).toBe(5);
|
|
expect(result.warnings.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("warns on non-array pathPatterns and defaults to []", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 1, pathPatterns: "*.ts", bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].pathPatterns).toEqual([]);
|
|
expect(result.warnings.some((w: string) => w.includes("pathPatterns") && w.includes("not an array"))).toBe(true);
|
|
});
|
|
|
|
test("removes non-string entries from pathPatterns with warning", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 1, pathPatterns: ["valid.ts", 42, null], bashPatterns: [] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].pathPatterns).toEqual(["valid.ts"]);
|
|
expect(result.warnings.some((w: string) => w.includes("pathPatterns[1]") && w.includes("not a string"))).toBe(true);
|
|
});
|
|
|
|
test("removes non-string entries from bashPatterns with warning", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 1, pathPatterns: [], bashPatterns: ["valid", 123] } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["test-skill"].bashPatterns).toEqual(["valid"]);
|
|
expect(result.warnings.some((w: string) => w.includes("bashPatterns[1]"))).toBe(true);
|
|
});
|
|
|
|
test("warns on unknown keys", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "test-skill": { priority: 1, pathPatterns: [], bashPatterns: [], description: "hi", foo: "bar" } },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(result.warnings.some((w: string) => w.includes('unknown key "description"'))).toBe(true);
|
|
expect(result.warnings.some((w: string) => w.includes('unknown key "foo"'))).toBe(true);
|
|
});
|
|
|
|
test("validates the frontmatter-built skill map successfully", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const raw = buildSkillMap(SKILLS_DIR);
|
|
const result = validateSkillMap(raw);
|
|
expect(result.ok).toBe(true);
|
|
// Filter out known chainTo warnings — self-referential upgradeToSkill rules
|
|
// (e.g., workflow → workflow) intentionally lack chainTo entries.
|
|
const unexpectedWarnings = result.warnings.filter(
|
|
(w: string) => !w.includes("has no matching chainTo entry"),
|
|
);
|
|
expect(unexpectedWarnings).toEqual([]);
|
|
expect(Object.keys(result.normalizedSkillMap.skills).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("returns error for non-object skill config", () => {
|
|
const result = validateSkillMap({
|
|
skills: { "bad-skill": "not-an-object" },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.errors[0]).toContain('skill "bad-skill"');
|
|
});
|
|
|
|
test("validates buildSkillMap output with coerced bare-string pathPatterns", async () => {
|
|
const { buildSkillMap } = await import("../hooks/skill-map-frontmatter.mjs");
|
|
const { mkdirSync, writeFileSync, rmSync } = await import("node:fs");
|
|
const { tmpdir } = await import("node:os");
|
|
const { join } = await import("node:path");
|
|
|
|
const tmp = join(tmpdir(), `validate-coerce-${Date.now()}`);
|
|
const skillDir = join(tmp, "coerce-skill");
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(skillDir, "SKILL.md"),
|
|
`---\nname: coerce-skill\ndescription: Test\nmetadata:\n priority: 3\n pathPatterns: 'src/**'\n bashPatterns: '\\bnpm\\b'\n---\n# Test`,
|
|
);
|
|
|
|
const built = buildSkillMap(tmp);
|
|
// buildSkillMap should have coerced bare strings to arrays
|
|
expect(built.warnings.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// validateSkillMap should pass on the coerced output
|
|
const result = validateSkillMap(built);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.normalizedSkillMap.skills["coerce-skill"].pathPatterns).toEqual(["src/**"]);
|
|
expect(result.normalizedSkillMap.skills["coerce-skill"].bashPatterns).toEqual(["\\bnpm\\b"]);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deterministic ordering tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("deterministic ordering", () => {
|
|
test("tie-priority skills produce identical order across multiple runs", async () => {
|
|
// Create a custom skill-map with several same-priority skills
|
|
const customMap = {
|
|
skills: {
|
|
"zeta-skill": { priority: 5, pathPatterns: ["**/*.ts"], bashPatterns: [] },
|
|
"alpha-skill": { priority: 5, pathPatterns: ["**/*.ts"], bashPatterns: [] },
|
|
"mu-skill": { priority: 5, pathPatterns: ["**/*.ts"], bashPatterns: [] },
|
|
"beta-skill": { priority: 5, pathPatterns: ["**/*.ts"], bashPatterns: [] },
|
|
},
|
|
};
|
|
|
|
// Simulate the sort comparator used in the hook
|
|
const entries = Object.entries(customMap.skills).map(([skill, config]: [string, any]) => ({
|
|
skill,
|
|
priority: config.priority,
|
|
}));
|
|
|
|
// Run the sort 10 times and verify identical results
|
|
const results: string[][] = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const shuffled = [...entries].sort(() => Math.random() - 0.5); // randomize input order
|
|
shuffled.sort((a, b) => (b.priority - a.priority) || a.skill.localeCompare(b.skill));
|
|
results.push(shuffled.map((e) => e.skill));
|
|
}
|
|
|
|
const expected = ["alpha-skill", "beta-skill", "mu-skill", "zeta-skill"];
|
|
for (const result of results) {
|
|
expect(result).toEqual(expected);
|
|
}
|
|
});
|
|
|
|
test("mixed priorities sort by priority DESC then name ASC", () => {
|
|
const entries = [
|
|
{ skill: "z-low", priority: 1 },
|
|
{ skill: "a-high", priority: 10 },
|
|
{ skill: "m-mid", priority: 5 },
|
|
{ skill: "b-high", priority: 10 },
|
|
{ skill: "a-mid", priority: 5 },
|
|
];
|
|
|
|
entries.sort((a, b) => (b.priority - a.priority) || a.skill.localeCompare(b.skill));
|
|
|
|
expect(entries.map((e) => e.skill)).toEqual([
|
|
"a-high",
|
|
"b-high",
|
|
"a-mid",
|
|
"m-mid",
|
|
"z-low",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// vercel.json control-plane multi-skill matching and MAX_SKILLS cap
|
|
// ---------------------------------------------------------------------------
|
|
describe("vercel.json control-plane coverage", () => {
|
|
// Helper: run hook with dedup disabled so each test is independent
|
|
async function matchFile(filePath: string): Promise<string[]> {
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: filePath },
|
|
session_id: `ctrl-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
});
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEDUP: "off", VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const result = JSON.parse(stdout);
|
|
if (!result.hookSpecificOutput) return [];
|
|
return getInjectedSkills(result.hookSpecificOutput);
|
|
}
|
|
|
|
test("vercel.json matches top 3 skills by priority (vercel-functions, cron-jobs, deployments-cicd or routing-middleware)", async () => {
|
|
// vercel.json is in pathPatterns for: vercel-functions(8), cron-jobs(6),
|
|
// routing-middleware(6), deployments-cicd(6), vercel-cli(4)
|
|
// With MAX_SKILLS=3, top 3 by priority inject
|
|
const skills = await matchFile("/project/vercel.json");
|
|
|
|
expect(skills.length).toBe(3);
|
|
|
|
// vercel-functions (priority 8) must be first
|
|
expect(skills[0]).toBe("vercel-functions");
|
|
|
|
// Two of the priority-6 skills fill remaining slots
|
|
const priority6Skills = skills.filter((s: string) =>
|
|
["cron-jobs", "deployments-cicd", "routing-middleware"].includes(s),
|
|
);
|
|
expect(priority6Skills.length).toBe(2);
|
|
});
|
|
|
|
test("apps/*/vercel.json matches same control-plane skills (monorepo)", async () => {
|
|
const skills = await matchFile("/project/apps/web/vercel.json");
|
|
|
|
// Same cap-3 behavior as root vercel.json
|
|
expect(skills.length).toBe(3);
|
|
expect(skills[0]).toBe("vercel-functions");
|
|
});
|
|
|
|
test("monorepo apps/web/pages/_app.tsx → observability", async () => {
|
|
const skills = await matchFile("/project/apps/web/pages/_app.tsx");
|
|
expect(skills).toContain("observability");
|
|
});
|
|
|
|
test("monorepo apps/web/src/pages/_app.jsx → observability", async () => {
|
|
const skills = await matchFile("/project/apps/web/src/pages/_app.jsx");
|
|
expect(skills).toContain("observability");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// skillInjection metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("hookSpecificOutput.skillInjection metadata", () => {
|
|
test("includes skillInjection with correct structure and version", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
|
|
// Version
|
|
expect(si.version).toBe(1);
|
|
|
|
// Tool metadata
|
|
expect(si.toolName).toBe("Read");
|
|
expect(si.toolTarget).toBe("/project/next.config.ts");
|
|
|
|
// Skill arrays
|
|
expect(Array.isArray(si.matchedSkills)).toBe(true);
|
|
expect(Array.isArray(si.injectedSkills)).toBe(true);
|
|
expect(Array.isArray(si.droppedByCap)).toBe(true);
|
|
expect(Array.isArray(si.droppedByBudget)).toBe(true);
|
|
expect(si.injectedSkills.length).toBeGreaterThan(0);
|
|
expect(si.matchedSkills).toContain("nextjs");
|
|
expect(si.injectedSkills).toContain("nextjs");
|
|
});
|
|
|
|
test("Bash tool populates toolTarget with the redacted command", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npx next build" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.toolName).toBe("Bash");
|
|
// No secrets → command passes through unchanged
|
|
expect(si.toolTarget).toBe("npx next build");
|
|
});
|
|
|
|
test("Bash toolTarget keeps HTML comment terminators inside metadata JSON", async () => {
|
|
const command = "npx next build --> echo injected";
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.toolTarget).toBe(command);
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("npx next build --\\u003E echo injected");
|
|
});
|
|
|
|
test("Bash toolTarget redacts secrets in skillInjection output", async () => {
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel --token sk_super_secret deploy" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.toolName).toBe("Bash");
|
|
// Token must be redacted
|
|
expect(si.toolTarget).not.toContain("sk_super_secret");
|
|
expect(si.toolTarget).toContain("--token [REDACTED]");
|
|
expect(si.toolTarget).toContain("deploy");
|
|
});
|
|
|
|
test("Read/Edit/Write tools have unredacted file_path in toolTarget", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.toolTarget).toBe("/project/next.config.ts");
|
|
});
|
|
|
|
test("droppedByCap lists skills beyond MAX_SKILLS", async () => {
|
|
// vercel.json matches 5 skills but cap is 3
|
|
const { code, stdout } = await runHookEnv(
|
|
{
|
|
tool_name: "Edit",
|
|
tool_input: { file_path: "/project/vercel.json" },
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
const si = extractSkillInjection(result.hookSpecificOutput);
|
|
expect(si).toBeDefined();
|
|
expect(si.injectedSkills.length).toBeLessThanOrEqual(3);
|
|
expect(Array.isArray(si.droppedByBudget)).toBe(true);
|
|
// 5 matched, cap 3 → droppedByCap should have the remaining 2
|
|
expect(si.droppedByCap.length + si.droppedByBudget.length).toBe(
|
|
si.matchedSkills.length - si.injectedSkills.length,
|
|
);
|
|
});
|
|
|
|
test("no skillInjection in output when nothing matches", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/unknown.txt" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// redactCommand()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("redactCommand", () => {
|
|
// We import the function dynamically since the hook is ESM
|
|
let redactCommand: (cmd: string) => string;
|
|
|
|
beforeEach(async () => {
|
|
// Dynamic import to get the exported function
|
|
const mod = await import("../hooks/pretooluse-skill-inject.mjs");
|
|
redactCommand = mod.redactCommand;
|
|
});
|
|
|
|
test("masks TOKEN= values", () => {
|
|
expect(redactCommand("curl -H TOKEN=abc123secret https://api.example.com")).toContain("TOKEN=[REDACTED]");
|
|
expect(redactCommand("curl -H TOKEN=abc123secret https://api.example.com")).not.toContain("abc123secret");
|
|
});
|
|
|
|
test("masks KEY= values preserving full key name", () => {
|
|
expect(redactCommand("VERCEL_API_KEY=sk_live_xyz deploy")).toContain("VERCEL_API_KEY=[REDACTED]");
|
|
expect(redactCommand("VERCEL_API_KEY=sk_live_xyz deploy")).not.toContain("sk_live_xyz");
|
|
});
|
|
|
|
test("masks SECRET= values preserving full key name", () => {
|
|
expect(redactCommand("MY_SECRET=hunter2 run")).toContain("MY_SECRET=[REDACTED]");
|
|
expect(redactCommand("MY_SECRET=hunter2 run")).not.toContain("hunter2");
|
|
});
|
|
|
|
test("masks --token flag values", () => {
|
|
expect(redactCommand("vercel --token tk_abcdef deploy")).toContain("--token [REDACTED]");
|
|
expect(redactCommand("vercel --token tk_abcdef deploy")).not.toContain("tk_abcdef");
|
|
});
|
|
|
|
test("masks --password flag values", () => {
|
|
expect(redactCommand("mysql --password s3cret -u root")).toContain("--password [REDACTED]");
|
|
expect(redactCommand("mysql --password s3cret -u root")).not.toContain("s3cret");
|
|
});
|
|
|
|
test("masks --api-key flag values", () => {
|
|
expect(redactCommand("cli --api-key my-key-123")).toContain("--api-key [REDACTED]");
|
|
expect(redactCommand("cli --api-key my-key-123")).not.toContain("my-key-123");
|
|
});
|
|
|
|
test("truncates long commands to 200 chars", () => {
|
|
const longCmd = "a".repeat(300);
|
|
const result = redactCommand(longCmd);
|
|
expect(result.length).toBeLessThan(300);
|
|
expect(result).toContain("…[truncated]");
|
|
// First 200 chars preserved
|
|
expect(result.startsWith("a".repeat(200))).toBe(true);
|
|
});
|
|
|
|
test("handles non-string input gracefully", () => {
|
|
expect(redactCommand(undefined as any)).toBe("");
|
|
expect(redactCommand(null as any)).toBe("");
|
|
expect(redactCommand(123 as any)).toBe("");
|
|
});
|
|
|
|
test("case-insensitive matching", () => {
|
|
expect(redactCommand("token=abc123")).toContain("[REDACTED]");
|
|
expect(redactCommand("Token=abc123")).toContain("[REDACTED]");
|
|
expect(redactCommand("--Token myval")).toContain("[REDACTED]");
|
|
});
|
|
|
|
test("multiple secrets in one command are all redacted", () => {
|
|
const cmd = "TOKEN=aaa KEY=bbb --password ccc";
|
|
const result = redactCommand(cmd);
|
|
expect(result).not.toContain("aaa");
|
|
expect(result).not.toContain("bbb");
|
|
expect(result).not.toContain("ccc");
|
|
});
|
|
|
|
test("preserves full env var key name with prefix (regression)", () => {
|
|
// Previously, VERCEL_TOKEN=abc would become TOKEN=[REDACTED] instead of VERCEL_TOKEN=[REDACTED]
|
|
expect(redactCommand("VERCEL_TOKEN=abc123")).toBe("VERCEL_TOKEN=[REDACTED]");
|
|
expect(redactCommand("MY_API_KEY=secret")).toBe("MY_API_KEY=[REDACTED]");
|
|
expect(redactCommand("APP_SECRET=s3cret")).toBe("APP_SECRET=[REDACTED]");
|
|
// Simple key without prefix should still work
|
|
expect(redactCommand("TOKEN=abc123")).toBe("TOKEN=[REDACTED]");
|
|
expect(redactCommand("KEY=abc123")).toBe("KEY=[REDACTED]");
|
|
expect(redactCommand("SECRET=abc123")).toBe("SECRET=[REDACTED]");
|
|
// Sensitive word in the middle of key name (not just suffix)
|
|
expect(redactCommand("MY_SECRET_VALUE=hunter2")).toBe("MY_SECRET_VALUE=[REDACTED]");
|
|
expect(redactCommand("CREDENTIAL_STORE=val")).toBe("CREDENTIAL_STORE=[REDACTED]");
|
|
expect(redactCommand("MY_TOKEN_ID=abc")).toBe("MY_TOKEN_ID=[REDACTED]");
|
|
});
|
|
|
|
// --- Broadened redaction patterns ---
|
|
|
|
test("masks Bearer tokens", () => {
|
|
expect(redactCommand("curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc'")).toContain("Bearer [REDACTED]");
|
|
expect(redactCommand("curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc'")).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
|
});
|
|
|
|
test("masks 'token xxx' authorization style", () => {
|
|
expect(redactCommand("gh api -H 'Authorization: token ghp_abc123XYZ456'")).toContain("token [REDACTED]");
|
|
expect(redactCommand("gh api -H 'Authorization: token ghp_abc123XYZ456'")).not.toContain("ghp_abc123XYZ456");
|
|
});
|
|
|
|
test("masks connection strings (postgres)", () => {
|
|
const cmd = "psql postgres://admin:s3cret@db.example.com:5432/mydb";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("postgres://[REDACTED]@db.example.com");
|
|
expect(result).not.toContain("s3cret");
|
|
expect(result).not.toContain("admin:");
|
|
});
|
|
|
|
test("masks connection strings (redis)", () => {
|
|
const cmd = "redis-cli -u redis://default:hunter2@cache.example.com:6379";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("redis://[REDACTED]@cache.example.com");
|
|
expect(result).not.toContain("hunter2");
|
|
});
|
|
|
|
test("masks JSON secret values", () => {
|
|
const cmd = 'echo \'{"token": "sk_live_abc123", "name": "test"}\'';
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain('"token": "[REDACTED]"');
|
|
expect(result).not.toContain("sk_live_abc123");
|
|
// Non-sensitive keys preserved
|
|
expect(result).toContain('"name": "test"');
|
|
});
|
|
|
|
test("masks JSON password and api_key values", () => {
|
|
expect(redactCommand('{"password": "hunter2"}')).toContain('"password": "[REDACTED]"');
|
|
expect(redactCommand('{"api_key": "ak_xyz"}')).toContain('"api_key": "[REDACTED]"');
|
|
expect(redactCommand('{"apiKey": "ak_xyz"}')).toContain('"apiKey": "[REDACTED]"');
|
|
});
|
|
|
|
test("masks URL query params with sensitive keys", () => {
|
|
const cmd = "curl 'https://x.co?token=abc123&name=foo'";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("?token=[REDACTED]");
|
|
expect(result).not.toContain("abc123");
|
|
expect(result).toContain("&name=foo");
|
|
});
|
|
|
|
test("masks multiple sensitive URL query params", () => {
|
|
const cmd = "curl 'https://x.co?key=k1&secret=s2&page=1'";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("?key=[REDACTED]");
|
|
expect(result).toContain("&secret=[REDACTED]");
|
|
expect(result).toContain("&page=1");
|
|
expect(result).not.toContain("k1");
|
|
expect(result).not.toContain("s2");
|
|
});
|
|
|
|
test("masks Cookie headers", () => {
|
|
const cmd = "curl -H 'Cookie: session=abc123; auth_tok=xyz789' https://example.com";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("Cookie: [REDACTED]");
|
|
expect(result).not.toContain("abc123");
|
|
expect(result).not.toContain("xyz789");
|
|
});
|
|
|
|
test("masks Set-Cookie headers", () => {
|
|
const cmd = "curl -v 'Set-Cookie: id=a3fWa; Path=/; HttpOnly'";
|
|
const result = redactCommand(cmd);
|
|
expect(result).toContain("Set-Cookie: [REDACTED]");
|
|
expect(result).not.toContain("a3fWa");
|
|
});
|
|
|
|
test("masks --secret and --auth flags", () => {
|
|
expect(redactCommand("tool --secret mysecretval")).toContain("--secret [REDACTED]");
|
|
expect(redactCommand("tool --auth bearer_tok")).toContain("--auth [REDACTED]");
|
|
});
|
|
|
|
test("masks PASSWORD= env vars", () => {
|
|
expect(redactCommand("DB_PASSWORD=hunter2 npm start")).toBe("DB_PASSWORD=[REDACTED] npm start");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// debug mode: tool-target event uses redacted command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("debug mode tool-target redaction", () => {
|
|
test("tool-target event appears in debug stderr with redacted secrets", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel --token sk_secret123 deploy" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
// Find tool-target event
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const targetEvent = lines.find((l: any) => l.event === "tool-target");
|
|
expect(targetEvent).toBeDefined();
|
|
expect(targetEvent.target).toContain("--token [REDACTED]");
|
|
expect(targetEvent.target).not.toContain("sk_secret123");
|
|
});
|
|
|
|
test("tool-target event NOT emitted without debug mode", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "vercel --token sk_secret123 deploy" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEBUG: "0", VERCEL_PLUGIN_HOOK_DEBUG: "0" },
|
|
);
|
|
// stderr should be empty (no debug output)
|
|
expect(stderr.trim()).toBe("");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import matching — importPatterns trigger skills from file content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("import matching", () => {
|
|
test("Edit with @ai-sdk/gateway import in old_string triggers ai-gateway skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/utils/models.ts",
|
|
old_string: `import { gateway } from '@ai-sdk/gateway';\nconst g = gateway("openai/gpt-4o");`,
|
|
new_string: `import { gateway } from '@ai-sdk/gateway';\nconst g = gateway("anthropic/claude-sonnet-4-5-20250514");`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-gateway)");
|
|
});
|
|
|
|
test("Write with ai import in content triggers ai-sdk skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/helpers/generate.ts",
|
|
content: `import { generateText } from 'ai';\n\nexport async function gen() { return generateText({ model: 'gpt-4o', prompt: 'hi' }); }`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-sdk)");
|
|
});
|
|
|
|
test("Edit with require('@ai-sdk/gateway') triggers ai-gateway skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/config.js",
|
|
old_string: `const { gateway } = require('@ai-sdk/gateway');`,
|
|
new_string: `const { gateway } = require('@ai-sdk/gateway');\nconst model = gateway("openai/gpt-4o");`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-gateway)");
|
|
});
|
|
|
|
test("Edit without import content does not trigger import-based skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/utils/helpers.ts",
|
|
old_string: "const x = 1;",
|
|
new_string: "const x = 2;",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
test("Read tool does not trigger import matching (no file content in input)", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/utils/models.ts",
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
// Read has no content in tool_input, so import matching should not fire
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
test("path match takes precedence over import match (no double injection)", async () => {
|
|
// app/api/chat/route.ts matches ai-sdk by path pattern — import matching
|
|
// should not cause it to be added twice
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/app/api/chat/route.ts",
|
|
old_string: `import { generateText } from 'ai';`,
|
|
new_string: `import { streamText } from 'ai';`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
// ai-sdk should appear exactly once (matched by path, not duplicated by import)
|
|
const aiSdkMatches = getInjectedSkills(result.hookSpecificOutput).filter(
|
|
(skill) => skill === "ai-sdk",
|
|
);
|
|
expect(aiSdkMatches.length).toBe(1);
|
|
});
|
|
|
|
test("import match reason has matchType 'import'", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/gateway-config.ts",
|
|
content: `import { gateway } from '@ai-sdk/gateway';\nexport const gw = gateway("openai/gpt-4o");`,
|
|
},
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
const lines = stderr.trim().split("\n").map((l: string) => JSON.parse(l));
|
|
const matchesFound = lines.find((l: any) => l.event === "matches-found");
|
|
expect(matchesFound).toBeDefined();
|
|
expect(matchesFound.reasons).toBeDefined();
|
|
// ai-gateway should have matchType "import"
|
|
expect(matchesFound.reasons["ai-gateway"]).toBeDefined();
|
|
expect(matchesFound.reasons["ai-gateway"].matchType).toBe("import");
|
|
});
|
|
|
|
test("Write with @ai-sdk/react sub-path import triggers ai-sdk skill", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/hooks/use-chat.ts",
|
|
content: `import { useChat } from '@ai-sdk/react';\n\nexport function ChatHook() { return useChat(); }`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-sdk)");
|
|
});
|
|
|
|
test("dynamic import() triggers import matching", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/Users/me/project/src/lazy.ts",
|
|
content: `const mod = await import('@ai-sdk/gateway');\nconst gw = mod.gateway("openai/gpt-4o");`,
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
expect(result.hookSpecificOutput.additionalContext).toContain("Skill(ai-gateway)");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// importPatternToRegex unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("importPatternToRegex", () => {
|
|
let importPatternToRegex: (pattern: string) => RegExp;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../hooks/patterns.mjs");
|
|
importPatternToRegex = mod.importPatternToRegex;
|
|
});
|
|
|
|
test("matches ESM import from 'ai'", () => {
|
|
const re = importPatternToRegex("ai");
|
|
expect(re.test(`import { generateText } from 'ai'`)).toBe(true);
|
|
});
|
|
|
|
test("matches ESM import from \"ai\"", () => {
|
|
const re = importPatternToRegex("ai");
|
|
expect(re.test(`import { generateText } from "ai"`)).toBe(true);
|
|
});
|
|
|
|
test("matches require('ai')", () => {
|
|
const re = importPatternToRegex("ai");
|
|
expect(re.test(`const { generateText } = require('ai')`)).toBe(true);
|
|
});
|
|
|
|
test("matches scoped package @ai-sdk/gateway", () => {
|
|
const re = importPatternToRegex("@ai-sdk/gateway");
|
|
expect(re.test(`import { gateway } from '@ai-sdk/gateway'`)).toBe(true);
|
|
});
|
|
|
|
test("matches sub-path import @ai-sdk/gateway/foo", () => {
|
|
const re = importPatternToRegex("@ai-sdk/gateway");
|
|
expect(re.test(`import { thing } from '@ai-sdk/gateway/providers'`)).toBe(true);
|
|
});
|
|
|
|
test("wildcard pattern @ai-sdk/* matches @ai-sdk/react", () => {
|
|
const re = importPatternToRegex("@ai-sdk/*");
|
|
expect(re.test(`import { useChat } from '@ai-sdk/react'`)).toBe(true);
|
|
});
|
|
|
|
test("wildcard pattern @ai-sdk/* matches @ai-sdk/gateway", () => {
|
|
const re = importPatternToRegex("@ai-sdk/*");
|
|
expect(re.test(`import { gateway } from '@ai-sdk/gateway'`)).toBe(true);
|
|
});
|
|
|
|
test("does not match partial package name", () => {
|
|
const re = importPatternToRegex("ai");
|
|
// 'ai-sdk' is a different package — should not match 'ai' pattern
|
|
expect(re.test(`import { thing } from 'ai-sdk'`)).toBe(false);
|
|
});
|
|
|
|
test("matches dynamic import()", () => {
|
|
const re = importPatternToRegex("@ai-sdk/gateway");
|
|
expect(re.test(`const mod = await import('@ai-sdk/gateway')`)).toBe(true);
|
|
});
|
|
|
|
test("throws on empty string", () => {
|
|
expect(() => importPatternToRegex("")).toThrow("pattern must not be empty");
|
|
});
|
|
|
|
test("throws on non-string input", () => {
|
|
expect(() => (importPatternToRegex as any)(42)).toThrow("expected string");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matchImportWithReason unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("matchImportWithReason", () => {
|
|
let matchImportWithReason: any;
|
|
let importPatternToRegex: any;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import("../hooks/patterns.mjs");
|
|
matchImportWithReason = mod.matchImportWithReason;
|
|
importPatternToRegex = mod.importPatternToRegex;
|
|
});
|
|
|
|
test("returns match with matchType 'import' for matching content", () => {
|
|
const patterns = ["@ai-sdk/gateway"];
|
|
const compiled = patterns.map((p: string) => ({ pattern: p, regex: importPatternToRegex(p) }));
|
|
const content = `import { gateway } from '@ai-sdk/gateway';\nconst g = gateway("openai/gpt-4o");`;
|
|
const result = matchImportWithReason(content, compiled);
|
|
expect(result).toEqual({ pattern: "@ai-sdk/gateway", matchType: "import" });
|
|
});
|
|
|
|
test("returns null for non-matching content", () => {
|
|
const patterns = ["@ai-sdk/gateway"];
|
|
const compiled = patterns.map((p: string) => ({ pattern: p, regex: importPatternToRegex(p) }));
|
|
const content = `const x = 1;\nconst y = 2;`;
|
|
const result = matchImportWithReason(content, compiled);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns null for empty content", () => {
|
|
const patterns = ["@ai-sdk/gateway"];
|
|
const compiled = patterns.map((p: string) => ({ pattern: p, regex: importPatternToRegex(p) }));
|
|
expect(matchImportWithReason("", compiled)).toBeNull();
|
|
});
|
|
|
|
test("returns null for empty compiled patterns", () => {
|
|
expect(matchImportWithReason("import { x } from 'ai'", [])).toBeNull();
|
|
});
|
|
|
|
test("returns first matching pattern", () => {
|
|
const patterns = ["ai", "@ai-sdk/gateway"];
|
|
const compiled = patterns.map((p: string) => ({ pattern: p, regex: importPatternToRegex(p) }));
|
|
const content = `import { generateText } from 'ai';\nimport { gateway } from '@ai-sdk/gateway';`;
|
|
const result = matchImportWithReason(content, compiled);
|
|
expect(result).toEqual({ pattern: "ai", matchType: "import" });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import-safety: importing the module must NOT read stdin or write stdout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("import safety", () => {
|
|
test("importing the module does not produce stdout output", async () => {
|
|
// Spawn a Node process that imports the module and then exits cleanly.
|
|
// If the main-module guard is missing, it would hang on stdin or write to stdout.
|
|
const proc = Bun.spawn(
|
|
[
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
`import "${HOOK_SCRIPT}";\nprocess.exit(0);`,
|
|
],
|
|
{
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
},
|
|
);
|
|
// Close stdin immediately — should not hang waiting for input
|
|
proc.stdin.end();
|
|
|
|
// Give it a generous timeout but fail if it hangs
|
|
const timeout = setTimeout(() => proc.kill(), 5000);
|
|
const code = await proc.exited;
|
|
clearTimeout(timeout);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
expect(code).toBe(0);
|
|
expect(stdout).toBe(""); // No output leaked to stdout
|
|
});
|
|
|
|
test("exported functions are importable without side effects", async () => {
|
|
// Verify that validateSkillMap and redactCommand can be imported
|
|
const proc = Bun.spawn(
|
|
[
|
|
"node",
|
|
"--input-type=module",
|
|
"-e",
|
|
`import { validateSkillMap, redactCommand, run } from "${HOOK_SCRIPT}";\n` +
|
|
`const checks = [\n` +
|
|
` typeof validateSkillMap === "function",\n` +
|
|
` typeof redactCommand === "function",\n` +
|
|
` typeof run === "function",\n` +
|
|
`];\n` +
|
|
`process.stdout.write(JSON.stringify(checks));\n` +
|
|
`process.exit(0);`,
|
|
],
|
|
{
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
},
|
|
);
|
|
proc.stdin.end();
|
|
|
|
const timeout = setTimeout(() => proc.kill(), 5000);
|
|
const code = await proc.exited;
|
|
clearTimeout(timeout);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual([true, true, true]);
|
|
});
|
|
|
|
test("running directly via node still works as before", async () => {
|
|
// This is essentially the same as runHook but confirms the guard lets
|
|
// direct execution through.
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decision logging: reason codes in the complete event
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Parse debug stderr into JSON objects and find the 'complete' event */
|
|
function parseComplete(stderr: string): any {
|
|
const lines = stderr.trim().split("\n").filter(Boolean).map((l: string) => JSON.parse(l));
|
|
return lines.find((l: any) => l.event === "complete");
|
|
}
|
|
|
|
describe("decision logging — reason codes", () => {
|
|
test("reason=stdin_empty when stdin is empty", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("stdin_empty");
|
|
expect(complete.matchedCount).toBe(0);
|
|
expect(complete.injectedCount).toBe(0);
|
|
expect(complete.dedupedCount).toBe(0);
|
|
expect(complete.cappedCount).toBe(0);
|
|
});
|
|
|
|
test("reason=stdin_parse_fail when stdin is invalid JSON", async () => {
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write("not-json");
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("stdin_parse_fail");
|
|
expect(complete.matchedCount).toBe(0);
|
|
});
|
|
|
|
test("reason=tool_unsupported for unsupported tool name", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Glob", tool_input: { pattern: "**/*.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("tool_unsupported");
|
|
expect(complete.matchedCount).toBe(0);
|
|
});
|
|
|
|
test("reason=no_matches when tool is supported but nothing matches", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/totally/unknown/file.xyz" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("no_matches");
|
|
expect(complete.matchedCount).toBe(0);
|
|
expect(complete.injectedCount).toBe(0);
|
|
});
|
|
|
|
test("reason=all_deduped when matches exist but all were previously injected", async () => {
|
|
// Pre-seed the env var with skills that match next.config.ts
|
|
// next.config.ts matches: nextjs, turbopack
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
seedSeenSkills(["nextjs", "turbopack"]);
|
|
|
|
const { stderr: stderr2 } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
);
|
|
|
|
const complete = parseComplete(stderr2);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("all_deduped");
|
|
expect(complete.matchedCount).toBeGreaterThan(0);
|
|
expect(complete.dedupedCount).toBeGreaterThan(0);
|
|
expect(complete.injectedCount).toBe(0);
|
|
});
|
|
|
|
test("reason=injected when skills are successfully injected", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("injected");
|
|
expect(complete.matchedCount).toBeGreaterThan(0);
|
|
expect(complete.injectedCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("reason=skillmap_fail when skills directory is empty", async () => {
|
|
const tempRoot = join(tmpdir(), `vp-reason-empty-${Date.now()}`);
|
|
const tempHooksDir = join(tempRoot, "hooks");
|
|
const tempSkillsDir = join(tempRoot, "skills");
|
|
mkdirSync(tempSkillsDir, { recursive: true });
|
|
copyTempHookRuntime(tempRoot, tempHooksDir);
|
|
|
|
const payload = JSON.stringify({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
session_id: testSession,
|
|
});
|
|
const proc = Bun.spawn(["node", join(tempHooksDir, "pretooluse-skill-inject.mjs")], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("skillmap_fail");
|
|
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("complete event has all aggregate count fields", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
for (const key of ["matchedCount", "injectedCount", "dedupedCount", "cappedCount"]) {
|
|
expect(typeof complete[key]).toBe("number");
|
|
}
|
|
expect(typeof complete.elapsed_ms).toBe("number");
|
|
expect(typeof complete.reason).toBe("string");
|
|
});
|
|
|
|
test("cappedCount > 0 when more than MAX_SKILLS match", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: {
|
|
command: "vercel deploy && turbo run build && npx v0 generate && npm install ai && vercel integration add neon",
|
|
},
|
|
},
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const complete = parseComplete(stderr);
|
|
expect(complete).toBeDefined();
|
|
expect(complete.reason).toBe("injected");
|
|
expect(complete.cappedCount).toBeGreaterThan(0);
|
|
expect(complete.injectedCount).toBe(3);
|
|
expect(complete.matchedCount).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
test("exactly one complete event per invocation", async () => {
|
|
const { stderr } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const lines = stderr.trim().split("\n").filter(Boolean).map((l: string) => JSON.parse(l));
|
|
const completeEvents = lines.filter((l: any) => l.event === "complete");
|
|
expect(completeEvents.length).toBe(1);
|
|
});
|
|
|
|
test("stdout contract unchanged — hookSpecificOutput shape", async () => {
|
|
const { stdout } = await runHookEnv(
|
|
{ tool_name: "Read", tool_input: { file_path: "/project/next.config.ts" } },
|
|
{ VERCEL_PLUGIN_HOOK_DEBUG: "1", VERCEL_PLUGIN_HOOK_DEDUP: "off" },
|
|
);
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toHaveProperty("hookSpecificOutput");
|
|
expect(result.hookSpecificOutput).toHaveProperty("additionalContext");
|
|
expect(extractSkillInjection(result.hookSpecificOutput)).toBeDefined();
|
|
expect(Object.keys(result)).toEqual(["hookSpecificOutput"]);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Golden snapshot tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("golden snapshots", () => {
|
|
const FIXTURES_DIR = join(ROOT, "tests", "fixtures");
|
|
|
|
const goldenFixtures = [
|
|
"golden-read-vercel-json.json",
|
|
"golden-bash-next-dev.json",
|
|
"golden-edit-middleware.json",
|
|
"golden-bash-cap-collision.json",
|
|
"golden-read-env-local.json",
|
|
];
|
|
|
|
for (const fixtureName of goldenFixtures) {
|
|
test(`golden: ${fixtureName}`, async () => {
|
|
const fixture = JSON.parse(
|
|
readFileSync(join(FIXTURES_DIR, fixtureName), "utf-8"),
|
|
);
|
|
const { code, stdout } = await runHook(fixture.input);
|
|
expect(code).toBe(0);
|
|
|
|
const result = JSON.parse(stdout);
|
|
expect(result).toHaveProperty("hookSpecificOutput");
|
|
expect(extractSkillInjection(result.hookSpecificOutput)).toBeDefined();
|
|
|
|
const actual = extractSkillInjection(result.hookSpecificOutput);
|
|
const expected = fixture.expected.skillInjection;
|
|
|
|
// Version and tool metadata must match exactly
|
|
expect(actual.version).toBe(expected.version);
|
|
expect(actual.toolName).toBe(expected.toolName);
|
|
expect(actual.toolTarget).toBe(expected.toolTarget);
|
|
|
|
// matchedSkills — same set (order may vary)
|
|
expect([...actual.matchedSkills].sort()).toEqual(
|
|
[...expected.matchedSkills].sort(),
|
|
);
|
|
|
|
// injectedSkills — exact ordered list (ranking matters)
|
|
expect(actual.injectedSkills).toEqual(expected.injectedSkills);
|
|
|
|
// droppedByCap — same set (order may vary)
|
|
expect([...actual.droppedByCap].sort()).toEqual(
|
|
[...expected.droppedByCap].sort(),
|
|
);
|
|
|
|
// droppedByBudget — same set (order may vary)
|
|
if (expected.droppedByBudget) {
|
|
expect([...(actual.droppedByBudget || [])].sort()).toEqual(
|
|
[...expected.droppedByBudget].sort(),
|
|
);
|
|
}
|
|
|
|
// Verify additionalContext contains skill markers for each injected skill
|
|
const ctx = result.hookSpecificOutput.additionalContext;
|
|
for (const skill of expected.injectedSkills) {
|
|
expect(ctx).toContain(`Skill(${skill})`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook output schema validation
|
|
// ---------------------------------------------------------------------------
|
|
// Claude Code validates hookSpecificOutput with a strict Zod schema.
|
|
// Unknown fields cause "Hook JSON output validation failed: Invalid input"
|
|
// and the entire hook output is silently discarded.
|
|
//
|
|
// Allowed fields in hookSpecificOutput for PreToolUse:
|
|
// hookEventName, permissionDecision, permissionDecisionReason,
|
|
// updatedInput, additionalContext
|
|
// See: hooks/types/hook-output.d.ts (vendored from @anthropic-ai/claude-agent-sdk)
|
|
|
|
const ALLOWED_HOOK_SPECIFIC_KEYS = new Set([
|
|
"hookEventName",
|
|
"permissionDecision",
|
|
"permissionDecisionReason",
|
|
"updatedInput",
|
|
"additionalContext",
|
|
]);
|
|
|
|
describe("hook output schema compliance", () => {
|
|
test("matched skill output has no unknown keys in hookSpecificOutput", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/Users/me/project/next.config.ts" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
const keys = Object.keys(result.hookSpecificOutput);
|
|
const unknownKeys = keys.filter((k) => !ALLOWED_HOOK_SPECIFIC_KEYS.has(k));
|
|
expect(unknownKeys).toEqual([]);
|
|
});
|
|
|
|
test("bash-matched skill output has no unknown keys", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm install ai @ai-sdk/openai" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
expect(result.hookSpecificOutput).toBeDefined();
|
|
const keys = Object.keys(result.hookSpecificOutput);
|
|
const unknownKeys = keys.filter((k) => !ALLOWED_HOOK_SPECIFIC_KEYS.has(k));
|
|
expect(unknownKeys).toEqual([]);
|
|
});
|
|
|
|
test("empty output ({}) is valid", async () => {
|
|
const { code, stdout } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/some/random/file.txt" },
|
|
});
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(stdout);
|
|
// {} has no hookSpecificOutput — that's fine
|
|
expect(result.hookSpecificOutput).toBeUndefined();
|
|
});
|
|
});
|
|
});
|