Files
STCourses/packages/feature-ai/test/web.test.tsx
T
Lucas WintherandClaude Opus 5.5 777ae2cc37 feat: resolve Phase 1 contract requests and compose every feature
Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:02:46 +01:00

57 lines
3.0 KiB
TypeScript

import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { readNdjson } from "../src/web/api";
import { MentorBanner, Markdown } from "../src/web/components";
import { aiWeb } from "../src/web";
test("readNdjson splits events across chunk boundaries", async () => {
const text = '{"type":"meta","n":1}\n{"type":"text","text":"héllo"}\n{"type":"done"}\n';
const bytes = new TextEncoder().encode(text);
const stream = new ReadableStream<Uint8Array>({
start(c) { for (let i = 0; i < bytes.length; i += 5) c.enqueue(bytes.slice(i, i + 5)); c.close(); },
});
const out: unknown[] = [];
for await (const ev of readNdjson(stream)) out.push(ev);
expect(out).toEqual([{ type: "meta", n: 1 }, { type: "text", text: "héllo" }, { type: "done" }]);
});
test("the mentor banner says so plainly when mentor mode is off", () => {
expect(renderToString(<MentorBanner on={false} available />)).toContain("Mentor mode off: I&#x27;ll give full answers");
expect(renderToString(<MentorBanner on available />)).toBe("");
expect(renderToString(<MentorBanner on={false} available={false} />)).toBe("");
});
test("markdown renders code and text without raw HTML", () => {
const html = renderToString(<Markdown text={"Use **this**:\n\n```js\nconst a = 1 < 2\n```\n\n- one `x`\n- <b>two</b>"} />);
expect(html).toContain("<strong>this</strong>");
expect(html).toContain('<pre class="code">');
expect(html).toContain("const a = 1 &lt; 2");
expect(html).toContain("<code>x</code>");
expect(html).toContain("&lt;b&gt;two&lt;/b&gt;");
});
test("web routes", () => {
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin", roles: ["owner"] });
});
test("lesson links go through one builder", async () => {
const { lessonUrl, courseUrl } = await import("../src/web/urls");
expect(lessonUrl("sample", "m2-ex-add")).toBe("/c/sample/m2-ex-add");
expect(lessonUrl("S08 - Docker", "l 1/x")).toBe("/c/S08%20-%20Docker/l%201%2Fx");
expect(courseUrl("sample")).toBe("/c/sample");
const src = await Bun.file(new URL("../src/web/AssistantPage.tsx", import.meta.url)).text();
expect(src).toContain("lessonUrl(courseId, c.lessonId)");
expect(src).not.toMatch(/`\/c\//);
});
test("citation links use the learner's registered lesson route", async () => {
const { learnerWeb } = await import("@stc/feature-learner/web");
const { matchPath } = await import("react-router-dom");
const { lessonUrl } = await import("../src/web/urls");
const url = lessonUrl("pure snake", "m1-ü/x");
const hit = learnerWeb.routes.map((r) => matchPath(r.path, url)).find((m) => m && m.pattern.path === "/c/:courseId/:lessonId");
// matchPath leaves params encoded (the router decodes the pathname before matching).
expect(Object.fromEntries(Object.entries(hit?.params ?? {}).map(([k, v]) => [k, decodeURIComponent(v!)]))).toEqual({ courseId: "pure snake", lessonId: "m1-ü/x" });
});