Files
STCourses/packages/course-format/test/load.test.ts
T
Lucas WintherandClaude Opus 5.5 771d755da4 fix(studio): review round 1 for imported-course editing
- Media exclusions move from the editable root .gitignore to the repo's
  info/exclude, and dirtyFiles never counts files listed in media.json, so an
  author's or remote's .gitignore cannot block publish or Sync. Publish and
  Sync refuse commits that track a file over referenced media.
- Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run
  under the course lock now shared with the studio (core withCourseLock) and
  re-check the course inside it; after conversion they fail with "this course
  is now edited in the studio". Convert answers 409 while an import job for
  the course is queued or running, and the job re-checks.
- Crash-safe conversion: the import folder is hidden (.converting-<id>) before
  the published worktree exists; cross-filesystem moves copy into a staging
  dir, fsync, verify size and sha256, then rename; the source is retired as
  .converted-<id>.<n>. Recovery runs at server start and before every convert;
  a re-run of a finished conversion reports success. The loader skips
  dot-folders.
- Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK,
  referenced media are not read whole by the editor, missing media are logged,
  mediaDir JSDoc, docs on per-author copies across filesystems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:53:50 +01:00

201 lines
10 KiB
TypeScript

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