Skip to content
Open
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
73 changes: 47 additions & 26 deletions apps/guide/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,33 @@ contract only. Lesson copy belongs to the guide author.

## Layout

| Path | Purpose |
| --------------------------------- | ------------------------------------------------ |
| `app/lessons/` | Lesson MDX files and metadata |
| `app/GuideLessonContent.res` | Build-time lesson discovery and validation |
| `app/GuideHome.res` | Guide workspace UI |
| `app/GuideCompilerBridgeHook.res` | Browser compiler and execution integration |
| `styles/main.css` | Guide-only visual system and responsive behavior |
| `__tests__/` | Browser and unit coverage for the guide |
| Path | Purpose |
| --------------------------------- | -------------------------------------------------- |
| `app/lessons/` | Lesson MDX files and metadata |
| `app/GuideLessonContent.res` | Build-time lesson discovery and validation |
| `app/GuideHome.res` | Guide workspace UI |
| `app/GuideCompilerBridgeHook.res` | Browser compiler and execution integration |
| `app/GuideLessonValidation.res` | Build-time compilation and reference output checks |
| `styles/main.css` | Guide-only visual system and responsive behavior |
| `__tests__/` | Browser and unit coverage for the guide |

## Lesson Contract

Every `.mdx` file under `app/lessons/` is discovered recursively and ordered by
its integer `position`. The build requires these frontmatter fields:

| Field | Required | Meaning |
| ------------------------- | -------- | ------------------------------------------------------ |
| `position` | Yes | Integer lesson order |
| `id` | Yes | Stable URL-safe hash and lesson identifier |
| `missionLabel` | Yes | Short lesson label |
| `title` | Yes | Lesson heading |
| `description` | Yes | Lesson summary used by the lesson model |
| `exercise.id` | Yes | Stable identifier for saved code and completion |
| `exercise.title` | Yes | Exercise label |
| `exercise.initialCode` | Yes | Initial editor contents |
| `exercise.expectedOutput` | No | Matching runtime log line that unlocks the next lesson |
| Field | Required | Meaning |
| ------------------------- | -------- | -------------------------------------------------------- |
| `position` | Yes | Integer lesson order |
| `id` | Yes | Stable URL-safe hash and lesson identifier |
| `missionLabel` | Yes | Short lesson label |
| `title` | Yes | Lesson heading |
| `description` | Yes | Lesson summary used by the lesson model |
| `exercise.id` | Yes | Stable identifier for saved code and completion |
| `exercise.title` | Yes | Exercise label |
| `exercise.initialCode` | Yes | Initial editor contents |
| `exercise.expectedOutput` | Yes | Matching runtime log line that unlocks the next lesson |
| `exercise.solutionCode` | No | Build-only reference solution; defaults to `initialCode` |

Lesson IDs determine durable hash-based deep links. Renaming a published lesson
ID requires a redirect or an explicit decision to let existing deep links fall
Expand All @@ -46,9 +48,17 @@ decision to discard existing learner progress.
Lesson IDs must be URL-safe slugs made from lowercase ASCII letters, digits,
and hyphens. Exercise IDs may use the established slash-delimited form.

An exercise without `expectedOutput` is parsed as a manual check, but manual
checks are not currently completable. Published lessons therefore need a
deterministic `expectedOutput` until another completion mechanism exists.
Every published exercise needs a deterministic `expectedOutput` string. The
build compiles `initialCode` and `solutionCode`, runs the reference solution,
and requires a matching runtime log line. Matching preserves whitespace and
uses the same argument serialization as the browser checkpoint.

When the learner must edit the starter to reach the checkpoint, supply
`exercise.solutionCode`. Otherwise it defaults to `initialCode`. Reference
solutions are only read during validation and are excluded from published
lesson data. Solutions must produce synchronous output and finish within one
second; browser-only APIs and asynchronous exercises need a separate validation
strategy before they can be authored.

## Local Development

Expand All @@ -71,10 +81,20 @@ yarn build:guide
yarn workspace @rescript-lang/guide ci:test
```

`yarn build:guide` compiles ReScript, pre-renders `/`, and copies the client
assets to `apps/guide/out/` for Wrangler. The guide fetches available compiler
versions from `https://cdn.rescript-lang.org` during pre-rendering; the
interactive compiler and runtime then run in the learner's browser.
`yarn build:guide` compiles ReScript, validates the lessons during pre-rendering,
and copies the client assets to `apps/guide/out/` for Wrangler. Validation uses
the actual browser compiler and its libraries from `https://cdn.rescript-lang.org`.
A failed download, compilation, execution, or output check stops the build.
`PLAYGROUND_BUNDLE_ENDPOINT` can override the CDN base URL.

