diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index a13c159..513bc79 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -7,6 +7,7 @@ import { libraryRoutes } from "./catalog"; import { lessonRoutes } from "./lesson"; import { userDataRoutes } from "./userdata"; import { codeRoutes } from "./code"; +import { transpileRoutes } from "./transpile"; /** * The learner feature: auth, library, reader, notes, progress and exercises. @@ -30,6 +31,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic ...lessonRoutes(ctx), ...userDataRoutes(ctx), ...codeRoutes(ctx), + ...transpileRoutes(), }; // Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()). for (const methods of Object.values(routes)) { diff --git a/packages/feature-learner/src/server/transpile.ts b/packages/feature-learner/src/server/transpile.ts new file mode 100644 index 0000000..09e6aca --- /dev/null +++ b/packages/feature-learner/src/server/transpile.ts @@ -0,0 +1,27 @@ +import type { Feature } from "@stc/contracts"; +import { badRequest, body, json } from "./http"; + +const MAX = 200_000; +const ts = new Bun.Transpiler({ loader: "ts", target: "browser" }); + +/** Type-strips TypeScript for the in-browser runner (the worker itself runs plain JavaScript). */ +export function transpileTs(code: string): string { + return ts.transformSync(code); +} + +export function transpileRoutes(): Feature["routes"] { + return { + "/api/learner/transpile": { + POST: async (req) => { + const b = await body<{ code?: string }>(req); + if (!b || typeof b.code !== "string") return badRequest("code is required"); + if (b.code.length > MAX) return badRequest("code is too long"); + try { + return json({ code: transpileTs(b.code) }); + } catch (e) { + return json({ error: (e as Error).message }, 422); + } + }, + }, + }; +} diff --git a/packages/feature-learner/src/web/runner/runner.ts b/packages/feature-learner/src/web/runner/runner.ts new file mode 100644 index 0000000..793f596 --- /dev/null +++ b/packages/feature-learner/src/web/runner/runner.ts @@ -0,0 +1,73 @@ +import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts"; +import { WORKER_SOURCE } from "./worker-source"; + +export interface RunnerOptions { + /** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */ + transpile?: (code: string) => Promise; + createWorker?: () => Worker; +} + +export async function serverTranspile(code: string): Promise { + const r = await fetch("/api/learner/transpile", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code }) }); + const b = (await r.json()) as { code?: string; error?: string }; + if (!r.ok || typeof b.code !== "string") throw new Error(b.error ?? "TypeScript could not be compiled"); + return b.code; +} + +const defaultWorker = () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }); + +/** + * Runs learner code against a tests module in a fresh Web Worker (never in the page), then terminates it. + * When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out. + */ +export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner { + const transpile = opts.transpile ?? serverTranspile; + const make = opts.createWorker ?? defaultWorker; + return { + language, + async run(learnerCode, testsModule, timeoutMs) { + let code = learnerCode, tests = testsModule; + if (language === "ts") { + try { code = await transpile(learnerCode); } catch (e) { + return [{ name: "Your code did not compile", passed: false, message: (e as Error).message }]; + } + tests = await transpile(testsModule); + } + const worker = make(); + return new Promise((resolve) => { + let names: string[] = []; + const results: TestResult[] = []; + let settled = false; + const finish = (extra: TestResult[] = []) => { + if (settled) return; + settled = true; + clearTimeout(timer); + worker.terminate(); + resolve([...results, ...extra]); + }; + const pending = (message: string) => names.slice(results.length).map((name) => ({ name, passed: false, message })); + const timer = setTimeout(() => { + const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`); + finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]); + }, timeoutMs); + worker.onmessage = (ev: MessageEvent) => { + const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string }; + if (m.type === "tests") names = m.names ?? []; + else if (m.type === "result" && m.result) results.push(m.result); + else if (m.type === "done") finish(); + else if (m.type === "error") { + if (m.stage === "code") { + const rest = pending(`Your code did not load. ${m.message}`); + finish(rest.length ? rest : [{ name: "Your code did not load", passed: false, message: m.message }]); + } else finish([{ name: "The tests did not load", passed: false, message: m.message }]); + } + }; + worker.onerror = (ev: ErrorEvent) => { + ev.preventDefault?.(); + finish(pending(ev.message || "The exercise worker crashed")); + }; + worker.postMessage({ code, tests }); + }); + }, + }; +} diff --git a/packages/feature-learner/src/web/runner/worker-source.ts b/packages/feature-learner/src/web/runner/worker-source.ts new file mode 100644 index 0000000..c31fd75 --- /dev/null +++ b/packages/feature-learner/src/web/runner/worker-source.ts @@ -0,0 +1,104 @@ +/** + * Source of the exercise Web Worker, kept as a string so it can be started from a blob URL without a + * separate bundle. Protocol (worker → page): {type:"tests", names}, {type:"result", result}, {type:"done"}, + * {type:"error", stage:"tests"|"code", message}. Page → worker: {code, tests} (plain JavaScript ES modules). + */ +export const WORKER_SOURCE = String.raw` +"use strict"; +const blocked = () => { throw new Error("Network access is turned off while exercises run"); }; +for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts"]) { + try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {} +} + +class AssertionError extends Error { + constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; } +} + +function fmt(v, seen = new Set()) { + if (v === undefined) return "undefined"; + if (v === null) return "null"; + if (typeof v === "string") return JSON.stringify(v); + if (typeof v === "number") return Object.is(v, -0) ? "-0" : String(v); + if (typeof v === "bigint") return v + "n"; + if (typeof v === "function") return "[Function " + (v.name || "anonymous") + "]"; + if (typeof v === "symbol") return v.toString(); + if (typeof v !== "object") return String(v); + if (seen.has(v)) return "[Circular]"; + seen.add(v); + let out; + if (Array.isArray(v)) out = "[" + v.map((x) => fmt(x, seen)).join(", ") + "]"; + else if (v instanceof Map) out = "Map {" + [...v].map(([k, x]) => fmt(k, seen) + " => " + fmt(x, seen)).join(", ") + "}"; + else if (v instanceof Set) out = "Set {" + [...v].map((x) => fmt(x, seen)).join(", ") + "}"; + else if (v instanceof Date) out = "Date(" + v.toISOString() + ")"; + else if (v instanceof Error) out = v.name + ": " + v.message; + else out = "{" + Object.keys(v).map((k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)) + ": " + fmt(v[k], seen)).join(", ") + "}"; + seen.delete(v); + return out; +} + +function deepEq(a, b, seen = new Map()) { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; + if (seen.get(a) === b) return true; + seen.set(a, b); + if (a instanceof Date) return a.getTime() === b.getTime(); + if (a instanceof Map || a instanceof Set) { + if (a.size !== b.size) return false; + if (a instanceof Set) { for (const x of a) if (!b.has(x)) return false; return true; } + for (const [k, x] of a) if (!b.has(k) || !deepEq(x, b.get(k), seen)) return false; + return true; + } + const ka = Object.keys(a), kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEq(a[k], b[k], seen)) return false; + return true; +} + +const assert = { + equal(actual, expected, message) { + if (!Object.is(actual, expected) && !(actual === expected)) throw new AssertionError(message || "Expected values to be strictly equal", expected, actual); + }, + deepEqual(actual, expected, message) { + if (!deepEq(actual, expected)) throw new AssertionError(message || "Expected values to be deeply equal", expected, actual); + }, + ok(value, message) { + if (!value) throw new AssertionError(message || "Expected a truthy value", true, value); + }, +}; + +const load = (src) => import(URL.createObjectURL(new Blob([src], { type: "text/javascript" }))); +const describe = (e) => (e && e.name && e.message ? e.name + ": " + e.message : String(e)); + +self.onmessage = async (ev) => { + const { code, tests } = ev.data; + let list; + try { + const t = await load(tests); + list = Array.isArray(t.tests) ? t.tests : Array.isArray(t.default) ? t.default : null; + if (!list) throw new Error("the tests module must export \"tests\" (an array)"); + } catch (e) { + self.postMessage({ type: "error", stage: "tests", message: describe(e) }); + return; + } + self.postMessage({ type: "tests", names: list.map((t, i) => String(t && t.name || "test " + (i + 1))) }); + let mod; + try { mod = await load(code); } catch (e) { + self.postMessage({ type: "error", stage: "code", message: describe(e) }); + return; + } + for (let i = 0; i < list.length; i++) { + const t = list[i]; + const name = String(t && t.name || "test " + (i + 1)); + try { + await t.run(mod, assert); + self.postMessage({ type: "result", result: { name, passed: true } }); + } catch (e) { + const r = { name, passed: false, message: describe(e) }; + if (e instanceof AssertionError && e.hasValues) { r.expected = fmt(e.expected); r.actual = fmt(e.actual); r.message = e.message; } + self.postMessage({ type: "result", result: r }); + } + } + self.postMessage({ type: "done" }); +}; +`; diff --git a/packages/feature-learner/test/runner.test.ts b/packages/feature-learner/test/runner.test.ts new file mode 100644 index 0000000..0fd1724 --- /dev/null +++ b/packages/feature-learner/test/runner.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createBrowserRunner } from "../src/web/runner/runner"; +import { transpileTs } from "../src/server/transpile"; +import { FIXTURES } from "./app"; + +const ex = (f: string) => readFileSync(join(FIXTURES, "sample/exercises/add", f), "utf8"); +const js = createBrowserRunner("js"); + +test("the sample starter fails with expected and actual values", async () => { + const r = await js.run(ex("starter.js"), ex("tests.js"), 5000); + expect(r).toEqual([ + { name: "adds 2 and 3", passed: false, message: "Expected values to be strictly equal", expected: "5", actual: "-1" }, + { name: "adds -1 and 1", passed: false, message: "Expected values to be strictly equal", expected: "0", actual: "-2" }, + ]); +}); + +test("the sample exercise passes after editing", async () => { + const edited = ex("starter.js").replace("a - b", "a + b"); + expect((await js.run(edited, ex("tests.js"), 5000)).every((t) => t.passed)).toBe(true); +}); + +test("syntax errors and missing exports are reported per test", async () => { + const r = await js.run("export function add(a, b) { return a +", ex("tests.js"), 5000); + expect(r.length).toBe(2); + expect(r[0]!.passed).toBe(false); + expect(r[0]!.message).toContain("did not load"); + const r2 = await js.run("export const nope = 1", ex("tests.js"), 5000); + expect(r2[0]!.message).toContain("TypeError"); +}); + +test("an endless loop is killed by the timeout", async () => { + const t0 = Date.now(); + const r = await js.run("export function add() { while (true) {} }", ex("tests.js"), 400); + expect(Date.now() - t0).toBeLessThan(3000); + expect(r.map((x) => x.passed)).toEqual([false, false]); + expect(r[0]!.message).toContain("Timed out"); +}); + +test("network access is blocked inside the worker", async () => { + const tests = `export const tests = [{ name: "net", run: async (m, assert) => assert.equal(await m.probe(), "blocked") }];`; + const code = `export async function probe() { try { await fetch("http://127.0.0.1:1/"); return "open"; } catch (e) { return String(e.message).includes("turned off") ? "blocked" : "other " + e.message; } }`; + expect((await js.run(code, tests, 3000))[0]!.passed).toBe(true); +}); + +test("deepEqual compares structures", async () => { + const tests = `export const tests = [ + { name: "same", run: (m, a) => a.deepEqual(m.v(), { x: [1, 2], y: { z: null } }) }, + { name: "diff", run: (m, a) => a.deepEqual(m.v(), { x: [1, 3] }) }, + ];`; + const r = await js.run("export const v = () => ({ x: [1, 2], y: { z: null } })", tests, 3000); + expect(r[0]!.passed).toBe(true); + expect(r[1]).toMatchObject({ passed: false, expected: "{x: [1, 3]}", actual: "{x: [1, 2], y: {z: null}}" }); +}); + +test("TypeScript exercises are transpiled first", async () => { + const runner = createBrowserRunner("ts", { transpile: async (c) => transpileTs(c) }); + const tests = `import type { X } from "./x"; export const tests = [{ name: "typed", run: (m: any, a: any) => a.equal(m.add(1, 2), 3) }];`; + const r = await runner.run("export function add(a: number, b: number): number { return a + b; }", tests, 3000); + expect(r).toEqual([{ name: "typed", passed: true }]); +});