Skip to content

Commit 2726b2f

Browse files
Merge pull request #11 from tlaplus/fponzi/tlcweb-reg
Add pluggable in-browser TLC regression tests for lessons
2 parents 2914c19 + 4160926 commit 2726b2f

26 files changed

Lines changed: 220 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,46 @@ jobs:
7878
body,
7979
});
8080
}
81+
82+
# In-browser TLC regression suite: runs every lesson (that declares an
83+
# `expect` outcome) through the real CheerpJ worker and asserts the result.
84+
# Needs a real browser + network (CheerpJ CDN). Runs on PRs (so changes can
85+
# be validated before merge) and on merges to main.
86+
e2e:
87+
runs-on: ubuntu-latest
88+
timeout-minutes: 25
89+
steps:
90+
- name: Checkout code
91+
uses: actions/checkout@v4
92+
93+
- name: Checkout Examples repo
94+
uses: actions/checkout@v4
95+
with:
96+
repository: tlaplus/Examples
97+
path: Examples
98+
99+
- name: Setup Node.js
100+
uses: actions/setup-node@v4
101+
with:
102+
node-version: '20'
103+
cache: 'npm'
104+
105+
- name: Download latest TLC JAR
106+
run: curl -fSL -o public/tlaplus-web-cheerpj.jar https://github.com/FedericoPonzi/tlaplus-web/releases/latest/download/tlaplus-web-cheerpj.jar
107+
108+
- name: Install dependencies
109+
run: npm ci
110+
111+
- name: Install Playwright browser
112+
run: npx playwright install --with-deps chromium
113+
114+
- name: Run lesson regression suite
115+
run: npm run test:e2e
116+
117+
- name: Upload Playwright report on failure
118+
if: failure()
119+
uses: actions/upload-artifact@v4
120+
with:
121+
name: playwright-report
122+
path: playwright-report/
123+
retention-days: 14

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ out/
44
Examples/
55
*.tsbuildinfo
66
next-env.d.ts
7+
test-results/
8+
playwright-report/

e2e/lessons.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { test, expect } from "@playwright/test";
2+
import { introLessons } from "@/content/intro";
3+
import { blockingQueueLessons } from "@/content/blocking-queue";
4+
import { runTlcInBrowser, classifyTlcOutput } from "./tlc-runner";
5+
6+
/**
7+
* Pluggable, per-lesson in-browser TLC regression suite.
8+
*
9+
* Every lesson that declares an `expect` field in its markdown frontmatter
10+
* (see src/lib/lessons.ts) is model-checked in a real browser via the CheerpJ
11+
* worker, and its outcome is asserted:
12+
*
13+
* expect: success -> TLC completes with no error
14+
* expect: violation -> TLC reports a counterexample (invariant / deadlock)
15+
*
16+
* To add coverage for a lesson, add `expect: success` or `expect: violation`
17+
* to its frontmatter — no test code changes required. Lessons without an
18+
* `expect` (e.g. animation-only lessons, lessons with no spec/cfg, or very
19+
* large models such as `intro/records` whose state space is impractical to
20+
* explore in-browser) are skipped automatically.
21+
*
22+
* This guards against the class of CheerpJ regression where in-browser TLC
23+
* silently stops after computing initial states and misses counterexamples.
24+
*/
25+
26+
const lessons = [...introLessons, ...blockingQueueLessons].filter((l) => l.expect);
27+
28+
test.describe("Lesson TLC regression", () => {
29+
test("at least one lesson declares an expected outcome", () => {
30+
expect(lessons.length).toBeGreaterThan(0);
31+
});
32+
33+
for (const lesson of lessons) {
34+
test(`${lesson.section}/${lesson.slug} -> ${lesson.expect}`, async ({ page }) => {
35+
await page.goto("/");
36+
const output = await runTlcInBrowser(page, lesson.spec, lesson.cfg);
37+
const { violated, completed } = classifyTlcOutput(output);
38+
39+
if (lesson.expect === "violation") {
40+
expect(violated, `expected a counterexample but none was reported:\n${output}`).toBe(true);
41+
} else {
42+
expect(violated, `unexpected counterexample:\n${output}`).toBe(false);
43+
expect(completed, `expected model checking to complete:\n${output}`).toBe(true);
44+
}
45+
});
46+
}
47+
});

