314 lines
9.5 KiB
TypeScript
314 lines
9.5 KiB
TypeScript
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"');
|
||
});
|