mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
/**
|
|
* Golden snapshot tests for skill injection.
|
|
*
|
|
* Each scenario runs the PreToolUse hook with a specific vercel.json fixture
|
|
* and compares the `skillInjection` metadata against a stored .snap file.
|
|
*
|
|
* To update baselines: bun test tests/snapshot-runner.test.ts -- --update-snapshots
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll } from "bun:test";
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
const SNAP_DIR = join(ROOT, "tests", "snapshots");
|
|
const FIXTURES_DIR = join(ROOT, "tests", "fixtures");
|
|
|
|
const UPDATE_SNAPSHOTS =
|
|
process.argv.includes("--update-snapshots") ||
|
|
process.env.UPDATE_SNAPSHOTS === "1";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** 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;
|
|
}
|
|
}
|
|
|
|
/** Run the hook and return the parsed skillInjection metadata. */
|
|
async function runHook(input: object): Promise<{
|
|
code: number;
|
|
skillInjection: Record<string, unknown> | null;
|
|
}> {
|
|
const session = `snap-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const payload = JSON.stringify({ ...input, session_id: session });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
VERCEL_PLUGIN_HOOK_DEDUP: "off", // disable dedup so each scenario is independent
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: "999999", // unlimited budget for snapshot tests
|
|
},
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
|
|
let skillInjection: Record<string, unknown> | null = null;
|
|
try {
|
|
const parsed = JSON.parse(stdout);
|
|
const ctx = parsed?.hookSpecificOutput?.additionalContext ?? "";
|
|
skillInjection = parseSkillInjection(ctx);
|
|
} catch {}
|
|
|
|
return { code, skillInjection };
|
|
}
|
|
|
|
/**
|
|
* Normalize the skillInjection object for deterministic comparison.
|
|
* - Removes toolTarget (contains temp paths that change per run)
|
|
* - Sorts array fields so ordering within sets is stable
|
|
*/
|
|
function normalize(injection: Record<string, unknown>): Record<string, unknown> {
|
|
const clone = { ...injection };
|
|
// toolTarget contains absolute paths that vary per machine/run
|
|
delete clone.toolTarget;
|
|
// Sort arrays for deterministic comparison
|
|
for (const key of ["matchedSkills", "injectedSkills", "droppedByCap", "droppedByBudget"] as const) {
|
|
if (Array.isArray(clone[key])) {
|
|
// injectedSkills order matters (priority), so only sort matchedSkills
|
|
if (key === "matchedSkills") {
|
|
clone[key] = [...(clone[key] as string[])].sort();
|
|
}
|
|
}
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
/** Read a .snap file. Returns null if it doesn't exist. */
|
|
function readSnap(name: string): string | null {
|
|
const p = join(SNAP_DIR, name);
|
|
if (!existsSync(p)) return null;
|
|
return readFileSync(p, "utf-8");
|
|
}
|
|
|
|
/** Write (or overwrite) a .snap file. */
|
|
function writeSnap(name: string, content: string): void {
|
|
if (!existsSync(SNAP_DIR)) mkdirSync(SNAP_DIR, { recursive: true });
|
|
writeFileSync(join(SNAP_DIR, name), content, "utf-8");
|
|
}
|
|
|
|
/** Assert that current output matches the stored snapshot. */
|
|
function assertSnapshot(snapName: string, actual: Record<string, unknown>) {
|
|
const serialized = JSON.stringify(actual, null, 2) + "\n";
|
|
|
|
if (UPDATE_SNAPSHOTS) {
|
|
writeSnap(snapName, serialized);
|
|
// Still pass the test when updating
|
|
return;
|
|
}
|
|
|
|
const stored = readSnap(snapName);
|
|
if (stored === null) {
|
|
throw new Error(
|
|
`Snapshot "${snapName}" does not exist. Run with --update-snapshots to create it.`,
|
|
);
|
|
}
|
|
|
|
expect(serialized).toBe(stored);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Write temp vercel.json fixtures that the hook can actually read
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Scenario {
|
|
name: string;
|
|
snapFile: string;
|
|
vercelJson: object;
|
|
toolName: string;
|
|
}
|
|
|
|
const scenarios: Scenario[] = [
|
|
{
|
|
name: "redirects-only",
|
|
snapFile: "inject-redirects.snap",
|
|
vercelJson: {
|
|
redirects: [
|
|
{ source: "/blog/:path*", destination: "https://blog.example.com/:path*" },
|
|
],
|
|
},
|
|
toolName: "Read",
|
|
},
|
|
{
|
|
name: "headers-only",
|
|
snapFile: "inject-headers.snap",
|
|
vercelJson: {
|
|
headers: [
|
|
{
|
|
source: "/(.*)",
|
|
headers: [
|
|
{ key: "X-Frame-Options", value: "DENY" },
|
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
toolName: "Edit",
|
|
},
|
|
{
|
|
name: "rewrites-only",
|
|
snapFile: "inject-rewrites.snap",
|
|
vercelJson: {
|
|
rewrites: [
|
|
{ source: "/api/:path*", destination: "https://api.example.com/:path*" },
|
|
],
|
|
},
|
|
toolName: "Write",
|
|
},
|
|
{
|
|
name: "mixed-all-keys",
|
|
snapFile: "inject-mixed.snap",
|
|
vercelJson: {
|
|
redirects: [{ source: "/old", destination: "/new" }],
|
|
headers: [
|
|
{
|
|
source: "/(.*)",
|
|
headers: [{ key: "X-Frame-Options", value: "DENY" }],
|
|
},
|
|
],
|
|
rewrites: [
|
|
{ source: "/proxy/:path*", destination: "https://backend.example.com/:path*" },
|
|
],
|
|
functions: { "api/**": { memory: 1024, maxDuration: 30 } },
|
|
crons: [{ path: "/api/cron/daily", schedule: "0 8 * * *" }],
|
|
buildCommand: "next build",
|
|
},
|
|
toolName: "Edit",
|
|
},
|
|
{
|
|
name: "functions-only",
|
|
snapFile: "inject-functions.snap",
|
|
vercelJson: {
|
|
functions: { "api/**": { memory: 1024 } },
|
|
regions: ["iad1"],
|
|
},
|
|
toolName: "Read",
|
|
},
|
|
{
|
|
name: "crons-only",
|
|
snapFile: "inject-crons.snap",
|
|
vercelJson: {
|
|
crons: [
|
|
{ path: "/api/cron/hourly", schedule: "0 * * * *" },
|
|
{ path: "/api/cron/daily", schedule: "0 8 * * *" },
|
|
],
|
|
},
|
|
toolName: "Read",
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let tempDir: string;
|
|
|
|
beforeAll(() => {
|
|
tempDir = join(tmpdir(), `vp-snap-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
describe("golden snapshot tests", () => {
|
|
for (const scenario of scenarios) {
|
|
test(`snapshot: ${scenario.name}`, async () => {
|
|
// Write a temp vercel.json the hook can read
|
|
const filePath = join(tempDir, `${scenario.name}-vercel.json`);
|
|
// The hook expects the file to be named vercel.json
|
|
const projectDir = join(tempDir, scenario.name);
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const vercelPath = join(projectDir, "vercel.json");
|
|
writeFileSync(vercelPath, JSON.stringify(scenario.vercelJson, null, 2), "utf-8");
|
|
|
|
const { code, skillInjection } = await runHook({
|
|
tool_name: scenario.toolName,
|
|
tool_input: { file_path: vercelPath },
|
|
});
|
|
|
|
expect(code).toBe(0);
|
|
expect(skillInjection).not.toBeNull();
|
|
|
|
const normalized = normalize(skillInjection!);
|
|
assertSnapshot(scenario.snapFile, normalized);
|
|
});
|
|
}
|
|
|
|
test("snapshot update mode is off by default", () => {
|
|
// Ensure we aren't accidentally always updating
|
|
if (!process.argv.includes("--update-snapshots") && process.env.UPDATE_SNAPSHOTS !== "1") {
|
|
expect(UPDATE_SNAPSHOTS).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Golden fixture tests — data-driven from tests/fixtures/golden-*.json
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("golden fixture tests", () => {
|
|
const goldenFiles = readdirSync(FIXTURES_DIR).filter(
|
|
(f) => f.startsWith("golden-") && f.endsWith(".json"),
|
|
);
|
|
|
|
for (const fixtureName of goldenFiles) {
|
|
test(`golden: ${fixtureName}`, async () => {
|
|
const fixture = JSON.parse(
|
|
readFileSync(join(FIXTURES_DIR, fixtureName), "utf-8"),
|
|
);
|
|
|
|
let input = fixture.input;
|
|
|
|
// If the fixture includes vercelJson content, write a temp file so
|
|
// vercel-config.mjs key-aware routing can read it.
|
|
if (fixture.vercelJson) {
|
|
const projectDir = join(tempDir, `golden-${fixtureName}`);
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const vercelPath = join(projectDir, "vercel.json");
|
|
writeFileSync(
|
|
vercelPath,
|
|
JSON.stringify(fixture.vercelJson, null, 2),
|
|
"utf-8",
|
|
);
|
|
input = {
|
|
...input,
|
|
tool_input: { ...input.tool_input, file_path: vercelPath },
|
|
};
|
|
}
|
|
|
|
const { code, skillInjection } = await runHook(input);
|
|
expect(code).toBe(0);
|
|
expect(skillInjection).not.toBeNull();
|
|
|
|
const actual = skillInjection!;
|
|
const expected = fixture.expected.skillInjection;
|
|
|
|
// Version and toolName must match exactly
|
|
expect(actual.version).toBe(expected.version);
|
|
expect(actual.toolName).toBe(expected.toolName);
|
|
|
|
// toolTarget — only assert when the fixture specifies it
|
|
// (vercelJson fixtures use temp paths so toolTarget varies)
|
|
if (expected.toolTarget) {
|
|
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(),
|
|
);
|
|
|
|
// Cap collision: if droppedByCap is expected non-empty, verify it
|
|
if (expected.droppedByCap.length > 0) {
|
|
expect((actual.droppedByCap as string[]).length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
}
|
|
});
|