mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
601 lines
20 KiB
TypeScript
601 lines
20 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { writeFileSync, readdirSync, rmSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import {
|
|
normalizePromptText,
|
|
compilePromptSignals,
|
|
matchPromptWithReason,
|
|
} from "../hooks/prompt-patterns.mjs";
|
|
import type { CompiledPromptSignals } from "../hooks/prompt-patterns.mjs";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const PRETOOLUSE_HOOK = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
const PROMPT_HOOK = join(ROOT, "hooks", "user-prompt-submit-skill-inject.mjs");
|
|
const UNLIMITED_BUDGET = "999999";
|
|
|
|
let testSession: string;
|
|
|
|
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)) {
|
|
rmSync(join(tmpdir(), entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testSession = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupSessionDedup();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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; }
|
|
}
|
|
|
|
async function runPreToolUseHook(
|
|
input: object,
|
|
env?: Record<string, string>,
|
|
): Promise<{ code: number; stdout: string; stderr: string; parsed: any }> {
|
|
const payload = JSON.stringify({ ...input, session_id: testSession });
|
|
const proc = Bun.spawn(["node", PRETOOLUSE_HOOK], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET,
|
|
VERCEL_PLUGIN_DEV_VERIFY_COUNT: "0",
|
|
VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "1",
|
|
...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();
|
|
let parsed: any;
|
|
try { parsed = JSON.parse(stdout); } catch { parsed = null; }
|
|
return { code, stdout, stderr, parsed };
|
|
}
|
|
|
|
async function runPromptHook(
|
|
prompt: string,
|
|
env?: Record<string, string>,
|
|
): Promise<{ code: number; stdout: string; stderr: string; parsed: any }> {
|
|
const payload = JSON.stringify({
|
|
prompt,
|
|
session_id: testSession,
|
|
cwd: ROOT,
|
|
hook_event_name: "UserPromptSubmit",
|
|
});
|
|
const proc = Bun.spawn(["node", PROMPT_HOOK], {
|
|
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();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
let parsed: any;
|
|
try { parsed = JSON.parse(stdout); } catch { parsed = null; }
|
|
return { code, stdout, stderr, parsed };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compiled prompt signals for direct unit testing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const verificationSignals: CompiledPromptSignals = compilePromptSignals({
|
|
phrases: [
|
|
"verify the flow",
|
|
"verify everything works",
|
|
"test the whole thing",
|
|
"does it actually work",
|
|
"check end to end",
|
|
"end to end test",
|
|
"why isn't it working right",
|
|
"why doesn't it work",
|
|
"it's not working correctly",
|
|
"something's off",
|
|
"not quite right",
|
|
"almost works but",
|
|
"works locally but",
|
|
"verify the feature",
|
|
"make sure it works",
|
|
"full verification",
|
|
],
|
|
allOf: [
|
|
["verify", "flow"],
|
|
["verify", "works"],
|
|
["check", "everything"],
|
|
["test", "end", "end"],
|
|
["not", "working", "right"],
|
|
["something", "off"],
|
|
["almost", "works"],
|
|
["make", "sure", "works"],
|
|
],
|
|
anyOf: [
|
|
"verify",
|
|
"verification",
|
|
"end-to-end",
|
|
"full flow",
|
|
"works",
|
|
"working",
|
|
],
|
|
noneOf: [
|
|
"unit test",
|
|
"jest",
|
|
"vitest",
|
|
"playwright test",
|
|
"cypress test",
|
|
],
|
|
minScore: 6,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Dev server detection co-injects verification alongside agent-browser-verify
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Dev server co-injection of verification skill", () => {
|
|
const devCommands = [
|
|
"next dev",
|
|
"npm run dev",
|
|
"pnpm dev",
|
|
"bun run dev",
|
|
"yarn dev",
|
|
"vite dev",
|
|
"vercel dev",
|
|
"astro dev",
|
|
];
|
|
|
|
for (const cmd of devCommands) {
|
|
test(`co-injects verification alongside agent-browser-verify for "${cmd}"`, async () => {
|
|
const { parsed } = await runPreToolUseHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: cmd },
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.hookSpecificOutput).toBeDefined();
|
|
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
// Both skills should be present
|
|
expect(ctx).toContain("Skill(agent-browser-verify)");
|
|
expect(ctx).toContain("Skill(verification)");
|
|
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("agent-browser-verify");
|
|
expect(meta.injectedSkills).toContain("verification");
|
|
});
|
|
}
|
|
|
|
test("verification appears after agent-browser-verify in injection order", async () => {
|
|
const { parsed } = await runPreToolUseHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
});
|
|
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
const verifyIdx = meta.injectedSkills.indexOf("agent-browser-verify");
|
|
const verifIdx = meta.injectedSkills.indexOf("verification");
|
|
expect(verifyIdx).toBeGreaterThanOrEqual(0);
|
|
expect(verifIdx).toBeGreaterThanOrEqual(0);
|
|
expect(verifIdx).toBeGreaterThan(verifyIdx);
|
|
});
|
|
|
|
test("does not co-inject verification for non-dev-server commands", async () => {
|
|
const { parsed } = await runPreToolUseHook({
|
|
tool_name: "Bash",
|
|
tool_input: { command: "git status" },
|
|
});
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
expect(ctx).not.toContain("Skill(verification)");
|
|
}
|
|
});
|
|
|
|
test("does not co-inject verification when agent-browser unavailable", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "0" },
|
|
);
|
|
|
|
// When agent-browser is unavailable, companion skills should not be injected
|
|
// (the unavailable warning path skips normal injection)
|
|
if (parsed?.hookSpecificOutput) {
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
expect(ctx).toContain("<!-- agent-browser-unavailable -->");
|
|
}
|
|
});
|
|
|
|
test("agent-browser-verify blocked when count >= max, but verification still injected", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEV_VERIFY_COUNT: "2" },
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.hookSpecificOutput).toBeDefined();
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
// Loop guard blocks agent-browser-verify synthetic injection
|
|
expect(ctx).not.toContain("<!-- marker:dev-server-verify");
|
|
// But verification is exempt from the iteration cap
|
|
expect(ctx).toContain("Skill(verification)");
|
|
});
|
|
|
|
test("verification injected on 3rd dev-server detection (count=2)", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEV_VERIFY_COUNT: "2" },
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.hookSpecificOutput).toBeDefined();
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
// verification survives past iteration cap
|
|
expect(ctx).toContain("Skill(verification)");
|
|
// agent-browser-verify is still blocked by loop guard
|
|
expect(ctx).not.toContain("Skill(agent-browser-verify)");
|
|
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
expect(meta.injectedSkills).toContain("verification");
|
|
expect(meta.injectedSkills).not.toContain("agent-browser-verify");
|
|
}
|
|
});
|
|
|
|
test("verification injected on 5th dev-server detection (count=4)", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEV_VERIFY_COUNT: "4" },
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.hookSpecificOutput).toBeDefined();
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
expect(ctx).toContain("Skill(verification)");
|
|
expect(ctx).not.toContain("Skill(agent-browser-verify)");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Prompt signals score correctly for target phrases (>= minScore 6)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Verification prompt signals scoring", () => {
|
|
// Each phrase alone should score +6 (exactly minScore)
|
|
const targetPhrases = [
|
|
"verify the flow",
|
|
"verify everything works",
|
|
"test the whole thing",
|
|
"does it actually work",
|
|
"check end to end",
|
|
"something's off", // contraction expansion: something is off → matches "something's off"
|
|
"not quite right",
|
|
"almost works but",
|
|
"verify the feature",
|
|
"make sure it works",
|
|
"full verification",
|
|
];
|
|
|
|
for (const phrase of targetPhrases) {
|
|
test(`phrase "${phrase}" scores >= minScore 6`, () => {
|
|
const result = matchPromptWithReason(phrase, verificationSignals);
|
|
expect(result.score).toBeGreaterThanOrEqual(6);
|
|
expect(result.matched).toBe(true);
|
|
});
|
|
}
|
|
|
|
test("combined phrase + allOf scores higher than single phrase", () => {
|
|
// "verify the flow works" hits phrase "verify the flow" (+6) + allOf [verify, works] (+4)
|
|
const result = matchPromptWithReason(
|
|
"can you verify the flow works end to end",
|
|
verificationSignals,
|
|
);
|
|
expect(result.score).toBeGreaterThan(6);
|
|
expect(result.matched).toBe(true);
|
|
});
|
|
|
|
test("anyOf terms alone do not reach minScore", () => {
|
|
// "verify" and "works" are anyOf terms (+1 each, capped at +2), which < 6
|
|
const result = matchPromptWithReason(
|
|
"does this feature work",
|
|
verificationSignals,
|
|
);
|
|
// Only anyOf hits — should not reach minScore 6
|
|
expect(result.score).toBeLessThan(6);
|
|
expect(result.matched).toBe(false);
|
|
});
|
|
|
|
test("allOf group [verify, flow] scores +4, not enough alone", () => {
|
|
// allOf alone gives +4, anyOf "verify" gives +1 = 5, still < 6
|
|
// but let's test a case where only allOf matches without phrase
|
|
const result = matchPromptWithReason(
|
|
"I need to verify the data flow through the system",
|
|
verificationSignals,
|
|
);
|
|
// "verify the flow" phrase matches (+6), so this actually will match
|
|
// Use a different wording that avoids exact phrase
|
|
const result2 = matchPromptWithReason(
|
|
"please verify my application flow is correct",
|
|
verificationSignals,
|
|
);
|
|
// allOf [verify, flow] = +4, anyOf "verify" = +1 = 5 < 6
|
|
// Unless "verify" + "flow" also triggers phrase... check:
|
|
// phrase is "verify the flow" — "verify my application flow" does NOT contain that substring
|
|
expect(result2.score).toBeLessThan(6);
|
|
expect(result2.matched).toBe(false);
|
|
});
|
|
|
|
test("contraction expansion works: \"it's not working correctly\"", () => {
|
|
// matchPromptWithReason expects pre-normalized text
|
|
const result = matchPromptWithReason(
|
|
normalizePromptText("it's not working correctly at all"),
|
|
verificationSignals,
|
|
);
|
|
expect(result.matched).toBe(true);
|
|
expect(result.score).toBeGreaterThanOrEqual(6);
|
|
});
|
|
|
|
test("\"why isn't it working right\" triggers via contraction expansion", () => {
|
|
// matchPromptWithReason expects pre-normalized text
|
|
const result = matchPromptWithReason(
|
|
normalizePromptText("why isn't it working right"),
|
|
verificationSignals,
|
|
);
|
|
expect(result.matched).toBe(true);
|
|
expect(result.score).toBeGreaterThanOrEqual(6);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. noneOf terms suppress injection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Verification noneOf suppression", () => {
|
|
const suppressTerms = [
|
|
"unit test",
|
|
"jest",
|
|
"vitest",
|
|
"playwright test",
|
|
"cypress test",
|
|
];
|
|
|
|
for (const term of suppressTerms) {
|
|
test(`noneOf term "${term}" suppresses even with phrase match`, () => {
|
|
// Combine a matching phrase with a noneOf term
|
|
const result = matchPromptWithReason(
|
|
`verify the flow using ${term} framework`,
|
|
verificationSignals,
|
|
);
|
|
expect(result.matched).toBe(false);
|
|
expect(result.score).toBe(-Infinity);
|
|
});
|
|
}
|
|
|
|
test("noneOf 'jest' suppresses: 'verify everything works with jest'", () => {
|
|
const result = matchPromptWithReason(
|
|
"verify everything works with jest",
|
|
verificationSignals,
|
|
);
|
|
expect(result.matched).toBe(false);
|
|
});
|
|
|
|
test("noneOf does not trigger on partial word match (e.g., 'jesting')", () => {
|
|
// noneOf uses word-boundary matching — "jest" is NOT a whole word in "jesting"
|
|
const result = matchPromptWithReason(
|
|
"verify the flow, stop jesting around",
|
|
verificationSignals,
|
|
);
|
|
// "jesting" does not contain "jest" as a whole word — no suppression
|
|
expect(result.matched).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Dedup prevents double injection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Dedup prevents double verification injection", () => {
|
|
test("verification not re-injected when already in seen-skills (non-dev-server)", async () => {
|
|
// When triggered via bashPatterns (not dev-server co-injection),
|
|
// dedup should prevent re-injection
|
|
seedSeenSkills(["verification"]);
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/next.config.ts" },
|
|
},
|
|
);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
expect(meta.injectedSkills).not.toContain("verification");
|
|
}
|
|
}
|
|
});
|
|
|
|
test("verification re-injected via dev-server companion even when in seen-skills", async () => {
|
|
// Dev server companion injection uses dedup bypass (like agent-browser-verify)
|
|
seedSeenSkills(["verification", "agent-browser-verify"]);
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_DEV_VERIFY_COUNT: "0" },
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.hookSpecificOutput).toBeDefined();
|
|
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
// Companion bypass: verification should be re-injected
|
|
expect(ctx).toContain("Skill(verification)");
|
|
expect(ctx).toContain("Skill(agent-browser-verify)");
|
|
});
|
|
|
|
test("prompt hook respects dedup for verification", async () => {
|
|
seedSeenSkills(["verification"]);
|
|
const { parsed } = await runPromptHook(
|
|
"verify the flow end to end please",
|
|
);
|
|
|
|
// Should not re-inject verification since it's already seen
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
expect(meta.injectedSkills).not.toContain("verification");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Budget is not exceeded with companion skills
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("Budget enforcement with verification + companions", () => {
|
|
test("injection stays within default 18KB budget", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{
|
|
// Use default budget (18KB) instead of unlimited
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: "18000",
|
|
},
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
if (parsed?.hookSpecificOutput) {
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
// Total context should not exceed 18KB + small margin for metadata comments
|
|
expect(Buffer.byteLength(ctx, "utf8")).toBeLessThanOrEqual(18500);
|
|
}
|
|
});
|
|
|
|
test("prompt hook stays within 8KB budget", async () => {
|
|
const { parsed } = await runPromptHook(
|
|
"verify the flow and make sure it works end to end",
|
|
{ VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET: "8000" },
|
|
);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const ctx = parsed.hookSpecificOutput.additionalContext || "";
|
|
expect(Buffer.byteLength(ctx, "utf8")).toBeLessThanOrEqual(8000);
|
|
}
|
|
});
|
|
|
|
test("verification fits alongside agent-browser-verify within budget", async () => {
|
|
const { parsed } = await runPreToolUseHook(
|
|
{
|
|
tool_name: "Bash",
|
|
tool_input: { command: "npm run dev" },
|
|
},
|
|
{ VERCEL_PLUGIN_INJECTION_BUDGET: "18000" },
|
|
);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
// Both skills should be injected (not dropped by budget)
|
|
const allInjected = [
|
|
...meta.injectedSkills,
|
|
...(meta.summaryOnly || []),
|
|
];
|
|
// At least agent-browser-verify should be present
|
|
expect(allInjected).toContain("agent-browser-verify");
|
|
// verification should be either fully injected or summary-only, not dropped
|
|
const dropped = meta.droppedByBudget || [];
|
|
expect(dropped).not.toContain("agent-browser-verify");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Integration: UserPromptSubmit injects verification for target prompts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("UserPromptSubmit integration for verification", () => {
|
|
test("injects verification skill for 'verify the flow' prompt", async () => {
|
|
const { code, parsed } = await runPromptHook(
|
|
"Can you verify the flow works correctly end to end?",
|
|
);
|
|
expect(code).toBe(0);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("verification");
|
|
}
|
|
});
|
|
|
|
test("injects verification for 'something is off' prompt", async () => {
|
|
const { code, parsed } = await runPromptHook(
|
|
"Something's off with the page, it loads but the data isn't showing",
|
|
);
|
|
expect(code).toBe(0);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
expect(meta.injectedSkills).toContain("verification");
|
|
}
|
|
}
|
|
});
|
|
|
|
test("does not inject verification for 'write a unit test' prompt", async () => {
|
|
const { code, parsed } = await runPromptHook(
|
|
"Write a unit test for the login component using jest",
|
|
);
|
|
expect(code).toBe(0);
|
|
|
|
if (parsed?.hookSpecificOutput) {
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta) {
|
|
expect(meta.injectedSkills).not.toContain("verification");
|
|
}
|
|
}
|
|
});
|
|
});
|