Files
STCourses/packages/course-format/test/load.test.ts
T

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();
});
});