import { afterAll, describe, expect, test } from "bun:test"; import { chmod, mkdtemp, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { createCourseRepository, discoverManifests, loadAll, loadCourse } from "../src/index"; const sampleDir = resolve(import.meta.dir, "../../../fixtures/courses/sample"); const tmps: string[] = []; async function tmp(): Promise { const d = await mkdtemp(join(tmpdir(), "stc-")); tmps.push(d); return d; } afterAll(() => Promise.all(tmps.map((d) => rm(d, { recursive: true, force: true })))); const manifest = (id: string, extra: string, lessons: string) => `schema: 1\nid: ${id}\ntitle: T\n${extra}modules:\n - id: m\n title: M\n lessons:\n${lessons}\n`; describe("sample fixture", () => { test("loads the sample fixture with no errors", async () => { const c = await loadCourse(join(sampleDir, "stcourse.yaml")); expect(c.diagnostics).toEqual([]); expect(c.status).toBe("ok"); expect(c.id).toBe("sample"); expect([...c.lessonIndex.keys()]).toEqual(["m1-intro", "m1-video", "m1-code", "m2-text", "m2-ex-add"]); }); test("repository reads lesson markdown and sections", async () => { const repo = await createCourseRepository([sampleDir]); expect(repo.list().map((c) => c.id)).toEqual(["sample"]); expect(await repo.readLessonMarkdown("sample", "m1-intro")).toContain("# Introduction"); const ex = await repo.readLessonMarkdown("sample", "m2-ex-add"); expect(ex!.startsWith("### Exercise 2.1")).toBe(true); expect(await repo.readLessonMarkdown("sample", "nope")).toBeNull(); expect(repo.get("nope")).toBeNull(); }); }); describe("discovery", () => { test("finds manifests at depth 0 to 2 only", async () => { const d = await tmp(); for (const p of ["stcourse.yaml", "a/stcourse.yaml", "a/b/stcourse.yaml", "a/b/c/stcourse.yaml"]) { await Bun.write(join(d, p), "x"); } const found = (await discoverManifests([d])).map((p) => p.slice(d.length + 1)); expect(found.sort()).toEqual(["a/b/stcourse.yaml", "a/stcourse.yaml", "stcourse.yaml"]); }); test("skips dot-folders (hidden or retired course folders, e.g. an import being converted)", async () => { const d = await tmp(); for (const p of ["a/stcourse.yaml", ".converting-a/stcourse.yaml", "x/.hidden/stcourse.yaml"]) await Bun.write(join(d, p), "x"); const found = (await discoverManifests([d])).map((p) => p.slice(d.length + 1)); expect(found).toEqual(["a/stcourse.yaml"]); }); }); describe("overlays and errors", () => { test("overlay with missing root gives status error", async () => { const d = await tmp(); const missing = join(d, "gone-repo"); await Bun.write(join(d, "overlay/stcourse.yaml"), manifest("broken", `root: ${missing}\n`, " - { id: l, title: L, type: text, file: a.md }")); await Bun.write(join(d, "ok/a.md"), "# A\n"); await Bun.write(join(d, "ok/stcourse.yaml"), manifest("fine", "", " - { id: l, title: L, type: text, file: a.md }")); const all = await loadAll([d]); expect(all.map((c) => c.id).sort()).toEqual(["broken", "fine"]); const broken = all.find((c) => c.id === "broken")!; expect(broken.status).toBe("error"); expect(broken.diagnostics.some((x) => x.level === "error" && x.message.includes(missing))).toBe(true); expect(all.find((c) => c.id === "fine")!.status).toBe("ok"); }); test("missing lesson file, anchor and hints each produce one error diagnostic naming lessonId", async () => { const d = await tmp(); await Bun.write(join(d, "a.md"), "# A\n\n## Real\n\ntext\n"); const lessons = [ " - { id: no-file, title: L, type: text, file: missing.md }", " - { id: no-anchor, title: L, type: text, file: a.md, anchor: ghost }", " - { id: no-hints, title: L, type: exercise, file: a.md, anchor: real, hints: { file: a.md, match: 'Exercise 1' } }", ].join("\n"); await Bun.write(join(d, "stcourse.yaml"), manifest("bad", "", lessons)); const c = await loadCourse(join(d, "stcourse.yaml")); expect(c.status).toBe("error"); const errs = c.diagnostics.filter((x) => x.level === "error"); expect(errs.map((e) => e.lessonId)).toEqual(["no-file", "no-anchor", "no-hints"]); expect(errs[0]!.path).toBe("missing.md"); expect(errs[1]!.message).toContain("ghost"); expect(errs[2]!.message).toContain("Exercise 1"); }); test("lesson file that is a directory gives a diagnostic", async () => { const d = await tmp(); await Bun.write(join(d, "adir/x.md"), "# X\n"); await Bun.write(join(d, "stcourse.yaml"), manifest("dirs", "", " - { id: as-file, title: L, type: text, file: adir }")); const c = await loadCourse(join(d, "stcourse.yaml")); expect(c.status).toBe("error"); const e = c.diagnostics.filter((x) => x.level === "error"); expect(e).toHaveLength(1); expect(e[0]!.lessonId).toBe("as-file"); expect(e[0]!.path).toBe("adir"); }); test("anchor resolved through a hints.file directory (no lesson file) gives one lesson diagnostic and loading continues", async () => { const d = await tmp(); await Bun.write(join(d, "adir/x.md"), "# X\n"); const lessons = [ " - { id: via-hints, title: L, type: exercise, anchor: sec, hints: { file: adir, match: 'E' } }", " - { id: after, title: L, type: text, file: missing.md }", ].join("\n"); await Bun.write(join(d, "stcourse.yaml"), manifest("dirs2", "", lessons)); const c = await loadCourse(join(d, "stcourse.yaml")); const e = c.diagnostics.filter((x) => x.level === "error"); expect(e.map((x) => x.lessonId)).toEqual(["via-hints", "after"]); expect(e[0]!.path).toBe("adir"); }); // An unreadable file passes requireFile (it is a regular file) but Bun.file().text() rejects. test.skipIf(process.getuid?.() === 0)("one throwing course does not break loadAll", async () => { const d = await tmp(); await Bun.write(join(d, "bad/a.md"), "# A\n## Sec\n"); await chmod(join(d, "bad/a.md"), 0o000); await Bun.write(join(d, "bad/stcourse.yaml"), manifest("bad", "", " - { id: l, title: L, type: text, file: a.md, anchor: sec }")); await Bun.write(join(d, "good/a.md"), "# A\n"); await Bun.write(join(d, "good/stcourse.yaml"), manifest("good", "", " - { id: l, title: L, type: text, file: a.md }")); try { const all = await loadAll([d]); expect(all.map((c) => c.id).sort()).toEqual(["bad", "good"]); const bad = all.find((c) => c.id === "bad")!; expect(bad.status).toBe("error"); expect(bad.diagnostics[0]!.message).toContain("unexpected error"); expect(all.find((c) => c.id === "good")!.status).toBe("ok"); } finally { await chmod(join(d, "bad/a.md"), 0o644); } }); test("invalid manifest gives an error course and does not throw", async () => { const d = await tmp(); await Bun.write(join(d, "stcourse.yaml"), "schema: 2\nid: x\n"); const c = await loadCourse(join(d, "stcourse.yaml")); expect(c.status).toBe("error"); expect(c.manifest).toBeNull(); expect(c.diagnostics.length).toBeGreaterThan(0); }); test("handles spaces and unicode in paths", async () => { const d = await tmp(); const rel = "S08 - Containerizing Applications/Æble notes.md"; await Bun.write(join(d, rel), "# Æble\n"); await Bun.write(join(d, "stcourse.yaml"), manifest("uni", "", ` - { id: l, title: L, type: text, file: "${rel}" }`)); const repo = await createCourseRepository([d]); expect(repo.get("uni")!.status).toBe("ok"); expect(await repo.readLessonMarkdown("uni", "l")).toBe("# Æble\n"); expect(repo.resolvePath("uni", rel)).toContain("Æble notes.md"); }); test("overlay wins over in-repo manifest with same id", async () => { const d = await tmp(); const lesson = " - { id: l, title: L, type: text, file: a.md }"; await Bun.write(join(d, "repo/a.md"), "# A\n"); await Bun.write(join(d, "repo/stcourse.yaml"), manifest("dup", "", lesson.replace("L,", "InRepo,"))); await Bun.write(join(d, "overlays/dup/stcourse.yaml"), manifest("dup", `root: ${join(d, "repo")}\n`, lesson.replace("L,", "Overlay,"))); const all = await loadAll([d]); const dup = all.filter((c) => c.id === "dup"); expect(dup).toHaveLength(1); expect(dup[0]!.manifestPath).toBe(join(d, "overlays/dup/stcourse.yaml")); const warn = dup[0]!.diagnostics.find((x) => x.level === "warning"); expect(warn!.path).toBe(join(d, "repo/stcourse.yaml")); }); test("overlay wins regardless of discovery order", async () => { const d = await tmp(); const lesson = " - { id: l, title: L, type: text, file: a.md }"; await Bun.write(join(d, "a-repo/a.md"), "# A\n"); await Bun.write(join(d, "a-repo/stcourse.yaml"), manifest("dup", "", lesson)); await Bun.write(join(d, "z-overlay/stcourse.yaml"), manifest("dup", `root: ${join(d, "a-repo")}\n`, lesson)); const all = await loadAll([d]); expect(all).toHaveLength(1); expect(all[0]!.manifestPath).toBe(join(d, "z-overlay/stcourse.yaml")); }); }); describe("resolvePath", () => { test("rejects ../, absolute paths and symlink escape", async () => { const d = await tmp(); await Bun.write(join(d, "root/a.md"), "# A\n"); await Bun.write(join(d, "outside.txt"), "secret"); await symlink("/etc", join(d, "root/etc-link")); await symlink(join(d, "outside.txt"), join(d, "root/out-link.txt")); await Bun.write(join(d, "root/stcourse.yaml"), manifest("r", "", " - { id: l, title: L, type: text, file: a.md }")); const repo = await createCourseRepository([join(d, "root")]); expect(repo.resolvePath("r", "a.md")).not.toBeNull(); expect(repo.resolvePath("r", "../outside.txt")).toBeNull(); expect(repo.resolvePath("r", join(d, "outside.txt"))).toBeNull(); expect(repo.resolvePath("r", "etc-link/passwd")).toBeNull(); expect(repo.resolvePath("r", "etc-link")).toBeNull(); expect(repo.resolvePath("r", "out-link.txt")).toBeNull(); expect(repo.resolvePath("r", "missing.md")).toBeNull(); expect(repo.resolvePath("nope", "a.md")).toBeNull(); }); });