Skip to content

Commit a40936b

Browse files
Verify codes
1 parent 001c7ed commit a40936b

4 files changed

Lines changed: 234 additions & 12 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,46 @@ concurrency:
3535
cancel-in-progress: true
3636

3737
jobs:
38-
# ── Job 1: Build WASM ────────────────────────────────────────────────────
38+
# ── Job 1: Test every executable doc block ────────────────────────────────
39+
test-blocks:
40+
name: Test doc code blocks
41+
runs-on: ubuntu-latest
42+
43+
steps:
44+
- name: Checkout docs repo
45+
uses: actions/checkout@v4
46+
47+
- name: Checkout multilingual source
48+
uses: actions/checkout@v4
49+
with:
50+
repository: johnsamuelwrites/multilingual
51+
path: multilingual-src
52+
53+
- name: Set up Python
54+
uses: actions/setup-python@v5
55+
with:
56+
python-version: '3.12'
57+
cache: pip
58+
cache-dependency-path: multilingual-src/pyproject.toml
59+
60+
- name: Install multilingual with WASM extras
61+
working-directory: multilingual-src
62+
run: pip install -e ".[dev,wasm]"
63+
64+
- name: Install wabt (wat2wasm, wasm-validate)
65+
run: sudo apt-get install -y wabt
66+
67+
- name: Install pytest
68+
run: pip install pytest
69+
70+
- name: Run code-block tests
71+
run: pytest _tests/ -v --tb=short
72+
73+
# ── Job 2: Build WASM ────────────────────────────────────────────────────
3974
build-wasm:
4075
name: Build WASM browser bundle
4176
runs-on: ubuntu-latest
77+
needs: test-blocks
4278

4379
steps:
4480
- name: Checkout docs repo (for demo.ml)
@@ -108,7 +144,7 @@ jobs:
108144
path: assets/wasm/
109145
retention-days: 1
110146

111-
# ── Job 2: Build Jekyll ───────────────────────────────────────────────────
147+
# ── Job 3: Build Jekyll ───────────────────────────────────────────────────
112148
build-jekyll:
113149
name: Build Jekyll site
114150
runs-on: ubuntu-latest
@@ -149,7 +185,7 @@ jobs:
149185
with:
150186
path: _site/
151187

152-
# ── Job 3: Deploy ─────────────────────────────────────────────────────────
188+
# ── Job 4: Deploy ─────────────────────────────────────────────────────────
153189
deploy:
154190
name: Deploy to GitHub Pages
155191
runs-on: ubuntu-latest

