diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res index 220dc7bf3..877a800f9 100644 --- a/apps/guide/__tests__/GuideHome_.test.res +++ b/apps/guide/__tests__/GuideHome_.test.res @@ -7,6 +7,35 @@ let renderGuideHome = GuideTestFixtures.renderGuideHome let renderGuideHomeWithDocsIntroNavigation = GuideTestFixtures.renderGuideHomeWithDocsIntroNavigation 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) let exerciseId = firstLesson.exercise.id @@ -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") @@ -122,6 +235,7 @@ test("shows Back before lesson forward actions and returns to the previous lesso 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,6 +316,8 @@ test("history back returns to the previous guide lesson", async () => { GuideLayout.clearCompletedExercises() GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) + GuideLayout.saveCompletedExercise(firstLesson.exercise.id) + let screen = await renderGuideHomeWithHistory() await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible @@ -225,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() @@ -242,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 e92e5f7b4..ff261bfe1 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( - + , ) diff --git a/apps/guide/__tests__/compilerFixture.js b/apps/guide/__tests__/compilerFixture.js new file mode 100644 index 000000000..0804ebf6e --- /dev/null +++ b/apps/guide/__tests__/compilerFixture.js @@ -0,0 +1,73 @@ +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: () => ({ module_system: "esmodule", warn_flags: "" }), + setModuleSystem: () => true, + setWarnFlags: () => true, + setOpenModules: () => true, + setExperimentalFeatures: () => true, + setJsxPreserveMode: () => 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/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$/); 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";