Skip to content

Commit b64cac3

Browse files
rcosta358codex
andcommitted
Verify tutorial exercises in the browser
Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent dc0f6c9 commit b64cac3

8 files changed

Lines changed: 135 additions & 23 deletions

File tree

‎.github/workflows/pages.yml‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ on:
44
push:
55
branches:
66
- main
7+
pull_request:
78
workflow_dispatch:
89

910
permissions:
1011
contents: read
11-
pages: write
12-
id-token: write
1312

1413
concurrency:
15-
group: pages
14+
group: pages-${{ github.event.pull_request.number || 'deploy' }}
1615
cancel-in-progress: true
1716

1817
jobs:
@@ -29,6 +28,27 @@ jobs:
2928
cache: npm
3029
cache-dependency-path: package-lock.json
3130

31+
- name: Check out shared browser verifier
32+
uses: actions/checkout@v4
33+
with:
34+
repository: liquid-java/liquidjava-docs
35+
ref: 7b61cdde7010b06629c4cdaf298e5b54daba4f63
36+
path: .browser-source
37+
38+
- name: Set up Java
39+
uses: actions/setup-java@v4
40+
with:
41+
distribution: temurin
42+
java-version: "17"
43+
44+
- name: Build shared browser verifier
45+
working-directory: .browser-source
46+
run: |
47+
npm ci --ignore-scripts --no-audit --no-fund
48+
npm run build:playground
49+
npm run test:playground
50+
node scripts/playground/export.mjs ..
51+
3252
- name: Install dependencies
3353
run: npm ci
3454

@@ -37,21 +57,26 @@ jobs:
3757
npm run check
3858
npm run build
3959
40-
- name: Configure GitHub Pages
41-
uses: actions/configure-pages@v5
42-
4360
- name: Upload GitHub Pages artifact
61+
if: github.event_name != 'pull_request'
4462
uses: actions/upload-pages-artifact@v4
4563
with:
4664
path: dist/client
4765

4866
deploy:
67+
if: github.event_name != 'pull_request'
68+
permissions:
69+
pages: write
70+
id-token: write
4971
needs: build
5072
runs-on: ubuntu-latest
5173
environment:
5274
name: github-pages
5375
url: ${{ steps.deployment.outputs.page_url }}
5476
steps:
77+
- name: Configure GitHub Pages
78+
uses: actions/configure-pages@v5
79+
5580
- name: Deploy to GitHub Pages
5681
id: deployment
5782
uses: actions/deploy-pages@v4

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,8 @@ dist
141141
vite.config.js.timestamp-*
142142
vite.config.ts.timestamp-*
143143
.vite/
144+
145+
# generated shared browser verifier
146+
verifier/
147+
isolation.js
148+
.browser-source/

‎README.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ This repository contains a web-based, interactive introduction to LiquidJava. Th
77
3. External socket state refinements;
88
4. Stack ghost variables.
99

10-
Learners can edit Java snippets, run lightweight in-browser checks, answer quick knowledge questions, and move freely between sections. The tutorial does not collect, persist, or export user data.
10+
Learners can edit Java snippets, run LiquidJava verification in the browser, answer quick knowledge questions, and move freely between sections. The tutorial does not collect, persist, or export user data.
1111

1212
## Preview Locally
1313

14-
Run the local development server:
14+
Build and export the shared runtime as described below, then run the local development server:
1515

