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..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/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..10d29ed11 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -1,22 +1,24 @@ 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)) } + await GuideLessonValidation.validate(~bundleBaseUrl=compilerData.bundleBaseUrl) + {compilerData, lessons} } @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..1727d0cfa --- /dev/null +++ b/apps/guide/scripts/lesson-compiler.mjs @@ -0,0 +1,113 @@ +import vm from "node:vm"; +import { build } from "esbuild"; +import { serializeArg } from "../../../packages/shared/src/runtimeConsole.js"; + +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; +} + +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/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()});`; 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"