_tests/test_code_blocks.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
"""
3+
_tests/test_code_blocks.py
4+
5+
Verify that every executable code block in the docs can be:
6+
1. Compiled to WAT text via `multilingual build-wasm-bundle`.
7+
2. Assembled to a binary via `wat2wasm`.
8+
3. Validated as a well-formed WASM module via `wasm-validate`.
9+
4. Executed (calling __main()) without trapping, via the wasmtime Python API.
10+
11+
Run locally (requires multilingual, wabt, and optionally wasmtime installed):
12+
pytest _tests/ -v
13+
14+
Each code block becomes its own parametrized test case, identified by
15+
its source file and zero-based index within that file:
16+
17+
test_code_blocks.py::test_block[getting-started/quick-start.md::block-2]
18+
"""
19+
20+
import re
21+
import subprocess
22+
from pathlib import Path
23+
24+
import pytest
25+
26+
# ---------------------------------------------------------------------------
27+
# Configuration — must match NON_EXECUTABLE in main.js and compile_blocks.py
28+
# ---------------------------------------------------------------------------
29+
30+
NON_EXECUTABLE = {
31+
'bash', 'sh', 'shell', 'powershell', 'cmd',
32+
'yaml', 'toml', 'json', 'plaintext', 'text',
33+
'output', 'wat', 'rust',
34+
'',
35+
}
36+
37+
REPO_ROOT = Path(__file__).parent.parent
38+
COMPILE_TIMEOUT = 60 # seconds — build-wasm-bundle per block
39+
ASSEMBLE_TIMEOUT = 30 # seconds — wat2wasm
40+
VALIDATE_TIMEOUT = 10 # seconds — wasm-validate
41+
EXECUTE_TIMEOUT = 10 # seconds — __main() wall-clock guard
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# Block collection
46+
# ---------------------------------------------------------------------------
47+
48+
def _collect_blocks():
49+
"""
50+
Yield pytest.param(code, id=...) for every executable fenced code block
51+
found in the docs markdown files.
52+
53+
A block is included when its language tag is NOT in NON_EXECUTABLE.
54+
Unlike compile_blocks.py we do NOT filter on `print(` — we want to test
55+
every block that the docs present to readers as executable.
56+
"""
57+
pattern = re.compile(r'^```(\w*)\n(.*?)^```', re.MULTILINE | re.DOTALL)
58+
59+
md_files = sorted(
60+
p for p in REPO_ROOT.rglob('*.md')
61+
if '.jekyll-cache' not in p.parts and 'vendor' not in p.parts
62+
)
63+
64+
for md_file in md_files:
65+
content = md_file.read_text(encoding='utf-8')
66+
rel = md_file.relative_to(REPO_ROOT).as_posix()
67+
for idx, m in enumerate(pattern.finditer(content)):
68+
lang = m.group(1).lower()
69+
code = m.group(2).strip()
70+
if lang in NON_EXECUTABLE or not code:
71+
continue
72+
yield pytest.param(code, id=f'{rel}::block-{idx}')
73+
74+
75+
# ---------------------------------------------------------------------------
76+
# Execution helper (wasmtime Python API)
77+
# ---------------------------------------------------------------------------
78+
79+
def _execute_wasm(wasm_path: Path) -> str:
80+
"""
81+
Instantiate *wasm_path* with the standard multilingual host imports and
82+
call ``__main()``. Returns the captured stdout string.
83+
84+
Raises ``pytest.skip`` if the wasmtime Python package is not installed.
85+
Raises ``AssertionError`` if ``__main`` traps or is not exported.
86+
"""
87+
try:
88+
from wasmtime import Engine, FuncType, Linker, Module, Store, ValType
89+
except ImportError:
90+
pytest.skip('wasmtime Python package not installed — skipping execution check')
91+
92+
engine = Engine()
93+
store = Store(engine)
94+
module = Module(engine, wasm_path.read_bytes())
95+
linker = Linker(engine)
96+
97+
buf = []
98+
mem_ref = [None]
99+
100+
# Host callbacks — mirror the ABI declared in the WAT backend
101+
def _print_str(ptr, length):
102+
if mem_ref[0] is not None:
103+
raw = bytes(mem_ref[0].data(store)[ptr: ptr + length])
104+
buf.append(raw.decode('utf-8', errors='replace'))
105+
106+
i32 = ValType.i32()
107+
f64 = ValType.f64()
108+
109+
linker.define_func('env', 'print_str', FuncType([i32, i32], []), _print_str)
110+
linker.define_func('env', 'print_f64', FuncType([f64], []), lambda v: buf.append(str(v)))
111+
linker.define_func('env', 'print_bool', FuncType([i32], []), lambda v: buf.append('True' if v else 'False'))
112+
linker.define_func('env', 'print_sep', FuncType([], []), lambda: buf.append(' '))
113+
linker.define_func('env', 'print_newline', FuncType([], []), lambda: buf.append('\n'))
114+
115+
instance = linker.instantiate(store, module)
116+
exports = instance.exports(store)
117+
118+
assert 'memory' in [e.name for e in module.exports], \
119+
"WASM module does not export 'memory'"
120+
assert '__main' in [e.name for e in module.exports], \
121+
"WASM module does not export '__main'"
122+
123+
mem_ref[0] = exports['memory']
124+
exports['__main'](store)
125+
126+
return ''.join(buf)
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# Test
131+
# ---------------------------------------------------------------------------
132+
133+
@pytest.mark.parametrize('code', _collect_blocks())
134+
def test_block(code, tmp_path):
135+
"""
136+
Each executable code block in the docs must:
137+
1. Compile to WAT text without error.
138+
2. Assemble into a valid WASM binary.
139+
3. Execute __main() without trapping.
140+
"""
141+
src_file = tmp_path / 'block.ml'
142+
out_dir = tmp_path / 'out'
143+
out_dir.mkdir()
144+
src_file.write_text(code, encoding='utf-8')
145+
146+
# ── Step 1: Compile to WAT ──────────────────────────────────────────────
147+
result = subprocess.run(
148+
['multilingual', 'build-wasm-bundle', str(src_file), '--out-dir', str(out_dir)],
149+
capture_output=True, text=True, timeout=COMPILE_TIMEOUT,
150+
)
151+
assert result.returncode == 0, (
152+
f'build-wasm-bundle failed (exit {result.returncode}):\n'
153+
f'{result.stderr.strip()}'
154+
)
155+
156+
wat_file = out_dir / 'module.wat'
157+
assert wat_file.exists(), (
158+
'build-wasm-bundle succeeded but produced no module.wat'
159+
)
160+
161+
# ── Step 2: Assemble WAT → WASM binary ─────────────────────────────────
162+
wasm_file = out_dir / 'module.wasm'
163+
result = subprocess.run(
164+
['wat2wasm', str(wat_file), '-o', str(wasm_file)],
165+
capture_output=True, timeout=ASSEMBLE_TIMEOUT,
166+
)
167+
assert result.returncode == 0, (
168+
f'wat2wasm failed (exit {result.returncode}):\n'
169+
f'{result.stderr.decode(errors="replace").strip()}'
170+
)
171+
172+
# ── Step 3: Validate ────────────────────────────────────────────────────
173+
result = subprocess.run(
174+
['wasm-validate', str(wasm_file)],
175+
capture_output=True, timeout=VALIDATE_TIMEOUT,
176+
)
177+
assert result.returncode == 0, (
178+
f'wasm-validate rejected the binary:\n'
179+
f'{result.stderr.decode(errors="replace").strip()}'
180+
)
181+
182+
# ── Step 4: Execute (requires wasmtime Python package) ──────────────────
183+
_execute_wasm(wasm_file)

