- 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]>
201 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|