The guide pins ReScript **v12.3.1** in `app/GuideCompilerSettings.res`. Both
validation and browser execution use that version and its runtime files; new
CDN releases cannot silently change lessons. To upgrade, change the pin and run
`yarn workspace @rescript-lang/guide test:compiler` and `yarn build:guide`.
The guide CI command runs the compiler checks before the browser tests.

In the browser, the output panel reports a failed compiler download or a
15-second loading timeout with instructions to reload.

## Current Capability

Expand All @@ -95,6 +115,7 @@ show a stop screen rather than a usable guide.
Before launch, complete and verify the following:

- [ ] Author, review, and run every lesson using its real expected output.
- [x] Compile starters and check reference solution output against a pinned compiler.
- [ ] Add a content-validation test for the complete lesson set, including
unique `position`, lesson IDs, and exercise IDs.
- [ ] Decide and implement the completion model for exercises that cannot use
Expand Down
46 changes: 46 additions & 0 deletions apps/guide/__tests__/GuideCompilerLoading_.test.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
open Vitest

@module("./compilerLoadingFixture.js")
external interceptCompilerScripts: bool => unit = "interceptCompilerScripts"

@module("./compilerLoadingFixture.js")
external expireCompilerLoading: unit => unit = "expireCompilerLoading"

let renderWithCompiler = () =>
render(
<ReactRouter.MemoryRouter>
<GuideHome
lessons=GuideTestFixtures.guideLessons
compilerData={bundleBaseUrl: "https://guide-compiler.test"}
/>
</ReactRouter.MemoryRouter>,
)

test("shows a compiler loading failure in the guide output", async () => {
await viewport(1440, 900)
interceptCompilerScripts(true)
let screen = await renderWithCompiler()
await (await screen->getByText("Compiler setup failed"))->element->toBeVisible
await (
await screen->getByText(
"Could not load compiler from url https://guide-compiler.test/v12.3.1/compiler.js",
)
)
->element
->toBeVisible
})

test("shows a useful message when the compiler CDN never responds", async () => {
await viewport(1440, 900)
interceptCompilerScripts(false)
expireCompilerLoading()
let screen = await renderWithCompiler()
await (await screen->getByText("Compiler setup failed"))->element->toBeVisible
await (
await screen->getByText(
"ReScript v12.3.1 did not load within 15 seconds. Reload the page to try again.",
)
)
->element
->toBeVisible
})
16 changes: 3 additions & 13 deletions apps/guide/__tests__/GuideCompilerSettings_.test.res
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
open Vitest

test("selects the latest stable ReScript compiler with ESM output", async () => {
let version =
GuideCompilerSettings.latestStableVersion([
"v12.1.0-alpha.1",
"v11.1.4",
"v12.0.0",
"v12.2.0-beta.1",
"v12.1.3",
])
->Option.getOrThrow
->Semver.toString

expect(version)->toBe("v12.1.3")
test("pins the guide to its validated v12 compiler with ESM output", async () => {
expect(GuideCompilerSettings.version)->toBe("v12.3.1")
expect(GuideCompilerSettings.parsedVersion->Semver.toString)->toBe(GuideCompilerSettings.version)
expect(GuideCompilerSettings.moduleSystem)->toBe("esmodule")
expect(GuideCompilerSettings.warnFlags->String.includes("-109"))->toBe(true)
})
193 changes: 193 additions & 0 deletions apps/guide/__tests__/compiler/lessons.test.js

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to write this in ReScript.

Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { beforeAll, afterEach, expect, test, vi } from "vitest";
import { loadCompiler, runProgram } from "../../scripts/lesson-compiler.mjs";
import * as Settings from "../../app/GuideCompilerSettings.jsx";
import * as Validation from "../../app/GuideLessonValidation.jsx";
import * as Content from "../../app/GuideLessonContent.jsx";

const bundleBaseUrl = "https://cdn.rescript-lang.org";
let compiler;
beforeAll(async () => {
compiler = await loadCompiler(
bundleBaseUrl,
Settings.version,
Settings.moduleSystem,
Settings.warnFlags,
);
}, 60000);
afterEach(() => vi.unstubAllGlobals());

function exercise(overrides = {}) {
return {
sourcePath: "test-lesson.mdx",
initialCode: 'let greeting = "hello"',
solutionCode: 'let greeting = "hello"',
expectedOutput: "hello",
...overrides,
};
}

test("the pinned browser compiler validates every real lesson", async () => {
expect(compiler.version).toBe(Settings.version.slice(1));
await expect(Validation.validate(bundleBaseUrl)).resolves.toBeUndefined();
const published = JSON.stringify(Content.load());
expect(published).not.toContain("solutionCode");
expect(published).not.toContain('greet(\\"Spock\\")');
});

test("invalid starter code fails even when its reference solution is valid", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({ initialCode: "let x =" }),
),
).rejects.toThrow(/test-lesson\.mdx exercise\.initialCode does not compile/);
});

