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 @live let loader: unit => promise> diff --git a/apps/guide/README.md b/apps/guide/README.md index 9504eec65..30bf2bf66 100644 --- a/apps/guide/README.md +++ b/apps/guide/README.md @@ -9,30 +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 | -| `public/` | Guide static assets, including `robots.txt` | -| `__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 | +| `public/` | Guide static assets, including `robots.txt` | +| `__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 | Signed 32-bit integer lesson order | -| `id` | Yes | Stable URL-safe hash and lesson identifier | -| `missionLabel` | Yes | Short lesson label | -| `title` | Yes | Lesson heading | -| `exercise.id` | Yes | Stable identifier for saved code and completion | -| `exercise.initialCode` | Yes | Initial editor contents | -| `exercise.expectedOutput` | Yes | Matching runtime log line that unlocks the next lesson | +| Field | Required | Meaning | +| ------------------------------------ | -------- | -------------------------------------------------------- | +| `position` | Yes | Signed 32-bit integer lesson order | +| `id` | Yes | Stable URL-safe hash and lesson identifier | +| `missionLabel` | Yes | Short lesson label | +| `title` | Yes | Lesson heading | +| `exercise.id` | Yes | Stable identifier for saved code and completion | +| `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 | The build rejects malformed fields and duplicate positions, lesson IDs, or exercise IDs. Errors identify the source lesson file and field. Source paths @@ -50,10 +53,45 @@ Lesson IDs must be URL-safe slugs made from lowercase ASCII letters and digits, with single hyphens between words. Exercise IDs may contain one or more such slugs separated by single slashes, such as `first-contact/greeting`. -Every exercise requires a deterministic `expectedOutput` string. Numeric or -boolean output must be quoted in YAML, for example `expectedOutput: "42"`. -Matching is exact: empty strings and whitespace are preserved. Manual checks -are not supported. +Every published exercise needs a deterministic `expectedOutput` string. The +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. + +Numeric or boolean output must be quoted in YAML, for example +`expectedOutput: "42"`. Matching is exact: empty strings and whitespace are +preserved. Manual checks are not supported. + +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. + +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 + 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 @@ -76,17 +114,28 @@ yarn build:guide yarn workspace @rescript-lang/guide ci:test ``` -The CI test command validates frontmatter and the complete lesson collection -in Node before running the browser tests. After compiling ReScript, run -`yarn workspace @rescript-lang/guide test:lessons` for just the lesson checks. - -`yarn build:guide` compiles ReScript and pre-renders `/` into -`apps/guide/build/client/`, the assets directory configured in `wrangler.toml`. -Vite serves and copies the guide's `public/` directory, including `robots.txt`. -The root route imports the shared docs favicons so Vite emits them with the -guide's other assets. 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 pre-renders `/` into `apps/guide/build/client/`, the assets directory configured +in `wrangler.toml`. Vite serves and copies the guide's `public/` directory, +including `robots.txt`. The root route imports the shared docs favicons so Vite +emits them with the guide's other assets. + +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 CI test command validates frontmatter, the complete lesson collection, and +compiler output in Node before running the browser tests. After compiling +ReScript, run `yarn workspace @rescript-lang/guide test:lessons` for just the +lesson authoring checks, or `yarn workspace @rescript-lang/guide test:compiler` +for the compiler checks. + +In the browser, the output panel reports a failed compiler download or a +15-second loading timeout with instructions to reload. ## Current Capability @@ -107,6 +156,7 @@ show a stop screen rather than a usable guide. Track outstanding guide launch work in this checklist: - [ ] Author, review, and run every lesson using its real expected output. +- [x] Compile starters and check reference solution output against a pinned compiler. - [x] 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..b07e31a06 --- /dev/null +++ b/apps/guide/__tests__/GuideCompilerLoading_.test.res @@ -0,0 +1,47 @@ +open Vitest + +@module("./compilerFixture.js") +external waitForCompilerRequest: unit => promise = "waitForCompilerRequest" +@module("./compilerFixture.js") external failCompilerLoading: unit => unit = "failCompilerLoading" + +@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) + let screen = await renderWithCompiler() + await waitForCompilerRequest() + failCompilerLoading() + 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) + 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 61a3ee575..a902d271d 100644 --- a/apps/guide/__tests__/GuideCompilerSettings_.test.res +++ b/apps/guide/__tests__/GuideCompilerSettings_.test.res @@ -1,14 +1,8 @@ open Vitest -test("selects the latest stable ReScript compiler with ESM output", async () => { - let version = - ["v12.1.0-alpha.1", "v11.1.4", "v12.0.0", "v12.2.0-beta.1", "v12.1.3"] - ->GuideCompilerSettings.supportedVersions - ->GuideCompilerSettings.latestStableParsedVersion - ->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__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res index 43852937d..62632eadf 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 new file mode 100644 index 000000000..dc0cd973f --- /dev/null +++ b/apps/guide/__tests__/compiler/GuideLessons_.test.res @@ -0,0 +1,361 @@ +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 beforeAll: (unit => promise, int) => unit = "beforeAll" +@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 @scope("not") external notToContain: (assertion, string) => unit = "toContain" +@send external toThrow: (assertion 'a>, string) => unit = "toThrow" +@send @scope("rejects") +external rejectsWith: (assertion>, string) => promise = "toThrow" +@send @scope("resolves") +external resolvesToUnit: assertion> => promise = "toBeUndefined" +@module("vitest") @scope("vi") +external stubFetch: (@as("fetch") _, string => promise) => unit = + "stubGlobal" +@module("vitest") @scope("vi") external unstubGlobals: unit => unit = "unstubAllGlobals" + +let bundleBaseUrl = "https://cdn.rescript-lang.org" +let compiler = ref(None) + +beforeAll(async () => { + compiler.contents = Some( + await GuideLessonValidation.loadCompiler( + ~bundleBaseUrl, + ~version=GuideCompilerSettings.version, + ~moduleSystem=GuideCompilerSettings.moduleSystem, + ~warnFlags=GuideCompilerSettings.warnFlags, + ), + ) +}, 60000) +afterEach(unstubGlobals) + +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 => + GuideLessonValidation.validateExercise( + ~compiler=compiler.contents->Option.getOrThrow, + ~bundleBaseUrl, + exercise, + ) + +let loadCompiler = bundleBaseUrl => + GuideLessonValidation.loadCompiler( + ~bundleBaseUrl, + ~version=GuideCompilerSettings.version, + ~moduleSystem=GuideCompilerSettings.moduleSystem, + ~warnFlags=GuideCompilerSettings.warnFlags, + ) + +test("the pinned browser compiler validates every real lesson", async () => { + expect(compiler.contents->Option.getOrThrow->RescriptCompilerApi.Compiler.version)->toBe( + GuideCompilerSettings.version->String.slice(~start=1), + ) + await expect(GuideLessonValidation.validate(~bundleBaseUrl))->resolvesToUnit + let published = GuideLessonContent.load()->JSON.stringifyAny->Option.getOrThrow + expect(published)->notToContain("solutionCode") + expect(published)->notToContain(`greet(\\"Spock\\")`) +}) + +test("invalid starter code fails even when its reference solution is valid", async () => { + await expect(validateExercise(exercise(~initialCode="let x =", ())))->rejectsWith( + "test-lesson.mdx exercise.initialCode does not compile", + ) +}) + +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", + ) +}) + +test("output mismatches fail with the expected and actual strings", async () => { + await expect( + validateExercise(exercise(~expectedOutput="hello ", ())), + )->rejectsWith(`exercise.expectedOutput "hello " did not match solution output "hello"`) +}) + +test("runtime failures identify the reference solution", async () => { + await expect( + validateExercise(exercise(~solutionCode=`JsError.throwWithMessage("boom")`, ())), + )->rejectsWith("test-lesson.mdx exercise.solutionCode failed at runtime: boom") +}) + +test("solutions execute against the pinned compiler's published runtime imports", async () => { + await expect( + validateExercise( + exercise( + ~solutionCode="let values = [1, 2]->Array.map(value => value + 1)", + ~expectedOutput="[2,3]", + (), + ), + ), + )->resolvesToUnit +}) + +test("final expressions use the browser's source instrumentation", async () => { + await expect( + validateExercise( + exercise( + ~solutionCode=`let greeting = "starter"\nInt.toString(42)`, + ~expectedOutput="42", + (), + ), + ), + )->resolvesToUnit +}) + +test("multiple console arguments match the browser checkpoint serialization", async () => { + await expect( + validateExercise( + exercise(~solutionCode=`Console.log2("answer", 42)`, ~expectedOutput="answer 42", ()), + ), + )->resolvesToUnit +}) + +test("runaway solutions stop with a timeout", async () => { + await expect( + GuideLessonValidation.runProgram({code: "while (true) {}", imports: Dict.make()}), + )->rejectsWith("timed out") +}) + +test("missing compiler assets fail with the URL and HTTP status", async () => { + stubFetch(async _url => WebAPI.Response.fromString("missing", ~init={status: 404})) + await expect(loadCompiler("https://missing-guide.test"))->rejectsWith( + `https://missing-guide.test/${GuideCompilerSettings.version}/compiler.js: HTTP 404`, + ) +}) + +test("a bundle claiming a different compiler version is rejected", async () => { + stubFetch(async url => { + let code = + url->String.endsWith("/compiler.js") + ? `globalThis.rescript_compiler = {make: () => ({version: "13.0.0"})};` + : "" + WebAPI.Response.fromString(code) + }) + await expect(loadCompiler("https://wrong-guide.test"))->rejectsWith( + `Expected guide compiler ${GuideCompilerSettings.version}, received 13.0.0`, + ) +}) + +for_([ + None, + Some(JSON.Number(42.0)), + Some(JSON.Boolean(false)), +])( + "reference validation rejects missing or non-string expected output: %s", + async expectedOutput => { + let field = switch expectedOutput { + | None => "" + | Some(value) => ` expectedOutput: ${value->JSON.stringify}\n` + } + let raw = `---\nexercise:\n initialCode: let x = 1\n${field}---\n` + expect(() => GuideLessonValidation.fromRaw(~raw, ~sourcePath="broken.mdx"))->toThrow( + "broken.mdx exercise.expectedOutput must be a string", + ) + }, +) + +test("reference code defaults to the starter and preserves an empty expected output", async () => { + let parsed = GuideLessonValidation.fromRaw( + ~raw=`---\nexercise:\n initialCode: let x = ""\n expectedOutput: ""\n---\n`, + ~sourcePath="empty.mdx", + ) + 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( + ~raw=`---\nexercise:\n initialCode: let x = 1\n expectedOutput: "1"\n solutionCode: ""\n---\n`, + ~sourcePath="empty.mdx", + ) + )->toThrow("empty.mdx exercise.solutionCode must be a non-empty string") +}) 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")]) + }) +}) 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/__tests__/compilerLoadingFixture.js b/apps/guide/__tests__/compilerLoadingFixture.js new file mode 100644 index 000000000..bacc376e0 --- /dev/null +++ b/apps/guide/__tests__/compilerLoadingFixture.js @@ -0,0 +1,14 @@ +import { afterEach, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +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..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 3f1d81c36..62d6f5aac 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 57e2c539f..52bdebc67 100644 --- a/apps/guide/app/GuideCompilerSettings.res +++ b/apps/guide/app/GuideCompilerSettings.res @@ -1,45 +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 loadingTimeoutMs = 15000 diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res index 1462ecde6..6aebc26dc 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 56ec4bfbb..b27a57825 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 a338c2e1b..ecd652700 100644 --- a/apps/guide/app/GuideLesson.res +++ b/apps/guide/app/GuideLesson.res @@ -46,7 +46,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) => output.runtimeLogs->Array.some(runtimeLog => diff --git a/apps/guide/app/GuideLessonValidation.res b/apps/guide/app/GuideLessonValidation.res new file mode 100644 index 000000000..763016264 --- /dev/null +++ b/apps/guide/app/GuideLessonValidation.res @@ -0,0 +1,174 @@ +// 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") +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 expectedInitialDiagnostic = switch dict->Dict.get("expectedInitialDiagnostic") { + | None => None + | Some(_) => Some(readString(~dict, ~sourcePath, ~key="expectedInitialDiagnostic")) + } + let solutionCode = switch dict->Dict.get("solutionCode") { + | 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") + } + { + sourcePath, + initialCode, + solutionCode, + expectedOutput: readString(~dict, ~sourcePath, ~key="expectedOutput", ~allowEmpty=true), + expectedInitialDiagnostic, + } + } +} + +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 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, 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, + ~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/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/apps/guide/app/lessons/02-functions.mdx b/apps/guide/app/lessons/02-functions.mdx index f61c10f59..9896aef9c 100644 --- a/apps/guide/app/lessons/02-functions.mdx +++ b/apps/guide/app/lessons/02-functions.mdx @@ -10,6 +10,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 fd319ca9b..8dc28cfe3 100644 --- a/apps/guide/package.json +++ b/apps/guide/package.json @@ -8,9 +8,10 @@ "build:res": "node ../docs/generate-route-types.mjs && 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 test:lessons && yarn vitest --run --browser.headless", + "ci:test": "yarn build:res && yarn test:lessons && yarn test:compiler && yarn vitest --run --browser.headless", "dev:vite": "yarn build:res && vite --host", "vitest": "vitest", + "test:compiler": "vitest run --config vitest.compiler.config.mjs", "test:lessons": "vitest run --config vitest.lessons.config.mjs", "test:e2e": "yarn build && playwright test" }, @@ -41,6 +42,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..7e0029d33 --- /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"], + testTimeout: 60000, + expect: { requireAssertions: true }, + }, +}); 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 aaee8db3d..4755c15bd 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 8897b0047..baaf17b64 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -1581,39 +1581,12 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { let (searchParams, _) = ReactRouter.useSearchParams() let containerRef = React.useRef(Nullable.null) let editorRef: React.ref> = React.useRef(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 68360b24d..4314e2153 100644 --- a/packages/shared/src/Babel.res +++ b/packages/shared/src/Babel.res @@ -58,13 +58,6 @@ module Ast = { | StringLiteral({value}) => value } - let specifierLocal = (specifier: Specifier.t) => - switch specifier { - | ImportSpecifier({local}) - | ImportDefaultSpecifier({local}) - | ImportNamespaceSpecifier({local}) => local - } - let variableDeclaratorId = (variableDeclarator: VariableDeclarator.t) => switch variableDeclarator { | VariableDeclarator({id}) => id @@ -101,6 +94,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, @@ -110,26 +122,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 72573289d..05b2c8f01 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 Os = { 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()});`; diff --git a/yarn.lock b/yarn.lock index a0558cc2c..a96118547 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1886,6 +1886,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"