Files
przeprogramowani__10x-cli/tests/sync-command.test.ts

686 lines
31 KiB
TypeScript

/**
* 10x sync — command-level behavior.
*
* Mocks api-content via the shared helper; writes a valid auth file + isolated
* config dir; chdir's into a per-test temp project root so applyBundle writes
* there. stdout is forced non-TTY, so resolveContext implies JSON — assertions
* read the JSON envelope on stdout and the exit code, not human strings.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import cac from "cac";
import type { ApiResult } from "../src/lib/api-client";
import type { CatalogResponse, LessonBundle, LessonSummary } from "../src/lib/api-content";
import { AUTH_FILE_VERSION, type AuthData, saveAuth } from "../src/lib/config";
import { MANIFEST_FILENAME } from "../src/lib/manifest";
import { apiContentMockState, resetApiContentMock } from "./helpers/api-content-mock";
import { redirectConfigDir, restoreConfigDir } from "./helpers/config-isolation";
interface CaptureResult {
stdout: string;
stderr: string;
exitCode?: number;
}
function captureStreams(fn: () => Promise<unknown>): Promise<CaptureResult> {
return new Promise((resolve) => {
const realExit = process.exit;
const realStdoutWrite = process.stdout.write.bind(process.stdout);
const realStderrWrite = process.stderr.write.bind(process.stderr);
let stdout = "";
let stderr = "";
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw Object.assign(new Error("__exit__"), { __exitCode: code });
}) as typeof process.exit;
// Restore SYNCHRONOUSLY inside the settled handler, before resolve(), so a
// test that runs the command twice doesn't have the first call's restore
// (in a trailing .finally) clobber the second call's stream capture.
const restore = () => {
process.stdout.write = realStdoutWrite;
process.stderr.write = realStderrWrite;
process.exit = realExit;
};
fn().then(
() => {
restore();
resolve({ stdout, stderr });
},
(err: unknown) => {
restore();
if (err && typeof err === "object" && "__exitCode" in err) {
resolve({ stdout, stderr, exitCode: (err as { __exitCode: number }).__exitCode });
} else {
resolve({
stdout,
stderr: `${stderr}\n[uncaught: ${err instanceof Error ? err.message : String(err)}]`,
});
}
},
);
});
}
interface ParsedFlags {
json?: boolean;
verbose?: boolean;
all?: boolean;
dryRun?: boolean;
force?: boolean;
module?: string;
course?: string;
tool?: string;
lang?: string;
}
/** Minimal argv→flags parser for the subset of options these tests pass. */
function parseArgs(argv: string[]): ParsedFlags {
const flags: ParsedFlags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--all") flags.all = true;
else if (a === "--dry-run") flags.dryRun = true;
else if (a === "--force") flags.force = true;
else if (a === "--module") flags.module = argv[++i];
else if (a === "--course") flags.course = argv[++i];
else if (a === "--tool") flags.tool = argv[++i];
else if (a === "--lang") flags.lang = argv[++i];
}
return flags;
}
/**
* Invoke the command's exported runSync directly with a parsed options object.
* Avoids cac re-entrancy when a single test runs sync more than once; the cac
* wiring itself is covered by index.ts registration + the smoke test.
*/
async function runSyncCmd(argv: string[]): Promise<CaptureResult> {
return captureStreams(async () => {
const { runSync } = await import("../src/commands/sync");
const { resolveContext } = await import("../src/lib/output");
const flags = parseArgs(argv);
await runSync(resolveContext(flags), flags);
});
}
function envelope(stdout: string): { status: string; data: Record<string, unknown> } {
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "{}";
return JSON.parse(line);
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
let tmp: string;
let priorIsTTY: boolean | undefined;
let priorCwd: string;
let fetched: string[];
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "10x-cli-sync-"));
redirectConfigDir(tmp);
priorIsTTY = process.stdout.isTTY;
process.stdout.isTTY = false;
priorCwd = process.cwd();
process.chdir(tmp);
fetched = [];
resetApiContentMock();
writeValidAuth();
});
afterEach(() => {
process.chdir(priorCwd);
restoreConfigDir();
if (priorIsTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
else process.stdout.isTTY = priorIsTTY;
resetApiContentMock();
rmSync(tmp, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function writeValidAuth(): void {
const data: AuthData = {
version: AUTH_FILE_VERSION,
email: "student@example.com",
access_token: "jwt-valid",
refresh_token: "rt-valid",
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1_000).toISOString(),
created_at: new Date().toISOString(),
};
saveAuth(data);
}
function lessonSummary(over: Partial<LessonSummary> & { lessonId: string; module: number; lesson: number }): LessonSummary {
return {
title: `Lesson ${over.lessonId}`,
summary: "summary",
bundlePath: `10xdevs3/lessons/${over.lessonId}.json`,
...over,
};
}
function makeCatalog(lessons: LessonSummary[], lockedModules: number[] = []): CatalogResponse {
const moduleNums = [...new Set(lessons.map((l) => l.module).concat(lockedModules))].sort();
return {
course: "10xdevs3",
modules: moduleNums.map((m) => ({
module: m,
title: `Module ${m}`,
releaseAt: "2026-04-01T00:00:00Z",
stateOverride: null,
effectiveState: lockedModules.includes(m) ? "locked" : "unlocked",
})),
lessons,
};
}
function makeBundle(lessonId: string, skillContent: string): LessonBundle {
const m = /^m(\d+)l(\d+)$/.exec(lessonId)!;
return {
lessonId,
module: Number(m[1]),
lesson: Number(m[2]),
title: `Lesson ${lessonId}`,
summary: "summary",
skills: [{ name: "auth-skill", files: [{ path: "SKILL.md", content: skillContent }] }],
prompts: [],
rules: [],
configs: [],
};
}
function okCatalog(catalog: CatalogResponse): ApiResult<CatalogResponse> {
return { ok: true, data: catalog, status: 200, responseHeaders: new Headers(), rawBody: "" };
}
function okLesson(bundle: LessonBundle): ApiResult<LessonBundle> {
return { ok: true, data: bundle, status: 200, responseHeaders: new Headers(), rawBody: "" };
}
/** Wire the catalog + a per-lesson bundle map, tracking fetched lesson ids. */
function wire(catalog: CatalogResponse, bundles: Record<string, LessonBundle>): void {
apiContentMockState.fetchCatalogImpl = () => okCatalog(catalog);
apiContentMockState.fetchLessonImpl = (_course, lessonId) => {
fetched.push(lessonId);
const bundle = bundles[lessonId];
if (!bundle) {
return { ok: false, status: 404, code: "lesson_not_found", error: "missing" } as ApiResult<LessonBundle>;
}
return okLesson(bundle);
};
}
function readManifestFile(): Record<string, unknown> {
return JSON.parse(readFileSync(join(tmp, ".claude", MANIFEST_FILENAME), "utf8"));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("10x sync — cac wiring", () => {
it("is registered and runs via the cac instance", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
const res = await captureStreams(async () => {
const { registerSyncCommand } = await import("../src/commands/sync");
const cli = cac("10x");
cli.option("--json", "Output as JSON");
cli.option("--verbose", "Verbose");
registerSyncCommand(cli);
cli.parse(["bun", "10x", "sync", "--all", "--tool", "claude-code"], { run: false });
await cli.runMatchedCommand();
});
expect(res.exitCode).toBeUndefined();
expect(envelope(res.stdout).status).toBe("ok");
expect(fetched).toEqual(["m1l1"]);
});
});
describe("10x sync — bulk download (--all)", () => {
it("downloads all unlocked lessons and writes their files", async () => {
const catalog = makeCatalog([
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h-m1l1" }),
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "h-m1l2" }),
]);
wire(catalog, { m1l1: makeBundle("m1l1", "v1"), m1l2: makeBundle("m1l2", "v1") });
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
expect(res.exitCode).toBeUndefined();
expect(fetched.sort()).toEqual(["m1l1", "m1l2"]);
expect(existsSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"))).toBe(true);
const data = envelope(res.stdout).data;
expect((data.lessons as unknown[]).length).toBe(2);
});
});
describe("10x sync — default targets manifest lessons; --module filters", () => {
it("default mode only targets already-downloaded lessons", async () => {
// Seed manifest with m1l1 only.
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
await runSyncCmd(["--all", "--tool", "claude-code"]);
// Now catalog also offers m1l2 (never downloaded) and m1l1 changed.
fetched = [];
wire(
makeCatalog([
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1-changed" }),
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "h2" }),
]),
{ m1l1: makeBundle("m1l1", "v2"), m1l2: makeBundle("m1l2", "v1") },
);
const res = await runSyncCmd(["--tool", "claude-code"]);
// m1l2 is not in the manifest → not targeted/fetched.
expect(fetched).toEqual(["m1l1"]);
const ids = (envelope(res.stdout).data.lessons as Array<{ lessonId: string }>).map((l) => l.lessonId);
expect(ids).toEqual(["m1l1"]);
});
it("--module filters to one module", async () => {
wire(
makeCatalog([
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
lessonSummary({ lessonId: "m2l1", module: 2, lesson: 1, contentHash: "b" }),
]),
{ m1l1: makeBundle("m1l1", "v1"), m2l1: makeBundle("m2l1", "v1") },
);
await runSyncCmd(["--all", "--module", "m2", "--tool", "claude-code"]);
expect(fetched).toEqual(["m2l1"]);
});
});
describe("10x sync — cheap-skip via catalog digest", () => {
it("does NOT fetch a lesson whose catalog contentHash matches the stored digest", async () => {
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
await runSyncCmd(["--all", "--tool", "claude-code"]); // seeds manifest digest h1
fetched = [];
const res = await runSyncCmd(["--tool", "claude-code"]); // same catalog (h1)
expect(fetched).toEqual([]); // cheap-skip: no download
const lessons = envelope(res.stdout).data.lessons as Array<{ lessonId: string; status: string; fetched: boolean }>;
expect(lessons[0]).toMatchObject({ lessonId: "m1l1", status: "unchanged", fetched: false });
});
it("fetches a lesson whose digest differs", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
await runSyncCmd(["--all", "--tool", "claude-code"]);
fetched = [];
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
m1l1: makeBundle("m1l1", "v2"),
});
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
});
it("--force bypasses the gate and fetches even when the digest matches", async () => {
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
await runSyncCmd(["--all", "--tool", "claude-code"]);
fetched = [];
await runSyncCmd(["--force", "--tool", "claude-code"]); // same digest h1
expect(fetched).toEqual(["m1l1"]);
});
});
describe("10x sync — manifest digest round-trip", () => {
it("stores the catalog contentHash into the manifest on apply", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "digest-xyz" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
await runSyncCmd(["--all", "--tool", "claude-code"]);
const manifest = readManifestFile() as { lessons: Record<string, { catalogContentHash?: string }> };
expect(manifest.lessons["m1l1"]!.catalogContentHash).toBe("digest-xyz");
});
});
describe("10x sync — conflicts", () => {
it("default reports skipped-conflict with a remediation command; local edit preserved", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
await runSyncCmd(["--all", "--tool", "claude-code"]);
// User edits the local skill, upstream also moves.
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "locally edited");
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
m1l1: makeBundle("m1l1", "v2"),
});
const res = await runSyncCmd(["--tool", "claude-code"]);
expect(res.exitCode).toBeUndefined(); // conflicts alone do NOT fail
const lessons = envelope(res.stdout).data.lessons as Array<{
resources: Array<{ bucket: string; remediation?: string }>;
}>;
const conflict = lessons[0]!.resources.find((r) => r.bucket === "skipped-conflict");
expect(conflict).toBeTruthy();
expect(conflict!.remediation).toBe("10x get m1l1 --type skills --name auth-skill");
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("locally edited");
});
it("--force overwrites the conflicted file with upstream", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
await runSyncCmd(["--all", "--tool", "claude-code"]);
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "locally edited");
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h2" })]), {
m1l1: makeBundle("m1l1", "v2"),
});
await runSyncCmd(["--force", "--tool", "claude-code"]);
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("v2");
});
});
describe("10x sync — dry-run", () => {
it("writes nothing and still reports the plan", async () => {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]), {
m1l1: makeBundle("m1l1", "v1"),
});
const res = await runSyncCmd(["--all", "--dry-run", "--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]); // dry-run still fetches to classify
expect(existsSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"))).toBe(false); // no write
expect(existsSync(join(tmp, ".claude", MANIFEST_FILENAME))).toBe(false); // no manifest write
const lessons = envelope(res.stdout).data.lessons as Array<{ resources: Array<{ bucket: string }> }>;
expect(lessons[0]!.resources.some((r) => r.bucket === "created")).toBe(true);
});
});
describe("10x sync — partial failure", () => {
it("exits 1 when a lesson errors but still emits the full report", async () => {
const catalog = makeCatalog([
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
lessonSummary({ lessonId: "m1l2", module: 1, lesson: 2, contentHash: "b" }),
]);
// m1l2 has no bundle → wire() returns 404 for it.
wire(catalog, { m1l1: makeBundle("m1l1", "v1") });
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
expect(res.exitCode).toBe(1);
const lessons = envelope(res.stdout).data.lessons as Array<{ lessonId: string; status: string }>;
expect(lessons.find((l) => l.lessonId === "m1l2")!.status).toBe("errored");
expect(lessons.find((l) => l.lessonId === "m1l1")!.status).not.toBe("errored");
});
});
describe("10x sync — locked modules", () => {
it("excludes locked-module lessons with a reason and does not fetch them", async () => {
const catalog = makeCatalog(
[
lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "a" }),
lessonSummary({ lessonId: "m2l1", module: 2, lesson: 1, contentHash: "b" }),
],
[2],
);
wire(catalog, { m1l1: makeBundle("m1l1", "v1"), m2l1: makeBundle("m2l1", "v1") });
const res = await runSyncCmd(["--all", "--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]); // m2l1 excluded, never fetched
const excluded = envelope(res.stdout).data.excluded as Array<{ lessonId: string; reason: string }>;
expect(excluded).toEqual([{ lessonId: "m2l1", reason: "module 2 is locked" }]);
});
});
describe("v4 operation release consistency", () => {
beforeEach(() => {
apiContentMockState.fetchCoursesImpl = () => ({ ok: true, status: 200, responseHeaders: new Headers(), rawBody: "", data: { courses: [{ id: "10xdevs-4", slug: "10xdevs4", title: "10xDevs 4", edition: 4, available: true }], defaultCourse: "10xdevs4" } });
});
it("sync reads one catalog and retains that release after current advances between lessons", async () => {
const release = { course: "10xdevs4", releaseId: `r-${"a".repeat(64)}`, releaseManifestHash: "b".repeat(64) };
const catalog = { ...makeCatalog([lessonSummary({lessonId:"m1l1",module:1,lesson:1}),lessonSummary({lessonId:"m1l2",module:1,lesson:2})]), ...release };
let catalogReads = 0;
const selections: unknown[] = [];
apiContentMockState.fetchCatalogImpl = () => { catalogReads++; return okCatalog(catalog); };
apiContentMockState.fetchLessonImpl = (_course,id,_token,options) => {
selections.push(options?.release);
return okLesson({ ...makeBundle(id, options?.release?.releaseId === release.releaseId ? "selected-old-bytes" : "new-current-bytes"), ...release });
};
const res = await runSyncCmd(["--all","--course","10xdevs4","--tool","claude-code"]);
expect(res.exitCode).toBeUndefined(); expect(envelope(res.stdout).status).toBe("ok");
expect(catalogReads).toBe(1); expect(selections).toEqual([release,release]);
expect(readFileSync(join(tmp,".claude/skills/auth-skill/SKILL.md"),"utf8")).toBe("selected-old-bytes");
});
it("printed get selects the catalog once and propagates release to individual artifact reads", async () => {
const release = { course:"10xdevs4", releaseId:`r-${"a".repeat(64)}`, releaseManifestHash:"b".repeat(64) };
let catalogReads=0; let selected:unknown;
apiContentMockState.fetchCatalogImpl=()=>{catalogReads++;return okCatalog({...makeCatalog([]),...release});};
apiContentMockState.fetchArtifactImpl=(_course,_lesson,_type,_name,_tool,_token,options)=>{selected=options?.release;return {ok:true,status:200,data:{type:"prompts",name:"hello",content:"selected artifact",...release},responseHeaders:new Headers(),rawBody:""};};
const res=await captureStreams(async()=>{const {runGet}=await import("../src/commands/get");const {resolveContext}=await import("../src/lib/output");await runGet(resolveContext({json:true}),"m1l1",{course:"10xdevs4",tool:"claude-code",print:true,type:"prompts",name:"hello"});});
expect(catalogReads).toBe(1);expect(selected).toEqual(release);expect(res.stdout).toContain("selected artifact");
});
});
describe("sync local state and representation", () => {
function seed() {
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "same-digest" })]), { m1l1: makeBundle("m1l1", "upstream") });
}
it("repairs missing files despite an unchanged catalog digest", async () => {
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
rmSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"));
fetched = [];
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("upstream");
});
it("reports local edits even when upstream digest has not changed", async () => {
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
writeFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "local");
fetched = [];
const response = await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
expect(response.stdout).toContain("skipped-conflict");
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("local");
});
it("changing language invalidates the digest shortcut and stores the delivered representation", async () => {
seed(); await runSyncCmd(["--all", "--tool", "claude-code", "--lang", "en"]);
fetched = [];
wire(makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "same-digest" })]), { m1l1: makeBundle("m1l1", "polska wersja") });
await runSyncCmd(["--tool", "claude-code", "--lang", "pl"]);
expect(fetched).toEqual(["m1l1"]);
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/SKILL.md"), "utf8")).toBe("polska wersja");
const state = readManifestFile() as { lessons: Record<string, { representation: { lang: string } }> };
expect(state.lessons.m1l1!.representation.lang).toBe("pl");
});
it("refetches old manifests without representation metadata", async () => {
seed(); await runSyncCmd(["--all", "--tool", "claude-code"]);
const state = readManifestFile() as { lessons: Record<string, { representation?: unknown }> };
delete state.lessons.m1l1!.representation;
writeFileSync(join(tmp, ".claude", MANIFEST_FILENAME), JSON.stringify(state));
fetched = [];
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
});
it("force still preserves edited managed rules and their upstream baseline", async () => {
const original = { ...makeBundle("m1l1", "upstream"), rules: [{ name: "rules", content: "original rule" }] };
const catalog = makeCatalog([lessonSummary({ lessonId: "m1l1", module: 1, lesson: 1, contentHash: "h1" })]);
wire(catalog, { m1l1: original });
await runSyncCmd(["--all", "--tool", "claude-code"]);
const before = readManifestFile().managedRules;
const path = join(tmp, "CLAUDE.md");
const local = readFileSync(path, "utf8").replace("original rule", "my local rule");
writeFileSync(path, local);
const response = await runSyncCmd(["--force", "--tool", "claude-code"]);
expect(response.stdout).toContain("skipped-conflict");
expect(readFileSync(path, "utf8")).toBe(local);
expect(readManifestFile().managedRules).toEqual(before);
});
});
describe("sync cumulative variants converge", () => {
function variants(secondId = "m1l2", firstId = "m1l1") {
const summaries = [firstId, secondId].map((id) => {
const [, module, lesson] = /^m(\d+)l(\d+)$/.exec(id)!;
return lessonSummary({ lessonId: id, module: Number(module), lesson: Number(lesson), contentHash: `digest-${id}` });
});
const bundles = Object.fromEntries(summaries.map(({ lessonId }) => {
const bundle = makeBundle(lessonId, `skill-${lessonId}`);
bundle.skills[0]!.files.push({ path: "references/shared.md", content: `reference-${lessonId}` });
bundle.prompts = [{ name: "shared", content: `prompt-${lessonId}` }];
bundle.rules = [{ name: "rules", content: `rules-${lessonId}` }];
return [lessonId, bundle];
}));
wire(makeCatalog(summaries), bundles);
return { summaries, bundles };
}
function skillPath() { return join(tmp, ".claude/skills/auth-skill/SKILL.md"); }
function assertFinal(secondId = "m1l2") {
expect(readFileSync(skillPath(), "utf8")).toBe(`skill-${secondId}`);
expect(readFileSync(join(tmp, ".claude/skills/auth-skill/references/shared.md"), "utf8")).toBe(`reference-${secondId}`);
expect(readFileSync(join(tmp, ".claude/prompts/shared.md"), "utf8")).toBe(`prompt-${secondId}`);
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain(`rules-${secondId}`);
}
async function assertIdle() {
const paths = [skillPath(), join(tmp, ".claude/skills/auth-skill/references/shared.md"), join(tmp, ".claude/prompts/shared.md"), join(tmp, "CLAUDE.md"), join(tmp, ".claude", MANIFEST_FILENAME)];
const before = paths.map((path) => {
utimesSync(path, new Date("2001-01-01T00:00:00Z"), new Date("2001-01-01T00:00:00Z"));
return { bytes: readFileSync(path), mtime: statSync(path).mtimeMs };
});
fetched = [];
const response = await runSyncCmd(["--tool", "claude-code"]);
expect(response.exitCode).toBeUndefined();
expect(envelope(response.stdout).data.totals).toMatchObject({ updated: 0, unchanged: 2, conflicts: 0, errored: 0, resources: { created: 0, upstreamUpdated: 0, removed: 0 } });
expect(fetched).toEqual([]);
paths.forEach((path, index) => {
expect(readFileSync(path)).toEqual(before[index]!.bytes);
expect(statSync(path).mtimeMs).toBe(before[index]!.mtime);
});
}
it("repeated sync of distinct cumulative skill, support, prompt and rules variants has zero writes", async () => {
variants();
await runSyncCmd(["--all", "--tool", "claude-code"]);
assertFinal();
await assertIdle();
await assertIdle();
});
it("orders supersession numerically when lesson numbers have different widths", async () => {
variants("m1l10", "m1l2");
await runSyncCmd(["--all", "--tool", "claude-code"]);
assertFinal("m1l10");
await assertIdle();
});
it("an earlier upstream change still reapplies the later owner before becoming idle", async () => {
const { summaries, bundles } = variants();
await runSyncCmd(["--all", "--tool", "claude-code"]);
summaries[0]!.contentHash = "earlier-new-digest";
bundles.m1l1!.skills[0]!.files[0]!.content = "earlier new upstream";
wire(makeCatalog(summaries), bundles);
fetched = [];
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1", "m1l2"]);
assertFinal();
await assertIdle();
});
it("module filtering preserves selected scope and a later full sync restores later ownership", async () => {
const { summaries, bundles } = variants("m2l1");
await runSyncCmd(["--all", "--tool", "claude-code"]);
summaries[0]!.contentHash = "earlier-new-digest";
bundles.m1l1!.skills[0]!.files[0]!.content = "earlier new upstream";
wire(makeCatalog(summaries), bundles);
fetched = [];
await runSyncCmd(["--module", "m1", "--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
expect(readFileSync(skillPath(), "utf8")).toBe("earlier new upstream");
fetched = [];
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m2l1"]);
assertFinal("m2l1");
await assertIdle();
});
it("repairs a missing shared support file with the final owner's exact bytes", async () => {
variants();
await runSyncCmd(["--all", "--tool", "claude-code"]);
rmSync(join(tmp, ".claude/skills/auth-skill/references/shared.md"));
await runSyncCmd(["--tool", "claude-code"]);
assertFinal();
await assertIdle();
});
it("preserves local shared edits despite matching digests", async () => {
variants();
await runSyncCmd(["--all", "--tool", "claude-code"]);
writeFileSync(skillPath(), "my local shared edit");
fetched = [];
const response = await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1", "m1l2"]);
expect(response.stdout).toContain("skipped-conflict");
expect(readFileSync(skillPath(), "utf8")).toBe("my local shared edit");
});
it("a module-scoped language change invalidates owners in the previous language", async () => {
const { summaries, bundles } = variants("m2l1");
await runSyncCmd(["--all", "--tool", "claude-code", "--lang", "en"]);
bundles.m2l1!.skills[0]!.files[0]!.content = "polski wariant";
wire(makeCatalog(summaries), bundles);
await runSyncCmd(["--module", "m2", "--tool", "claude-code", "--lang", "pl"]);
expect(readFileSync(skillPath(), "utf8")).toBe("polski wariant");
fetched = [];
await runSyncCmd(["--module", "m1", "--tool", "claude-code", "--lang", "en"]);
expect(fetched).toEqual(["m1l1"]);
expect(readFileSync(skillPath(), "utf8")).toBe("skill-m1l1");
});
it("a later lesson with no rules retains the earlier managed block without repeated writes", async () => {
const { summaries, bundles } = variants();
bundles.m1l2!.rules = [];
wire(makeCatalog(summaries), bundles);
await runSyncCmd(["--all", "--tool", "claude-code"]);
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain("rules-m1l1");
await assertIdle();
});
it("a partial later write cannot certify a complete cumulative representation", async () => {
variants();
await runSyncCmd(["--all", "--tool", "claude-code"]);
const { applyBundle } = await import("../src/lib/writer");
await applyBundle(makeBundle("m1l2", "partial later variant"), tmp, { partial: true });
expect(readFileSync(skillPath(), "utf8")).toBe("partial later variant");
fetched = [];
await runSyncCmd(["--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1", "m1l2"]);
assertFinal();
await assertIdle();
});
it("a module-scoped rules opt-out does not preserve earlier owners' enabled-policy shortcut", async () => {
variants("m2l1");
await runSyncCmd(["--all", "--tool", "claude-code"]);
const argv = process.argv;
try {
process.argv = [...argv, "--no-course-rules"];
await runSyncCmd(["--module", "m2", "--tool", "claude-code"]);
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).not.toContain("rules-m2l1");
process.argv = [...argv, "--course-rules"];
fetched = [];
await runSyncCmd(["--module", "m1", "--tool", "claude-code"]);
expect(fetched).toEqual(["m1l1"]);
expect(readFileSync(join(tmp, "CLAUDE.md"), "utf8")).toContain("rules-m1l1");
} finally { process.argv = argv; }
});
});