Files
skills-vault/test/restore.test.ts
T

437 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "bun:test";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import {
parseManifestText,
type Manifest,
} from "../src/internal/manifest/parse-manifest";
import { runRestore } from "../src/commands/restore";
import { parseRestoreArgs } from "../src/internal/args/parse-restore-args";
import {
defaultGlobalLockFilePath,
defaultGlobalSkillsPath,
} from "../src/internal/paths/defaults";
const sampleManifest: Manifest = {
scope: "global",
totalSources: 2,
totalSkills: 3,
sources: new Map([
["anthropics/skills", { count: 2, skills: ["alpha", "beta"] }],
["github/awesome-copilot", { count: 1, skills: ["gamma"] }],
]),
};
test("shows restore help text", async () => {
const result = await runRestore(["--help"]);
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain("Usage:");
expect(result.stdout).toContain("skvlt restore");
expect(result.stdout).not.toContain("skvlt restore --dry-run");
expect(plainStdout).toContain(
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
);
expect(
plainStdout.endsWith(
"Explore the open-source repo at https://github.com/xixu-me/skills-vault\n\n",
),
).toBe(true);
expect(result.stdout).toContain(
`Global restore verifies ${defaultGlobalSkillsPath} against ${defaultGlobalLockFilePath}`,
);
expect(result.stdout).toContain(
"Explore the open-source repo at https://github.com/xixu-me/skills-vault",
);
});
test("defaults restore manifest path to the current working directory", () => {
const options = parseRestoreArgs([]);
expect(options.manifestPath).toBe("./skvlt.yaml");
});
test("prints per-source install commands during restore dry-run", async () => {
const result = await runRestore(
["--dry-run", "--only-source", "anthropics/skills", "--concurrency", "4"],
{
readManifest: () => sampleManifest,
},
);
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain(
"Parallel install concurrency: 1 (configured).",
);
expect(result.stdout).toContain(
"[dry-run] bunx skills add anthropics/skills -g -y --skill alpha beta",
);
expect(plainStdout.endsWith("\n\n")).toBe(true);
expect(result.stdout).not.toContain("github/awesome-copilot");
});
test("prints passthrough skills add options during restore dry-run", async () => {
const result = await runRestore(
[
"--dry-run",
"--only-source",
"anthropics/skills",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--all",
],
{
readManifest: () => sampleManifest,
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"[dry-run] bunx skills add anthropics/skills -g -y --agent codex --agent claude-code --copy --all",
);
expect(result.stdout).not.toContain("--skill");
});
test("uses the manifest scope when no scope flag is provided", async () => {
const scopeAwareManifest: Manifest = {
scope: "project",
totalSources: 1,
totalSkills: 1,
sources: new Map([["anthropics/skills", { count: 1, skills: ["alpha"] }]]),
};
const scopeCalls: boolean[] = [];
const commands: string[][] = [];
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => scopeAwareManifest,
getInstalledSkillNames: async (projectScope) => {
scopeCalls.push(projectScope);
return new Set();
},
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
expect(result.exitCode).toBe(0);
expect(scopeCalls).toEqual([true]);
expect(commands).toEqual([
["skills", "add", "anthropics/skills", "-y", "--skill", "alpha"],
]);
expect(result.payload?.projectScope).toBe(true);
});
test("restores only missing skills during live execution", async () => {
const commands: string[][] = [];
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(0);
expect(plainStdout.startsWith("\n")).toBe(true);
expect(result.stdout).toContain("Loading manifest from ignored.yaml...");
expect(result.stdout).toContain("Planning restore operations...");
expect(result.stdout).toContain(
"Source 1/2: anthropics/skills (1 pending, 1 already installed).",
);
expect(result.stdout).toContain(
"Source 2/2: github/awesome-copilot (1 pending, 0 already installed).",
);
expect(result.stdout).toContain("2 pending, 1 already installed.");
expect(result.stdout).toContain(
"Installing 1 missing skill(s) from anthropics/skills and skipping 1 already installed.",
);
expect(result.stdout).toContain(
"Restore summary: 2 source(s) processed, 2 source install(s) attempted, 2 skill(s) scheduled.",
);
expect(plainStdout.endsWith("\n\n")).toBe(true);
expect(result.stdout).not.toContain("›");
expect(commands).toEqual([
["skills", "add", "anthropics/skills", "-g", "-y", "--skill", "beta"],
["skills", "add", "github/awesome-copilot", "-g", "-y", "--skill", "gamma"],
]);
});
test("passes agent and copy options through during live restore", async () => {
const commands: string[][] = [];
const result = await runRestore(
[
"--manifest",
"ignored.yaml",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
],
{
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
},
);
expect(result.exitCode).toBe(0);
expect(commands).toEqual([
[
"skills",
"add",
"anthropics/skills",
"-g",
"-y",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--skill",
"beta",
],
[
"skills",
"add",
"github/awesome-copilot",
"-g",
"-y",
"--agent",
"codex",
"--agent",
"claude-code",
"--copy",
"--skill",
"gamma",
],
]);
});
test("installs all source skills when --all is enabled", async () => {
const commands: string[][] = [];
const result = await runRestore(["--manifest", "ignored.yaml", "--all"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha", "beta", "gamma"]),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
commands.push(args);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
);
expect(result.stdout).toContain(
"Restore summary: 2 source(s) processed, 2 source install(s) attempted, upstream source state determined the installed skill count.",
);
expect(commands).toEqual([
["skills", "add", "anthropics/skills", "-g", "-y", "--all"],
["skills", "add", "github/awesome-copilot", "-g", "-y", "--all"],
]);
});
test("clarifies dry-run semantics when --all is enabled", async () => {
const result = await runRestore(["--dry-run", "--all"], {
readManifest: () => sampleManifest,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
);
});
test("serializes live installs to avoid lock file races", async () => {
let activeInstalls = 0;
let maxActiveInstalls = 0;
let releaseFirstInstall!: () => void;
const firstInstallGate = new Promise<void>((resolve) => {
releaseFirstInstall = resolve;
});
const restorePromise = runRestore(
["--manifest", "ignored.yaml", "--concurrency", "4"],
{
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
activeInstalls += 1;
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
if (args[2] === "anthropics/skills") {
await firstInstallGate;
}
activeInstalls -= 1;
return { exitCode: 0, stdout: "", stderr: "" };
},
},
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(maxActiveInstalls).toBe(1);
releaseFirstInstall();
const result = await restorePromise;
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Installing from 2 source(s) with concurrency 1 (lock-safe mode).",
);
expect(result.stdout).not.toContain("›");
});
test("fails when global skills and the lock file diverge after restore", async () => {
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha"]),
readTrackedGlobalSkillNames: async () => new Set(["alpha"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
"Global skills state mismatch: 2 installed skill(s) missing from .skill-lock.json.",
);
expect(result.stderr).toContain("beta");
expect(result.stderr).toContain("gamma");
});
test("fails when the lock file tracks skills missing from the skills directory", async () => {
const result = await runRestore(["--manifest", "ignored.yaml"], {
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(["alpha", "beta", "gamma"]),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma", "stale-lock-only"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("GLOBAL_STATE_MISMATCH");
expect(result.stderr).toContain(
"Global skills state mismatch: 1 tracked skill(s) missing from the skills directory.",
);
expect(result.stderr).toContain("stale-lock-only");
});
test("continues and returns success when continue-on-error is enabled", async () => {
const result = await runRestore(
["--manifest", "ignored.yaml", "--continue-on-error"],
{
readManifest: () => sampleManifest,
getInstalledSkillNames: async () => new Set(),
readTrackedGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
readInstalledGlobalSkillNames: async () =>
new Set(["alpha", "beta", "gamma"]),
runBunx: async (args) => {
if (args[2] === "anthropics/skills") {
return { exitCode: 1, stdout: "", stderr: "source failed" };
}
return { exitCode: 0, stdout: "installed gamma", stderr: "" };
},
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Installing from 2 source(s) with concurrency 1 (lock-safe mode).",
);
expect(result.stderr).toContain("source failed");
expect(result.stderr).toContain(
"Install failed for source 'anthropics/skills' with exit code 1",
);
expect(result.stderr).toContain(
"Completed restore with 1 source failure(s).",
);
expect(result.stderr).not.toContain(" ERROR ");
});
test("parses manifest text with quoted source and skill names", () => {
const manifest = parseManifestText(
[
"total_sources: 1",
"total_skills: 2",
"",
"sources:",
' "owner/repo":',
" count: 2",
" skills:",
" - 'simple'",
' - "needs \\"quotes\\""',
"",
].join("\n"),
"memory.yaml",
);
expect(manifest.totalSources).toBe(1);
expect(manifest.totalSkills).toBe(2);
expect(manifest.sources.get("owner/repo")).toEqual({
count: 2,
skills: ["simple", 'needs "quotes"'],
});
});
test("returns a stable error code when the manifest is missing", async () => {
const result = await runRestore(["--manifest", "missing.yaml"]);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("MANIFEST_NOT_FOUND");
expect(result.stderr).toContain("Manifest not found: missing.yaml");
});
test("renders restore argument errors as a usage page", async () => {
const result = await runRestore(["--concurrency"]);
const plainStderr = result.stderr.replace(/\x1b\[[0-9;]*m/g, "");
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("INVALID_ARGUMENT");
expect(plainStderr.startsWith("\n")).toBe(true);
expect(plainStderr).toContain("ERROR");
expect(plainStderr).toContain("--concurrency requires a value");
expect(plainStderr).toContain("Usage:");
expect(plainStderr).toContain("skvlt restore [options]");
expect(plainStderr).toContain("Example:");
expect(plainStderr).toContain("skvlt restore --all");
expect(plainStderr.endsWith("\n\n")).toBe(true);
});