mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
5ce557a33e
New ai-generation-persistence skill (priority 6) injects guidance for treating AI generations as first-class persistent resources — unique IDs, addressable URLs, database/Blob storage, cost tracking, and generate-then-redirect UX patterns. Triggers on AI SDK imports and broad prompt signals. New verification skill added. Updated ecosystem graph, catalog, manifest, fixtures, and snapshots.
167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
/**
|
|
* Workflow smoke test: verifies the end-to-end regeneration pipeline.
|
|
*
|
|
* 1. Modify a skill section that a template references
|
|
* 2. Verify --check detects staleness
|
|
* 3. Run build:from-skills and verify the dependent .md output changes
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
import {
|
|
readFileSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
readdirSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { execSync } from "node:child_process";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
|
|
/**
|
|
* Pick the first template that has an include marker, and identify
|
|
* the skill + heading it references so we can mutate the skill.
|
|
*/
|
|
function findIncludeDependency(): {
|
|
templatePath: string;
|
|
outputPath: string;
|
|
skillName: string;
|
|
heading: string;
|
|
} {
|
|
const dirs = [join(ROOT, "agents"), join(ROOT, "commands")];
|
|
const markerRe = /\{\{include:skill:([^:}]+):([^}]+)\}\}/;
|
|
|
|
for (const dir of dirs) {
|
|
for (const f of readdirSync(dir)) {
|
|
if (!f.endsWith(".md.tmpl") || f.startsWith("_")) continue;
|
|
const content = readFileSync(join(dir, f), "utf8");
|
|
const m = content.match(markerRe);
|
|
if (m) {
|
|
const [, skillName, target] = m;
|
|
// Skip frontmatter refs — we want a section include
|
|
if (target.startsWith("frontmatter:")) continue;
|
|
return {
|
|
templatePath: join(dir, f),
|
|
outputPath: join(dir, f.replace(/\.tmpl$/, "")),
|
|
skillName,
|
|
heading: target,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
throw new Error("No template with a skill section include found");
|
|
}
|
|
|
|
/**
|
|
* Insert a sentinel line right after the first line of the referenced heading
|
|
* in the skill file, so it falls within the extracted section.
|
|
*/
|
|
function insertSentinelInSection(
|
|
skillContent: string,
|
|
heading: string,
|
|
sentinel: string,
|
|
): string {
|
|
// The heading path may use ">" for nested headings — use the last segment
|
|
const leafHeading = heading.includes(">")
|
|
? heading.split(">").pop()!.trim()
|
|
: heading.trim();
|
|
const lines = skillContent.split("\n");
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const stripped = lines[i].replace(/^#+\s*/, "").trim();
|
|
if (stripped.toLowerCase() === leafHeading.toLowerCase()) {
|
|
// Insert sentinel on the line after the heading
|
|
lines.splice(i + 1, 0, sentinel);
|
|
return lines.join("\n");
|
|
}
|
|
}
|
|
throw new Error(`Could not find heading "${leafHeading}" in skill content`);
|
|
}
|
|
|
|
describe("build-from-skills workflow smoke test", () => {
|
|
let dep: ReturnType<typeof findIncludeDependency>;
|
|
let skillPath: string;
|
|
let originalSkillContent: string;
|
|
let originalOutputContent: string;
|
|
|
|
beforeAll(() => {
|
|
dep = findIncludeDependency();
|
|
skillPath = join(ROOT, "skills", dep.skillName, "SKILL.md");
|
|
originalSkillContent = readFileSync(skillPath, "utf8");
|
|
originalOutputContent = readFileSync(dep.outputPath, "utf8");
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Restore original files
|
|
writeFileSync(skillPath, originalSkillContent);
|
|
writeFileSync(dep.outputPath, originalOutputContent);
|
|
});
|
|
|
|
test("--check reports fresh when outputs are up-to-date", () => {
|
|
const result = execSync(
|
|
`bun run scripts/build-from-skills.ts --check --json ${dep.templatePath}`,
|
|
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
const json = JSON.parse(result);
|
|
expect(json.stale).toBe(0);
|
|
});
|
|
|
|
test("mutating a skill makes --check detect staleness", () => {
|
|
// Insert a sentinel inside the referenced section
|
|
const sentinel = `<!-- WORKFLOW_SMOKE_TEST_SENTINEL_${Date.now()} -->`;
|
|
const mutated = insertSentinelInSection(originalSkillContent, dep.heading, sentinel);
|
|
writeFileSync(skillPath, mutated);
|
|
|
|
try {
|
|
execSync(
|
|
`bun run scripts/build-from-skills.ts --check --json ${dep.templatePath}`,
|
|
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
// --check should exit non-zero when stale
|
|
throw new Error("Expected --check to exit with non-zero status");
|
|
} catch (err: any) {
|
|
// execSync throws on non-zero exit
|
|
expect(err.status).toBe(1);
|
|
const json = JSON.parse(err.stdout.toString());
|
|
expect(json.stale).toBeGreaterThan(0);
|
|
} finally {
|
|
// Restore skill for next test
|
|
writeFileSync(skillPath, originalSkillContent);
|
|
}
|
|
});
|
|
|
|
test("build:from-skills regenerates output after skill change", () => {
|
|
// Insert a sentinel inside the referenced section
|
|
const sentinel = `<!-- WORKFLOW_SMOKE_TEST_SENTINEL_${Date.now()} -->`;
|
|
const mutated = insertSentinelInSection(originalSkillContent, dep.heading, sentinel);
|
|
writeFileSync(skillPath, mutated);
|
|
|
|
try {
|
|
// Run build (not --check) to regenerate
|
|
execSync(
|
|
`bun run scripts/build-from-skills.ts ${dep.templatePath}`,
|
|
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
|
|
// The output file should now contain the sentinel
|
|
const updatedOutput = readFileSync(dep.outputPath, "utf8");
|
|
expect(updatedOutput).toContain("WORKFLOW_SMOKE_TEST_SENTINEL");
|
|
expect(updatedOutput).not.toBe(originalOutputContent);
|
|
|
|
// --check should now report fresh again
|
|
const checkResult = execSync(
|
|
`bun run scripts/build-from-skills.ts --check --json ${dep.templatePath}`,
|
|
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
const json = JSON.parse(checkResult);
|
|
expect(json.stale).toBe(0);
|
|
} finally {
|
|
// Restore both files
|
|
writeFileSync(skillPath, originalSkillContent);
|
|
writeFileSync(dep.outputPath, originalOutputContent);
|
|
}
|
|
});
|
|
});
|