From 9145607f9d26b4e9f7019417c94b7ae4414e39b1 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 9 Oct 2026 23:26:43 +0200 Subject: [PATCH 1/7] 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 ccfce52f81ae7fd2806a6454d2d8dcaff330b4d5 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Sat, 10 Oct 2026 00:25:48 +0200 Subject: [PATCH 2/7] fix: share guide and playground compiler helpers Centralize version comparison, runtime import collection and mapping, console messages, and compiler endpoint configuration. Sort numeric version components correctly and preserve the guide compiler pin from #1439. Add regression coverage for both runtime transforms, iframe serialization, metadata failures, endpoint overrides, and multi-digit compiler versions. Fixes #1407. Part of #1421. --- apps/docs/app/routes/TryRoute.res | 46 +-- apps/docs/app/routes/TryRoute.resi | 5 +- .../__tests__/playground-shared.test.js | 289 ++++++++++++++++++ apps/docs/vitest.scripts.config.mjs | 2 +- .../__tests__/GuideCompilerSettings_.test.res | 16 +- apps/guide/app/GuideCompilerBridge.res | 9 +- apps/guide/app/GuideCompilerBridgeHook.res | 78 +++-- apps/guide/app/GuideCompilerData.res | 44 +-- apps/guide/app/GuideCompilerFeedback.res | 8 +- apps/guide/app/GuideCompilerSettings.res | 49 +-- apps/guide/app/GuideHome.res | 8 +- apps/guide/app/GuideHomeRoute.res | 6 +- apps/guide/app/GuideHomeRoute.resi | 2 +- apps/guide/app/GuideLesson.res | 2 +- apps/guide/app/GuideRuntimeImport.res | 46 +-- apps/guide/app/GuideRuntimeProgram.res | 15 + apps/guide/app/GuideRuntimeTransform.res | 18 +- .../playground/src/CompilerManagerHook.res | 50 +-- packages/playground/src/ConsolePanel.res | 33 +- packages/playground/src/EvalIFrame.res | 18 +- packages/playground/src/Playground.res | 31 +- packages/shared/src/Babel.res | 36 ++- packages/shared/src/CompilerData.res | 49 +++ packages/shared/src/CompilerEnvironment.res | 6 + packages/shared/src/CompilerRuntimeImport.res | 54 ++++ packages/shared/src/CompilerVersions.res | 20 ++ packages/shared/src/Node.res | 5 - packages/shared/src/RuntimeConsole.res | 39 +++ packages/shared/src/Semver.res | 36 +++ packages/shared/src/Semver.resi | 2 + packages/shared/src/runtimeConsole.js | 25 ++ 31 files changed, 635 insertions(+), 412 deletions(-) create mode 100644 apps/docs/scripts/__tests__/playground-shared.test.js create mode 100644 apps/guide/app/GuideRuntimeProgram.res create mode 100644 packages/shared/src/CompilerData.res create mode 100644 packages/shared/src/CompilerEnvironment.res create mode 100644 packages/shared/src/CompilerRuntimeImport.res create mode 100644 packages/shared/src/CompilerVersions.res create mode 100644 packages/shared/src/RuntimeConsole.res create mode 100644 packages/shared/src/runtimeConsole.js diff --git a/apps/docs/app/routes/TryRoute.res b/apps/docs/app/routes/TryRoute.res index 22785fe5b..01acc9d54 100644 --- a/apps/docs/app/routes/TryRoute.res +++ b/apps/docs/app/routes/TryRoute.res @@ -5,50 +5,10 @@ type stylesheet = {rel: string, href: string} let links = () => [{rel: "stylesheet", href: playgroundCss}] -type props = { - bundleBaseUrl: string, - versions: array, -} - -let loader = async () => { - let (bundleBaseUrl, versionsBaseUrl) = switch ( - Node.Process.Env.playgroundBundleEndpoint, - Node.Process.Env.nodeEnv, - ) { - | (Some(baseUrl), _) => (baseUrl, baseUrl) - | (None, "development") => { - // Use remote bundles in dev - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - } - | (None, _) => ( - // Use same-origin requests for the bundle - "/playground-bundles", - // There is no version endpoint in the build phase - "https://cdn.rescript-lang.org", - ) - } +type props = CompilerData.t - try { - let versions = { - 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 loader = () => CompilerData.load(~location=SameOriginInProduction) - Some({ - bundleBaseUrl, - versions, - }) - } catch { - | JsExn(e) => - Console.error2("error while fetching compiler versions", e) - None - } -} module ClientOnly = { @react.component let make = (~bundleBaseUrl, ~versions) => { @@ -59,7 +19,7 @@ module ClientOnly = { } let default = () => { - let data = ReactRouter.useLoaderData() + let data: option = ReactRouter.useLoaderData() <> array -type props = { - bundleBaseUrl: string, - versions: array, -} +type props = CompilerData.t let loader: unit => promise> diff --git a/apps/docs/scripts/__tests__/playground-shared.test.js b/apps/docs/scripts/__tests__/playground-shared.test.js new file mode 100644 index 000000000..ab3585c7b --- /dev/null +++ b/apps/docs/scripts/__tests__/playground-shared.test.js @@ -0,0 +1,289 @@ +import { runInNewContext } from "node:vm"; +import { parse } from "@babel/parser"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as Babel from "../../../../packages/shared/src/Babel.jsx"; +import * as CompilerData from "../../../../packages/shared/src/CompilerData.jsx"; +import * as CompilerRuntimeImport from "../../../../packages/shared/src/CompilerRuntimeImport.jsx"; +import * as CompilerVersions from "../../../../packages/shared/src/CompilerVersions.jsx"; +import * as RuntimeConsole from "../../../../packages/shared/src/RuntimeConsole.jsx"; +import * as Semver from "../../../../packages/shared/src/Semver.jsx"; +import * as GuideCompilerData from "../../../guide/app/GuideCompilerData.jsx"; +import * as GuideCompilerSettings from "../../../guide/app/GuideCompilerSettings.jsx"; +import * as GuideRuntimeImport from "../../../guide/app/GuideRuntimeImport.jsx"; +import * as GuideRuntimeTransform from "../../../guide/app/GuideRuntimeTransform.jsx"; +import { CdnMeta } from "../../../../packages/playground/src/CompilerManagerHook.jsx"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("compiler versions", () => { + test("sorts numeric version components and prerelease identifiers", () => { + const versions = [ + "v12.9.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.10.0-alpha.10", + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + ]; + expect(CompilerVersions.supported(versions).map(Semver.toString)).toEqual([ + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + "v12.10.0-alpha.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.9.10", + ]); + expect( + Semver.compare(Semver.parse("v12.10.0"), Semver.parse("v12.10.0")), + ).toBe(0); + expect( + CompilerVersions.latestStable(CompilerVersions.supported(versions)), + ).toEqual(Semver.parse("v12.10.0")); + }); + + test("keeps the supported compiler range and ignores invalid versions", () => { + expect( + CompilerVersions.supported([ + "invalid", + "v9.1.0", + "v10.0.1", + "v10.1.0", + "v11.1.3", + "v11.1.4", + "v11.2.0-beta.2", + "v11.2.0", + "v12.1.0-alpha.1", + "v12.1.0", + "v12.2.0-alpha.1", + "v13.0.0-alpha.1", + ]).map(Semver.toString), + ).toEqual([ + "v13.0.0-alpha.1", + "v12.2.0-alpha.1", + "v12.1.0", + "v11.2.0", + "v11.1.4", + "v10.1.0", + ]); + }); +}); + +describe("runtime imports", () => { + test.each([ + ["v12.3.1", "./stdlib/Stdlib_Array.js", "v12.3.1", "Stdlib_Array.js"], + [ + "v13.0.0-alpha.1", + "./stdlib/Stdlib_Array.mjs", + "v13.0.0-alpha.1", + "Stdlib_Array.js", + ], + [ + "v12.0.0-alpha.7", + "./stdlib/core__Array.js", + "v12.0.0-alpha.9", + "Array.js", + ], + [ + "v12.0.0-alpha.8", + "./stdlib/core__Array.js", + "v12.0.0-alpha.9", + "core__Array.js", + ], + ["v11.1.4", "./stdlib/core__Array.js", "v11.2.0-beta.2", "Core__Array.js"], + ])( + "maps %s imports consistently in both apps", + (version, path, runtimeVersion, filename) => { + const compilerVersion = Semver.parse(version); + const expected = `https://bundles.test/${runtimeVersion}/compiler-builtins/stdlib/${filename}`; + expect( + CompilerRuntimeImport.url( + "https://bundles.test", + compilerVersion, + path, + ), + ).toBe(expected); + expect( + GuideRuntimeImport.url("https://bundles.test", compilerVersion, path), + ).toBe(expected); + expect( + CdnMeta.getStdlibRuntimeUrl( + "https://bundles.test", + Semver.parse(runtimeVersion), + filename, + ), + ).toBe(expected); + }, + ); + + test("both transforms collect namespace imports without accepting lookalike paths", () => { + const source = ` + import * as Arrays from "./stdlib/Stdlib_Array.js"; + import * as Other from "./stdlib-other/Other.js"; + import Default from "./stdlib/Default.js"; + import { value } from "./stdlib/Named.js"; + import "./stdlib/SideEffect.js"; + import * as React from "react"; + const answer = Arrays.length([]); + export { answer }; + `; + const expected = { Arrays: "./stdlib/Stdlib_Array.js" }; + expect( + Babel.RuntimeImports.collect(parse(source, { sourceType: "module" })), + ).toEqual(expected); + const playground = Babel.PlaygroundValidator.validate( + parse(source, { sourceType: "module" }), + ); + const guide = GuideRuntimeTransform.transform(undefined, source); + expect(playground.imports).toEqual(expected); + expect(guide.imports).toEqual(expected); + expect(playground.code).not.toMatch(/\b(import|export)\b/); + expect(guide.code).not.toMatch(/\b(import|export)\b/); + }); + + test("preserves the playground React entry point detection", () => { + const source = + "function Playground$App() {} const App = {make: Playground$App}; export {App};"; + expect( + Babel.PlaygroundValidator.validate( + parse(source, { sourceType: "module" }), + ).entryPointExists, + ).toBe(true); + }); +}); + +describe("console bridge", () => { + test("serializes iframe logs for the shared decoder, including null", () => { + const messages = []; + const sandbox = { + console: {}, + parent: { + window: { + postMessage: (message, origin) => messages.push({ message, origin }), + }, + }, + }; + runInNewContext(RuntimeConsole.bridgeScript, sandbox); + runInNewContext( + `console.log(null, undefined, {answer: 42}, 5, true); console.warn("warning"); console.error("error")`, + sandbox, + ); + expect(messages.map(({ origin }) => origin)).toEqual(["*", "*", "*"]); + expect( + messages.map(({ message }) => RuntimeConsole.fromMessage(message)), + ).toEqual([ + { + level: "log", + content: ["null", "undefined", '{"answer":42}', "5", "true"], + }, + { level: "warn", content: ["warning"] }, + { level: "error", content: ["error"] }, + ]); + expect( + RuntimeConsole.text(RuntimeConsole.fromMessage(messages[0].message)), + ).toBe('null undefined {"answer":42} 5 true'); + }); + + test.each([ + null, + [], + "message", + {}, + { type: "other", args: [] }, + { type: "log", args: "text" }, + { type: "log", args: [null] }, + { type: "log", args: [{}] }, + ])("ignores malformed or unrelated messages: %j", (message) => { + expect(RuntimeConsole.fromMessage(message)).toBeUndefined(); + }); +}); + +describe("compiler metadata", () => { + test("uses same-origin bundles in production, the CDN in development, and explicit overrides", () => { + expect( + CompilerData.endpoints("SameOriginInProduction", "production", undefined), + ).toEqual({ + bundleBaseUrl: "/playground-bundles", + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect( + CompilerData.endpoints( + "SameOriginInProduction", + "development", + undefined, + ), + ).toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect(CompilerData.endpoints("Remote", "production", undefined)).toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect( + CompilerData.endpoints( + "SameOriginInProduction", + "production", + "https://override.test", + ), + ).toEqual({ + bundleBaseUrl: "https://override.test", + versionsBaseUrl: "https://override.test", + }); + }); + + test("fetches metadata from the override without changing the guide pin", async () => { + vi.stubEnv("PLAYGROUND_BUNDLE_ENDPOINT", "https://override.test"); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(["v13.0.0", "v12.3.1"]))); + vi.stubGlobal("fetch", fetchMock); + expect(await CompilerData.load("SameOriginInProduction")).toEqual({ + bundleBaseUrl: "https://override.test", + versions: ["v13.0.0", "v12.3.1"], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://override.test/playground-bundles/versions.json", + ); + expect(GuideCompilerData.load()).toEqual({ + bundleBaseUrl: "https://override.test", + }); + expect(GuideCompilerSettings.version).toBe("v12.3.1"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("rejects an HTTP error even if its body looks like metadata", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("[]", { status: 503 })), + ); + await expect( + CompilerData.fetchVersions("https://bundles.test"), + ).rejects.toThrow("HTTP 503"); + }); + + test.each(["{", "{}", '["v12.3.1", 123]'])( + "rejects invalid metadata: %s", + async (body) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body))); + await expect( + CompilerData.fetchVersions("https://bundles.test"), + ).rejects.toBeDefined(); + }, + ); + + test("returns the loader's failure state when the network is unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await CompilerData.load("SameOriginInProduction")).toBeUndefined(); + expect(error).toHaveBeenCalledWith( + "error while fetching compiler versions", + expect.any(Error), + ); + }); +}); diff --git a/apps/docs/vitest.scripts.config.mjs b/apps/docs/vitest.scripts.config.mjs index adecde336..ec7d1f18f 100644 --- a/apps/docs/vitest.scripts.config.mjs +++ b/apps/docs/vitest.scripts.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["scripts/__tests__/*.test.jsx"], + include: ["scripts/__tests__/*.test.{jsx,js}"], expect: { requireAssertions: true }, }, }); 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/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..c8c508eb5 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 => @@ -39,29 +52,15 @@ let useCompilerBridge = ( } }, (code, compilerState, compilerDispatch)) - React.useEffect(() => { - let cb = event => { - let data = event["data"] - let appendLog = (level, content) => { - let runtimeLog = {GuideCompilerFeedback.Output.level, content} - if isWaitingForRuntimeOutput.current { - isWaitingForRuntimeOutput.current = false - setOutput(_ => runtimeLog->GuideCompilerFeedback.Output.fromRuntimeLog) - } else { - setOutput(output => output->GuideCompilerFeedback.Output.withRuntimeLog(runtimeLog)) - } - } - - switch data["type"] { - | #log => appendLog(#log, data["args"]) - | #warn => appendLog(#warn, data["args"]) - | #error => appendLog(#error, data["args"]) - | _ => () - } + let onLog = React.useCallback(runtimeLog => { + if isWaitingForRuntimeOutput.current { + isWaitingForRuntimeOutput.current = false + setOutput(_ => runtimeLog->GuideCompilerFeedback.Output.fromRuntimeLog) + } else { + setOutput(output => output->GuideCompilerFeedback.Output.withRuntimeLog(runtimeLog)) } - WebAPI.Window.addEventListener(window, Custom("message"), cb) - Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), cb)) }, [setOutput]) + RuntimeConsole.useLogs(onLog) React.useEffect(() => { let feedback = compilerState->GuideCompilerFeedback.editorFeedbackFromState @@ -83,18 +82,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..0bfb0d297 100644 --- a/apps/guide/app/GuideCompilerData.res +++ b/apps/guide/app/GuideCompilerData.res @@ -1,43 +1,3 @@ -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: CompilerEnvironment.remoteBundleBaseUrl()} diff --git a/apps/guide/app/GuideCompilerFeedback.res b/apps/guide/app/GuideCompilerFeedback.res index 2fd329729..c70e7950c 100644 --- a/apps/guide/app/GuideCompilerFeedback.res +++ b/apps/guide/app/GuideCompilerFeedback.res @@ -1,12 +1,8 @@ module Api = RescriptCompilerApi module Output = { - type level = [ - | #log - | #warn - | #error - ] - type runtimeLog = {level: level, content: array} + type level = RuntimeConsole.level + type runtimeLog = RuntimeConsole.log = {level: level, content: array} type t = { status: string, 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..240ce08c6 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -1,12 +1,12 @@ 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() let lessons = switch GuideLessonContent.load() { | Ok(lessons) => lessons | Error(message) => throw(LessonLoadError(message)) @@ -18,5 +18,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/GuideLesson.res b/apps/guide/app/GuideLesson.res index e6630824e..7c23526da 100644 --- a/apps/guide/app/GuideLesson.res +++ b/apps/guide/app/GuideLesson.res @@ -53,7 +53,7 @@ let hasPreviousLesson = index => index > 0 let hasNextLesson = (~lessons, index) => index < lessons->Array.length - 1 let runtimeLogText = (runtimeLog: GuideCompilerFeedback.Output.runtimeLog) => - runtimeLog.content->Array.join(" ") + RuntimeConsole.text(runtimeLog) let isExerciseComplete = (~exercise, ~output: GuideCompilerFeedback.Output.t) => switch exercise.check { diff --git a/apps/guide/app/GuideRuntimeImport.res b/apps/guide/app/GuideRuntimeImport.res index dac45a148..38588c39b 100644 --- a/apps/guide/app/GuideRuntimeImport.res +++ b/apps/guide/app/GuideRuntimeImport.res @@ -1,45 +1 @@ -let capitalizeFirstLetter = string => { - let firstLetter = string->String.charAt(0)->String.toUpperCase - `${firstLetter}${string->String.slice(~start=1)}` -} - -let filenameForCompiler = (~compilerVersion: Semver.t, path) => { - let filename = path->String.slice(~start=9) - switch compilerVersion { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => - let filename = if filename->String.startsWith("core__") { - filename->String.slice(~start=6) - } else { - filename - } - filename->capitalizeFirstLetter - | {major} if major < 12 && filename->String.startsWith("core__") => - filename->capitalizeFirstLetter - | _ => filename - } -} - -let compilerVersionForRuntimeImport = (compilerVersion: Semver.t) => - // Older compiler builds emitted stdlib import paths that no longer match the CDN layout. - switch compilerVersion { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { - Semver.major: 12, - minor: 0, - patch: 0, - preRelease: Some(Alpha(9)), - } - | {major, minor} if (major === 11 && minor < 2) || major < 11 => { - major: 11, - minor: 2, - patch: 0, - preRelease: Some(Beta(2)), - } - | version => version - } - -let url = (~bundleBaseUrl, ~compilerVersion: Semver.t, path) => { - let filename = path->filenameForCompiler(~compilerVersion) - let compilerVersion = compilerVersion->compilerVersionForRuntimeImport - - CompilerManagerHook.CdnMeta.getStdlibRuntimeUrl(bundleBaseUrl, compilerVersion, filename) -} +include CompilerRuntimeImport 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/GuideRuntimeTransform.res b/apps/guide/app/GuideRuntimeTransform.res index 3163f6b00..c8fbadbe3 100644 --- a/apps/guide/app/GuideRuntimeTransform.res +++ b/apps/guide/app/GuideRuntimeTransform.res @@ -10,21 +10,6 @@ let isModuleBoundary = statement => { } } -let collectRuntimeImport = (~imports, statement) => { - switch statement->Babel.Ast.nodeType { - | "ImportDeclaration" => - let sourceValue = statement->Babel.Ast.source->Babel.Ast.stringLiteralValue - if sourceValue->String.startsWith("./stdlib") { - switch statement->Babel.Ast.specifiers { - | [specifier] => - imports->Dict.set(specifier->Babel.Ast.specifierLocal->Babel.Ast.lvalName, sourceValue) - | _ => () - } - } - | _ => () - } -} - let consoleLogStatement = expression => { let consoleLog = Babel.Types.memberExpression( Babel.Types.identifier("console"), @@ -95,8 +80,7 @@ let appendLastBindingLog = body => let transform = (~resultBindingName=?, jsCode) => try { let ast = Babel.Parser.parse(jsCode, {sourceType: "module"}) - let imports = Dict.make() - ast.program.body->Array.forEach(statement => statement->collectRuntimeImport(~imports)) + let imports = Babel.RuntimeImports.collect(ast) let executableBody = ast.program.body->Array.filter(statement => !isModuleBoundary(statement)) diff --git a/packages/playground/src/CompilerManagerHook.res b/packages/playground/src/CompilerManagerHook.res index 1b3f35c53..17452c290 100644 --- a/packages/playground/src/CompilerManagerHook.res +++ b/packages/playground/src/CompilerManagerHook.res @@ -41,15 +41,7 @@ module CdnMeta = { let getLibraryCmijUrl = (baseUrl, version, libraryName: string): string => `${baseUrl}/${Semver.toString(version)}/${libraryName}/cmij.js` - let getStdlibRuntimeUrl = (baseUrl, version, filename) => { - // The compiler emits .mjs imports, while the published runtime bundles use .js. - let filename = if filename->String.endsWith(".mjs") { - filename->String.slice(~start=0, ~end=-4) ++ ".js" - } else { - filename - } - `${baseUrl}/${Semver.toString(version)}/compiler-builtins/stdlib/${filename}` - } + let getStdlibRuntimeUrl = CompilerRuntimeImport.stdlibUrl } module FinalResult = { @@ -150,11 +142,6 @@ let wrapReactApp = code => window.reactRoot.render(React.createElement(App.make, {})); })();` -let capitalizeFirstLetter = string => { - let firstLetter = string->String.charAt(0)->String.toUpperCase - `${firstLetter}${string->String.slice(~start=1)}` -} - type error = | SetupError(string) | CompilerLoadingError(string) @@ -595,37 +582,10 @@ let useCompilerManager = ( let ast = Parser.parse(jsCode, {sourceType: "module"}) let {entryPointExists, code, imports} = PlaygroundValidator.validate(ast) - let imports = imports->Dict.mapValues(path => { - let filename = path->String.slice(~start=9) // the part after "./stdlib/" - let filename = switch state.selected.id { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => - let filename = if filename->String.startsWith("core__") { - filename->String.slice(~start=6) - } else { - filename - } - capitalizeFirstLetter(filename) - | {major} if major < 12 && filename->String.startsWith("core__") => - capitalizeFirstLetter(filename) - | _ => filename - } - let compilerVersion = switch state.selected.id { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { - Semver.major: 12, - minor: 0, - patch: 0, - preRelease: Some(Alpha(9)), - } - | {major, minor} if (major === 11 && minor < 2) || major < 11 => { - major: 11, - minor: 2, - patch: 0, - preRelease: Some(Beta(2)), - } - | version => version - } - CdnMeta.getStdlibRuntimeUrl(bundleBaseUrl, compilerVersion, filename) - }) + let imports = + imports->Dict.mapValues(path => + CompilerRuntimeImport.url(~bundleBaseUrl, ~compilerVersion=state.selected.id, path) + ) entryPointExists ? code->wrapReactApp->EvalIFrame.sendOutput(imports) diff --git a/packages/playground/src/ConsolePanel.res b/packages/playground/src/ConsolePanel.res index 0c9bff4ed..238fd9e94 100644 --- a/packages/playground/src/ConsolePanel.res +++ b/packages/playground/src/ConsolePanel.res @@ -1,25 +1,13 @@ -type logLevel = [ - | #log - | #warn - | #error -] -type log = {level: logLevel, content: array} +type logLevel = RuntimeConsole.level +type log = RuntimeConsole.log = {level: logLevel, content: array} @react.component let make = (~logs, ~appendLog) => { - React.useEffect(() => { - let cb = e => { - let data = e["data"] - switch data["type"] { - | #...logLevel as logLevel => - let args: array = data["args"] - appendLog(logLevel, args) - | _ => () - } - } - WebAPI.Window.addEventListener(window, Custom("message"), cb) - Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), cb)) - }, [appendLog]) + let onLog = React.useCallback( + ({RuntimeConsole.level: level, content}) => appendLog(level, content), + [appendLog], + ) + RuntimeConsole.useLogs(onLog)