test("invalid reference solutions identify the file and field", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({ solutionCode: "let x: int = false" }),
),
).rejects.toThrow(/test-lesson\.mdx exercise\.solutionCode does not compile/);
});

test("output mismatches fail with the expected and actual strings", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({ expectedOutput: "hello " }),
),
).rejects.toThrow(
'exercise.expectedOutput "hello " did not match solution output "hello"',
);
});

test("runtime failures identify the reference solution", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({ solutionCode: 'JsError.throwWithMessage("boom")' }),
),
).rejects.toThrow(
/test-lesson\.mdx exercise\.solutionCode failed at runtime: boom/,
);
});

test("solutions execute against the pinned compiler's published runtime imports", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({
solutionCode: "let values = [1, 2]->Array.map(value => value + 1)",
expectedOutput: "[2,3]",
}),
),
).resolves.toBeUndefined();
});

test("final expressions use the browser's source instrumentation", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({
solutionCode: 'let greeting = "starter"\nInt.toString(42)',
expectedOutput: "42",
}),
),
).resolves.toBeUndefined();
});

test("multiple console arguments match the browser checkpoint serialization", async () => {
await expect(
Validation.validateExercise(
compiler,
bundleBaseUrl,
exercise({
solutionCode: 'Console.log2("answer", 42)',
expectedOutput: "answer 42",
}),
),
).resolves.toBeUndefined();
});

test("runaway solutions stop with a timeout", async () => {
await expect(
runProgram({ code: "while (true) {}", imports: {} }),
).rejects.toThrow(/timed out/);
});

test("missing compiler assets fail with the URL and HTTP status", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("missing", { status: 404 })),
);
await expect(
loadCompiler(
"https://missing-guide.test",
Settings.version,
Settings.moduleSystem,
Settings.warnFlags,
),
).rejects.toThrow(
`https://missing-guide.test/${Settings.version}/compiler.js: HTTP 404`,
);
});

test("a bundle claiming a different compiler version is rejected", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async (url) =>
new Response(
url.endsWith("/compiler.js")
? 'globalThis.rescript_compiler = {make: () => ({version: "13.0.0"})};'
: "",
),
),
);
await expect(
loadCompiler(
"https://wrong-guide.test",
Settings.version,
Settings.moduleSystem,
Settings.warnFlags,
),
).rejects.toThrow(
`Expected guide compiler ${Settings.version}, received 13.0.0`,
);
});

test.each([undefined, 42, false])(
"reference validation rejects missing or non-string expected output: %s",
(expectedOutput) => {
const raw = `---\nexercise:\n initialCode: let x = 1\n${expectedOutput === undefined ? "" : ` expectedOutput: ${expectedOutput}\n`}---\n`;
expect(() => Validation.fromRaw(raw, "broken.mdx")).toThrow(
"broken.mdx exercise.expectedOutput must be a string",
);
},
);

test("reference code defaults to the starter and preserves an empty expected output", () => {
const parsed = Validation.fromRaw(
'---\nexercise:\n initialCode: let x = ""\n expectedOutput: ""\n---\n',
"empty.mdx",
);
expect(parsed.solutionCode).toBe(parsed.initialCode);
expect(parsed.expectedOutput).toBe("");
});

test("empty reference code is an authoring error", () => {
expect(() =>
Validation.fromRaw(
'---\nexercise:\n initialCode: let x = 1\n expectedOutput: "1"\n solutionCode: ""\n---\n',
"empty.mdx",
),
).toThrow("empty.mdx exercise.solutionCode must be a non-empty string");
});
29 changes: 29 additions & 0 deletions apps/guide/__tests__/compilerLoadingFixture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterEach, vi } from "vitest";

afterEach(() => {
vi.restoreAllMocks();
});

// Intercept script insertion rather than loading a remote compiler in UI failure tests.
export function interceptCompilerScripts(fail) {
const appendChild = document.body.appendChild.bind(document.body);
vi.spyOn(document.body, "appendChild").mockImplementation((node) => {
if (
node instanceof HTMLScriptElement &&
node.src.includes("/compiler.js")
) {
if (fail) queueMicrotask(() => node.dispatchEvent(new Event("error")));
return node;
}
return appendChild(node);
});
}

export function expireCompilerLoading() {
// Advance only the guide's loading deadline; leave editor and test-library timers real.
const setTimeout = window.setTimeout.bind(window);
vi.spyOn(window, "setTimeout").mockImplementation(
(callback, delay, ...args) =>
setTimeout(callback, delay === 15000 ? 0 : delay, ...args),
);
}
Loading
Loading