mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
8e24d0694a
Add a chainTo frontmatter field to skills that triggers follow-up skill injection when PostToolUse file contents match regex patterns. Add upgradeToSkill/upgradeWhy fields to validation rules so validate errors can recommend loading a specific skill. Register posttooluse-bash-chain.mjs in hooks.json. Add ChainToRule interface to patterns.mts and skill-map-frontmatter.mts, with parseChainToRules() parser. Add duplicate-key detection to the YAML parser. Reset dedup claim dir and session file on clear/compact events in session-start-seen-skills so skills re-inject into fresh context windows. Add chainTo rules to: agent-browser-verify, agent-browser, ai-elements, ai-gateway, ai-generation-persistence, ai-sdk, auth, bootstrap, chat-sdk, cms, cron-jobs, deployments-cicd, email, env-vars, geist, investigation-mode, json-render, marketplace, micro, ncc, next-forge, nextjs, observability, payments, react-best-practices, routing-middleware, runtime-cache, satori, shadcn, sign-in-with-vercel, swr, turbopack, turborepo, v0-dev, vercel-agent, vercel-api, vercel-cli, vercel-firewall, vercel-flags, vercel-functions, vercel-queues, vercel-sandbox, vercel-storage, verification, workflow. Add upgradeToSkill to ai-elements and ai-sdk validate rules. Expand ai-sdk validate messages with Run Skill() hints. Update nextjs, vercel-storage, runtime-cache, workflow, turborepo skill bodies. Add new skills: geistdocs (Geist design system docs), zzz-test-meta-name-mask (test fixture). Add skills/_chain-audit.md chain coverage audit doc. Delete .claude-plugin/marketplace.json, .claude-plugin/plugin.json (deprecated), skills/edge-runtime/SKILL.md (consolidated into vercel-functions). Add tests: posttooluse-chain.test.ts (4699 lines, chain injection e2e), ai-sdk-companion.test.ts (181 lines). Expand build-skill-map.test.ts (+335 lines), validate-rules.test.ts (+936 lines), session-start-seen-skills.test.ts (+74 lines), skill-map-frontmatter.test.ts (+50 lines), verification-skill.test.ts (+20 lines). Update build-manifest.ts to emit chainTo rules and upgradeToSkill fields. Rebuild generated/skill-manifest.json, generated/skill-catalog.md, generated/build-from-skills.manifest.json. Rebuild all compiled hooks/*.mjs. Update CLAUDE.md lexical prompt default to on. Update vercel.md ecosystem graph, docs, and cli-reference.
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
/**
|
|
* Golden snapshot tests for hook payloads.
|
|
*
|
|
* Asserts exact matchedSkills, injectedSkills, and droppedByCap values
|
|
* for representative fixtures loaded from tests/fixtures/golden-payloads.json.
|
|
*
|
|
* Covers: vercel.json edit, next.config.ts read, bash deploy command,
|
|
* AI SDK file edit, and cap-collision scenarios.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
const PAYLOADS_PATH = join(ROOT, "tests", "fixtures", "consolidated-payloads.json");
|
|
|
|
// Unique session ID per test to avoid cross-test dedup conflicts
|
|
let testSession: string;
|
|
|
|
beforeEach(() => {
|
|
testSession = `snap-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
// High budget disables budget-based limiting so cap tests are unaffected
|
|
const UNLIMITED_BUDGET = "999999";
|
|
|
|
interface HookResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
skillInjection: Record<string, unknown> | null;
|
|
additionalContext: string;
|
|
}
|
|
|
|
/** Extract skillInjection metadata from the HTML comment in additionalContext. */
|
|
function parseSkillInjection(additionalContext: string): Record<string, unknown> | null {
|
|
const match = additionalContext.match(/<!-- skillInjection: (\{.*?\}) -->/);
|
|
if (!match) return null;
|
|
try {
|
|
return JSON.parse(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function runHook(input: object): Promise<HookResult> {
|
|
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_HOOK_DEDUP: "off",
|
|
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();
|
|
|
|
let skillInjection: Record<string, unknown> | null = null;
|
|
let additionalContext = "";
|
|
try {
|
|
const parsed = JSON.parse(stdout);
|
|
additionalContext = parsed?.hookSpecificOutput?.additionalContext ?? "";
|
|
skillInjection = parseSkillInjection(additionalContext);
|
|
} catch {}
|
|
|
|
return { code, stdout, stderr, skillInjection, additionalContext };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Load consolidated golden payloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GoldenFixture {
|
|
name: string;
|
|
input: {
|
|
tool_name: string;
|
|
tool_input: Record<string, string>;
|
|
};
|
|
expected: {
|
|
skillInjection: {
|
|
version: number;
|
|
toolName: string;
|
|
toolTarget: string;
|
|
matchedSkills: string[];
|
|
injectedSkills: string[];
|
|
droppedByCap: string[];
|
|
droppedByBudget: string[];
|
|
};
|
|
};
|
|
}
|
|
|
|
const payloads: { fixtures: GoldenFixture[] } = JSON.parse(
|
|
readFileSync(PAYLOADS_PATH, "utf-8"),
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("golden payload snapshots", () => {
|
|
for (const fixture of payloads.fixtures) {
|
|
test(`golden: ${fixture.name}`, async () => {
|
|
const { code, skillInjection: actual, additionalContext } = await runHook(fixture.input);
|
|
expect(code).toBe(0);
|
|
expect(actual).not.toBeNull();
|
|
|
|
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 as string[])].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 as string[])].sort()).toEqual(
|
|
[...expected.droppedByCap].sort(),
|
|
);
|
|
|
|
// droppedByBudget — same set (order may vary)
|
|
expect([...((actual!.droppedByBudget as string[]) || [])].sort()).toEqual(
|
|
[...expected.droppedByBudget].sort(),
|
|
);
|
|
|
|
// Invariant: injected + droppedByCap + droppedByBudget + summaryOnly = matchedSkills
|
|
const summaryOnlyLen = Array.isArray(actual!.summaryOnly) ? (actual!.summaryOnly as string[]).length : 0;
|
|
expect(
|
|
(actual!.injectedSkills as string[]).length +
|
|
(actual!.droppedByCap as string[]).length +
|
|
((actual!.droppedByBudget as string[])?.length || 0) +
|
|
summaryOnlyLen,
|
|
).toBe((actual!.matchedSkills as string[]).length);
|
|
|
|
// Verify additionalContext contains skill markers for each injected skill
|
|
for (const skill of expected.injectedSkills) {
|
|
expect(additionalContext).toContain(`Skill(${skill})`);
|
|
}
|
|
});
|
|
}
|
|
|
|
test("consolidated payloads file has at least 5 fixtures", () => {
|
|
expect(payloads.fixtures.length).toBeGreaterThanOrEqual(5);
|
|
});
|
|
});
|