{React.string("Console")}

@@ -33,17 +21,16 @@ let make = (~logs, ~appendLog) => { | logs => let content = logs - ->Array.mapWithIndex(({level: logLevel, content: log}, i) => { - let log = Array.join(log, " ") + ->Array.mapWithIndex((log, i) => {
 ""
             | #warn => "text-orange"
             | #error => "text-fire"
             }}
           >
-            {React.string(log)}
+            {React.string(RuntimeConsole.text(log))}
           
}) ->React.array diff --git a/packages/playground/src/EvalIFrame.res b/packages/playground/src/EvalIFrame.res index 3531edcf1..746697637 100644 --- a/packages/playground/src/EvalIFrame.res +++ b/packages/playground/src/EvalIFrame.res @@ -47,23 +47,7 @@ let srcDoc = ` console.error(err); } }); - const sendLog = (logLevel) => (...args) => { - let finalArgs = args.map(arg => { - if (arg === undefined) { - return 'undefined'; - } - else if (typeof arg === 'object') { - return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); - } else if (typeof arg === 'function') { - return arg.toString() - } - return arg; - }); - parent.window.postMessage({ type: logLevel, args: finalArgs }, '*'); - }; - console.log = sendLog('log'); - console.warn = sendLog('warn'); - console.error = sendLog('error'); + ${RuntimeConsole.bridgeScript} diff --git a/packages/playground/src/Playground.res b/packages/playground/src/Playground.res index 70038bc9e..8529260e8 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -1657,39 +1657,12 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { None }, []) - let versions = - versions - ->Array.filterMap(v => v->Semver.parse) - ->Array.filter(v => - switch v.major { - | 8 | 9 => false - | 10 => v.minor >= 1 - | 11 => - v.minor >= 1 && v.preRelease->Option.isNone && (v.minor == 1 && v.patch >= 4) ? true : false - | 12 => - switch v.preRelease { - | None => true - | Some(_) => v.minor > 1 - } - | _ => true - } - ) - ->Array.toSorted((a, b) => { - let cmp = ({Semver.major: major, minor, patch, _}) => { - [major, minor, patch] - ->Array.map(v => v->Int.toString) - ->Array.join("") - ->Int.fromString - ->Option.getOr(0) - } - - cmp(b) > cmp(a) ? 1.0 : -1.0 - }) + let versions = CompilerVersions.supported(versions) let initialVersion = switch versions { | [v] => Some(v) // only single version available. maybe local dev. | versions => { - let lastStableVersion = versions->Array.find(version => version.preRelease->Option.isNone) + let lastStableVersion = versions->CompilerVersions.latestStable switch Nullable.make( searchParams->WebAPI.URLSearchParams.get((CompilerManagerHook.Version :> string)), ) { diff --git a/packages/shared/src/Babel.res b/packages/shared/src/Babel.res index 561cd4eec..990e9f29d 100644 --- a/packages/shared/src/Babel.res +++ b/packages/shared/src/Babel.res @@ -127,6 +127,25 @@ module Types = { external expressionStatement: Ast.expression => Ast.statement = "expressionStatement" } +module RuntimeImports = { + let collect = ast => { + let imports = Dict.make() + ast.Ast.program.body->Array.forEach(statement => { + if statement->Ast.nodeType === "ImportDeclaration" { + let source = statement->Ast.source->Ast.stringLiteralValue + if source->String.startsWith("./stdlib/") { + switch statement->Ast.specifiers { + | [ImportNamespaceSpecifier({local: Identifier({name})})] => + imports->Dict.set(name, source) + | _ => () + } + } + } + }) + imports + } +} + module PlaygroundValidator = { type validator = { entryPointExists: bool, @@ -136,26 +155,13 @@ module PlaygroundValidator = { let validate = ast => { let entryPoint = ref(false) - let imports = Dict.make() + let imports = RuntimeImports.collect(ast) let remove = nodePath => Generator.remove(nodePath) Traverse.traverse( ast, { - "ImportDeclaration": ( - { - node: ImportDeclaration({specifiers, source: StringLiteral({value: source})}), - } as nodePath: Ast.nodePath, - ) => { - if source->String.startsWith("./stdlib") { - switch specifiers { - | [ImportNamespaceSpecifier({local: Identifier({name})})] => - imports->Dict.set(name, source) - | _ => () - } - } - remove(nodePath) - }, + "ImportDeclaration": remove, "ExportNamedDeclaration": remove, "VariableDeclaration": ( {node: VariableDeclaration({declarations})}: Ast.nodePath, diff --git a/packages/shared/src/CompilerData.res b/packages/shared/src/CompilerData.res new file mode 100644 index 000000000..074a706e8 --- /dev/null +++ b/packages/shared/src/CompilerData.res @@ -0,0 +1,49 @@ +type t = {bundleBaseUrl: string, versions: array} +type endpoints = {bundleBaseUrl: string, versionsBaseUrl: string} +type bundleLocation = Remote | SameOriginInProduction + +let cdnBaseUrl = CompilerEnvironment.cdnBaseUrl + +let endpoints = (~location, ~nodeEnv, ~override) => + switch override { + | Some(baseUrl) => {bundleBaseUrl: baseUrl, versionsBaseUrl: baseUrl} + | None => { + bundleBaseUrl: location === SameOriginInProduction && nodeEnv !== "development" + ? "/playground-bundles" + : cdnBaseUrl, + versionsBaseUrl: cdnBaseUrl, + } + } + +let configuredEndpoints = location => + endpoints( + ~location, + ~nodeEnv=CompilerEnvironment.nodeEnv, + ~override=CompilerEnvironment.playgroundBundleEndpoint, + ) + +let fetchVersions = async versionsBaseUrl => { + let response = await fetch(versionsBaseUrl ++ "/playground-bundles/versions.json") + if response.ok === false { + JsError.throwWithMessage( + `Could not fetch compiler versions from ${versionsBaseUrl}: HTTP ${response.status->Int.toString}`, + ) + } + let json = await response->WebAPI.Response.json + json + ->JSON.Decode.array + ->Option.getOrThrow + ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) +} + +let load = async (~location) => { + let {bundleBaseUrl, versionsBaseUrl} = configuredEndpoints(location) + try { + let versions = await fetchVersions(versionsBaseUrl) + Some({bundleBaseUrl, versions}) + } catch { + | JsExn(error) => + Console.error2("error while fetching compiler versions", error) + None + } +} 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/CompilerRuntimeImport.res b/packages/shared/src/CompilerRuntimeImport.res new file mode 100644 index 000000000..d39e1ea74 --- /dev/null +++ b/packages/shared/src/CompilerRuntimeImport.res @@ -0,0 +1,54 @@ +let capitalizeFirstLetter = string => { + let firstLetter = string->String.charAt(0)->String.toUpperCase + `${firstLetter}${string->String.slice(~start=1)}` +} + +let filenameForCompiler = (~compilerVersion: Semver.t, path) => { + let filename = path->String.slice(~start=9) + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => + let filename = if filename->String.startsWith("core__") { + filename->String.slice(~start=6) + } else { + filename + } + filename->capitalizeFirstLetter + | {major} if major < 12 && filename->String.startsWith("core__") => + filename->capitalizeFirstLetter + | _ => filename + } +} + +let compilerVersionForRuntimeImport = (compilerVersion: Semver.t) => + // Older compiler builds emitted stdlib import paths that no longer match the CDN layout. + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { + Semver.major: 12, + minor: 0, + patch: 0, + preRelease: Some(Alpha(9)), + } + | {major, minor} if (major === 11 && minor < 2) || major < 11 => { + major: 11, + minor: 2, + patch: 0, + preRelease: Some(Beta(2)), + } + | version => version + } + +let stdlibUrl = (baseUrl, version, filename) => { + // Published runtime bundles use .js even when the compiler emits .mjs imports. + let filename = + filename->String.endsWith(".mjs") + ? filename->String.slice(~start=0, ~end=-4) ++ ".js" + : filename + `${baseUrl}/${Semver.toString(version)}/compiler-builtins/stdlib/${filename}` +} + +let url = (~bundleBaseUrl, ~compilerVersion: Semver.t, path) => { + let filename = path->filenameForCompiler(~compilerVersion) + let compilerVersion = compilerVersion->compilerVersionForRuntimeImport + + stdlibUrl(bundleBaseUrl, compilerVersion, filename) +} diff --git a/packages/shared/src/CompilerVersions.res b/packages/shared/src/CompilerVersions.res new file mode 100644 index 000000000..1d612897f --- /dev/null +++ b/packages/shared/src/CompilerVersions.res @@ -0,0 +1,20 @@ +let isSupported = (version: Semver.t) => + switch version.major { + | major if major < 10 => false + | 10 => version.minor >= 1 + | 11 => + // Preserve the playground's minimum stable v11 version. + version.preRelease->Option.isNone && + (version.minor > 1 || (version.minor === 1 && version.patch >= 4)) + | 12 => version.preRelease->Option.isNone || version.minor > 1 + | _ => true + } + +let supported = versions => + versions + ->Array.filterMap(Semver.parse) + ->Array.filter(isSupported) + ->Array.toSorted((a, b) => Semver.compare(b, a)) + +let latestStable = versions => + versions->Array.find(version => version.Semver.preRelease->Option.isNone) diff --git a/packages/shared/src/Node.res b/packages/shared/src/Node.res index cc49181dd..6631af477 100644 --- a/packages/shared/src/Node.res +++ b/packages/shared/src/Node.res @@ -16,11 +16,6 @@ module Process = { @scope("process") external env: Dict.t = "env" @scope("process") @val external argv: array = "argv" @scope("process") external exit: int => unit = "exit" - module Env = { - @scope(("process", "env")) external nodeEnv: string = "NODE_ENV" - @scope(("process", "env")) - external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" - } } module Fs = { diff --git a/packages/shared/src/RuntimeConsole.res b/packages/shared/src/RuntimeConsole.res new file mode 100644 index 000000000..d5853afac --- /dev/null +++ b/packages/shared/src/RuntimeConsole.res @@ -0,0 +1,39 @@ +type level = [#log | #warn | #error] +type log = {level: level, content: array} + +@module("./runtimeConsole.js") external bridgeScript: string = "bridgeScript" + +@val external toText: JSON.t => string = "String" + +let argumentText = value => + switch value { + | JSON.String(_) | JSON.Number(_) | JSON.Boolean(_) => Some(toText(value)) + | _ => None + } + +let fromMessage = data => + switch data { + | JSON.Object(dict) => + let level = switch dict->Dict.get("type") { + | Some(JSON.String("log")) => Some(#log) + | Some(JSON.String("warn")) => Some(#warn) + | Some(JSON.String("error")) => Some(#error) + | _ => None + } + let args = dict->Dict.get("args")->Option.flatMap(JSON.Decode.array) + switch (level, args) { + | (Some(level), Some(args)) => + let content = args->Array.filterMap(argumentText) + content->Array.length === args->Array.length ? Some({level, content}) : None + | _ => None + } + | _ => None + } + +let text = log => log.content->Array.join(" ") + +let useLogs = onLog => React.useEffect(() => { + let listener = event => event["data"]->fromMessage->Option.forEach(onLog) + WebAPI.Window.addEventListener(window, Custom("message"), listener) + Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), listener)) + }, [onLog]) diff --git a/packages/shared/src/Semver.res b/packages/shared/src/Semver.res index c0a6420f5..a8b3e6208 100644 --- a/packages/shared/src/Semver.res +++ b/packages/shared/src/Semver.res @@ -73,3 +73,39 @@ let tryGetMajorString = (versionStr: string) => | None => versionStr // fallback to given version if it cannot be parsed | Some({major}) => "v" ++ major->Int.toString } + +let preReleaseParts = preRelease => + switch preRelease { + | Alpha(number) => ("alpha", number) + | Beta(number) => ("beta", number) + | Dev(number) => ("dev", number) + | Rc(number) => ("rc", number) + } + +let comparePreRelease = (a, b) => + switch (a, b) { + | (None, None) => 0.0 + | (None, Some(_)) => 1.0 + | (Some(_), None) => -1.0 + | (Some(a), Some(b)) => + let (aName, aNumber) = preReleaseParts(a) + let (bName, bNumber) = preReleaseParts(b) + switch String.compare(aName, bName) { + | 0.0 => Int.compare(aNumber, bNumber) + | result => result + } + } + +let compare = (a, b) => + switch Int.compare(a.major, b.major) { + | 0.0 => + switch Int.compare(a.minor, b.minor) { + | 0.0 => + switch Int.compare(a.patch, b.patch) { + | 0.0 => comparePreRelease(a.preRelease, b.preRelease) + | result => result + } + | result => result + } + | result => result + } diff --git a/packages/shared/src/Semver.resi b/packages/shared/src/Semver.resi index f49e4c0b6..c3289785a 100644 --- a/packages/shared/src/Semver.resi +++ b/packages/shared/src/Semver.resi @@ -16,3 +16,5 @@ let parse: string => option let toString: t => string let tryGetMajorString: string => string + +let compare: (t, t) => float 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 1dc82547c029512ade31f0e131f381e672cf62ae Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Sat, 10 Oct 2026 00:30:37 +0200 Subject: [PATCH 3/7] 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 4/7] 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} } From dfcdbf9bad73433ef212e198221288255ea190ea Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 16:45:04 +0200 Subject: [PATCH 5/7] feat: validate intentional errors in guide lesson starters Add build-only expectedInitialDiagnostic metadata for syntax and type errors. Require every diagnostic message to match and an explicit solution that still compiles, runs, and produces the checkpoint output. Cover authoring errors and compiler failures, document the mode, and align browser fixtures with the pinned compiler. --- apps/guide/README.md | 52 ++++-- apps/guide/__tests__/GuideHome_.test.res | 1 - .../__tests__/compiler/GuideLessons_.test.res | 173 ++++++++++++++++++ apps/guide/__tests__/compilerFixture.js | 3 +- apps/guide/app/GuideLessonValidation.res | 51 +++++- 5 files changed, 261 insertions(+), 19 deletions(-) diff --git a/apps/guide/README.md b/apps/guide/README.md index 3cd4cd240..8ad68a6e1 100644 --- a/apps/guide/README.md +++ b/apps/guide/README.md @@ -25,18 +25,19 @@ contract only. Lesson copy belongs to the guide author. 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` | Yes | Matching runtime log line that unlocks the next lesson | -| `exercise.solutionCode` | No | Build-only reference solution; defaults to `initialCode` | +| 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` | +| `exercise.expectedInitialDiagnostic` | No | Build-only expected syntax/type error in the starter | 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 @@ -50,7 +51,7 @@ Lesson IDs must be URL-safe slugs made from lowercase ASCII letters, digits, and hyphens. Exercise IDs may use the established slash-delimited form. Every published exercise needs a deterministic `expectedOutput` string. The -build compiles `initialCode` and `solutionCode`, runs the reference solution, +build checks `initialCode` and compiles `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. @@ -61,6 +62,31 @@ 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. +For an exercise that deliberately starts with a compiler error, set +`exercise.expectedInitialDiagnostic` to a non-empty, case-sensitive substring +of the compiler's diagnostic message and provide a separate `solutionCode`: + +```yaml +exercise: + id: types/fix-a-mismatch + title: Fix a type mismatch + initialCode: | + let answer: int = false + expectedInitialDiagnostic: "But it's expected to have type: int" + solutionCode: | + let answer: int = 42 + expectedOutput: "42" +``` + +The starter must fail with syntax or type errors, and every reported error's +message must contain the configured text. Matching ignores ANSI formatting and +does not search source excerpts or line numbers. If the starter compiles, reports +a different error, or encounters a compiler/configuration failure, the build +fails. The reference solution must still compile, run successfully, and produce +the exact checkpoint output. Both validation fields are omitted from published +lesson data; learners still complete the exercise by fixing the code and +producing `expectedOutput`. + ## Local Development From the repository root: diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res index 877a800f9..d6f6b440c 100644 --- a/apps/guide/__tests__/GuideHome_.test.res +++ b/apps/guide/__tests__/GuideHome_.test.res @@ -33,7 +33,6 @@ afterEach(clearGuideState) let compilerData: GuideCompilerData.t = { bundleBaseUrl: "/test-compiler", - versions: ["12.2.0"], } test("loads saved guide editor code into the editor", async () => { diff --git a/apps/guide/__tests__/compiler/GuideLessons_.test.res b/apps/guide/__tests__/compiler/GuideLessons_.test.res index 7fef0b29f..dc0cd973f 100644 --- a/apps/guide/__tests__/compiler/GuideLessons_.test.res +++ b/apps/guide/__tests__/compiler/GuideLessons_.test.res @@ -36,12 +36,14 @@ let exercise = ( ~initialCode=`let greeting = "hello"`, ~solutionCode=`let greeting = "hello"`, ~expectedOutput="hello", + ~expectedInitialDiagnostic=?, (), ): GuideLessonValidation.exercise => { sourcePath: "test-lesson.mdx", initialCode, solutionCode, expectedOutput, + expectedInitialDiagnostic, } let validateExercise = exercise => @@ -75,6 +77,105 @@ test("invalid starter code fails even when its reference solution is valid", asy ) }) +test("an expected starter type error still validates the reference output", async () => { + await expect( + validateExercise( + exercise( + ~initialCode="let answer: int = false", + ~expectedInitialDiagnostic="This has type: bool", + (), + ), + ), + )->resolvesToUnit +}) + +test("an expected starter syntax error still validates the reference output", async () => { + await expect( + validateExercise( + exercise( + ~initialCode="let answer =", + ~expectedInitialDiagnostic="This let-binding misses an expression", + (), + ), + ), + )->resolvesToUnit +}) + +test("expected starter diagnostics fail if the starter now compiles", async () => { + await expect( + validateExercise(exercise(~expectedInitialDiagnostic="This has type:", ())), + )->rejectsWith(`test-lesson.mdx exercise.initialCode expected a syntax or type diagnostic containing "This has type:", but compiled successfully.`) +}) + +let diagnostic = (~shortMsg, ~fullMsg="") => { + RescriptCompilerApi.LocMsg.fullMsg, + shortMsg, + row: 1, + column: 0, + endRow: 1, + endColumn: 1, +} + +for_([ + [], + [diagnostic(~shortMsg="An unrelated error", ~fullMsg="The source includes expected error")], + [diagnostic(~shortMsg="expected error"), diagnostic(~shortMsg="An unrelated error")], +])("only matching diagnostic messages can satisfy the expectation: %s", async messages => { + expect(() => + GuideLessonValidation.validateInitialDiagnostic( + ~sourcePath="broken.mdx", + ~expected="expected error", + RescriptCompilerApi.CompilationResult.Fail(TypecheckErr(messages)), + ) + )->toThrow(`containing "expected error", but received:`) +}) + +test("an unrelated starter error cannot satisfy the expected diagnostic", async () => { + await expect( + validateExercise( + exercise( + ~initialCode="let answer: int = false", + ~expectedInitialDiagnostic="Unbound value", + (), + ), + ), + )->rejectsWith(`containing "Unbound value", but received:`) +}) + +for_([ + ("let answer: int = false", "exercise.solutionCode does not compile"), + (`JsError.throwWithMessage("boom")`, "exercise.solutionCode failed at runtime: boom"), + (`let greeting = "wrong"`, "exercise.expectedOutput"), +])("expected starter errors do not bypass reference validation: %s", async (( + solutionCode, + message, +)) => { + await expect( + validateExercise( + exercise( + ~initialCode="let answer: int = false", + ~expectedInitialDiagnostic="This has type:", + ~solutionCode, + (), + ), + ), + )->rejectsWith(message) +}) + +for_([ + RescriptCompilerApi.CompilationResult.UnexpectedError("compiler crashed"), + Unknown("unknown compiler response", JSON.Null), + Fail(WarningFlagErr({msg: "invalid warning flags", warn_flags: "", warn_error_flags: ""})), +])("compiler failures cannot count as expected starter diagnostics: %s", async result => { + expect(() => + GuideLessonValidation.validateInitialDiagnostic( + ~sourcePath="broken.mdx", + ~expected="compiler", + result, + ) + )->toThrow("broken.mdx exercise.initialCode expected a syntax or type diagnostic containing") +}) + test("invalid reference solutions identify the file and field", async () => { await expect(validateExercise(exercise(~solutionCode="let x: int = false", ())))->rejectsWith( "test-lesson.mdx exercise.solutionCode does not compile", @@ -176,8 +277,80 @@ test("reference code defaults to the starter and preserves an empty expected out ) expect(parsed.solutionCode)->toBe(parsed.initialCode) expect(parsed.expectedOutput)->toBe("") + expect(parsed.expectedInitialDiagnostic)->toBe(None) +}) + +for_([ + JSON.String(""), + JSON.String(" "), + JSON.Number(42.0), + JSON.Boolean(false), + JSON.Null, +])("expected starter diagnostics must be non-empty strings: %s", async value => { + let raw = `--- +exercise: + initialCode: let answer = 1 + solutionCode: let answer = 42 + expectedOutput: "42" + expectedInitialDiagnostic: ${value->JSON.stringify} +--- +` + expect(() => GuideLessonValidation.fromRaw(~raw, ~sourcePath="broken.mdx"))->toThrow( + "broken.mdx exercise.expectedInitialDiagnostic must be a non-empty string", + ) +}) + +test("an expected starter error requires a separate reference solution", async () => { + expect(() => + GuideLessonValidation.fromRaw( + ~raw=`--- +exercise: + initialCode: let answer = unknown + expectedOutput: "42" + expectedInitialDiagnostic: "can't be found" +--- +`, + ~sourcePath="broken.mdx", + ) + )->toThrow( + "broken.mdx exercise.solutionCode is required when exercise.expectedInitialDiagnostic is set", + ) }) +test( + "expected-error metadata is parsed for validation and omitted from published lessons", + async () => { + let raw = `--- +position: 1 +id: fix-a-type +missionLabel: Mission 01 +title: Fix a type +description: Repair a deliberately invalid starter. +exercise: + id: types/fix-a-mismatch + title: Fix a type + initialCode: | + let answer: int = false + expectedInitialDiagnostic: "This has type:" + solutionCode: | + let answer: int = 42 + expectedOutput: "42" +--- +Fix the type error. +` + let exercise = GuideLessonValidation.fromRaw(~raw, ~sourcePath="types.mdx") + expect(exercise.expectedInitialDiagnostic)->toBe(Some("This has type:")) + await expect(validateExercise(exercise))->resolvesToUnit + let published = + GuideLessonFrontmatter.fromRaw(~raw, ~sourcePath="types.mdx") + ->JSON.stringifyAny + ->Option.getOrThrow + expect(published)->notToContain("expectedInitialDiagnostic") + expect(published)->notToContain("solutionCode") + expect(published)->notToContain("let answer: int = 42") + }, +) + test("empty reference code is an authoring error", async () => { expect(() => GuideLessonValidation.fromRaw( diff --git a/apps/guide/__tests__/compilerFixture.js b/apps/guide/__tests__/compilerFixture.js index 0804ebf6e..7201c4783 100644 --- a/apps/guide/__tests__/compilerFixture.js +++ b/apps/guide/__tests__/compilerFixture.js @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, vi } from "vitest"; +import { version as guideCompilerVersion } from "../app/GuideCompilerSettings.jsx"; const { loadScript, compile } = vi.hoisted(() => ({ loadScript: vi.fn(), @@ -55,7 +56,7 @@ function finishLoading(result) { vi.stubGlobal("rescript_compiler", { api_version: "8", make: () => ({ - version: "12.2.0", + version: guideCompilerVersion.slice(1), getConfig: () => ({ module_system: "esmodule", warn_flags: "" }), setModuleSystem: () => true, setWarnFlags: () => true, diff --git a/apps/guide/app/GuideLessonValidation.res b/apps/guide/app/GuideLessonValidation.res index 5860fc56a..763016264 100644 --- a/apps/guide/app/GuideLessonValidation.res +++ b/apps/guide/app/GuideLessonValidation.res @@ -1,9 +1,10 @@ -// Build-only authoring data: reference solutions never enter the route's loader data. +// Build-only authoring data: solutions and expected diagnostics never enter loader data. type exercise = { sourcePath: string, initialCode: string, solutionCode: string, expectedOutput: string, + expectedInitialDiagnostic: option, } @module("../scripts/lesson-compiler.mjs") @@ -43,8 +44,19 @@ let fromRaw = (~raw, ~sourcePath) => { | None => fail(`Guide lesson ${sourcePath} must have exercise frontmatter.`) | Some(dict) => let initialCode = readString(~dict, ~sourcePath, ~key="initialCode") + let expectedInitialDiagnostic = switch dict->Dict.get("expectedInitialDiagnostic") { + | None => None + | Some(_) => Some(readString(~dict, ~sourcePath, ~key="expectedInitialDiagnostic")) + } let solutionCode = switch dict->Dict.get("solutionCode") { - | None => initialCode + | None => + switch expectedInitialDiagnostic { + | None => initialCode + | Some(_) => + fail( + `Guide lesson ${sourcePath} exercise.solutionCode is required when exercise.expectedInitialDiagnostic is set.`, + ) + } | Some(_) => readString(~dict, ~sourcePath, ~key="solutionCode") } { @@ -52,6 +64,7 @@ let fromRaw = (~raw, ~sourcePath) => { initialCode, solutionCode, expectedOutput: readString(~dict, ~sourcePath, ~key="expectedOutput", ~allowEmpty=true), + expectedInitialDiagnostic, } } } @@ -68,9 +81,39 @@ let compile = (~compiler, ~sourcePath, ~field, code) => fail(`Guide lesson ${sourcePath} exercise.${field}: ${message}`) } +let validateInitialDiagnostic = (~sourcePath, ~expected, result) => { + let expectation = `Guide lesson ${sourcePath} exercise.initialCode expected a syntax or type diagnostic containing ${expected + ->JSON.Encode.string + ->JSON.stringify}` + switch result { + | RescriptCompilerApi.CompilationResult.Fail(SyntaxErr(messages) | TypecheckErr(messages)) => + let actual = messages->Array.map(message => message.shortMsg->GuideCompilerFeedback.plainText) + if ( + actual->Array.length === 0 || + !(actual->Array.every(message => message->String.includes(expected))) + ) { + fail(`${expectation}, but received:\n${actual->Array.join("\n")}`) + } + | Fail(failure) => + let details = failure->GuideCompilerFeedback.compileFailToOutputLines->Array.join("\n") + fail(`${expectation}, but received a different compiler failure:\n${details}`) + | Success(_) => fail(`${expectation}, but compiled successfully.`) + | UnexpectedError(message) | Unknown(message, _) => + fail(`${expectation}, but the compiler failed unexpectedly: ${message}`) + } +} + let validateExercise = async (~compiler, ~bundleBaseUrl, exercise) => { - let {sourcePath, initialCode, solutionCode, expectedOutput} = exercise - compile(~compiler, ~sourcePath, ~field="initialCode", initialCode)->ignore + let {sourcePath, initialCode, solutionCode, expectedOutput, expectedInitialDiagnostic} = exercise + switch expectedInitialDiagnostic { + | None => compile(~compiler, ~sourcePath, ~field="initialCode", initialCode)->ignore + | Some(expected) => + validateInitialDiagnostic( + ~sourcePath, + ~expected, + compiler->RescriptCompilerApi.Compiler.resCompile(initialCode), + ) + } let {jsCode, typeHints} = compile(~compiler, ~sourcePath, ~field="solutionCode", solutionCode) let program = switch GuideRuntimeProgram.fromCompilation( ~compiler, From f51342be08b825a8bc15fa062847f0c466293c19 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 16:52:11 +0200 Subject: [PATCH 6/7] test: share compiler loading controls across guide tests Drive loading failures through the shared CDN mock installed by the guide test setup. Remove the obsolete DOM script interception, which cannot see requests handled by that mock, while retaining timeout coverage. --- .../__tests__/GuideCompilerLoading_.test.res | 9 +++++---- apps/guide/__tests__/compilerLoadingFixture.js | 15 --------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/apps/guide/__tests__/GuideCompilerLoading_.test.res b/apps/guide/__tests__/GuideCompilerLoading_.test.res index ff56f9fd6..b07e31a06 100644 --- a/apps/guide/__tests__/GuideCompilerLoading_.test.res +++ b/apps/guide/__tests__/GuideCompilerLoading_.test.res @@ -1,7 +1,8 @@ open Vitest -@module("./compilerLoadingFixture.js") -external interceptCompilerScripts: bool => unit = "interceptCompilerScripts" +@module("./compilerFixture.js") +external waitForCompilerRequest: unit => promise = "waitForCompilerRequest" +@module("./compilerFixture.js") external failCompilerLoading: unit => unit = "failCompilerLoading" @module("./compilerLoadingFixture.js") external expireCompilerLoading: unit => unit = "expireCompilerLoading" @@ -18,8 +19,9 @@ let renderWithCompiler = () => test("shows a compiler loading failure in the guide output", async () => { await viewport(1440, 900) - interceptCompilerScripts(true) let screen = await renderWithCompiler() + await waitForCompilerRequest() + failCompilerLoading() await (await screen->getByText("Compiler setup failed"))->element->toBeVisible await ( await screen->getByText( @@ -32,7 +34,6 @@ test("shows a compiler loading failure in the guide output", async () => { 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 diff --git a/apps/guide/__tests__/compilerLoadingFixture.js b/apps/guide/__tests__/compilerLoadingFixture.js index bbab7ce3c..bacc376e0 100644 --- a/apps/guide/__tests__/compilerLoadingFixture.js +++ b/apps/guide/__tests__/compilerLoadingFixture.js @@ -4,21 +4,6 @@ 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); From 9c0d5af13c13bf56adc8ff4a60890d9e01ea80e4 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 17:03:53 +0200 Subject: [PATCH 7/7] test: rewrite shared compiler coverage in ReScript Move all 25 shared compiler cases into the guide compiler suite, where they can use the guide and shared modules directly. Preserve version, import, console bridge, and metadata coverage using typed Vitest and Node VM bindings, and remove the JavaScript-only docs test glob. --- .../__tests__/playground-shared.test.js | 289 ----------------- apps/docs/vitest.scripts.config.mjs | 2 +- .../compiler/PlaygroundShared_.test.res | 290 ++++++++++++++++++ 3 files changed, 291 insertions(+), 290 deletions(-) delete mode 100644 apps/docs/scripts/__tests__/playground-shared.test.js create mode 100644 apps/guide/__tests__/compiler/PlaygroundShared_.test.res diff --git a/apps/docs/scripts/__tests__/playground-shared.test.js b/apps/docs/scripts/__tests__/playground-shared.test.js deleted file mode 100644 index ab3585c7b..000000000 --- a/apps/docs/scripts/__tests__/playground-shared.test.js +++ /dev/null @@ -1,289 +0,0 @@ -import { runInNewContext } from "node:vm"; -import { parse } from "@babel/parser"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import * as Babel from "../../../../packages/shared/src/Babel.jsx"; -import * as CompilerData from "../../../../packages/shared/src/CompilerData.jsx"; -import * as CompilerRuntimeImport from "../../../../packages/shared/src/CompilerRuntimeImport.jsx"; -import * as CompilerVersions from "../../../../packages/shared/src/CompilerVersions.jsx"; -import * as RuntimeConsole from "../../../../packages/shared/src/RuntimeConsole.jsx"; -import * as Semver from "../../../../packages/shared/src/Semver.jsx"; -import * as GuideCompilerData from "../../../guide/app/GuideCompilerData.jsx"; -import * as GuideCompilerSettings from "../../../guide/app/GuideCompilerSettings.jsx"; -import * as GuideRuntimeImport from "../../../guide/app/GuideRuntimeImport.jsx"; -import * as GuideRuntimeTransform from "../../../guide/app/GuideRuntimeTransform.jsx"; -import { CdnMeta } from "../../../../packages/playground/src/CompilerManagerHook.jsx"; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -describe("compiler versions", () => { - test("sorts numeric version components and prerelease identifiers", () => { - const versions = [ - "v12.9.10", - "v12.10.0-alpha.2", - "v12.9.11", - "v12.10.0-alpha.10", - "v12.10.0", - "v12.10.0-rc.1", - "v12.10.0-beta.1", - ]; - expect(CompilerVersions.supported(versions).map(Semver.toString)).toEqual([ - "v12.10.0", - "v12.10.0-rc.1", - "v12.10.0-beta.1", - "v12.10.0-alpha.10", - "v12.10.0-alpha.2", - "v12.9.11", - "v12.9.10", - ]); - expect( - Semver.compare(Semver.parse("v12.10.0"), Semver.parse("v12.10.0")), - ).toBe(0); - expect( - CompilerVersions.latestStable(CompilerVersions.supported(versions)), - ).toEqual(Semver.parse("v12.10.0")); - }); - - test("keeps the supported compiler range and ignores invalid versions", () => { - expect( - CompilerVersions.supported([ - "invalid", - "v9.1.0", - "v10.0.1", - "v10.1.0", - "v11.1.3", - "v11.1.4", - "v11.2.0-beta.2", - "v11.2.0", - "v12.1.0-alpha.1", - "v12.1.0", - "v12.2.0-alpha.1", - "v13.0.0-alpha.1", - ]).map(Semver.toString), - ).toEqual([ - "v13.0.0-alpha.1", - "v12.2.0-alpha.1", - "v12.1.0", - "v11.2.0", - "v11.1.4", - "v10.1.0", - ]); - }); -}); - -describe("runtime imports", () => { - test.each([ - ["v12.3.1", "./stdlib/Stdlib_Array.js", "v12.3.1", "Stdlib_Array.js"], - [ - "v13.0.0-alpha.1", - "./stdlib/Stdlib_Array.mjs", - "v13.0.0-alpha.1", - "Stdlib_Array.js", - ], - [ - "v12.0.0-alpha.7", - "./stdlib/core__Array.js", - "v12.0.0-alpha.9", - "Array.js", - ], - [ - "v12.0.0-alpha.8", - "./stdlib/core__Array.js", - "v12.0.0-alpha.9", - "core__Array.js", - ], - ["v11.1.4", "./stdlib/core__Array.js", "v11.2.0-beta.2", "Core__Array.js"], - ])( - "maps %s imports consistently in both apps", - (version, path, runtimeVersion, filename) => { - const compilerVersion = Semver.parse(version); - const expected = `https://bundles.test/${runtimeVersion}/compiler-builtins/stdlib/${filename}`; - expect( - CompilerRuntimeImport.url( - "https://bundles.test", - compilerVersion, - path, - ), - ).toBe(expected); - expect( - GuideRuntimeImport.url("https://bundles.test", compilerVersion, path), - ).toBe(expected); - expect( - CdnMeta.getStdlibRuntimeUrl( - "https://bundles.test", - Semver.parse(runtimeVersion), - filename, - ), - ).toBe(expected); - }, - ); - - test("both transforms collect namespace imports without accepting lookalike paths", () => { - const source = ` - import * as Arrays from "./stdlib/Stdlib_Array.js"; - import * as Other from "./stdlib-other/Other.js"; - import Default from "./stdlib/Default.js"; - import { value } from "./stdlib/Named.js"; - import "./stdlib/SideEffect.js"; - import * as React from "react"; - const answer = Arrays.length([]); - export { answer }; - `; - const expected = { Arrays: "./stdlib/Stdlib_Array.js" }; - expect( - Babel.RuntimeImports.collect(parse(source, { sourceType: "module" })), - ).toEqual(expected); - const playground = Babel.PlaygroundValidator.validate( - parse(source, { sourceType: "module" }), - ); - const guide = GuideRuntimeTransform.transform(undefined, source); - expect(playground.imports).toEqual(expected); - expect(guide.imports).toEqual(expected); - expect(playground.code).not.toMatch(/\b(import|export)\b/); - expect(guide.code).not.toMatch(/\b(import|export)\b/); - }); - - test("preserves the playground React entry point detection", () => { - const source = - "function Playground$App() {} const App = {make: Playground$App}; export {App};"; - expect( - Babel.PlaygroundValidator.validate( - parse(source, { sourceType: "module" }), - ).entryPointExists, - ).toBe(true); - }); -}); - -describe("console bridge", () => { - test("serializes iframe logs for the shared decoder, including null", () => { - const messages = []; - const sandbox = { - console: {}, - parent: { - window: { - postMessage: (message, origin) => messages.push({ message, origin }), - }, - }, - }; - runInNewContext(RuntimeConsole.bridgeScript, sandbox); - runInNewContext( - `console.log(null, undefined, {answer: 42}, 5, true); console.warn("warning"); console.error("error")`, - sandbox, - ); - expect(messages.map(({ origin }) => origin)).toEqual(["*", "*", "*"]); - expect( - messages.map(({ message }) => RuntimeConsole.fromMessage(message)), - ).toEqual([ - { - level: "log", - content: ["null", "undefined", '{"answer":42}', "5", "true"], - }, - { level: "warn", content: ["warning"] }, - { level: "error", content: ["error"] }, - ]); - expect( - RuntimeConsole.text(RuntimeConsole.fromMessage(messages[0].message)), - ).toBe('null undefined {"answer":42} 5 true'); - }); - - test.each([ - null, - [], - "message", - {}, - { type: "other", args: [] }, - { type: "log", args: "text" }, - { type: "log", args: [null] }, - { type: "log", args: [{}] }, - ])("ignores malformed or unrelated messages: %j", (message) => { - expect(RuntimeConsole.fromMessage(message)).toBeUndefined(); - }); -}); - -describe("compiler metadata", () => { - test("uses same-origin bundles in production, the CDN in development, and explicit overrides", () => { - expect( - CompilerData.endpoints("SameOriginInProduction", "production", undefined), - ).toEqual({ - bundleBaseUrl: "/playground-bundles", - versionsBaseUrl: CompilerData.cdnBaseUrl, - }); - expect( - CompilerData.endpoints( - "SameOriginInProduction", - "development", - undefined, - ), - ).toEqual({ - bundleBaseUrl: CompilerData.cdnBaseUrl, - versionsBaseUrl: CompilerData.cdnBaseUrl, - }); - expect(CompilerData.endpoints("Remote", "production", undefined)).toEqual({ - bundleBaseUrl: CompilerData.cdnBaseUrl, - versionsBaseUrl: CompilerData.cdnBaseUrl, - }); - expect( - CompilerData.endpoints( - "SameOriginInProduction", - "production", - "https://override.test", - ), - ).toEqual({ - bundleBaseUrl: "https://override.test", - versionsBaseUrl: "https://override.test", - }); - }); - - test("fetches metadata from the override without changing the guide pin", async () => { - vi.stubEnv("PLAYGROUND_BUNDLE_ENDPOINT", "https://override.test"); - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify(["v13.0.0", "v12.3.1"]))); - vi.stubGlobal("fetch", fetchMock); - expect(await CompilerData.load("SameOriginInProduction")).toEqual({ - bundleBaseUrl: "https://override.test", - versions: ["v13.0.0", "v12.3.1"], - }); - expect(fetchMock).toHaveBeenCalledWith( - "https://override.test/playground-bundles/versions.json", - ); - expect(GuideCompilerData.load()).toEqual({ - bundleBaseUrl: "https://override.test", - }); - expect(GuideCompilerSettings.version).toBe("v12.3.1"); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test("rejects an HTTP error even if its body looks like metadata", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response("[]", { status: 503 })), - ); - await expect( - CompilerData.fetchVersions("https://bundles.test"), - ).rejects.toThrow("HTTP 503"); - }); - - test.each(["{", "{}", '["v12.3.1", 123]'])( - "rejects invalid metadata: %s", - async (body) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body))); - await expect( - CompilerData.fetchVersions("https://bundles.test"), - ).rejects.toBeDefined(); - }, - ); - - test("returns the loader's failure state when the network is unavailable", async () => { - vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(await CompilerData.load("SameOriginInProduction")).toBeUndefined(); - expect(error).toHaveBeenCalledWith( - "error while fetching compiler versions", - expect.any(Error), - ); - }); -}); diff --git a/apps/docs/vitest.scripts.config.mjs b/apps/docs/vitest.scripts.config.mjs index ec7d1f18f..adecde336 100644 --- a/apps/docs/vitest.scripts.config.mjs +++ b/apps/docs/vitest.scripts.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["scripts/__tests__/*.test.{jsx,js}"], + include: ["scripts/__tests__/*.test.jsx"], expect: { requireAssertions: true }, }, }); diff --git a/apps/guide/__tests__/compiler/PlaygroundShared_.test.res b/apps/guide/__tests__/compiler/PlaygroundShared_.test.res new file mode 100644 index 000000000..8bf3f7ca1 --- /dev/null +++ b/apps/guide/__tests__/compiler/PlaygroundShared_.test.res @@ -0,0 +1,290 @@ +type assertion<'a> +@module("vitest") external test: (string, unit => promise) => unit = "test" +@module("vitest") @scope("test") +external for_: array<'a> => (string, 'a => promise) => unit = "for" +@module("vitest") external describe: (string, unit => unit) => unit = "describe" +@module("vitest") external afterEach: (unit => unit) => unit = "afterEach" +@module("vitest") external expect: 'a => assertion<'a> = "expect" +@send external toBe: (assertion<'a>, 'a) => unit = "toBe" +@send external toEqual: (assertion<'a>, 'a) => unit = "toEqual" +@send @scope("not") external notToMatch: (assertion, RegExp.t) => unit = "toMatch" +@send @scope("rejects") +external rejectsWith: (assertion>, string) => promise = "toThrow" +@send @scope("rejects") +external rejectsWithError: assertion> => promise = "toBeDefined" +@module("vitest") @scope("vi") external restoreMocks: unit => unit = "restoreAllMocks" +@module("vitest") @scope("vi") external unstubEnvs: unit => unit = "unstubAllEnvs" +@module("vitest") @scope("vi") external unstubGlobals: unit => unit = "unstubAllGlobals" +@module("vitest") @scope("vi") external stubEnv: (string, string) => unit = "stubEnv" +@module("vitest") @scope("vi") +external stubFetch: (@as("fetch") _, string => promise) => unit = + "stubGlobal" + +type console +type mock +@val external console: console = "console" +@module("vitest") @scope("vi") external spyOn: (console, @as("error") _) => mock = "spyOn" +@send +external mockImplementation: (mock, (string, JsError.t) => unit) => unit = "mockImplementation" + +type sandbox = { + console: Dict.t, + parent: {window: {postMessage: (JSON.t, string) => unit}}, +} +type context +@module("node:vm") external createContext: sandbox => context = "createContext" +@module("node:vm") external runInContext: (string, context) => unit = "runInContext" + +afterEach(() => { + restoreMocks() + unstubEnvs() + unstubGlobals() +}) + +describe("compiler versions", () => { + test("sorts numeric version components and prerelease identifiers", async () => { + let versions = [ + "v12.9.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.10.0-alpha.10", + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + ] + expect(CompilerVersions.supported(versions)->Array.map(Semver.toString))->toEqual([ + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + "v12.10.0-alpha.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.9.10", + ]) + let stable = Semver.parse("v12.10.0")->Option.getOrThrow + expect(Semver.compare(stable, stable))->toBe(0.) + expect(CompilerVersions.latestStable(CompilerVersions.supported(versions)))->toEqual( + Some(stable), + ) + }) + + test("keeps the supported compiler range and ignores invalid versions", async () => { + expect( + CompilerVersions.supported([ + "invalid", + "v9.1.0", + "v10.0.1", + "v10.1.0", + "v11.1.3", + "v11.1.4", + "v11.2.0-beta.2", + "v11.2.0", + "v12.1.0-alpha.1", + "v12.1.0", + "v12.2.0-alpha.1", + "v13.0.0-alpha.1", + ])->Array.map(Semver.toString), + )->toEqual(["v13.0.0-alpha.1", "v12.2.0-alpha.1", "v12.1.0", "v11.2.0", "v11.1.4", "v10.1.0"]) + }) +}) + +describe("runtime imports", () => { + for_([ + ("v12.3.1", "./stdlib/Stdlib_Array.js", "v12.3.1", "Stdlib_Array.js"), + ("v13.0.0-alpha.1", "./stdlib/Stdlib_Array.mjs", "v13.0.0-alpha.1", "Stdlib_Array.js"), + ("v12.0.0-alpha.7", "./stdlib/core__Array.js", "v12.0.0-alpha.9", "Array.js"), + ("v12.0.0-alpha.8", "./stdlib/core__Array.js", "v12.0.0-alpha.9", "core__Array.js"), + ("v11.1.4", "./stdlib/core__Array.js", "v11.2.0-beta.2", "Core__Array.js"), + ])("maps %s imports consistently in both apps", async (( + version, + path, + runtimeVersion, + filename, + )) => { + let compilerVersion = Semver.parse(version)->Option.getOrThrow + let expected = `https://bundles.test/${runtimeVersion}/compiler-builtins/stdlib/${filename}` + expect( + CompilerRuntimeImport.url(~bundleBaseUrl="https://bundles.test", ~compilerVersion, path), + )->toBe(expected) + expect( + GuideRuntimeImport.url(~bundleBaseUrl="https://bundles.test", ~compilerVersion, path), + )->toBe(expected) + expect( + CompilerManagerHook.CdnMeta.getStdlibRuntimeUrl( + "https://bundles.test", + Semver.parse(runtimeVersion)->Option.getOrThrow, + filename, + ), + )->toBe(expected) + }) + + test("both transforms collect namespace imports without accepting lookalike paths", async () => { + let source = ` + import * as Arrays from "./stdlib/Stdlib_Array.js"; + import * as Other from "./stdlib-other/Other.js"; + import Default from "./stdlib/Default.js"; + import { value } from "./stdlib/Named.js"; + import "./stdlib/SideEffect.js"; + import * as React from "react"; + const answer = Arrays.length([]); + export { answer }; + ` + let expected = Dict.fromArray([("Arrays", "./stdlib/Stdlib_Array.js")]) + expect( + Babel.RuntimeImports.collect(Babel.Parser.parse(source, {sourceType: "module"})), + )->toEqual(expected) + let playground = Babel.PlaygroundValidator.validate( + Babel.Parser.parse(source, {sourceType: "module"}), + ) + let guide = GuideRuntimeTransform.transform(source)->Option.getOrThrow + expect(playground.imports)->toEqual(expected) + expect(guide.imports)->toEqual(expected) + expect(playground.code)->notToMatch(/\b(import|export)\b/) + expect(guide.code)->notToMatch(/\b(import|export)\b/) + }) + + test("preserves the playground React entry point detection", async () => { + let source = "function Playground$App() {} const App = {make: Playground$App}; export {App};" + expect( + Babel.PlaygroundValidator.validate( + Babel.Parser.parse(source, {sourceType: "module"}), + ).entryPointExists, + )->toBe(true) + }) +}) + +describe("console bridge", () => { + test("serializes iframe logs for the shared decoder, including null", async () => { + let messages = [] + let origins = [] + let sandbox = createContext({ + console: Dict.make(), + parent: { + window: { + postMessage: (message, origin) => { + messages->Array.push(message) + origins->Array.push(origin) + }, + }, + }, + }) + runInContext(RuntimeConsole.bridgeScript, sandbox) + runInContext( + `console.log(null, undefined, {answer: 42}, 5, true); console.warn("warning"); console.error("error")`, + sandbox, + ) + expect(origins)->toEqual(["*", "*", "*"]) + expect(messages->Array.map(RuntimeConsole.fromMessage))->toEqual([ + Some({level: #log, content: ["null", "undefined", `{"answer":42}`, "5", "true"]}), + Some({level: #warn, content: ["warning"]}), + Some({level: #error, content: ["error"]}), + ]) + expect( + messages + ->Array.getUnsafe(0) + ->RuntimeConsole.fromMessage + ->Option.getOrThrow + ->RuntimeConsole.text, + )->toBe(`null undefined {"answer":42} 5 true`) + }) + + for_([ + "null", + "[]", + `"message"`, + "{}", + `{"type":"other","args":[]}`, + `{"type":"log","args":"text"}`, + `{"type":"log","args":[null]}`, + `{"type":"log","args":[{}]}`, + ])("ignores malformed or unrelated messages: %s", async message => { + expect(message->JSON.parseOrThrow->RuntimeConsole.fromMessage)->toBe(None) + }) +}) + +describe("compiler metadata", () => { + test( + "uses same-origin bundles in production, the CDN in development, and explicit overrides", + async () => { + expect( + CompilerData.endpoints( + ~location=SameOriginInProduction, + ~nodeEnv="production", + ~override=None, + ), + )->toEqual({ + bundleBaseUrl: "/playground-bundles", + versionsBaseUrl: CompilerData.cdnBaseUrl, + }) + expect( + CompilerData.endpoints( + ~location=SameOriginInProduction, + ~nodeEnv="development", + ~override=None, + ), + )->toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }) + expect( + CompilerData.endpoints(~location=Remote, ~nodeEnv="production", ~override=None), + )->toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }) + expect( + CompilerData.endpoints( + ~location=SameOriginInProduction, + ~nodeEnv="production", + ~override=Some("https://override.test"), + ), + )->toEqual({ + bundleBaseUrl: "https://override.test", + versionsBaseUrl: "https://override.test", + }) + }, + ) + + test("fetches metadata from the override without changing the guide pin", async () => { + stubEnv("PLAYGROUND_BUNDLE_ENDPOINT", "https://override.test") + let urls = [] + stubFetch( + url => { + urls->Array.push(url) + Promise.resolve(WebAPI.Response.fromString(`["v13.0.0","v12.3.1"]`)) + }, + ) + expect(await CompilerData.load(~location=SameOriginInProduction))->toEqual( + Some({ + bundleBaseUrl: "https://override.test", + versions: ["v13.0.0", "v12.3.1"], + }), + ) + expect(urls)->toEqual(["https://override.test/playground-bundles/versions.json"]) + expect(GuideCompilerData.load())->toEqual({bundleBaseUrl: "https://override.test"}) + expect(GuideCompilerSettings.version)->toBe("v12.3.1") + expect(urls->Array.length)->toBe(1) + }) + + test("rejects an HTTP error even if its body looks like metadata", async () => { + stubFetch(_ => Promise.resolve(WebAPI.Response.fromString("[]", ~init={status: 503}))) + await expect(CompilerData.fetchVersions("https://bundles.test"))->rejectsWith("HTTP 503") + }) + + for_(["{", "{}", `["v12.3.1",123]`])("rejects invalid metadata: %s", async body => { + stubFetch(_ => Promise.resolve(WebAPI.Response.fromString(body))) + await expect(CompilerData.fetchVersions("https://bundles.test"))->rejectsWithError + }) + + test("returns the loader's failure state when the network is unavailable", async () => { + stubFetch(async _ => JsError.throwWithMessage("offline")) + let errors = [] + spyOn(console)->mockImplementation( + (message, error) => { + errors->Array.push((message, error->JsError.message)) + }, + ) + expect(await CompilerData.load(~location=SameOriginInProduction))->toBe(None) + expect(errors)->toEqual([("error while fetching compiler versions", "offline")]) + }) +})