From 9145607f9d26b4e9f7019417c94b7ae4414e39b1 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 9 Oct 2026 23:26:43 +0200 Subject: [PATCH 1/3] fix: pin the guide compiler and validate lesson output Pin browser compilation and lesson validation to ReScript v12.3.1. Compile each starter and reference solution during prerendering, then compare its runtime output with the checkpoint using the shared guide transformations. Keep reference solutions out of published lesson data, fail builds on compiler CDN errors, and show stalled browser compiler loading with a reload message. Add real compiler validation and browser failure coverage. Fixes #1405. Part of #1421. --- apps/guide/README.md | 73 ++++--- .../__tests__/GuideCompilerLoading_.test.res | 46 +++++ .../__tests__/GuideCompilerSettings_.test.res | 16 +- apps/guide/__tests__/compiler/lessons.test.js | 193 ++++++++++++++++++ .../guide/__tests__/compilerLoadingFixture.js | 29 +++ apps/guide/app/GuideCompilerBridge.res | 9 +- apps/guide/app/GuideCompilerBridgeHook.res | 50 +++-- apps/guide/app/GuideCompilerData.res | 39 +--- apps/guide/app/GuideCompilerSettings.res | 49 +---- apps/guide/app/GuideHome.res | 8 +- apps/guide/app/GuideHomeRoute.res | 7 +- apps/guide/app/GuideHomeRoute.resi | 2 +- apps/guide/app/GuideLessonValidation.res | 131 ++++++++++++ apps/guide/app/GuideRuntimeProgram.res | 15 ++ apps/guide/app/lessons/02-functions.mdx | 4 + apps/guide/package.json | 6 +- apps/guide/scripts/lesson-compiler.mjs | 121 +++++++++++ apps/guide/vitest.compiler.config.mjs | 10 + yarn.lock | 1 + 19 files changed, 647 insertions(+), 162 deletions(-) create mode 100644 apps/guide/__tests__/GuideCompilerLoading_.test.res create mode 100644 apps/guide/__tests__/compiler/lessons.test.js create mode 100644 apps/guide/__tests__/compilerLoadingFixture.js create mode 100644 apps/guide/app/GuideLessonValidation.res create mode 100644 apps/guide/app/GuideRuntimeProgram.res create mode 100644 apps/guide/scripts/lesson-compiler.mjs create mode 100644 apps/guide/vitest.compiler.config.mjs diff --git a/apps/guide/README.md b/apps/guide/README.md index 4dd962e6e..74ed6fd8a 100644 --- a/apps/guide/README.md +++ b/apps/guide/README.md @@ -9,31 +9,33 @@ contract only. Lesson copy belongs to the guide author. ## Layout -| Path | Purpose | -| --------------------------------- | ------------------------------------------------ | -| `app/lessons/` | Lesson MDX files and metadata | -| `app/GuideLessonContent.res` | Build-time lesson discovery and validation | -| `app/GuideHome.res` | Guide workspace UI | -| `app/GuideCompilerBridgeHook.res` | Browser compiler and execution integration | -| `styles/main.css` | Guide-only visual system and responsive behavior | -| `__tests__/` | Browser and unit coverage for the guide | +| Path | Purpose | +| --------------------------------- | -------------------------------------------------- | +| `app/lessons/` | Lesson MDX files and metadata | +| `app/GuideLessonContent.res` | Build-time lesson discovery and validation | +| `app/GuideHome.res` | Guide workspace UI | +| `app/GuideCompilerBridgeHook.res` | Browser compiler and execution integration | +| `app/GuideLessonValidation.res` | Build-time compilation and reference output checks | +| `styles/main.css` | Guide-only visual system and responsive behavior | +| `__tests__/` | Browser and unit coverage for the guide | ## Lesson Contract Every `.mdx` file under `app/lessons/` is discovered recursively and ordered by its integer `position`. The build requires these frontmatter fields: -| Field | Required | Meaning | -| ------------------------- | -------- | ------------------------------------------------------ | -| `position` | Yes | Integer lesson order | -| `id` | Yes | Stable URL-safe hash and lesson identifier | -| `missionLabel` | Yes | Short lesson label | -| `title` | Yes | Lesson heading | -| `description` | Yes | Lesson summary used by the lesson model | -| `exercise.id` | Yes | Stable identifier for saved code and completion | -| `exercise.title` | Yes | Exercise label | -| `exercise.initialCode` | Yes | Initial editor contents | -| `exercise.expectedOutput` | No | Matching runtime log line that unlocks the next lesson | +| Field | Required | Meaning | +| ------------------------- | -------- | -------------------------------------------------------- | +| `position` | Yes | Integer lesson order | +| `id` | Yes | Stable URL-safe hash and lesson identifier | +| `missionLabel` | Yes | Short lesson label | +| `title` | Yes | Lesson heading | +| `description` | Yes | Lesson summary used by the lesson model | +| `exercise.id` | Yes | Stable identifier for saved code and completion | +| `exercise.title` | Yes | Exercise label | +| `exercise.initialCode` | Yes | Initial editor contents | +| `exercise.expectedOutput` | Yes | Matching runtime log line that unlocks the next lesson | +| `exercise.solutionCode` | No | Build-only reference solution; defaults to `initialCode` | Lesson IDs determine durable hash-based deep links. Renaming a published lesson ID requires a redirect or an explicit decision to let existing deep links fall @@ -46,9 +48,17 @@ decision to discard existing learner progress. Lesson IDs must be URL-safe slugs made from lowercase ASCII letters, digits, and hyphens. Exercise IDs may use the established slash-delimited form. -An exercise without `expectedOutput` is parsed as a manual check, but manual -checks are not currently completable. Published lessons therefore need a -deterministic `expectedOutput` until another completion mechanism exists. +Every published exercise needs a deterministic `expectedOutput` string. The +build compiles `initialCode` and `solutionCode`, runs the reference solution, +and requires a matching runtime log line. Matching preserves whitespace and +uses the same argument serialization as the browser checkpoint. + +When the learner must edit the starter to reach the checkpoint, supply +`exercise.solutionCode`. Otherwise it defaults to `initialCode`. Reference +solutions are only read during validation and are excluded from published +lesson data. Solutions must produce synchronous output and finish within one +second; browser-only APIs and asynchronous exercises need a separate validation +strategy before they can be authored. ## Local Development @@ -71,10 +81,20 @@ yarn build:guide yarn workspace @rescript-lang/guide ci:test ``` -`yarn build:guide` compiles ReScript, pre-renders `/`, and copies the client -assets to `apps/guide/out/` for Wrangler. The guide fetches available compiler -versions from `https://cdn.rescript-lang.org` during pre-rendering; the -interactive compiler and runtime then run in the learner's browser. +`yarn build:guide` compiles ReScript, validates the lessons during pre-rendering, +and copies the client assets to `apps/guide/out/` for Wrangler. Validation uses +the actual browser compiler and its libraries from `https://cdn.rescript-lang.org`. +A failed download, compilation, execution, or output check stops the build. +`PLAYGROUND_BUNDLE_ENDPOINT` can override the CDN base URL. + +The guide pins ReScript **v12.3.1** in `app/GuideCompilerSettings.res`. Both +validation and browser execution use that version and its runtime files; new +CDN releases cannot silently change lessons. To upgrade, change the pin and run +`yarn workspace @rescript-lang/guide test:compiler` and `yarn build:guide`. +The guide CI command runs the compiler checks before the browser tests. + +In the browser, the output panel reports a failed compiler download or a +15-second loading timeout with instructions to reload. ## Current Capability @@ -95,6 +115,7 @@ show a stop screen rather than a usable guide. Before launch, complete and verify the following: - [ ] Author, review, and run every lesson using its real expected output. +- [x] Compile starters and check reference solution output against a pinned compiler. - [ ] Add a content-validation test for the complete lesson set, including unique `position`, lesson IDs, and exercise IDs. - [ ] Decide and implement the completion model for exercises that cannot use diff --git a/apps/guide/__tests__/GuideCompilerLoading_.test.res b/apps/guide/__tests__/GuideCompilerLoading_.test.res new file mode 100644 index 000000000..ff56f9fd6 --- /dev/null +++ b/apps/guide/__tests__/GuideCompilerLoading_.test.res @@ -0,0 +1,46 @@ +open Vitest + +@module("./compilerLoadingFixture.js") +external interceptCompilerScripts: bool => unit = "interceptCompilerScripts" + +@module("./compilerLoadingFixture.js") +external expireCompilerLoading: unit => unit = "expireCompilerLoading" + +let renderWithCompiler = () => + render( + + + , + ) + +test("shows a compiler loading failure in the guide output", async () => { + await viewport(1440, 900) + interceptCompilerScripts(true) + let screen = await renderWithCompiler() + await (await screen->getByText("Compiler setup failed"))->element->toBeVisible + await ( + await screen->getByText( + "Could not load compiler from url https://guide-compiler.test/v12.3.1/compiler.js", + ) + ) + ->element + ->toBeVisible +}) + +test("shows a useful message when the compiler CDN never responds", async () => { + await viewport(1440, 900) + interceptCompilerScripts(false) + expireCompilerLoading() + let screen = await renderWithCompiler() + await (await screen->getByText("Compiler setup failed"))->element->toBeVisible + await ( + await screen->getByText( + "ReScript v12.3.1 did not load within 15 seconds. Reload the page to try again.", + ) + ) + ->element + ->toBeVisible +}) diff --git a/apps/guide/__tests__/GuideCompilerSettings_.test.res b/apps/guide/__tests__/GuideCompilerSettings_.test.res index 853643400..a902d271d 100644 --- a/apps/guide/__tests__/GuideCompilerSettings_.test.res +++ b/apps/guide/__tests__/GuideCompilerSettings_.test.res @@ -1,18 +1,8 @@ open Vitest -test("selects the latest stable ReScript compiler with ESM output", async () => { - let version = - GuideCompilerSettings.latestStableVersion([ - "v12.1.0-alpha.1", - "v11.1.4", - "v12.0.0", - "v12.2.0-beta.1", - "v12.1.3", - ]) - ->Option.getOrThrow - ->Semver.toString - - expect(version)->toBe("v12.1.3") +test("pins the guide to its validated v12 compiler with ESM output", async () => { + expect(GuideCompilerSettings.version)->toBe("v12.3.1") + expect(GuideCompilerSettings.parsedVersion->Semver.toString)->toBe(GuideCompilerSettings.version) expect(GuideCompilerSettings.moduleSystem)->toBe("esmodule") expect(GuideCompilerSettings.warnFlags->String.includes("-109"))->toBe(true) }) diff --git a/apps/guide/__tests__/compiler/lessons.test.js b/apps/guide/__tests__/compiler/lessons.test.js new file mode 100644 index 000000000..de963f226 --- /dev/null +++ b/apps/guide/__tests__/compiler/lessons.test.js @@ -0,0 +1,193 @@ +import { beforeAll, afterEach, expect, test, vi } from "vitest"; +import { loadCompiler, runProgram } from "../../scripts/lesson-compiler.mjs"; +import * as Settings from "../../app/GuideCompilerSettings.jsx"; +import * as Validation from "../../app/GuideLessonValidation.jsx"; +import * as Content from "../../app/GuideLessonContent.jsx"; + +const bundleBaseUrl = "https://cdn.rescript-lang.org"; +let compiler; +beforeAll(async () => { + compiler = await loadCompiler( + bundleBaseUrl, + Settings.version, + Settings.moduleSystem, + Settings.warnFlags, + ); +}, 60000); +afterEach(() => vi.unstubAllGlobals()); + +function exercise(overrides = {}) { + return { + sourcePath: "test-lesson.mdx", + initialCode: 'let greeting = "hello"', + solutionCode: 'let greeting = "hello"', + expectedOutput: "hello", + ...overrides, + }; +} + +test("the pinned browser compiler validates every real lesson", async () => { + expect(compiler.version).toBe(Settings.version.slice(1)); + await expect(Validation.validate(bundleBaseUrl)).resolves.toBeUndefined(); + const published = JSON.stringify(Content.load()); + expect(published).not.toContain("solutionCode"); + expect(published).not.toContain('greet(\\"Spock\\")'); +}); + +test("invalid starter code fails even when its reference solution is valid", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ initialCode: "let x =" }), + ), + ).rejects.toThrow(/test-lesson\.mdx exercise\.initialCode does not compile/); +}); + +test("invalid reference solutions identify the file and field", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ solutionCode: "let x: int = false" }), + ), + ).rejects.toThrow(/test-lesson\.mdx exercise\.solutionCode does not compile/); +}); + +test("output mismatches fail with the expected and actual strings", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ expectedOutput: "hello " }), + ), + ).rejects.toThrow( + 'exercise.expectedOutput "hello " did not match solution output "hello"', + ); +}); + +test("runtime failures identify the reference solution", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ solutionCode: 'JsError.throwWithMessage("boom")' }), + ), + ).rejects.toThrow( + /test-lesson\.mdx exercise\.solutionCode failed at runtime: boom/, + ); +}); + +test("solutions execute against the pinned compiler's published runtime imports", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ + solutionCode: "let values = [1, 2]->Array.map(value => value + 1)", + expectedOutput: "[2,3]", + }), + ), + ).resolves.toBeUndefined(); +}); + +test("final expressions use the browser's source instrumentation", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ + solutionCode: 'let greeting = "starter"\nInt.toString(42)', + expectedOutput: "42", + }), + ), + ).resolves.toBeUndefined(); +}); + +test("multiple console arguments match the browser checkpoint serialization", async () => { + await expect( + Validation.validateExercise( + compiler, + bundleBaseUrl, + exercise({ + solutionCode: 'Console.log2("answer", 42)', + expectedOutput: "answer 42", + }), + ), + ).resolves.toBeUndefined(); +}); + +test("runaway solutions stop with a timeout", async () => { + await expect( + runProgram({ code: "while (true) {}", imports: {} }), + ).rejects.toThrow(/timed out/); +}); + +test("missing compiler assets fail with the URL and HTTP status", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("missing", { status: 404 })), + ); + await expect( + loadCompiler( + "https://missing-guide.test", + Settings.version, + Settings.moduleSystem, + Settings.warnFlags, + ), + ).rejects.toThrow( + `https://missing-guide.test/${Settings.version}/compiler.js: HTTP 404`, + ); +}); + +test("a bundle claiming a different compiler version is rejected", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async (url) => + new Response( + url.endsWith("/compiler.js") + ? 'globalThis.rescript_compiler = {make: () => ({version: "13.0.0"})};' + : "", + ), + ), + ); + await expect( + loadCompiler( + "https://wrong-guide.test", + Settings.version, + Settings.moduleSystem, + Settings.warnFlags, + ), + ).rejects.toThrow( + `Expected guide compiler ${Settings.version}, received 13.0.0`, + ); +}); + +test.each([undefined, 42, false])( + "reference validation rejects missing or non-string expected output: %s", + (expectedOutput) => { + const raw = `---\nexercise:\n initialCode: let x = 1\n${expectedOutput === undefined ? "" : ` expectedOutput: ${expectedOutput}\n`}---\n`; + expect(() => Validation.fromRaw(raw, "broken.mdx")).toThrow( + "broken.mdx exercise.expectedOutput must be a string", + ); + }, +); + +test("reference code defaults to the starter and preserves an empty expected output", () => { + const parsed = Validation.fromRaw( + '---\nexercise:\n initialCode: let x = ""\n expectedOutput: ""\n---\n', + "empty.mdx", + ); + expect(parsed.solutionCode).toBe(parsed.initialCode); + expect(parsed.expectedOutput).toBe(""); +}); + +test("empty reference code is an authoring error", () => { + expect(() => + Validation.fromRaw( + '---\nexercise:\n initialCode: let x = 1\n expectedOutput: "1"\n solutionCode: ""\n---\n', + "empty.mdx", + ), + ).toThrow("empty.mdx exercise.solutionCode must be a non-empty string"); +}); diff --git a/apps/guide/__tests__/compilerLoadingFixture.js b/apps/guide/__tests__/compilerLoadingFixture.js new file mode 100644 index 000000000..bbab7ce3c --- /dev/null +++ b/apps/guide/__tests__/compilerLoadingFixture.js @@ -0,0 +1,29 @@ +import { afterEach, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// Intercept script insertion rather than loading a remote compiler in UI failure tests. +export function interceptCompilerScripts(fail) { + const appendChild = document.body.appendChild.bind(document.body); + vi.spyOn(document.body, "appendChild").mockImplementation((node) => { + if ( + node instanceof HTMLScriptElement && + node.src.includes("/compiler.js") + ) { + if (fail) queueMicrotask(() => node.dispatchEvent(new Event("error"))); + return node; + } + return appendChild(node); + }); +} + +export function expireCompilerLoading() { + // Advance only the guide's loading deadline; leave editor and test-library timers real. + const setTimeout = window.setTimeout.bind(window); + vi.spyOn(window, "setTimeout").mockImplementation( + (callback, delay, ...args) => + setTimeout(callback, delay === 15000 ? 0 : delay, ...args), + ); +} diff --git a/apps/guide/app/GuideCompilerBridge.res b/apps/guide/app/GuideCompilerBridge.res index 18072bd76..fe0698018 100644 --- a/apps/guide/app/GuideCompilerBridge.res +++ b/apps/guide/app/GuideCompilerBridge.res @@ -1,18 +1,11 @@ @react.component let make = ( ~bundleBaseUrl, - ~versions: array, ~code, ~editorRef: React.ref>, ~setOutput, ) => { - GuideCompilerBridgeHook.useCompilerBridge( - ~bundleBaseUrl, - ~versions, - ~code, - ~editorRef, - ~setOutput, - ) + GuideCompilerBridgeHook.useCompilerBridge(~bundleBaseUrl, ~code, ~editorRef, ~setOutput)
diff --git a/apps/guide/app/GuideCompilerBridgeHook.res b/apps/guide/app/GuideCompilerBridgeHook.res index 2b19d1a1b..6efa9c34e 100644 --- a/apps/guide/app/GuideCompilerBridgeHook.res +++ b/apps/guide/app/GuideCompilerBridgeHook.res @@ -1,22 +1,14 @@ let useCompilerBridge = ( ~bundleBaseUrl, - ~versions, ~code, ~editorRef: React.ref>, ~setOutput, ) => { - let compilerVersions = React.useMemo( - () => GuideCompilerSettings.supportedVersions(versions), - [versions], - ) - let initialVersion = React.useMemo( - () => compilerVersions->GuideCompilerSettings.latestStableParsedVersion, - [compilerVersions], - ) + let compilerVersions = React.useMemo(() => [GuideCompilerSettings.parsedVersion], []) let (compilerState, compilerDispatch) = CompilerManagerHook.useCompilerManager( ~bundleBaseUrl, - ~initialVersion?, + ~initialVersion=GuideCompilerSettings.parsedVersion, ~initialModuleSystem=GuideCompilerSettings.moduleSystem, ~initialWarnFlags=GuideCompilerSettings.warnFlags, ~syncUrl=false, @@ -27,6 +19,27 @@ let useCompilerBridge = ( let lastExecutedJsCode = React.useRef("") let isWaitingForRuntimeOutput = React.useRef(false) + React.useEffect(() => { + switch compilerState { + | Init => + let timer = setTimeout( + ~handler=() => + setOutput( + _ => + GuideCompilerFeedback.Output.make( + ~status="Compiler setup failed", + ~diagnostics=[ + `ReScript ${GuideCompilerSettings.version} did not load within 15 seconds. Reload the page to try again.`, + ], + ), + ), + ~timeout=GuideCompilerSettings.loadingTimeoutMs, + ) + Some(() => clearTimeout(timer)) + | _ => None + } + }, (compilerState, setOutput)) + React.useEffect(() => { switch compilerState { | Ready({targetLang}) if code !== lastCompiledCode.current => @@ -83,18 +96,11 @@ let useCompilerBridge = ( | Ready({selected, result: Comp(Success({jsCode, typeHints}))}) if jsCode !== lastExecutedJsCode.current => lastExecutedJsCode.current = jsCode - let runtimeJsCode = switch GuideRuntimeSource.instrument(~code, ~typeHints) { - | Some(runtimeCode) => - // The instrumented source may fail on compiler internals; fall back to user JS in that case. - switch selected.instance->RescriptCompilerApi.Compiler.resCompile(runtimeCode) { - | Success({jsCode}) => jsCode - | Fail(_) | UnexpectedError(_) | Unknown(_, _) => jsCode - } - | None => jsCode - } - - switch runtimeJsCode->GuideRuntimeTransform.transform( - ~resultBindingName=GuideRuntimeSource.resultBindingName, + switch GuideRuntimeProgram.fromCompilation( + ~compiler=selected.instance, + ~code, + ~jsCode, + ~typeHints, ) { | Some({code: runtimeCode, imports}) => isWaitingForRuntimeOutput.current = true diff --git a/apps/guide/app/GuideCompilerData.res b/apps/guide/app/GuideCompilerData.res index d4a32be91..9ab9bc235 100644 --- a/apps/guide/app/GuideCompilerData.res +++ b/apps/guide/app/GuideCompilerData.res @@ -1,43 +1,10 @@ -type t = { - bundleBaseUrl: string, - versions: array, -} +type t = {bundleBaseUrl: string} module Env = { - @scope(("process", "env")) external nodeEnv: string = "NODE_ENV" @scope(("process", "env")) external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" } -let fetchVersions = async versionsBaseUrl => { - let response = await fetch(versionsBaseUrl ++ "/playground-bundles/versions.json") - let json = await WebAPI.Response.json(response) - json - ->JSON.Decode.array - ->Option.getOrThrow - ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) -} - -let load = async () => { - let (bundleBaseUrl, versionsBaseUrl) = switch (Env.playgroundBundleEndpoint, Env.nodeEnv) { - | (Some(baseUrl), _) => (baseUrl, baseUrl) - | (None, "development") => - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - | (None, _) => - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - } - - try { - let versions = await fetchVersions(versionsBaseUrl) - Some({ - bundleBaseUrl, - versions, - }) - } catch { - | JsExn(error) => - Console.error2("error while fetching guide compiler versions", error) - None - } +let load = () => { + bundleBaseUrl: Env.playgroundBundleEndpoint->Option.getOr("https://cdn.rescript-lang.org"), } diff --git a/apps/guide/app/GuideCompilerSettings.res b/apps/guide/app/GuideCompilerSettings.res index 843d36835..52bdebc67 100644 --- a/apps/guide/app/GuideCompilerSettings.res +++ b/apps/guide/app/GuideCompilerSettings.res @@ -1,47 +1,6 @@ +// Upgrade deliberately, together with the lesson validation run. +let version = "v12.3.1" +let parsedVersion = version->Semver.parse->Option.getOrThrow let moduleSystem = "esmodule" let warnFlags = "+a-4-9-20-40-41-42-50-61-102-109" - -let isSupportedVersion = (version: Semver.t) => - switch version.major { - | 8 | 9 => false - | 10 => version.minor >= 1 - | 11 => version.minor >= 1 && version.preRelease->Option.isNone - | 12 => - switch version.preRelease { - | None => true - | Some(_) => version.minor > 1 - } - | _ => true - } - -let compareIntDescending = (a, b) => { - if a > b { - -1.0 - } else if a < b { - 1.0 - } else { - 0.0 - } -} - -let compareVersionDescending = (a: Semver.t, b: Semver.t) => { - switch compareIntDescending(a.major, b.major) { - | 0.0 => - switch compareIntDescending(a.minor, b.minor) { - | 0.0 => compareIntDescending(a.patch, b.patch) - | result => result - } - | result => result - } -} - -let supportedVersions = versions => - versions - ->Array.filterMap(Semver.parse) - ->Array.filter(isSupportedVersion) - ->Array.toSorted(compareVersionDescending) - -let latestStableParsedVersion = (versions: array) => - versions->Array.find(version => version.preRelease->Option.isNone) - -let latestStableVersion = versions => versions->supportedVersions->latestStableParsedVersion +let loadingTimeoutMs = 15000 diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res index 250e6dbaf..5d0197970 100644 --- a/apps/guide/app/GuideHome.res +++ b/apps/guide/app/GuideHome.res @@ -128,13 +128,9 @@ let make = (
{switch compilerData { - | Some({bundleBaseUrl, versions}) => + | Some({bundleBaseUrl}) => | None => React.null }} diff --git a/apps/guide/app/GuideHomeRoute.res b/apps/guide/app/GuideHomeRoute.res index 1f36b5179..a63341abb 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -1,12 +1,13 @@ type loaderData = { - compilerData: option, + compilerData: GuideCompilerData.t, lessons: array, } exception LessonLoadError(string) let loader: ReactRouter.Loader.t = async _ => { - let compilerData = await GuideCompilerData.load() + let compilerData = GuideCompilerData.load() + await GuideLessonValidation.validate(~bundleBaseUrl=compilerData.bundleBaseUrl) let lessons = switch GuideLessonContent.load() { | Ok(lessons) => lessons | Error(message) => throw(LessonLoadError(message)) @@ -18,5 +19,5 @@ let loader: ReactRouter.Loader.t = async _ => { @react.component let default = () => { let {compilerData, lessons}: loaderData = ReactRouter.useLoaderData() - + } diff --git a/apps/guide/app/GuideHomeRoute.resi b/apps/guide/app/GuideHomeRoute.resi index 380c80ddc..321b35251 100644 --- a/apps/guide/app/GuideHomeRoute.resi +++ b/apps/guide/app/GuideHomeRoute.resi @@ -1,5 +1,5 @@ type loaderData = { - compilerData: option, + compilerData: GuideCompilerData.t, lessons: array, } diff --git a/apps/guide/app/GuideLessonValidation.res b/apps/guide/app/GuideLessonValidation.res new file mode 100644 index 000000000..5860fc56a --- /dev/null +++ b/apps/guide/app/GuideLessonValidation.res @@ -0,0 +1,131 @@ +// Build-only authoring data: reference solutions never enter the route's loader data. +type exercise = { + sourcePath: string, + initialCode: string, + solutionCode: string, + expectedOutput: string, +} + +@module("../scripts/lesson-compiler.mjs") +external loadCompiler: ( + ~bundleBaseUrl: string, + ~version: string, + ~moduleSystem: string, + ~warnFlags: string, +) => promise = "loadCompiler" + +@module("../scripts/lesson-compiler.mjs") +external runProgram: GuideRuntimeTransform.t => promise< + array, +> = "runProgram" + +let fail = message => JsError.throwWithMessage(message) + +let readString = (~dict, ~sourcePath, ~key, ~allowEmpty=false) => + switch dict->Dict.get(key) { + | Some(JSON.String(value)) if allowEmpty || value->String.trim !== "" => value + | _ => + fail( + `Guide lesson ${sourcePath} exercise.${key} must be ${allowEmpty + ? "a string" + : "a non-empty string"}.`, + ) + } + +let fromRaw = (~raw, ~sourcePath) => { + let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw) + let dict = + frontmatter + ->JSON.Decode.object + ->Option.flatMap(dict => dict->Dict.get("exercise")) + ->Option.flatMap(JSON.Decode.object) + switch dict { + | None => fail(`Guide lesson ${sourcePath} must have exercise frontmatter.`) + | Some(dict) => + let initialCode = readString(~dict, ~sourcePath, ~key="initialCode") + let solutionCode = switch dict->Dict.get("solutionCode") { + | None => initialCode + | Some(_) => readString(~dict, ~sourcePath, ~key="solutionCode") + } + { + sourcePath, + initialCode, + solutionCode, + expectedOutput: readString(~dict, ~sourcePath, ~key="expectedOutput", ~allowEmpty=true), + } + } +} + +let compile = (~compiler, ~sourcePath, ~field, code) => + switch compiler->RescriptCompilerApi.Compiler.resCompile(code) { + | Success(result) => result + | Fail(failure) => + let details = failure->GuideCompilerFeedback.compileFailToOutputLines->Array.join("\n") + fail( + `Guide lesson ${sourcePath} exercise.${field} does not compile with ${GuideCompilerSettings.version}:\n${details}`, + ) + | UnexpectedError(message) | Unknown(message, _) => + fail(`Guide lesson ${sourcePath} exercise.${field}: ${message}`) + } + +let validateExercise = async (~compiler, ~bundleBaseUrl, exercise) => { + let {sourcePath, initialCode, solutionCode, expectedOutput} = exercise + compile(~compiler, ~sourcePath, ~field="initialCode", initialCode)->ignore + let {jsCode, typeHints} = compile(~compiler, ~sourcePath, ~field="solutionCode", solutionCode) + let program = switch GuideRuntimeProgram.fromCompilation( + ~compiler, + ~code=solutionCode, + ~jsCode, + ~typeHints, + ) { + | Some(program) => { + ...program, + imports: program.imports->Dict.mapValues(path => + GuideRuntimeImport.url( + ~bundleBaseUrl, + ~compilerVersion=GuideCompilerSettings.parsedVersion, + path, + ) + ), + } + | None => fail(`Guide lesson ${sourcePath} exercise.solutionCode has no executable output.`) + } + + let logs = try { + await runProgram(program) + } catch { + | JsExn(error) => + fail( + `Guide lesson ${sourcePath} exercise.solutionCode failed at runtime: ${error + ->JsExn.message + ->Option.getOr("Unknown runtime error")}`, + ) + } + if !(logs->Array.some(log => GuideLesson.runtimeLogText(log) === expectedOutput)) { + let actual = logs->Array.map(GuideLesson.runtimeLogText)->Array.join("\n") + fail( + `Guide lesson ${sourcePath} exercise.expectedOutput ${expectedOutput + ->JSON.Encode.string + ->JSON.stringify} did not match solution output ${actual + ->JSON.Encode.string + ->JSON.stringify}.`, + ) + } +} + +let validate = async (~bundleBaseUrl, ~dir=GuideLessonContent.lessonsDir()) => { + let exercises = GuideLessonContent.scanDir(dir)->Array.map(sourcePath => { + let raw = Node.Fs.readFileSync2(sourcePath, "utf8") + fromRaw(~raw, ~sourcePath) + }) + let compiler = await loadCompiler( + ~bundleBaseUrl, + ~version=GuideCompilerSettings.version, + ~moduleSystem=GuideCompilerSettings.moduleSystem, + ~warnFlags=GuideCompilerSettings.warnFlags, + ) + // The browser compiler is stateful; compile lessons sequentially. + for index in 0 to exercises->Array.length - 1 { + await validateExercise(~compiler, ~bundleBaseUrl, exercises->Array.getUnsafe(index)) + } +} diff --git a/apps/guide/app/GuideRuntimeProgram.res b/apps/guide/app/GuideRuntimeProgram.res new file mode 100644 index 000000000..c6a6b4a6e --- /dev/null +++ b/apps/guide/app/GuideRuntimeProgram.res @@ -0,0 +1,15 @@ +// Keep lesson validation and browser execution on the same transformation path. +let fromCompilation = (~compiler, ~code, ~jsCode, ~typeHints) => { + let runtimeJsCode = switch GuideRuntimeSource.instrument(~code, ~typeHints) { + | Some(runtimeCode) => + switch compiler->RescriptCompilerApi.Compiler.resCompile(runtimeCode) { + | Success({jsCode}) => jsCode + | Fail(_) | UnexpectedError(_) | Unknown(_, _) => jsCode + } + | None => jsCode + } + + runtimeJsCode->GuideRuntimeTransform.transform( + ~resultBindingName=GuideRuntimeSource.resultBindingName, + ) +} diff --git a/apps/guide/app/lessons/02-functions.mdx b/apps/guide/app/lessons/02-functions.mdx index 46504a93a..cedf3cd12 100644 --- a/apps/guide/app/lessons/02-functions.mdx +++ b/apps/guide/app/lessons/02-functions.mdx @@ -12,6 +12,10 @@ exercise: let greeting = greet("ReScript") expectedOutput: Hello, Spock! + solutionCode: | + let greet = name => "Hello, " ++ name ++ "!" + + let greeting = greet("Spock") --- Functions take values as input and return a new value. diff --git a/apps/guide/package.json b/apps/guide/package.json index eda0193b4..1291b1466 100644 --- a/apps/guide/package.json +++ b/apps/guide/package.json @@ -8,9 +8,10 @@ "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:vite": "react-router build", "build": "yarn build:res && yarn build:vite", - "ci:test": "yarn build:res && yarn vitest --run --browser.headless", + "ci:test": "yarn build:res && yarn test:compiler && yarn vitest --run --browser.headless", "dev:vite": "yarn build:res && vite --host", - "vitest": "vitest" + "vitest": "vitest", + "test:compiler": "vitest run --config vitest.compiler.config.mjs" }, "dependencies": { "@babel/generator": "^7.29.1", @@ -40,6 +41,7 @@ "@types/react": "^19.2.14", "@vitejs/plugin-react": "^6.0.1", "@vitest/browser-playwright": "^5.0.0", + "esbuild": "0.28.1", "lightningcss": "^1.32.0", "playwright": "^1.63.0", "vite": "^8.3.0", diff --git a/apps/guide/scripts/lesson-compiler.mjs b/apps/guide/scripts/lesson-compiler.mjs new file mode 100644 index 000000000..f354de5e9 --- /dev/null +++ b/apps/guide/scripts/lesson-compiler.mjs @@ -0,0 +1,121 @@ +import vm from "node:vm"; +import { build } from "esbuild"; + +const sources = new Map(); + +async function fetchSource(url) { + if (!sources.has(url)) { + sources.set( + url, + (async () => { + const response = await fetch(url, { + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) + throw new Error( + `Could not load guide compiler asset ${url}: HTTP ${response.status}`, + ); + return response.text(); + })(), + ); + } + try { + return await sources.get(url); + } catch (cause) { + sources.delete(url); + throw new Error( + `Could not load guide compiler asset ${url}: ${cause.message}`, + { cause }, + ); + } +} + +export async function loadCompiler( + bundleBaseUrl, + version, + moduleSystem, + warnFlags, +) { + const context = vm.createContext({ console }); + for (const path of [ + "compiler.js", + "@rescript/react/cmij.js", + "compiler-builtins/cmij.js", + ]) { + const url = `${bundleBaseUrl}/${version}/${path}`; + vm.runInContext(await fetchSource(url), context, { + filename: url, + timeout: 10000, + }); + } + const compiler = context.rescript_compiler.make(); + if (`v${compiler.version}` !== version) + throw new Error( + `Expected guide compiler ${version}, received ${compiler.version}`, + ); + compiler.setModuleSystem(moduleSystem === "esmodule" ? "es6" : "nodejs"); + compiler.setWarnFlags(warnFlags); + compiler.setExperimentalFeatures([]); + compiler.setJsxPreserveMode(false); + return compiler; +} + +// Match EvalIFrame's argument serialization and the checkpoint's space-joined log lines. +function serializeArg(arg) { + if (arg === undefined) return "undefined"; + if (typeof arg === "object") + return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); + if (typeof arg === "function") return arg.toString(); + return arg; +} + +export async function runProgram({ code, imports }) { + const importCode = Object.entries(imports) + .map(([name, url]) => `import * as ${name} from ${JSON.stringify(url)};`) + .join("\n"); + const result = await build({ + // Bundle the runtime dependencies only; preserve the guide's executable JS verbatim. + stdin: { + contents: `${importCode}\nglobalThis.__guideRuntimeImports = {${Object.keys(imports).join(",")}};`, + sourcefile: "guide-runtime.js", + }, + bundle: true, + format: "iife", + write: false, + logLevel: "silent", + plugins: [ + { + name: "guide-cdn-runtime", + setup(build) { + build.onResolve({ filter: /^https?:\/\// }, (args) => ({ + path: args.path, + namespace: "guide-cdn", + })); + build.onResolve({ filter: /.*/, namespace: "guide-cdn" }, (args) => ({ + path: new URL(args.path, args.importer).href, + namespace: "guide-cdn", + })); + build.onLoad( + { filter: /.*/, namespace: "guide-cdn" }, + async (args) => ({ + contents: await fetchSource(args.path), + loader: "js", + }), + ); + }, + }, + ], + }); + const logs = []; + const console = Object.fromEntries( + ["log", "warn", "error"].map((level) => [ + level, + (...args) => logs.push({ level, content: args.map(serializeArg) }), + ]), + ); + const context = vm.createContext({ console }); + vm.runInContext(result.outputFiles[0].text, context, { timeout: 1000 }); + Object.assign(context, context.__guideRuntimeImports); + vm.runInContext(code, context, { timeout: 1000 }); + return logs; +} diff --git a/apps/guide/vitest.compiler.config.mjs b/apps/guide/vitest.compiler.config.mjs new file mode 100644 index 000000000..3f2c6e1a4 --- /dev/null +++ b/apps/guide/vitest.compiler.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["__tests__/compiler/*.test.{jsx,js}"], + testTimeout: 60000, + expect: { requireAssertions: true }, + }, +}); diff --git a/yarn.lock b/yarn.lock index fbac4a176..8418aeafd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2048,6 +2048,7 @@ __metadata: "@types/react": "npm:^19.2.14" "@vitejs/plugin-react": "npm:^6.0.1" "@vitest/browser-playwright": "npm:^5.0.0" + esbuild: "npm:0.28.1" isbot: "npm:^5" lightningcss: "npm:^1.32.0" playwright: "npm:^1.63.0" From 1dc82547c029512ade31f0e131f381e672cf62ae Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Sat, 10 Oct 2026 00:30:37 +0200 Subject: [PATCH 2/3] refactor: share guide validation runtime helpers Use common compiler endpoint configuration and console serialization so the guide compiler pin and lesson validator stay compatible with the playground refactor in #1441. Keep this PR independently buildable before #1441 is merged. Fixes #1405. Part of #1421. --- apps/guide/app/GuideCompilerData.res | 9 +------- apps/guide/scripts/lesson-compiler.mjs | 10 +-------- packages/shared/src/CompilerEnvironment.res | 6 +++++ packages/shared/src/runtimeConsole.js | 25 +++++++++++++++++++++ 4 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 packages/shared/src/CompilerEnvironment.res create mode 100644 packages/shared/src/runtimeConsole.js diff --git a/apps/guide/app/GuideCompilerData.res b/apps/guide/app/GuideCompilerData.res index 9ab9bc235..0bfb0d297 100644 --- a/apps/guide/app/GuideCompilerData.res +++ b/apps/guide/app/GuideCompilerData.res @@ -1,10 +1,3 @@ type t = {bundleBaseUrl: string} -module Env = { - @scope(("process", "env")) - external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" -} - -let load = () => { - bundleBaseUrl: Env.playgroundBundleEndpoint->Option.getOr("https://cdn.rescript-lang.org"), -} +let load = () => {bundleBaseUrl: CompilerEnvironment.remoteBundleBaseUrl()} diff --git a/apps/guide/scripts/lesson-compiler.mjs b/apps/guide/scripts/lesson-compiler.mjs index f354de5e9..1727d0cfa 100644 --- a/apps/guide/scripts/lesson-compiler.mjs +++ b/apps/guide/scripts/lesson-compiler.mjs @@ -1,5 +1,6 @@ import vm from "node:vm"; import { build } from "esbuild"; +import { serializeArg } from "../../../packages/shared/src/runtimeConsole.js"; const sources = new Map(); @@ -60,15 +61,6 @@ export async function loadCompiler( return compiler; } -// Match EvalIFrame's argument serialization and the checkpoint's space-joined log lines. -function serializeArg(arg) { - if (arg === undefined) return "undefined"; - if (typeof arg === "object") - return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); - if (typeof arg === "function") return arg.toString(); - return arg; -} - export async function runProgram({ code, imports }) { const importCode = Object.entries(imports) .map(([name, url]) => `import * as ${name} from ${JSON.stringify(url)};`) diff --git a/packages/shared/src/CompilerEnvironment.res b/packages/shared/src/CompilerEnvironment.res new file mode 100644 index 000000000..a2e25a269 --- /dev/null +++ b/packages/shared/src/CompilerEnvironment.res @@ -0,0 +1,6 @@ +@scope(("process", "env")) external nodeEnv: string = "NODE_ENV" +@scope(("process", "env")) +external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" + +let cdnBaseUrl = "https://cdn.rescript-lang.org" +let remoteBundleBaseUrl = () => playgroundBundleEndpoint->Option.getOr(cdnBaseUrl) diff --git a/packages/shared/src/runtimeConsole.js b/packages/shared/src/runtimeConsole.js new file mode 100644 index 000000000..be097cb8b --- /dev/null +++ b/packages/shared/src/runtimeConsole.js @@ -0,0 +1,25 @@ +export function serializeArg(arg) { + if (arg === undefined) return "undefined"; + if (arg === null) return "null"; + if (typeof arg === "object") + return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); + if (typeof arg === "function") return arg.toString(); + return arg; +} + +function installConsole(serializeArg) { + const sendLog = + (level) => + (...args) => { + parent.window.postMessage( + { type: level, args: args.map(serializeArg) }, + "*", + ); + }; + console.log = sendLog("log"); + console.warn = sendLog("warn"); + console.error = sendLog("error"); +} + +// Both functions are self-contained so the iframe can install them without module loading. +export const bridgeScript = `(${installConsole.toString()})(${serializeArg.toString()});`; From 81250458845bd56ac08628517c2b2f8ba7d0d579 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Sat, 10 Oct 2026 00:31:06 +0200 Subject: [PATCH 3/3] refactor: validate guide lessons after loading content Keep validation failures fatal while allowing the shared compiler loader refactor in #1441 to merge cleanly. Part of #1421. --- apps/guide/app/GuideHomeRoute.res | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/guide/app/GuideHomeRoute.res b/apps/guide/app/GuideHomeRoute.res index a63341abb..10d29ed11 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -7,12 +7,13 @@ exception LessonLoadError(string) let loader: ReactRouter.Loader.t = async _ => { let compilerData = GuideCompilerData.load() - await GuideLessonValidation.validate(~bundleBaseUrl=compilerData.bundleBaseUrl) let lessons = switch GuideLessonContent.load() { | Ok(lessons) => lessons | Error(message) => throw(LessonLoadError(message)) } + await GuideLessonValidation.validate(~bundleBaseUrl=compilerData.bundleBaseUrl) + {compilerData, lessons} }