1616
```sh
1717
npm run dev
@@ -27,11 +27,24 @@ Each lesson contains:
2727

2828
- Explanatory copy and a read-only example;
2929
- Starter and solution code;
30-
- Regular-expression checks for the coding task;
30+
- A Java filename and exercise-pattern hints for the coding task;
3131
- Automatically checked multiple-choice and short-answer questions.
3232

3333
Add, remove, or reorder lesson objects to change the tutorial without editing `app.js` or `index.html`.
3434

3535
## Checker Scope
3636

37-
The browser checker is intentionally lightweight: it recognizes the requested annotations and values but does not run the LiquidJava compiler. Use the LiquidJava VS Code extension for real verification tasks.
37+
Checks run the real LiquidJava verifier locally in a browser worker. Results show diagnostic titles and messages. Exercise-pattern hints are separate from verification: valid Java with a missing requested contract is reported as “Exercise incomplete”. Editing, resetting, or leaving a lesson cancels pending verification.
38+
39+
Before development or building, build the shared runtime in the adjacent `liquidjava-docs` checkout with JDK 17, then export it here:
40+
41+
```sh
42+
cd ../liquidjava-docs
43+
npm ci
44+
npm run build:playground
45+
node scripts/playground/export.mjs ../liquidjava-interactive-tutorial
46+
cd ../liquidjava-interactive-tutorial
47+
npm run dev
48+
```
49+
50+
The workflow builds and exports the runtime from a pinned docs commit, validates pull requests, and deploys only from `main`. Update the checkout `ref` after testing a shared-runtime upgrade. Serve over HTTPS (or localhost) with HTTP range support; `npm run dev` supports JAR range requests. The isolation service worker reloads once before mounting the tutorial. The verifier downloads only when you first check an exercise; Stop terminates it. No source code is sent to a verification server.

‎app.js‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { tutorial } from "./tutorial-data.js";
2+
import { BrowserVerifier, prepareVerifier } from "./verifier/client.mjs";
3+
4+
let preparationError;
5+
try { await prepareVerifier(); } catch (error) { preparationError = error.message; }
26

37
(function () {
48
"use strict";
@@ -20,6 +24,8 @@ import { tutorial } from "./tutorial-data.js";
2024
});
2125

2226
let state = blankState();
27+
const verifier = new BrowserVerifier();
28+
let checkToken = 0;
2329

2430
const screen = document.querySelector("#screen");
2531
const navigation = document.querySelector("#step-navigation");
@@ -57,6 +63,8 @@ import { tutorial } from "./tutorial-data.js";
5763
}
5864

5965
function render() {
66+
checkToken++;
67+
if (verifier.pending) verifier.cancel();
6068
renderNavigation();
6169
const step = steps[state.currentStep];
6270
document.title = step.id === "welcome" ? content.meta.title : `${step.shortTitle} · ${content.meta.title}`;
@@ -162,7 +170,7 @@ import { tutorial } from "./tutorial-data.js";
162170
<h2 id="exercise-title-${lesson.id}">${escapeHtml(lesson.exercise.title)}</h2>
163171
<p>${escapeHtml(lesson.exercise.prompt)}</p>
164172
</div>
165-
<span class="editor-badge">Java</span>
173+
<span class="editor-badge">${escapeHtml(lesson.exercise.filename)}</span>
166174
</div>
167175
${
168176
lesson.exercise.guide
@@ -203,16 +211,15 @@ import { tutorial } from "./tutorial-data.js";
203211
}
204212

205213
function renderExerciseFeedback(result) {
206-
if (!result) return "<p>Run the check when you are ready. This checker looks for the contract, not exact formatting.</p>";
207-
if (result.passed) {
208-
return '<p><strong>Contract satisfied.</strong> Your annotations express the requested guarantee.</p>';
209-
}
210-
return `<p><strong>Almost there.</strong></p><ul>${result.messages.map((message) => `<li>${escapeHtml(message)}</li>`).join("")}</ul>`;
214+
if (!result) return "<p>Check your Java code and refinements in your browser.</p>";
215+
return result.diagnostics.map(({ title, message }) =>
216+
`<p><strong>${escapeHtml(title)}</strong><br>${escapeHtml(message)}</p>`).join("");
211217
}
212218

213219
function wireLesson(lesson) {
214220
const textarea = document.querySelector(`#code-${lesson.id}`);
215221
const feedback = document.querySelector(`#exercise-feedback-${lesson.id}`);
222+
const checkButton = document.querySelector(`#check-${lesson.id}`);
216223
const solutionButton = document.querySelector(`#solution-${lesson.id}`);
217224
const solutionPanel = document.querySelector(`#solution-panel-${lesson.id}`);
218225

@@ -250,6 +257,9 @@ import { tutorial } from "./tutorial-data.js";
250257
const focusEditor = () => (editor ? editor.focus() : textarea.focus());
251258

