148 lines
7.3 KiB
TypeScript
148 lines
7.3 KiB
TypeScript
import { afterAll, describe, expect, test } from "bun:test";
|
|
import { 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<string> {
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
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("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();
|
|
});
|
|
});
|