feat(learner): JS/TS exercise runner in a Web Worker with timeout and network blocked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
752e44696f
commit
9ff5862382
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string>;
|
||||
createWorker?: () => Worker;
|
||||
}
|
||||
|
||||
export async function serverTranspile(code: string): Promise<string> {
|
||||
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<TestResult[]>((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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
};
|
||||
`;
|
||||
@@ -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 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user