assets/js/main.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
const baseUrl = (document.querySelector('meta[name="base-url"]') || {}).content || '';
3232

3333
const MLWasm = (() => {
34-
let _instance = null;
3534
let _memory = null;
3635
let _outputBuf = '';
3736
let _modulePromise = null;
@@ -75,8 +74,7 @@
7574

7675
_modulePromise = WebAssembly.instantiateStreaming(fetch(wasmUrl), importObject)
7776
.then(({ instance }) => {
78-
_instance = instance;
79-
_memory = instance.exports.memory;
77+
_memory = instance.exports.memory;
8078
return true;
8179
});
8280

@@ -135,12 +133,15 @@
135133
const mod = await loadBlockModule(hash16);
136134

137135
if (!mod) {
138-
/* Fall back to the pre-compiled demo binary. */
139-
await load();
140-
_outputBuf = '';
141-
try { _instance.exports.__main(); }
142-
catch (e) { return { stdout: _outputBuf, stderr: String(e) }; }
143-
return { stdout: _outputBuf, stderr: '' };
136+
/* No per-block binary available for this code. Rather than
137+
* running the unrelated demo program, tell the user clearly. */
138+
return {
139+
stdout: '',
140+
stderr: 'This block could not be executed: no WASM binary was\n'
141+
+ 'compiled for it during the CI build (the compiler may not\n'
142+
+ 'yet support all constructs used here).\n'
143+
+ 'Try the REPL panel to run the full demo program.',
144+
};
144145
}
145146

146147
/* Instantiate the per-block module with fresh state for each run. */

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
testpaths = _tests

0 commit comments

Comments
 (0)