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

314 lines
9.5 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 { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runBackup } from "../src/commands/backup";
import { parseBackupArgs } from "../src/internal/args/parse-backup-args";
import { buildManifest } from "../src/internal/manifest/build-manifest";
import { defaultGlobalLockFilePath } from "../src/internal/paths/defaults";
test("rejects flag-like values for backup paths", () => {
expect(() => parseBackupArgs(["--output", "--dry-run"])).toThrow(
"--output requires a path",
);
expect(() => parseBackupArgs(["--lock-file", "--dry-run"])).toThrow(
"--lock-file requires a path",
);
});
test("renders backup argument errors as a usage page", async () => {
const result = await runBackup(["--output", "--dry-run"]);
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("--output requires a path");
expect(plainStderr).toContain("Usage:");
expect(plainStderr).toContain("bunx skvlt backup [options]");
expect(plainStderr).toContain("Example:");
expect(plainStderr).toContain("bunx skvlt backup --output");
expect(plainStderr.endsWith("\n\n")).toBe(true);
});
test("treats an explicit default lock file as provided", () => {
const options = parseBackupArgs([
"--project-scope",
"--lock-file",
defaultGlobalLockFilePath,
]);
expect(options.lockFilePath).toBe(defaultGlobalLockFilePath);
expect(options.lockFileProvided).toBe(true);
});
test("defaults backup output path to the current working directory", () => {
const options = parseBackupArgs([]);
expect(options.outputPath).toBe("./skvlt.yaml");
});
test("writes the default manifest in the current working directory", async () => {
const originalCwd = process.cwd();
const workspace = mkdtempSync(join(tmpdir(), "skills-vault-default-backup-"));
process.chdir(workspace);
try {
const result = await runBackup([], {
getInstalledSkillNames: async () => ["alpha"],
readLockFile: () => ({
skills: {
alpha: { source: "owner/skills" },
},
}),
});
expect(result.exitCode).toBe(0);
expect(existsSync(join(workspace, "skvlt.yaml"))).toBe(true);
expect(readFileSync(join(workspace, "skvlt.yaml"), "utf8")).toContain(
' "owner/skills":',
);
} finally {
process.chdir(originalCwd);
}
});
test("prints deterministic manifest YAML during backup dry-run", async () => {
let writeCalls = 0;
const result = await runBackup(["--dry-run"], {
getInstalledSkillNames: async () => ["zulu", "alpha", "beta"],
readLockFile: () => ({
skills: {
alpha: { source: "beta/source" },
beta: { source: "alpha/source" },
zulu: { source: "beta/source" },
},
}),
writeManifest: () => {
writeCalls += 1;
},
});
expect(result.exitCode).toBe(0);
expect(writeCalls).toBe(0);
expect(result.errorCode).toBeUndefined();
expect(result.payload).toMatchObject({
dryRun: true,
totalSources: 2,
totalSkills: 3,
});
expect(result.stdout).toBe(
[
"total_sources: 2",
"total_skills: 3",
'scope: "global"',
"",
"sources:",
' "alpha/source":',
" count: 1",
" skills:",
' - "beta"',
"",
' "beta/source":',
" count: 2",
" skills:",
' - "alpha"',
' - "zulu"',
"",
].join("\n") + "\n",
);
});
test("creates parent directories and prints backup progress", async () => {
const workspace = mkdtempSync(join(tmpdir(), "skills-vault-backup-"));
const outputPath = join(workspace, "nested", "skvlt.yaml");
const result = await runBackup(["--output", outputPath], {
getInstalledSkillNames: async () => ["alpha", "beta"],
readLockFile: () => ({
skills: {
alpha: { source: "owner/skills" },
beta: { source: "owner/skills" },
},
}),
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Listing installed skills...");
expect(result.stdout).toContain("Reading lock file metadata...");
expect(result.stdout).toContain("Building manifest...");
expect(result.stdout).toContain(`Writing manifest to ${outputPath}...`);
expect(result.stdout).toContain(
`Backup summary: 2 skill(s) across 1 source(s) written to ${outputPath}`,
);
expect(existsSync(outputPath)).toBe(true);
expect(readFileSync(outputPath, "utf8")).toContain(' "owner/skills":');
});
test("formats backup paths for the host platform in user-facing output", async () => {
const originalCwd = process.cwd();
const workspace = mkdtempSync(join(tmpdir(), "skills-vault-format-backup-"));
process.chdir(workspace);
try {
const result = await runBackup(["--output", "./nested/skvlt.yaml"], {
getInstalledSkillNames: async () => ["alpha"],
readLockFile: () => ({
skills: {
alpha: { source: "owner/skills" },
},
}),
});
const expectedPath =
process.platform === "win32"
? ".\\nested\\skvlt.yaml"
: "./nested/skvlt.yaml";
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(`Writing manifest to ${expectedPath}...`);
expect(result.stdout).toContain(
`Backup summary: 1 skill(s) across 1 source(s) written to ${expectedPath}`,
);
expect(existsSync(join(workspace, "nested", "skvlt.yaml"))).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
test("shows help before project-scope validation", async () => {
const result = await runBackup(["--help", "--project-scope"]);
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("\x1b[1mUsage:\x1b[0m");
expect(result.stdout).toContain("\x1b[1mExamples:\x1b[0m");
expect(result.stdout).toContain("\x1b[38;5;102m$\x1b[0m");
expect(result.stdout).not.toContain("bunx skvlt backup --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(
"Explore the open-source repo at https://github.com/xixu-me/skills-vault",
);
});
test("styles backup progress output", async () => {
const originalCwd = process.cwd();
const workspace = mkdtempSync(join(tmpdir(), "skills-vault-style-backup-"));
process.chdir(workspace);
try {
const result = await runBackup(["--output", "./nested/skvlt.yaml"], {
getInstalledSkillNames: async () => ["alpha"],
readLockFile: () => ({
skills: {
alpha: { source: "owner/skills" },
},
}),
});
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("\x1b[");
expect(result.stdout).toContain("Listing installed skills...");
expect(result.stdout).toContain("Backup summary:");
expect(plainStdout.endsWith("\n\n")).toBe(true);
expect(result.stdout).not.toContain("›");
expect(result.stdout).not.toContain(" ERROR ");
expect(existsSync(join(workspace, "nested", "skvlt.yaml"))).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
test("builds a grouped manifest with stable source and skill ordering", () => {
const manifest = buildManifest(["zulu", "alpha", "beta"], {
skills: {
alpha: { source: "beta/source" },
beta: { source: "alpha/source" },
zulu: { source: "beta/source" },
},
});
expect(manifest.totalSources).toBe(2);
expect(manifest.totalSkills).toBe(3);
expect(manifest.yaml).toBe(
[
"total_sources: 2",
"total_skills: 3",
'scope: "global"',
"",
"sources:",
' "alpha/source":',
" count: 1",
" skills:",
' - "beta"',
"",
' "beta/source":',
" count: 2",
" skills:",
' - "alpha"',
' - "zulu"',
"",
].join("\n") + "\n",
);
});
test("builds double-quoted yaml scalars with escaped special characters", () => {
const manifest = buildManifest(['skill "one"', "path\\skill"], {
skills: {
'skill "one"': { source: 'owner/"repo"' },
"path\\skill": { source: "owner/path\\repo" },
},
});
expect(manifest.yaml).toBe(
[
"total_sources: 2",
"total_skills: 2",
'scope: "global"',
"",
"sources:",
' "owner/\\"repo\\"":',
" count: 1",
" skills:",
' - "skill \\"one\\""',
"",
' "owner/path\\\\repo":',
" count: 1",
" skills:",
' - "path\\\\skill"',
"",
].join("\n") + "\n",
);
});
test("records project scope in the manifest output", async () => {
const result = await runBackup(
["--dry-run", "--project-scope", "--lock-file", defaultGlobalLockFilePath],
{
getInstalledSkillNames: async () => ["alpha"],
readLockFile: () => ({
skills: {
alpha: { source: "owner/skills" },
},
}),
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('scope: "project"');
});