252259
const handleCodeChange = () => {
260+
checkToken++;
261+
if (verifier.pending) verifier.cancel();
262+
checkButton.textContent = "Check my work";
253263
state.code[lesson.id] = getCode();
254264
delete state.checkResults[lesson.id];
255265
feedback.className = "exercise-feedback";
@@ -259,13 +269,41 @@ import { tutorial } from "./tutorial-data.js";
259269
if (editor) editor.on("change", handleCodeChange);
260270
else textarea.addEventListener("input", handleCodeChange);
261271

262-
document.querySelector(`#check-${lesson.id}`).addEventListener("click", () => {
263-
const failures = lesson.exercise.checks.filter((check) => !new RegExp(check.pattern, "m").test(getCode()));
264-
const result = { passed: failures.length === 0, messages: failures.map((check) => check.message) };
265-
state.checkResults[lesson.id] = result;
266-
feedback.className = `exercise-feedback ${result.passed ? "is-success" : "is-error"}`;
267-
feedback.innerHTML = renderExerciseFeedback(result);
268-
feedback.scrollIntoView({ behavior: "smooth", block: "nearest" });
272+
checkButton.addEventListener("click", async () => {
273+
if (verifier.pending) {
274+
checkToken++;
275+
verifier.cancel();
276+
checkButton.textContent = "Check my work";
277+
feedback.className = "exercise-feedback";
278+
feedback.innerHTML = renderExerciseFeedback({ diagnostics: [{ title: "Verification stopped", message: "Check again when you are ready." }] });
279+
return;
280+
}
281+
const token = ++checkToken;
282+
const code = getCode();
283+
checkButton.textContent = "Stop";
284+
feedback.className = "exercise-feedback";
285+
try {
286+
if (preparationError) throw new Error(preparationError);
287+
const result = await verifier.verify({ [lesson.exercise.filename]: code }, message => {
288+
if (token === checkToken) feedback.textContent = message;
289+
});
290+
if (token !== checkToken) return;
291+
// exercise hints describe the requested contract, independently of verification
292+
if (result.status === "success") {
293+
const missing = lesson.exercise.checks.filter(check => !new RegExp(check.pattern, "m").test(code));
294+
if (missing.length) result.diagnostics.push({ title: "Exercise incomplete", message: missing.map(check => check.message).join(" ") });
295+
result.passed = missing.length === 0;
296+
}
297+
state.checkResults[lesson.id] = result;
298+
feedback.className = `exercise-feedback ${result.passed ? "is-success" : "is-error"}`;
299+
feedback.innerHTML = renderExerciseFeedback(result);
300+
} catch (error) {
301+
if (token !== checkToken || error.name === "AbortError") return;
302+
feedback.className = "exercise-feedback is-error";
303+
feedback.innerHTML = renderExerciseFeedback({ diagnostics: [{ title: "Verification could not complete", message: error.message }] });
304+
} finally {
305+
if (token === checkToken) checkButton.textContent = "Check my work";
306+
}
269307
});
270308

271309
document.querySelector(`#reset-${lesson.id}`).addEventListener("click", () => {

‎scripts/build-site.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const staticEntries = [
1313
"styles.css",
1414
"images",
1515
"vendor",
16+
"verifier",
17+
"isolation.js",
1618
];
1719

1820
await access(resolve(root, "index.html"));

‎scripts/dev-server.mjs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const mimeTypes = {
1111
".gif": "image/gif",
1212
".html": "text/html; charset=utf-8",
1313
".ico": "image/x-icon",
14+
".mjs": "text/javascript; charset=utf-8",
15+
".wasm": "application/wasm",
1416
".js": "text/javascript; charset=utf-8",
1517
".jpg": "image/jpeg",
1618
".jpeg": "image/jpeg",
@@ -56,7 +58,27 @@ const server = createServer(async (request, response) => {
5658
}
5759
}
5860

61+
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
62+
if (range) {
63+
const start = Number(range[1]);
64+
const end = Math.min(range[2] ? Number(range[2]) : file.size - 1, file.size - 1);
65+
if (start > end || start >= file.size) {
66+
response.writeHead(416, { "Content-Range": `bytes */${file.size}` }).end();
67+
return;
68+
}
69+
response.writeHead(206, {
70+
"Content-Type": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream",
71+
"Content-Range": `bytes ${start}-${end}/${file.size}`,
72+
"Content-Length": end - start + 1,
73+
"Accept-Ranges": "bytes",
74+
});
75+
if (request.method === "HEAD") response.end();
76+
else createReadStream(target, { start, end }).pipe(response);
77+
return;
78+
}
5979
response.writeHead(200, {
80+
"Accept-Ranges": "bytes",
81+
"Content-Length": file.size,
6082
"Cache-Control": "no-store",
6183
"Content-Type": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream",
6284
});

‎scripts/validate-content.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ if (!tutorial?.lessons?.length) {
1212

1313
for (const lesson of tutorial?.lessons ?? []) {
1414
const label = lesson.id || "unnamed lesson";
15+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*\.java$/.test(lesson.exercise?.filename || "")) {
16+
failures.push(`${label}: exercise needs a Java filename.`);
17+
}
1518

1619
for (const check of lesson.exercise?.checks ?? []) {
1720
if (!new RegExp(check.pattern, "m").test(lesson.exercise.solutionCode)) {

‎tutorial-data.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const tutorial = {
3737
],
3838
},
3939
exercise: {
40+
filename: "RGB.java",
4041
title: "Repair the red channel",
4142
prompt:
4243
"Add a refinement that limits red to 0–255, then replace the invalid value with any value that satisfies it.",
@@ -112,6 +113,7 @@ public static int divide(
112113
],
113114
},
114115
exercise: {
116+
filename: "Midpoint.java",
115117
title: "Complete the midpoint contract",
116118
prompt:
117119
"Replace both true refinements: low must be no greater than high, and the return value must stay between the two bounds.",
@@ -194,6 +196,7 @@ public class LightBulb {
194196
],
195197
},
196198
exercise: {
199+
filename: "SocketRefinements.java",
197200
title: "Complete the socket transitions",
198201
prompt:
199202
"Replace the true refinements so bind, connect, sendUrgentData, and close follow the socket protocol.",
@@ -323,6 +326,7 @@ public interface ArrayListRefinements<E> {
323326
],
324327
},
325328
exercise: {
329+
filename: "StackRefinements.java",
326330
title: "Complete the stack refinements",
327331
prompt:
328332
"Replace the true refinements so the constructor, push, pop, and peek maintain and check the size ghost variable.",

0 commit comments

Comments
 (0)