e2e/tlc-runner.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/**
4+
* Drives the real in-browser TLC worker (`public/tlc-worker.html`) — the same
5+
* CheerpJ runtime + jar the app uses — from within the app origin.
6+
*
7+
* `page` must already be navigated to the app (e.g. `await page.goto("/")`) so
8+
* that the worker iframe is same-origin. Returns the raw TLC output string.
9+
*
10+
* A fresh Playwright page should be used per call: CheerpJ does not reliably
11+
* support many runtime initialisations within a single page.
12+
*/
13+
export async function runTlcInBrowser(page: Page, spec: string, cfg: string): Promise<string> {
14+
return page.evaluate(
15+
({ spec, cfg }) =>
16+
new Promise<string>((resolve) => {
17+
const iframe = document.createElement("iframe");
18+
iframe.style.display = "none";
19+
let done = false;
20+
function onMsg(ev: MessageEvent) {
21+
if (ev.source !== iframe.contentWindow) return; // ignore the app's own worker
22+
const d = ev.data;
23+
if (!d || !d.type) return;
24+
if (d.type === "ready") {
25+
iframe.contentWindow!.postMessage(
26+
{ type: "run", spec, cfg, workers: 1, checkDeadlock: true, extraModules: [] },
27+
"*",
28+
);
29+
} else if (d.type === "result") {
30+
if (done) return;
31+
done = true;
32+
window.removeEventListener("message", onMsg);
33+
iframe.remove();
34+
resolve(d.output as string);
35+
} else if (d.type === "error") {
36+
if (done) return;
37+
done = true;
38+
window.removeEventListener("message", onMsg);
39+
iframe.remove();
40+
resolve("INIT ERROR: " + d.message);
41+
}
42+
}
43+
window.addEventListener("message", onMsg);
44+
iframe.src = "/tlc-worker.html";
45+
document.body.appendChild(iframe);
46+
}),
47+
{ spec, cfg },
48+
);
49+
}
50+
51+
/** Classify a raw TLC output string into the outcome dimensions the suite asserts on. */
52+
export function classifyTlcOutput(output: string): { violated: boolean; completed: boolean } {
53+
return {
54+
// A counterexample: an invariant/property violation or a deadlock.
55+
violated: /is violated|Deadlock reached/.test(output),
56+
// A clean, exhaustive exploration with no error.
57+
completed: /Model checking completed/.test(output),
58+
};
59+
}

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ module.exports = {
1818
moduleNameMapper: {
1919
"^@/(.*)$": "<rootDir>/src/$1",
2020
},
21+
testPathIgnorePatterns: ["/node_modules/", "/e2e/"],
2122
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "next lint",
10-
"test": "jest"
10+
"test": "jest",
11+
"test:e2e": "playwright test"
1112
},
1213
"dependencies": {
1314
"@codemirror/lang-javascript": "^6.2.4",

playwright.config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
/**
4+
* E2E config for the in-browser TLC checker.
5+
*
6+
* These tests run TLC inside a real browser via CheerpJ, so they need a browser
7+
* and network access (CheerpJ downloads its runtime from a CDN at load time).
8+
* They are intentionally kept separate from the jsdom `npm test` unit suite.
9+
*/
10+
export default defineConfig({
11+
testDir: "./e2e",
12+
timeout: 5 * 60 * 1000,
13+
expect: { timeout: 4 * 60 * 1000 },
14+
fullyParallel: false,
15+
workers: 1,
16+
reporter: [["list"], ["html", { open: "never" }]],
17+
use: {
18+
baseURL: "http://localhost:3000",
19+
headless: true,
20+
trace: "retain-on-failure",
21+
},
22+
projects: [
23+
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
24+
],
25+
webServer: {
26+
command: "npm run dev",
27+
url: "http://localhost:3000",
28+
reuseExistingServer: !process.env.CI,
29+
timeout: 120 * 1000,
30+
},
31+
});

src/content/blocking-queue/01-introduction.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
slug: introduction
3+
expect: success
34
title: Introduction
45
section: blocking-queue
56
commitSha: "11410864"

src/content/blocking-queue/02-state-graph.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
slug: state-graph
3+
expect: success
34
title: "State Graph (Minimum Config)"
45
section: blocking-queue
56
commitSha: "d21cd0fa"

src/content/blocking-queue/03-larger-config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
slug: larger-config
3+
expect: violation
34
title: "Larger Configuration"
45
section: blocking-queue
56
commitSha: "7d05fdfa"

0 commit comments

Comments
 (0)