From 0a484411518916c3abc96319b4e24dd65f95ca74 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 9 Oct 2026 22:47:13 +0200 Subject: [PATCH 1/3] fix: wait for runtime output before completing guide checkpoint Start every lesson with empty output instead of pre-completing the first checkpoint. Cover compiler loading and failure, iframe output, saved progress, and nonmatching output. Arrange navigation tests with explicit progress and isolate their storage. Fixes #1403 Part of #1421. --- apps/guide/__tests__/GuideHome_.test.res | 152 +++++++++++++++---- apps/guide/__tests__/GuideTestFixtures.res | 21 ++- apps/guide/__tests__/compilerFixture.js | 70 +++++++++ apps/guide/app/GuideCompilerFeedback.res | 2 +- apps/guide/app/GuideLessonNavigationHook.res | 13 +- apps/guide/vitest.setup.mjs | 2 + 6 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 apps/guide/__tests__/compilerFixture.js diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res index 5833174f4..877a800f9 100644 --- a/apps/guide/__tests__/GuideHome_.test.res +++ b/apps/guide/__tests__/GuideHome_.test.res @@ -5,7 +5,36 @@ let secondLesson = GuideTestFixtures.secondLesson let guideLessonsWithFinal = GuideTestFixtures.guideLessonsWithFinal let renderGuideHome = GuideTestFixtures.renderGuideHome let renderGuideHomeWithDocsIntroNavigation = GuideTestFixtures.renderGuideHomeWithDocsIntroNavigation -let renderGuideHomeInBrowser = GuideTestFixtures.renderGuideHomeInBrowser +let renderGuideHomeWithHistory = GuideTestFixtures.renderGuideHomeWithHistory + +@module("vitest") external beforeEach: (unit => unit) => unit = "beforeEach" +@module("vitest") external afterEach: (unit => unit) => unit = "afterEach" + +@module("./compilerFixture.js") +external waitForCompilerRequest: unit => promise = "waitForCompilerRequest" +@module("./compilerFixture.js") external failCompilerLoading: unit => unit = "failCompilerLoading" +@module("./compilerFixture.js") +external finishCompilerLoading: string => unit = "finishCompilerLoading" +@module("./compilerFixture.js") +external finishCompilerLoadingWithError: unit => unit = "finishCompilerLoadingWithError" +@module("./compilerFixture.js") external expectCompiled: string => unit = "expectCompiled" + +let clearGuideState = () => { + GuideLayout.clearCompletedExercises() + GuideTestFixtures.guideLessonsWithFinal->Array.forEach(lesson => + GuideLayout.clearExerciseCode(lesson.exercise.id) + ) + GuideLayout.removeLocalStorageItem(GuideLayout.themeStorageKey) + GuideLayout.clearPaneSizes() +} + +beforeEach(clearGuideState) +afterEach(clearGuideState) + +let compilerData: GuideCompilerData.t = { + bundleBaseUrl: "/test-compiler", + versions: ["12.2.0"], +} test("loads saved guide editor code into the editor", async () => { await viewport(1440, 900) @@ -84,19 +113,103 @@ test("shows the guide workspace at the desktop minimum width", async () => { await shell->element->toBeVisible }) -test("shows the first checkpoint as complete when output matches", async () => { +test("keeps the first checkpoint pending without a compiler", async () => { await viewport(1440, 900) let screen = await renderGuideHome() - let checkpoint = await screen->getByText("Checkpoint complete") + await (await screen->getByText("Waiting for matching output"))->element->toBeVisible + await (await screen->getByText("Next"))->element->toBeDisabled + await (await screen->getByTestId("guide-output"))->element->toHaveTextContent("") + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(false) +}) - await checkpoint->element->toBeVisible +test( + "keeps the first checkpoint pending while the compiler loads and after loading fails", + async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome(~compilerData, ()) + await waitForCompilerRequest() + await (await screen->getByText("Waiting for matching output"))->element->toBeVisible + await (await screen->getByText("Next"))->element->toBeDisabled + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(false) + + failCompilerLoading() + + await (await screen->getByText("Compiler setup failed"))->element->toBeVisible + await (await screen->getByText("Waiting for matching output"))->element->toBeVisible + await (await screen->getByText("Next"))->element->toBeDisabled + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(false) + }, +) + +test( + "completes and saves the first checkpoint after compiled code produces matching runtime output", + async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome(~compilerData, ()) + await waitForCompilerRequest() + await (await screen->getByText("Next"))->element->toBeDisabled + + finishCompilerLoading(`let greeting = "hello, world!";`) + + let checkpoint = await screen->getByText("Checkpoint complete") + await checkpoint->element->toBeVisible + let outputPanel = await screen->getByTestId("guide-output") + await (await outputPanel->getByText("hello, world!"))->element->toBeVisible + await (await screen->getByText("Next"))->element->notToBeDisabled + expectCompiled(firstLesson.exercise.initialCode) + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(true) + + await screen->unmount + let restoredScreen = await renderGuideHome() + await (await restoredScreen->getByText("Checkpoint complete"))->element->toBeVisible + await (await restoredScreen->getByText("Next"))->element->notToBeDisabled + await (await restoredScreen->getByTestId("guide-output"))->element->toHaveTextContent("") + }, +) + +test("keeps the first checkpoint pending when compilation fails", async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome(~compilerData, ()) + await waitForCompilerRequest() + finishCompilerLoadingWithError() + + await (await screen->getByText("Compiler error"))->element->toBeVisible + await (await screen->getByText("Compilation failed"))->element->toBeVisible + await (await screen->getByText("Waiting for matching output"))->element->toBeVisible + await (await screen->getByText("Next"))->element->toBeDisabled + expectCompiled(firstLesson.exercise.initialCode) + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(false) }) +test( + "keeps the first checkpoint pending when executed code produces different output", + async () => { + await viewport(1440, 900) + let code = `let greeting = "goodbye"` + GuideLayout.saveExerciseCode(~exerciseId=firstLesson.exercise.id, ~code) + + let screen = await renderGuideHome(~compilerData, ()) + await waitForCompilerRequest() + finishCompilerLoading(`let greeting = "goodbye";`) + + let outputPanel = await screen->getByTestId("guide-output") + await (await outputPanel->getByText("goodbye"))->element->toBeVisible + await (await screen->getByText("Waiting for matching output"))->element->toBeVisible + await (await screen->getByText("Next"))->element->toBeDisabled + expectCompiled(code) + expect(GuideLayout.isExerciseCompleted(firstLesson.exercise.id))->toBe(false) + }, +) + test("navigates to the function argument page", async () => { await viewport(1440, 900) GuideLayout.clearCompletedExercises() GuideLayout.clearExerciseCode(secondLesson.exercise.id) + GuideLayout.saveCompletedExercise(firstLesson.exercise.id) let screen = await renderGuideHome() let nextButton = await screen->getByText("Next") @@ -117,16 +230,12 @@ test("navigates to the function argument page", async () => { GuideLayout.clearExerciseCode(secondLesson.exercise.id) }) -let guideTestUrl = hash => window.location.pathname ++ window.location.search ++ hash - -let resetGuideTestUrl = () => - WebAPI.History.replaceState(window.history, ~data=JSON.Null, ~unused="", ~url=guideTestUrl("")) - test("shows Back before lesson forward actions and returns to the previous lesson", async () => { await viewport(1440, 900) GuideLayout.clearCompletedExercises() GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) + GuideLayout.saveCompletedExercise(firstLesson.exercise.id) let screen = await renderGuideHome(~initialEntries=["/#first-contact"], ()) let firstLessonText = screen->container->textContent->Nullable.toOption->Option.getOrThrow @@ -202,37 +311,25 @@ test("Done on a completed final lesson opens the ReScript docs intro", async () GuideLayout.clearExerciseCode(secondLesson.exercise.id) }) -test("browser back returns to the previous guide lesson", async () => { +test("history back returns to the previous guide lesson", async () => { await viewport(1440, 900) GuideLayout.clearCompletedExercises() GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) - WebAPI.History.replaceState( - window.history, - ~data=JSON.Null, - ~unused="", - ~url=guideTestUrl("#guide-test-start"), - ) - WebAPI.History.pushState( - window.history, - ~data=JSON.Null, - ~unused="", - ~url=guideTestUrl("#first-contact"), - ) + GuideLayout.saveCompletedExercise(firstLesson.exercise.id) - let screen = await renderGuideHomeInBrowser() + let screen = await renderGuideHomeWithHistory() await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible await (await screen->getByText("Next"))->click await (await screen->getByText("Call A Function"))->element->toBeVisible - WebAPI.History.back(window.history) + await (await screen->getByText("History back"))->click await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) - resetGuideTestUrl() }) test("stretches the output surface to the full output panel", async () => { @@ -244,7 +341,7 @@ test("stretches the output surface to the full output panel", async () => { await output->element->toHaveClass("guide-output-frame") }) -test("renders the first guide MVP exercise and output", async () => { +test("renders the first guide MVP exercise with empty output", async () => { await viewport(1440, 900) let screen = await renderGuideHome() @@ -261,6 +358,5 @@ test("renders the first guide MVP exercise and output", async () => { let editorCode = await screen->getByText("let greeting = \"hello, world!\"") await editorCode->element->toBeVisible let outputPanel = await screen->getByTestId("guide-output") - let output = await outputPanel->getByText("hello, world!") - await output->element->toBeVisible + await outputPanel->element->toHaveTextContent("") }) diff --git a/apps/guide/__tests__/GuideTestFixtures.res b/apps/guide/__tests__/GuideTestFixtures.res index b3d199fb9..eb3c3f699 100644 --- a/apps/guide/__tests__/GuideTestFixtures.res +++ b/apps/guide/__tests__/GuideTestFixtures.res @@ -57,10 +57,10 @@ let finalLesson: GuideLesson.t = { let guideLessons = [firstLesson, secondLesson] let guideLessonsWithFinal = [firstLesson, secondLesson, finalLesson] -let renderGuideHome = (~initialEntries=["/"], ()) => +let renderGuideHome = (~initialEntries=["/"], ~compilerData=?, ()) => render( - + , ) @@ -71,9 +71,20 @@ let renderGuideHomeWithDocsIntroNavigation = (goToDocsIntro, ~initialEntries=["/ , ) -let renderGuideHomeInBrowser = () => +module HistoryBackButton = { + @module("react-router") external useNavigate: unit => int => unit = "useNavigate" + + @react.component + let make = () => { + let navigate = useNavigate() + + } +} + +let renderGuideHomeWithHistory = () => render( - + - , + + , ) diff --git a/apps/guide/__tests__/compilerFixture.js b/apps/guide/__tests__/compilerFixture.js new file mode 100644 index 000000000..0ad7af476 --- /dev/null +++ b/apps/guide/__tests__/compilerFixture.js @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, expect, vi } from "vitest"; + +const { loadScript, compile } = vi.hoisted(() => ({ + loadScript: vi.fn(), + compile: vi.fn(), +})); + +// Control the CDN boundary while exercising the real compiler manager, bridge, +// runtime transform, and iframe. No compiler bundles are fetched. +vi.mock("../../../packages/playground/ffi/loadScript.js", () => ({ + default: loadScript, + removeScript: vi.fn(), +})); + +let compilerRequest; + +beforeEach(() => { + compilerRequest = undefined; + compile.mockReset(); + loadScript.mockImplementation((src, onSuccess, onError) => { + if (src.endsWith("/compiler.js")) { + compilerRequest = { onSuccess, onError }; + } else { + onSuccess(); + } + return () => {}; + }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +export async function waitForCompilerRequest() { + await expect.poll(() => compilerRequest).toBeDefined(); +} + +export function failCompilerLoading() { + compilerRequest.onError(new Error("Compiler unavailable")); +} + +export function finishCompilerLoading(jsCode) { + finishLoading({ + type: "success", + js_code: jsCode, + warnings: [], + type_hints: [], + }); +} + +export function finishCompilerLoadingWithError() { + finishLoading({ type: "unexpected_error", msg: "Compilation failed" }); +} + +function finishLoading(result) { + compile.mockReturnValue(result); + vi.stubGlobal("rescript_compiler", { + api_version: "8", + make: () => ({ + version: "12.2.0", + getConfig: () => ({ moduleSystem: "esmodule", warnFlags: "" }), + setModuleSystem: () => true, + setWarnFlags: () => true, + rescript: { compile }, + }), + }); + compilerRequest.onSuccess(); +} + +export function expectCompiled(code) { + expect(compile).toHaveBeenCalledWith(code); +} diff --git a/apps/guide/app/GuideCompilerFeedback.res b/apps/guide/app/GuideCompilerFeedback.res index 2fd329729..0b2e7a6d0 100644 --- a/apps/guide/app/GuideCompilerFeedback.res +++ b/apps/guide/app/GuideCompilerFeedback.res @@ -22,7 +22,7 @@ module Output = { runtimeLogs, } - let initial = make(~status="Output", ~runtimeLogs=[{level: #log, content: ["hello, world!"]}]) + let initial = make(~status="Output") let withRuntimeLog = (output, runtimeLog) => { ...output, diff --git a/apps/guide/app/GuideLessonNavigationHook.res b/apps/guide/app/GuideLessonNavigationHook.res index a6ef996d7..48ffef2c4 100644 --- a/apps/guide/app/GuideLessonNavigationHook.res +++ b/apps/guide/app/GuideLessonNavigationHook.res @@ -10,22 +10,13 @@ type t = { goToNextLesson: ReactEvent.Mouse.t => unit, } -let emptyOutput = () => GuideCompilerFeedback.Output.make(~status="Output") - -let outputForLessonIndex = index => - if index === 0 { - GuideCompilerFeedback.Output.initial - } else { - emptyOutput() - } - let docsIntroUrl = "https://rescript-lang.org/docs/manual/introduction" let navigateToLesson = (~goToHash, ~setLessonIndex, ~setOutput, ~lessons, index) => { let lesson = GuideLesson.lessonAt(~lessons, index) goToHash(lesson->GuideLesson.hashForLesson) setLessonIndex(_ => index) - setOutput(_ => index->outputForLessonIndex) + setOutput(_ => GuideCompilerFeedback.Output.initial) } let useLessonNavigation = (~lessons, ~goToDocsIntro): t => { @@ -42,7 +33,7 @@ let useLessonNavigation = (~lessons, ~goToDocsIntro): t => { let nextLessonHash = nextLesson->GuideLesson.hashForLesson setLessonIndex(_ => nextLessonIndex) - setOutput(_ => nextLessonIndex->outputForLessonIndex) + setOutput(_ => GuideCompilerFeedback.Output.initial) // Keep the hash canonical so direct links, browser back, and MemoryRouter tests share one path. if currentHash !== nextLessonHash { diff --git a/apps/guide/vitest.setup.mjs b/apps/guide/vitest.setup.mjs index 9dc01a105..b3cbaca37 100644 --- a/apps/guide/vitest.setup.mjs +++ b/apps/guide/vitest.setup.mjs @@ -1 +1,3 @@ import "./styles/main.css"; +// Register the compiler CDN mock before components import the script loader. +import "./__tests__/compilerFixture.js"; From 45bee615e9c76db6364550a76b4b63f2723ec0f8 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 9 Oct 2026 22:52:31 +0200 Subject: [PATCH 2/3] test: implement compiler configuration methods in guide fixture Match the API v8 configuration shape and setters used during compiler setup so checkpoint regressions reach compilation and iframe execution. --- apps/guide/__tests__/compilerFixture.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/guide/__tests__/compilerFixture.js b/apps/guide/__tests__/compilerFixture.js index 0ad7af476..0804ebf6e 100644 --- a/apps/guide/__tests__/compilerFixture.js +++ b/apps/guide/__tests__/compilerFixture.js @@ -56,9 +56,12 @@ function finishLoading(result) { api_version: "8", make: () => ({ version: "12.2.0", - getConfig: () => ({ moduleSystem: "esmodule", warnFlags: "" }), + getConfig: () => ({ module_system: "esmodule", warn_flags: "" }), setModuleSystem: () => true, setWarnFlags: () => true, + setOpenModules: () => true, + setExperimentalFeatures: () => true, + setJsxPreserveMode: () => true, rescript: { compile }, }), }); From 3534fad074439b13eede919012371445a13fc8dd Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Wed, 14 Oct 2026 22:14:45 +0200 Subject: [PATCH 3/3] test: seed guide browser progress in the stored format Use the completedExerciseIds object expected by GuideLayout and check restored completion before navigating. The checkpoint fix no longer masks malformed saved progress with prefilled output. --- apps/guide/e2e/navigation.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/guide/e2e/navigation.test.mjs b/apps/guide/e2e/navigation.test.mjs index 10a335aaf..43f3c5ae8 100644 --- a/apps/guide/e2e/navigation.test.mjs +++ b/apps/guide/e2e/navigation.test.mjs @@ -7,7 +7,7 @@ test("browser Back and Forward restore the guide lesson and saved code", async ( await page.addInitScript((code) => { localStorage.setItem( "rescript-guide:v1:progress", - JSON.stringify(["first-contact/greeting"]), + JSON.stringify({ completedExerciseIds: ["first-contact/greeting"] }), ); localStorage.setItem( "rescript-guide:v1:exercise:first-contact/greeting", @@ -20,6 +20,9 @@ test("browser Back and Forward restore the guide lesson and saved code", async ( page.getByRole("heading", { name: "Learn ReScript Guide", exact: true }), ).toBeVisible(); await expect(page.getByTestId("guide-code-editor")).toContainText(savedCode); + await expect(page.getByTestId("guide-check-status")).toContainText( + "Checkpoint complete", + ); await page.getByRole("button", { name: "Next", exact: true }).click(); await expect(page).toHaveURL(/#functions$/);