Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 121 additions & 6 deletions apps/guide/__tests__/GuideHome_.test.res
Original file line number Diff line number Diff line change
Expand Up @@ -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<unit> = "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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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("")
})
4 changes: 2 additions & 2 deletions apps/guide/__tests__/GuideTestFixtures.res
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ReactRouter.MemoryRouter initialEntries>
<GuideHome lessons=guideLessons />
<GuideHome lessons=guideLessons ?compilerData />
</ReactRouter.MemoryRouter>,
)

Expand Down
73 changes: 73 additions & 0 deletions apps/guide/__tests__/compilerFixture.js
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion apps/guide/app/GuideCompilerFeedback.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 2 additions & 11 deletions apps/guide/app/GuideLessonNavigationHook.res
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion apps/guide/e2e/navigation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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$/);
Expand Down
2 changes: 2 additions & 0 deletions apps/guide/vitest.setup.mjs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
import "./styles/main.css";
// Register the compiler CDN mock before components import the script loader.
import "./__tests__/compilerFixture.js";
Loading