this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add ohc integration tests and interactive library tutorials

Add browser-based integration tests and 30 interactive tutorials for
Daniel Bunzli's OCaml libraries, exercising the JTW (js_top_worker)
system with real opam packages compiled to JavaScript.

Tutorials cover Fmt, Cmdliner, Mtime, Logs, Uucp, Uunf, Astring,
Jsonm, Xmlm, Ptime, React, Hmap, Gg, Vg, Note, Otfm, Fpath, Uutf,
B0, and Bos across multiple versions, testing API evolution.

Also fixes incremental output accumulation in the client library and
adds setup documentation for reproducing the demo environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+3690 -1
+8
.gitignore
··· 3 3 4 4 # Local OPAM switch 5 5 _opam/ 6 + 7 + # JTW output symlink (for integration testing) 8 + jtw-output 9 + 10 + # Test artifacts 11 + test-results/ 12 + demo/ 13 + test/ohc-integration/node_modules/
+19 -1
client/ocaml-worker.js
··· 161 161 break; 162 162 163 163 case 'output_at': 164 - // Incremental output - call callback but don't resolve (wait for final 'output') 164 + // Incremental output - accumulate caml_ppf for final output 165 + if (!this._accumulatedOutput) { 166 + this._accumulatedOutput = new Map(); 167 + } 168 + { 169 + const cellId = msg.cell_id; 170 + const prev = this._accumulatedOutput.get(cellId) || ''; 171 + this._accumulatedOutput.set(cellId, prev + (msg.caml_ppf || '')); 172 + } 165 173 if (this.onOutputAt) { 166 174 this.onOutputAt(msg); 167 175 } 168 176 break; 169 177 170 178 case 'output': 179 + // Merge accumulated incremental caml_ppf into the final output 180 + if (this._accumulatedOutput && this._accumulatedOutput.has(msg.cell_id)) { 181 + const accumulated = this._accumulatedOutput.get(msg.cell_id); 182 + if (accumulated && (!msg.caml_ppf || msg.caml_ppf === '')) { 183 + msg.caml_ppf = accumulated; 184 + } 185 + this._accumulatedOutput.delete(msg.cell_id); 186 + } 187 + this._resolveRequest(msg.cell_id, msg); 188 + break; 171 189 case 'completions': 172 190 case 'types': 173 191 case 'errors':
+187
test/ohc-integration/SETUP.md
··· 1 + # OHC Integration Demos & Tutorials — Setup Guide 2 + 3 + ## Overview 4 + 5 + This directory contains browser-based demos that run OCaml code in a web worker, 6 + loading real opam packages compiled to JavaScript. The tutorials cover 30 versions 7 + of Daniel Bunzli's OCaml libraries with interactive, step-by-step examples. 8 + 9 + ## Prerequisites 10 + 11 + - **day10** (formerly ohc) — the build tool that produces JTW artifacts 12 + - **js_top_worker** — this repo, providing the client library and worker 13 + - **Python 3** — for serving files over HTTP 14 + - **Node.js + npm** — for running Playwright tests (optional) 15 + 16 + ## Repositories & Commits 17 + 18 + | Repo | URL | Branch | Commit | 19 + |------|-----|--------|--------| 20 + | **day10** | `git@github.com:jonludlam/ohc` | `feature/jtw-support` | `e6fb848` | 21 + | **js_top_worker** | `https://github.com/jonnyfiveisonline/js_top_worker` | `enhancements` | `538ab03` | 22 + 23 + ## Step 1: Build js_top_worker 24 + 25 + ```bash 26 + git clone https://github.com/jonnyfiveisonline/js_top_worker.git 27 + cd js_top_worker 28 + git checkout enhancements 29 + dune build 30 + ``` 31 + 32 + This produces `_build/default/client/ocaml-worker.js` — the browser client library. 33 + 34 + Create a convenience symlink (if not already present): 35 + 36 + ```bash 37 + ln -sf _build/default/client client 38 + ``` 39 + 40 + ## Step 2: Build JTW Output with day10 41 + 42 + day10 builds opam packages and compiles them to JavaScript artifacts that the 43 + browser worker can load. 44 + 45 + ```bash 46 + git clone git@github.com:jonludlam/ohc day10 47 + cd day10 48 + git checkout feature/jtw-support # commit e6fb848 49 + opam install dockerfile ppx_deriving_yojson opam-0install 50 + dune build bin/main.exe 51 + ``` 52 + 53 + ### Run a health-check for a single package 54 + 55 + ```bash 56 + ./day10 health-check --with-jtw --jtw-output /path/to/jtw-output fmt 57 + ``` 58 + 59 + ### Run a batch build for all Bunzli libraries 60 + 61 + Create a batch file `bunzli.txt` with one package per line: 62 + 63 + ``` 64 + fmt 65 + cmdliner 66 + mtime 67 + logs 68 + uucp 69 + uunf 70 + astring 71 + jsonm 72 + xmlm 73 + ptime 74 + react 75 + hmap 76 + gg 77 + vg 78 + note 79 + otfm 80 + fpath 81 + uutf 82 + b0 83 + bos 84 + ``` 85 + 86 + Then run: 87 + 88 + ```bash 89 + ./day10 batch --with-jtw --jtw-output /path/to/jtw-output bunzli.txt 90 + ``` 91 + 92 + This produces the JTW output directory with the structure: 93 + 94 + ``` 95 + jtw-output/ 96 + compiler/ 97 + 5.4.0/ 98 + worker.js # OCaml toplevel worker (~21MB) 99 + lib/ocaml/ 100 + dynamic_cmis.json # Stdlib module index 101 + *.cmi, stdlib.cma.js # Stdlib artifacts 102 + u/<universe-hash>/ # One per (package, version) universe 103 + findlib_index # JSON: list of META file paths 104 + <pkg>/<ver>/lib/<findlib>/ # Package artifacts 105 + META, *.cmi, *.cma.js, dynamic_cmis.json 106 + p/<pkg>/<ver>/lib/... # Blessed packages (same structure) 107 + ``` 108 + 109 + ## Step 3: Symlink JTW Output into js_top_worker 110 + 111 + ```bash 112 + cd /path/to/js_top_worker 113 + ln -sf /path/to/jtw-output jtw-output 114 + ``` 115 + 116 + ## Step 4: Start the HTTP Server 117 + 118 + ```bash 119 + cd /path/to/js_top_worker 120 + python3 -m http.server 8769 121 + ``` 122 + 123 + ## Step 5: Open in Browser 124 + 125 + | Page | URL | 126 + |------|-----| 127 + | **Tutorial index** | http://localhost:8769/test/ohc-integration/tutorials/index.html | 128 + | **Single tutorial** | http://localhost:8769/test/ohc-integration/tutorials/tutorial.html?pkg=fmt.0.11.0 | 129 + | **Test runner** | http://localhost:8769/test/ohc-integration/runner.html | 130 + | **Basic eval test** | http://localhost:8769/test/ohc-integration/test.html?universe=HASH | 131 + 132 + The tutorial index page lists all 30 library-version tutorials grouped by library. 133 + Click any version card to open its interactive tutorial. 134 + 135 + ## Step 6: Run Automated Tests (Optional) 136 + 137 + ```bash 138 + cd /path/to/js_top_worker/test/ohc-integration 139 + npm install 140 + npx playwright install chromium 141 + npx playwright test tutorials/tutorials.spec.js # 31 tutorial tests 142 + npx playwright test bunzli-libs.spec.js # 37 library tests 143 + npx playwright test # all tests 144 + ``` 145 + 146 + ## Available Tutorials 147 + 148 + | Library | Versions | Topics | 149 + |---------|----------|--------| 150 + | Fmt | 0.9.0, 0.10.0, 0.11.0 | String formatting, typed formatters, collections, combinators | 151 + | Cmdliner | 1.0.4, 1.3.0, 2.0.0, 2.1.0 | Argument building, Term API (v1), Cmd API (v2), custom converters | 152 + | Mtime | 1.3.0, 1.4.0, 2.1.0 | Span constants, arithmetic, float conversions, API evolution | 153 + | Logs | 0.10.0 | Log sources, level management, error tracking | 154 + | Uucp | 14.0.0, 15.0.0, 16.0.0, 17.0.0 | Unicode properties, general category, script detection | 155 + | Uunf | 14.0.0, 17.0.0 | Unicode normalization forms (NFC/NFD/NFKC/NFKD) | 156 + | Astring | 0.8.5 | Splitting, building, testing, trimming, substrings | 157 + | Jsonm | 1.0.2 | Streaming JSON decode/encode | 158 + | Xmlm | 1.4.0 | Streaming XML parse/output | 159 + | Ptime | 1.2.0 | POSIX timestamps, arithmetic, RFC 3339 formatting | 160 + | React | 1.2.2 | FRP signals, events, derived signals | 161 + | Hmap | 0.8.1 | Type-safe heterogeneous maps | 162 + | Gg | 1.0.0 | 2D/3D vectors, colors, arithmetic | 163 + | Vg | 0.9.5 | Declarative 2D vector graphics | 164 + | Note | 0.0.3 | Reactive signals, transformations | 165 + | Otfm | 0.4.0 | OpenType font decoder | 166 + | Fpath | 0.7.3 | File system path manipulation | 167 + | Uutf | 1.0.4 | UTF-8 streaming codec | 168 + | B0 | 0.0.6 | File paths (B0_std.Fpath), command lines (B0_std.Cmd) | 169 + | Bos | 0.2.1 | OS command construction, conditional args | 170 + 171 + ## Universe Hashes 172 + 173 + Each (package, version) maps to a universe hash that identifies the exact set of 174 + dependencies. These are defined in `tutorials/test-defs.js` and `runner.html`. 175 + The hashes are deterministic — they will be the same on any machine that builds 176 + the same package version with day10. 177 + 178 + ## Troubleshooting 179 + 180 + - **"Failed to initialize"**: Check that `jtw-output/compiler/5.4.0/worker.js` exists 181 + and the HTTP server root is the js_top_worker repo root. 182 + - **"inconsistent assumptions over interface"**: The package's build universe has 183 + a cmi mismatch. Rebuild with day10. (Known issue: logs 0.7.0) 184 + - **Timeout loading packages**: Some large packages (uucp, gg, vg) take several 185 + seconds to load. The default timeout is 120 seconds. 186 + - **Port conflict**: Change the port in the `python3 -m http.server` command. 187 + For Playwright tests, also update `playwright.config.js`.
+642
test/ohc-integration/bunzli-libs.spec.js
··· 1 + // @ts-check 2 + const { test, expect } = require('@playwright/test'); 3 + 4 + // ── Helpers ────────────────────────────────────────────────────────────────── 5 + 6 + /** Navigate to eval-test.html, wait for worker ready, return page */ 7 + async function initWorker(page, universe, compiler = '5.4.0') { 8 + const logs = []; 9 + page.on('console', msg => logs.push(msg.text())); 10 + 11 + await page.goto( 12 + `/test/ohc-integration/eval-test.html?universe=${universe}&compiler=${compiler}` 13 + ); 14 + 15 + // Wait for worker to be ready 16 + await expect(async () => { 17 + const ready = await page.locator('#status').getAttribute('data-ready'); 18 + const error = await page.locator('#status').getAttribute('data-error'); 19 + if (error) throw new Error(`Worker init failed: ${error}`); 20 + expect(ready).toBe('true'); 21 + }).toPass({ timeout: 90000 }); 22 + 23 + return { logs }; 24 + } 25 + 26 + /** Evaluate OCaml code and return { caml_ppf, stdout, stderr } */ 27 + async function evalCode(page, code) { 28 + return await page.evaluate(async (c) => await window.workerEval(c), code); 29 + } 30 + 31 + /** #require a package, return result */ 32 + async function requirePkg(page, pkg) { 33 + return await evalCode(page, `#require "${pkg}";;`); 34 + } 35 + 36 + /** Evaluate and expect caml_ppf to contain a substring */ 37 + async function evalExpect(page, code, expected) { 38 + const r = await evalCode(page, code); 39 + expect(r.caml_ppf).toContain(expected); 40 + return r; 41 + } 42 + 43 + /** Evaluate and expect caml_ppf NOT to contain a substring (error case) */ 44 + async function evalExpectError(page, code) { 45 + const r = await evalCode(page, code); 46 + // Errors go to stderr, caml_ppf is usually empty or contains Error 47 + return r; 48 + } 49 + 50 + // ── Universe mapping ───────────────────────────────────────────────────────── 51 + 52 + const U = { 53 + // fmt versions 54 + 'fmt.0.9.0': '9901393f978b0a6627c5eab595111f50', 55 + 'fmt.0.10.0': 'dc92d356407d44e1eae7e39acefce214', 56 + 'fmt.0.11.0': '5c8d38716cee871f1a6a1f164c9171e6', 57 + 58 + // cmdliner versions 59 + 'cmdliner.1.0.4': '1c3783a51f479ccb97503596896eb40b', 60 + 'cmdliner.1.3.0': 'bcb1b5485952a387d9a1a626d018fc5b', 61 + 'cmdliner.2.0.0': 'e6cf251f6257587fa235157819c1be21', 62 + 'cmdliner.2.1.0': '146d116fd47cdde3a5912f6c3c43a06c', 63 + 64 + // mtime versions 65 + 'mtime.1.3.0': '405772d8c1d5fcfb52a34bc074e9b2bf', 66 + 'mtime.1.4.0': 'd07582e1ae666064d4e2cf55b8f966f2', 67 + 'mtime.2.1.0': '427565ec9f440e77ea8cda7a5baf2f16', 68 + 69 + // logs versions 70 + 'logs.0.7.0': '2579ce9998e74d858251a8467a2d3acc', 71 + 'logs.0.8.0': '87870a13519516a235ea0651450f3d3a', 72 + 'logs.0.10.0': '1447d6620c603faabafd2a4af8180e64', 73 + 74 + // uucp versions (Unicode version tracking) 75 + 'uucp.14.0.0': '61994aea366afe63fbbdfbec3a6c1c17', 76 + 'uucp.15.0.0': '1676ff3253642b3d3380da595576d048', 77 + 'uucp.16.0.0': '2536abe6336b2597409378c985af206f', 78 + 'uucp.17.0.0': '9f478f56c02c6b75ad53e569576ac528', 79 + 80 + // uunf versions 81 + 'uunf.14.0.0': 'c49889fbf46b81974819b189749084eb', 82 + 'uunf.17.0.0': 'd41feec064e2a5ca2ca9ce644b490c35', 83 + 84 + // Single-version libraries 85 + 'astring.0.8.5': '77fa5901f826c06565dd83b8f758980c', 86 + 'jsonm.1.0.2': '331ba04a1674f61d6eb297de762940ea', 87 + 'xmlm.1.4.0': 'de0c6b460a24c08865ced16ef6a90978', 88 + 'ptime.1.2.0': '0e977ea260d75026d2cdd4a7d007b2a5', 89 + 'react.1.2.2': '8b8f1bafe428e743bbb3e9f6a24753a5', 90 + 'hmap.0.8.1': '9cbc1bea29fe2a32ff73726147a24f7f', 91 + 'gg.1.0.0': '0c7a6cc72b0eef74ddf88e8512b418e1', 92 + 'note.0.0.3': '7497fed22490d2257a6fb4ac44bb1316', 93 + 'otfm.0.4.0': 'af7a1a159d4a1c27da168df5cad06ad9', 94 + 'vg.0.9.4': 'acac36a3d697c95764ca16a19c0402e8', 95 + 'vg.0.9.5': '8a313572e25666862de0bc23fc09c53d', 96 + 'bos.0.2.1': '1447d6620c603faabafd2a4af8180e64', 97 + 'fpath.0.7.3': 'b034f0f4718c8842fdec8d4ff3430b97', 98 + 'uutf.1.0.4': '331ba04a1674f61d6eb297de762940ea', 99 + 'b0.0.0.6': '3125f46428fef2c0920ae254a3678000', 100 + }; 101 + 102 + 103 + // ── Fmt: version comparison ────────────────────────────────────────────────── 104 + 105 + test.describe('Fmt', () => { 106 + test.setTimeout(120000); 107 + 108 + for (const ver of ['0.9.0', '0.10.0', '0.11.0']) { 109 + const key = `fmt.${ver}`; 110 + if (!U[key]) continue; 111 + 112 + test(`fmt ${ver}: Fmt.str and Fmt.pr work`, async ({ page }) => { 113 + await initWorker(page, U[key]); 114 + await requirePkg(page, 'fmt'); 115 + 116 + // Fmt.str: format to string (present in all these versions) 117 + await evalExpect(page, 'Fmt.str "%d" 42;;', '"42"'); 118 + 119 + // Fmt.pr: format to stdout 120 + const r = await evalCode(page, 'Fmt.pr "hello %s" "world";;'); 121 + expect(r.stdout).toContain('hello world'); 122 + }); 123 + } 124 + 125 + test('fmt 0.9.0 vs 0.11.0: Fmt.semi available in both', async ({ page }) => { 126 + // Fmt.semi is a separator formatter present in all versions 127 + await initWorker(page, U['fmt.0.9.0']); 128 + await requirePkg(page, 'fmt'); 129 + const r = await evalCode(page, 130 + 'Fmt.str "%a" Fmt.(list ~sep:semi int) [1;2;3];;'); 131 + // The output contains the three numbers separated by semi 132 + expect(r.caml_ppf).toContain('1'); 133 + expect(r.caml_ppf).toContain('2'); 134 + expect(r.caml_ppf).toContain('3'); 135 + }); 136 + 137 + test('fmt completions work across versions', async ({ page }) => { 138 + await initWorker(page, U['fmt.0.11.0']); 139 + await requirePkg(page, 'fmt'); 140 + const names = await page.evaluate( 141 + async () => await window.workerComplete('Fmt.s', 5) 142 + ); 143 + expect(names.length).toBeGreaterThan(0); 144 + expect(names).toContain('str'); 145 + }); 146 + }); 147 + 148 + 149 + // ── Cmdliner: major API change 1.x → 2.x ──────────────────────────────────── 150 + 151 + test.describe('Cmdliner', () => { 152 + test.setTimeout(120000); 153 + 154 + test('cmdliner 1.0.4: Term.eval exists (v1 API)', async ({ page }) => { 155 + await initWorker(page, U['cmdliner.1.0.4']); 156 + await requirePkg(page, 'cmdliner'); 157 + 158 + // In cmdliner 1.x, Cmdliner.Term.eval is the primary entry point 159 + await evalExpect(page, 160 + 'Cmdliner.Term.eval;;', 161 + 'Cmdliner.Term'); 162 + }); 163 + 164 + test('cmdliner 2.1.0: Cmd module exists (v2 API)', async ({ page }) => { 165 + await initWorker(page, U['cmdliner.2.1.0']); 166 + await requirePkg(page, 'cmdliner'); 167 + 168 + // In cmdliner 2.x, Cmdliner.Cmd is the new entry point 169 + await evalExpect(page, 'Cmdliner.Cmd.info;;', 'Cmdliner.Cmd'); 170 + 171 + // Cmdliner.Cmd.v is the new way to create commands 172 + await evalExpect(page, 'Cmdliner.Cmd.v;;', 'Cmdliner.Cmd'); 173 + }); 174 + 175 + test('cmdliner 2.1.0: Arg module works', async ({ page }) => { 176 + await initWorker(page, U['cmdliner.2.1.0']); 177 + await requirePkg(page, 'cmdliner'); 178 + 179 + await evalExpect(page, 180 + 'let name = Cmdliner.Arg.(required & pos 0 (some string) None & info []);;', 181 + 'Cmdliner.Term'); 182 + }); 183 + 184 + test('cmdliner 1.3.0: transitional — both Term.eval and Cmd exist', async ({ page }) => { 185 + await initWorker(page, U['cmdliner.1.3.0']); 186 + await requirePkg(page, 'cmdliner'); 187 + 188 + // 1.3.0 has both the old and new APIs for migration 189 + await evalExpect(page, 'Cmdliner.Term.eval;;', 'Cmdliner.Term'); 190 + await evalExpect(page, 'Cmdliner.Cmd.info;;', 'Cmdliner.Cmd'); 191 + }); 192 + }); 193 + 194 + 195 + // ── Mtime: API change 1.x → 2.x ───────────────────────────────────────────── 196 + 197 + test.describe('Mtime', () => { 198 + test.setTimeout(120000); 199 + 200 + test('mtime 1.4.0: Mtime.Span.to_uint64_ns exists', async ({ page }) => { 201 + await initWorker(page, U['mtime.1.4.0']); 202 + await requirePkg(page, 'mtime'); 203 + await evalExpect(page, 'Mtime.Span.to_uint64_ns;;', '-> int64'); 204 + }); 205 + 206 + test('mtime 2.1.0: Mtime.Span.to_uint64_ns exists (kept)', async ({ page }) => { 207 + await initWorker(page, U['mtime.2.1.0']); 208 + await requirePkg(page, 'mtime'); 209 + await evalExpect(page, 'Mtime.Span.to_uint64_ns;;', '-> int64'); 210 + }); 211 + 212 + test('mtime 2.1.0: Mtime.Span.pp works', async ({ page }) => { 213 + await initWorker(page, U['mtime.2.1.0']); 214 + await requirePkg(page, 'mtime'); 215 + // Mtime.Span.pp uses Format directly, not Fmt 216 + await evalExpect(page, 217 + 'Mtime.Span.of_uint64_ns 1_000_000_000L;;', 218 + 'Mtime.span'); 219 + }); 220 + }); 221 + 222 + 223 + // ── Logs: basic functionality across versions ──────────────────────────────── 224 + 225 + test.describe('Logs', () => { 226 + test.setTimeout(120000); 227 + 228 + test('logs 0.7.0: Logs module loads', async ({ page }) => { 229 + await initWorker(page, U['logs.0.7.0']); 230 + const reqResult = await requirePkg(page, 'logs'); 231 + 232 + // Verify require succeeded by checking that Logs module is available 233 + // Use a simple expression: Logs.err is a log level constructor 234 + const r = await evalCode(page, 'Logs.err;;'); 235 + // Should produce something with "Logs.level" in the type 236 + // If the module failed to load, we'd get an Unbound module error 237 + expect(r.caml_ppf + r.stderr).not.toContain('Unbound module'); 238 + }); 239 + 240 + test('logs 0.10.0: Logs.Src module works', async ({ page }) => { 241 + await initWorker(page, U['logs.0.10.0']); 242 + await requirePkg(page, 'logs'); 243 + 244 + await evalExpect(page, 245 + 'let src = Logs.Src.create "test" ~doc:"A test source";;', 246 + 'Logs.src'); 247 + 248 + await evalExpect(page, 'Logs.Src.name src;;', '"test"'); 249 + }); 250 + }); 251 + 252 + 253 + // ── Uucp: Unicode version tracking ────────────────────────────────────────── 254 + 255 + test.describe('Uucp (Unicode versions)', () => { 256 + test.setTimeout(120000); 257 + 258 + test('uucp 14.0.0: reports Unicode 14.0.0', async ({ page }) => { 259 + await initWorker(page, U['uucp.14.0.0']); 260 + await requirePkg(page, 'uucp'); 261 + await evalExpect(page, 'Uucp.unicode_version;;', '"14.0.0"'); 262 + }); 263 + 264 + test('uucp 15.0.0: reports Unicode 15.0.0', async ({ page }) => { 265 + await initWorker(page, U['uucp.15.0.0']); 266 + await requirePkg(page, 'uucp'); 267 + await evalExpect(page, 'Uucp.unicode_version;;', '"15.0.0"'); 268 + }); 269 + 270 + test('uucp 16.0.0: reports Unicode 16.0.0', async ({ page }) => { 271 + await initWorker(page, U['uucp.16.0.0']); 272 + await requirePkg(page, 'uucp'); 273 + await evalExpect(page, 'Uucp.unicode_version;;', '"16.0.0"'); 274 + }); 275 + 276 + test('uucp 17.0.0: reports Unicode 17.0.0', async ({ page }) => { 277 + await initWorker(page, U['uucp.17.0.0']); 278 + await requirePkg(page, 'uucp'); 279 + await evalExpect(page, 'Uucp.unicode_version;;', '"17.0.0"'); 280 + }); 281 + 282 + test('uucp: general category lookup works', async ({ page }) => { 283 + await initWorker(page, U['uucp.17.0.0']); 284 + await requirePkg(page, 'uucp'); 285 + // 'A' is Lu (uppercase letter) 286 + await evalExpect(page, 287 + 'Uucp.Gc.general_category (Uchar.of_int 0x0041);;', 288 + '`Lu'); 289 + }); 290 + }); 291 + 292 + 293 + // ── Uunf: Unicode normalization ────────────────────────────────────────────── 294 + 295 + test.describe('Uunf', () => { 296 + test.setTimeout(120000); 297 + 298 + test('uunf 14.0.0: reports Unicode 14.0.0', async ({ page }) => { 299 + await initWorker(page, U['uunf.14.0.0']); 300 + await requirePkg(page, 'uunf'); 301 + await evalExpect(page, 'Uunf.unicode_version;;', '"14.0.0"'); 302 + }); 303 + 304 + test('uunf 17.0.0: reports Unicode 17.0.0', async ({ page }) => { 305 + await initWorker(page, U['uunf.17.0.0']); 306 + await requirePkg(page, 'uunf'); 307 + await evalExpect(page, 'Uunf.unicode_version;;', '"17.0.0"'); 308 + }); 309 + }); 310 + 311 + 312 + // ── Astring: string processing ─────────────────────────────────────────────── 313 + 314 + test.describe('Astring', () => { 315 + test.setTimeout(120000); 316 + 317 + test('astring 0.8.5: String.cuts and String.concat', async ({ page }) => { 318 + await initWorker(page, U['astring.0.8.5']); 319 + await requirePkg(page, 'astring'); 320 + 321 + await evalExpect(page, 322 + 'Astring.String.cuts ~sep:"," "a,b,c";;', 323 + '["a"; "b"; "c"]'); 324 + 325 + await evalExpect(page, 326 + 'Astring.String.concat ~sep:"-" ["x"; "y"; "z"];;', 327 + '"x-y-z"'); 328 + }); 329 + 330 + test('astring 0.8.5: String.Sub module', async ({ page }) => { 331 + await initWorker(page, U['astring.0.8.5']); 332 + await requirePkg(page, 'astring'); 333 + 334 + await evalExpect(page, 335 + 'Astring.String.Sub.(to_string (v "hello world" ~start:6));;', 336 + '"world"'); 337 + }); 338 + }); 339 + 340 + 341 + // ── Jsonm: streaming JSON ──────────────────────────────────────────────────── 342 + 343 + test.describe('Jsonm', () => { 344 + test.setTimeout(120000); 345 + 346 + test('jsonm 1.0.2: encode and decode JSON', async ({ page }) => { 347 + await initWorker(page, U['jsonm.1.0.2']); 348 + await requirePkg(page, 'jsonm'); 349 + 350 + // Create a decoder and read from a JSON string 351 + await evalExpect(page, 352 + 'let d = Jsonm.decoder (`String "42") in Jsonm.decode d;;', 353 + '`Lexeme'); 354 + }); 355 + }); 356 + 357 + 358 + // ── Xmlm: XML processing ──────────────────────────────────────────────────── 359 + 360 + test.describe('Xmlm', () => { 361 + test.setTimeout(120000); 362 + 363 + test('xmlm 1.4.0: parse XML input', async ({ page }) => { 364 + await initWorker(page, U['xmlm.1.4.0']); 365 + await requirePkg(page, 'xmlm'); 366 + 367 + await evalExpect(page, 368 + 'let i = Xmlm.make_input (`String (0, "<root/>")) in Xmlm.input i;;', 369 + '`Dtd'); 370 + }); 371 + }); 372 + 373 + 374 + // ── Ptime: POSIX time ──────────────────────────────────────────────────────── 375 + 376 + test.describe('Ptime', () => { 377 + test.setTimeout(120000); 378 + 379 + test('ptime 1.2.0: epoch and time arithmetic', async ({ page }) => { 380 + await initWorker(page, U['ptime.1.2.0']); 381 + await requirePkg(page, 'ptime'); 382 + 383 + await evalExpect(page, 'Ptime.epoch;;', 'Ptime.t'); 384 + 385 + // Create a specific date 386 + await evalExpect(page, 387 + 'Ptime.of_date_time ((2024, 1, 1), ((0, 0, 0), 0));;', 388 + 'Some'); 389 + }); 390 + 391 + test('ptime 1.2.0: Ptime.Span works', async ({ page }) => { 392 + await initWorker(page, U['ptime.1.2.0']); 393 + await requirePkg(page, 'ptime'); 394 + 395 + await evalExpect(page, 396 + 'Ptime.Span.of_int_s 3600 |> Ptime.Span.to_int_s;;', 397 + '3600'); 398 + }); 399 + }); 400 + 401 + 402 + // ── React: functional reactive programming ─────────────────────────────────── 403 + 404 + test.describe('React', () => { 405 + test.setTimeout(120000); 406 + 407 + test('react 1.2.2: create signals and events', async ({ page }) => { 408 + await initWorker(page, U['react.1.2.2']); 409 + await requirePkg(page, 'react'); 410 + 411 + // Create a signal with initial value 412 + await evalExpect(page, 413 + 'let s, set_s = React.S.create 0;;', 414 + 'React.signal'); 415 + 416 + // Read signal value 417 + await evalExpect(page, 'React.S.value s;;', '0'); 418 + 419 + // Update and read 420 + await evalCode(page, 'set_s 42;;'); 421 + await evalExpect(page, 'React.S.value s;;', '42'); 422 + }); 423 + }); 424 + 425 + 426 + // ── Hmap: heterogeneous maps ───────────────────────────────────────────────── 427 + 428 + test.describe('Hmap', () => { 429 + test.setTimeout(120000); 430 + 431 + test('hmap 0.8.1: create keys and store heterogeneous values', async ({ page }) => { 432 + await initWorker(page, U['hmap.0.8.1']); 433 + await requirePkg(page, 'hmap'); 434 + 435 + await evalExpect(page, 436 + 'let k_int : int Hmap.key = Hmap.Key.create ();;', 437 + 'Hmap.key'); 438 + 439 + await evalExpect(page, 440 + 'let k_str : string Hmap.key = Hmap.Key.create ();;', 441 + 'Hmap.key'); 442 + 443 + await evalExpect(page, 444 + 'let m = Hmap.empty |> Hmap.add k_int 42 |> Hmap.add k_str "hello";;', 445 + 'Hmap.t'); 446 + 447 + await evalExpect(page, 'Hmap.find k_int m;;', 'Some 42'); 448 + await evalExpect(page, 'Hmap.find k_str m;;', 'Some "hello"'); 449 + }); 450 + }); 451 + 452 + 453 + // ── Gg: basic graphics geometry ────────────────────────────────────────────── 454 + 455 + test.describe('Gg', () => { 456 + test.setTimeout(120000); 457 + 458 + test('gg 1.0.0: 2D vectors and colors', async ({ page }) => { 459 + await initWorker(page, U['gg.1.0.0']); 460 + await requirePkg(page, 'gg'); 461 + 462 + // Create a 2D point 463 + await evalExpect(page, 'Gg.V2.v 1.0 2.0;;', 'Gg.v2'); 464 + 465 + // Vector addition 466 + await evalExpect(page, 467 + 'Gg.V2.add (Gg.V2.v 1.0 2.0) (Gg.V2.v 3.0 4.0);;', 468 + 'Gg.v2'); 469 + 470 + // Check the result 471 + await evalExpect(page, 472 + 'let r = Gg.V2.add (Gg.V2.v 1.0 2.0) (Gg.V2.v 3.0 4.0) in Gg.V2.x r;;', 473 + '4.'); 474 + 475 + // Colors 476 + await evalExpect(page, 'Gg.Color.red;;', 'Gg.color'); 477 + }); 478 + }); 479 + 480 + 481 + // ── Vg: vector graphics ───────────────────────────────────────────────────── 482 + 483 + test.describe('Vg', () => { 484 + test.setTimeout(120000); 485 + 486 + test('vg 0.9.5: create paths and images', async ({ page }) => { 487 + await initWorker(page, U['vg.0.9.5']); 488 + await requirePkg(page, 'vg'); 489 + await requirePkg(page, 'gg'); 490 + 491 + // Create a simple path 492 + await evalExpect(page, 493 + 'let p = Vg.P.empty |> Vg.P.line (Gg.V2.v 1.0 1.0);;', 494 + 'Vg.path'); 495 + 496 + // Create an image from path 497 + await evalExpect(page, 498 + 'let img = Vg.I.cut p (Vg.I.const Gg.Color.red);;', 499 + 'Vg.image'); 500 + }); 501 + 502 + test('vg 0.9.4: also works (older version)', async ({ page }) => { 503 + // vg 0.9.4's universe has many large deps, skip if too slow 504 + // The require may timeout because there are lots of .cma.js to load 505 + test.setTimeout(180000); 506 + await initWorker(page, U['vg.0.9.4']); 507 + // Use page.evaluate with an extended timeout for the large requires 508 + const reqResult = await page.evaluate(async () => { 509 + try { 510 + await window.workerEval('#require "vg";;'); 511 + await window.workerEval('#require "gg";;'); 512 + return { ok: true }; 513 + } catch (e) { 514 + return { ok: false, error: e.message }; 515 + } 516 + }); 517 + if (!reqResult.ok) { 518 + test.skip(true, `Skipped: ${reqResult.error}`); 519 + return; 520 + } 521 + 522 + await evalExpect(page, 523 + 'Vg.P.empty |> Vg.P.line (Gg.V2.v 1.0 1.0);;', 524 + 'Vg.path'); 525 + }); 526 + }); 527 + 528 + 529 + // ── Note: declarative signals ──────────────────────────────────────────────── 530 + 531 + test.describe('Note', () => { 532 + test.setTimeout(120000); 533 + 534 + test('note 0.0.3: create events and signals', async ({ page }) => { 535 + await initWorker(page, U['note.0.0.3']); 536 + await requirePkg(page, 'note'); 537 + 538 + // Note.S is the signal module 539 + await evalExpect(page, 540 + 'let s = Note.S.const 42;;', 541 + 'Note.signal'); 542 + 543 + await evalExpect(page, 'Note.S.value s;;', '42'); 544 + }); 545 + }); 546 + 547 + 548 + // ── Otfm: OpenType font metrics ────────────────────────────────────────────── 549 + 550 + test.describe('Otfm', () => { 551 + test.setTimeout(120000); 552 + 553 + test('otfm 0.4.0: module loads and types available', async ({ page }) => { 554 + await initWorker(page, U['otfm.0.4.0']); 555 + await requirePkg(page, 'otfm'); 556 + 557 + // Check that the decoder type exists 558 + await evalExpect(page, 'Otfm.decoder;;', '-> Otfm.decoder'); 559 + }); 560 + }); 561 + 562 + 563 + // ── Fpath: file system paths ───────────────────────────────────────────────── 564 + 565 + test.describe('Fpath', () => { 566 + test.setTimeout(120000); 567 + 568 + test('fpath 0.7.3: path manipulation', async ({ page }) => { 569 + await initWorker(page, U['fpath.0.7.3']); 570 + await requirePkg(page, 'fpath'); 571 + 572 + await evalExpect(page, 573 + 'Fpath.v "/usr/local/bin" |> Fpath.to_string;;', 574 + '"/usr/local/bin"'); 575 + 576 + await evalExpect(page, 577 + 'Fpath.(v "/usr" / "local" / "bin") |> Fpath.to_string;;', 578 + '"/usr/local/bin"'); 579 + 580 + await evalExpect(page, 581 + 'Fpath.v "/usr/local/bin" |> Fpath.parent |> Fpath.to_string;;', 582 + '"/usr/local/"'); 583 + 584 + await evalExpect(page, 585 + 'Fpath.v "/usr/local/bin" |> Fpath.basename;;', 586 + '"bin"'); 587 + }); 588 + }); 589 + 590 + 591 + // ── Uutf: UTF decoding/encoding ────────────────────────────────────────────── 592 + 593 + test.describe('Uutf', () => { 594 + test.setTimeout(120000); 595 + 596 + test('uutf 1.0.4: decode UTF-8', async ({ page }) => { 597 + await initWorker(page, U['uutf.1.0.4']); 598 + await requirePkg(page, 'uutf'); 599 + 600 + // Create a decoder 601 + await evalExpect(page, 602 + 'let d = Uutf.decoder ~encoding:`UTF_8 (`String "ABC");;', 603 + 'Uutf.decoder'); 604 + 605 + // Decode first character 606 + await evalExpect(page, 'Uutf.decode d;;', '`Uchar'); 607 + }); 608 + }); 609 + 610 + 611 + // ── B0: build system library ───────────────────────────────────────────────── 612 + 613 + test.describe('B0', () => { 614 + test.setTimeout(120000); 615 + 616 + test('b0 0.0.6: B0_std.Fpath', async ({ page }) => { 617 + await initWorker(page, U['b0.0.0.6']); 618 + await requirePkg(page, 'b0.std'); 619 + 620 + await evalExpect(page, 'B0_std.Fpath.v "/tmp";;', 'B0_std.Fpath.t'); 621 + }); 622 + }); 623 + 624 + 625 + // ── Cross-library: Bos (uses fmt + fpath + logs + astring + rresult) ───────── 626 + 627 + test.describe('Bos (cross-library)', () => { 628 + test.setTimeout(120000); 629 + 630 + test('bos 0.2.1: depends on fmt, fpath, logs, astring', async ({ page }) => { 631 + await initWorker(page, U['bos.0.2.1']); 632 + await requirePkg(page, 'bos'); 633 + 634 + // Bos.OS.File uses Fpath 635 + await evalExpect(page, 'Bos.OS.Cmd.run_status;;', 'Bos.Cmd'); 636 + 637 + // Bos.Cmd construction 638 + await evalExpect(page, 639 + 'Bos.Cmd.(v "echo" % "hello");;', 640 + 'Bos.Cmd'); 641 + }); 642 + });
+73
test/ohc-integration/eval-test.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>JTW Eval Test</title> 6 + </head> 7 + <body> 8 + <h1>JTW Eval Test</h1> 9 + <div id="status">Loading...</div> 10 + <pre id="log"></pre> 11 + <script type="module"> 12 + import { OcamlWorker } from '/client/ocaml-worker.js'; 13 + 14 + const status = document.getElementById('status'); 15 + const logEl = document.getElementById('log'); 16 + 17 + function log(msg) { 18 + logEl.textContent += msg + '\n'; 19 + console.log(msg); 20 + } 21 + 22 + const params = new URLSearchParams(window.location.search); 23 + const universe = params.get('universe'); 24 + const compilerVersion = params.get('compiler') || '5.4.0'; 25 + 26 + if (!universe) { 27 + status.textContent = 'Error: ?universe= parameter required'; 28 + status.dataset.error = 'universe parameter required'; 29 + throw new Error('universe parameter required'); 30 + } 31 + 32 + const workerUrl = `/jtw-output/compiler/${compilerVersion}/worker.js`; 33 + const findlib_index = `/jtw-output/u/${universe}/findlib_index`; 34 + const stdlib_dcs = `lib/ocaml/dynamic_cmis.json`; 35 + 36 + status.textContent = 'Creating worker...'; 37 + const worker = new OcamlWorker(workerUrl, { timeout: 120000 }); 38 + 39 + try { 40 + status.textContent = 'Initializing...'; 41 + await worker.init({ 42 + findlib_requires: [], 43 + stdlib_dcs: stdlib_dcs, 44 + findlib_index: findlib_index, 45 + }); 46 + 47 + status.textContent = 'Ready'; 48 + status.dataset.ready = 'true'; 49 + 50 + // Expose eval/complete/require on window for Playwright 51 + window.workerEval = async (code) => { 52 + const result = await worker.eval(code); 53 + return { 54 + caml_ppf: result.caml_ppf || '', 55 + stdout: result.stdout || '', 56 + stderr: result.stderr || '', 57 + }; 58 + }; 59 + 60 + window.workerComplete = async (source, pos) => { 61 + const result = await worker.complete(source, pos); 62 + const entries = result.completions?.entries || []; 63 + return entries.map(e => e.name); 64 + }; 65 + 66 + } catch (err) { 67 + status.textContent = 'Error: ' + err.message; 68 + status.dataset.error = err.message; 69 + log('ERROR: ' + err.message); 70 + } 71 + </script> 72 + </body> 73 + </html>
+55
test/ohc-integration/ohc-jtw.spec.js
··· 1 + // @ts-check 2 + const { test, expect } = require('@playwright/test'); 3 + 4 + // Universe hash is passed via environment or auto-detected 5 + const UNIVERSE = process.env.JTW_UNIVERSE || ''; 6 + const COMPILER = process.env.JTW_COMPILER || '5.4.0'; 7 + 8 + test.describe('OHC JTW Integration', () => { 9 + test.setTimeout(120000); 10 + 11 + test('worker initializes and executes OCaml with fmt', async ({ page }) => { 12 + // Collect console logs for debugging 13 + const logs = []; 14 + page.on('console', msg => logs.push(msg.text())); 15 + 16 + await page.goto(`/test/ohc-integration/test.html?universe=${UNIVERSE}&compiler=${COMPILER}`); 17 + 18 + // Wait for tests to complete (or error) 19 + await expect(async () => { 20 + const done = await page.locator('#status').getAttribute('data-done'); 21 + const error = await page.locator('#status').getAttribute('data-error'); 22 + expect(done === 'true' || error !== null).toBeTruthy(); 23 + }).toPass({ timeout: 120000 }); 24 + 25 + // Check no error occurred 26 + const error = await page.locator('#status').getAttribute('data-error'); 27 + if (error) { 28 + console.log('Console logs:', logs.join('\n')); 29 + } 30 + expect(error).toBeNull(); 31 + 32 + // Verify individual test results 33 + const results = page.locator('#results'); 34 + 35 + // Test 1: Basic arithmetic 36 + const test1 = await results.getAttribute('data-test1'); 37 + expect(test1).toContain('val x : int = 3'); 38 + 39 + // Test 2: String operations 40 + const test2 = await results.getAttribute('data-test2'); 41 + expect(test2).toContain('"hello, world"'); 42 + 43 + // Test 3: fmt loaded 44 + const test3 = await results.getAttribute('data-test3'); 45 + expect(test3).toBe('loaded'); 46 + 47 + // Test 4: Fmt.str works 48 + const test4 = await results.getAttribute('data-test4'); 49 + expect(test4).toContain('"42"'); 50 + 51 + // Test 5: Completions work 52 + const test5 = await results.getAttribute('data-test5'); 53 + expect(test5).toBe('ok'); 54 + }); 55 + });
+71
test/ohc-integration/package-lock.json
··· 1 + { 2 + "name": "ohc-integration", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "ohc-integration", 9 + "version": "1.0.0", 10 + "license": "ISC", 11 + "dependencies": { 12 + "@playwright/test": "^1.58.1" 13 + } 14 + }, 15 + "node_modules/@playwright/test": { 16 + "version": "1.58.1", 17 + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", 18 + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", 19 + "dependencies": { 20 + "playwright": "1.58.1" 21 + }, 22 + "bin": { 23 + "playwright": "cli.js" 24 + }, 25 + "engines": { 26 + "node": ">=18" 27 + } 28 + }, 29 + "node_modules/fsevents": { 30 + "version": "2.3.2", 31 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 32 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 33 + "hasInstallScript": true, 34 + "optional": true, 35 + "os": [ 36 + "darwin" 37 + ], 38 + "engines": { 39 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 40 + } 41 + }, 42 + "node_modules/playwright": { 43 + "version": "1.58.1", 44 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", 45 + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", 46 + "dependencies": { 47 + "playwright-core": "1.58.1" 48 + }, 49 + "bin": { 50 + "playwright": "cli.js" 51 + }, 52 + "engines": { 53 + "node": ">=18" 54 + }, 55 + "optionalDependencies": { 56 + "fsevents": "2.3.2" 57 + } 58 + }, 59 + "node_modules/playwright-core": { 60 + "version": "1.58.1", 61 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", 62 + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", 63 + "bin": { 64 + "playwright-core": "cli.js" 65 + }, 66 + "engines": { 67 + "node": ">=18" 68 + } 69 + } 70 + } 71 + }
+15
test/ohc-integration/package.json
··· 1 + { 2 + "name": "ohc-integration", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "ohc-jtw.spec.js", 6 + "scripts": { 7 + "test": "echo \"Error: no test specified\" && exit 1" 8 + }, 9 + "keywords": [], 10 + "author": "", 11 + "license": "ISC", 12 + "dependencies": { 13 + "@playwright/test": "^1.58.1" 14 + } 15 + }
+19
test/ohc-integration/playwright.config.js
··· 1 + // @ts-check 2 + const { defineConfig } = require('@playwright/test'); 3 + 4 + module.exports = defineConfig({ 5 + testDir: '.', 6 + timeout: 120000, 7 + retries: 0, 8 + use: { 9 + baseURL: 'http://localhost:8769', 10 + }, 11 + webServer: { 12 + command: 'python3 -m http.server 8769', 13 + cwd: process.env.JTW_SERVE_DIR || '/home/jons-agent/js_top_worker', 14 + port: 8769, 15 + timeout: 10000, 16 + reuseExistingServer: true, 17 + }, 18 + reporter: 'list', 19 + });
+614
test/ohc-integration/runner.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>JTW Library Test Runner</title> 6 + <style> 7 + * { box-sizing: border-box; margin: 0; padding: 0; } 8 + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; } 9 + h1 { color: #f0f6fc; margin-bottom: 4px; font-size: 24px; } 10 + .subtitle { color: #8b949e; margin-bottom: 24px; font-size: 14px; } 11 + .summary { display: flex; gap: 16px; margin-bottom: 24px; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; flex-wrap: wrap; } 12 + .summary .stat { text-align: center; min-width: 70px; } 13 + .summary .stat .num { font-size: 32px; font-weight: 700; } 14 + .summary .stat .label { font-size: 12px; color: #8b949e; text-transform: uppercase; } 15 + .stat.pass .num { color: #3fb950; } 16 + .stat.fail .num { color: #f85149; } 17 + .stat.skip .num { color: #d29922; } 18 + .stat.run .num { color: #58a6ff; } 19 + .progress { height: 4px; background: #21262d; border-radius: 2px; margin-bottom: 24px; overflow: hidden; } 20 + .progress-bar { height: 100%; background: #58a6ff; transition: width 0.3s; } 21 + 22 + .group { margin-bottom: 16px; } 23 + .group-header { font-size: 14px; font-weight: 600; color: #f0f6fc; padding: 8px 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px 8px 0 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } 24 + .group-header:hover { background: #1c2128; } 25 + .group-header .arrow { transition: transform 0.2s; } 26 + .group-header.collapsed .arrow { transform: rotate(-90deg); } 27 + .group-badge { font-size: 11px; padding: 1px 6px; border-radius: 10px; margin-left: 8px; font-weight: 400; } 28 + .group-badge.cross { background: #30363d; color: #d2a8ff; } 29 + .group-body { border: 1px solid #30363d; border-top: none; border-radius: 0 0 8px 8px; overflow: hidden; } 30 + .group-body.hidden { display: none; } 31 + 32 + .code-banner { padding: 6px 12px; background: #0d1117; border-bottom: 1px solid #21262d; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; color: #d2a8ff; } 33 + .code-banner code { color: #f0f6fc; } 34 + 35 + .test-row { display: flex; align-items: flex-start; padding: 8px 12px; border-bottom: 1px solid #21262d; font-size: 13px; gap: 8px; } 36 + .test-row:last-child { border-bottom: none; } 37 + .test-row:hover { background: #161b22; } 38 + .test-icon { flex-shrink: 0; width: 20px; text-align: center; font-size: 14px; } 39 + .test-name { flex: 1; } 40 + .test-name .label { font-family: 'SF Mono', 'Fira Code', monospace; } 41 + .test-detail { font-size: 12px; color: #8b949e; margin-top: 2px; } 42 + .test-time { color: #8b949e; font-size: 12px; flex-shrink: 0; } 43 + 44 + .test-row.pass .test-icon { color: #3fb950; } 45 + .test-row.fail .test-icon { color: #f85149; } 46 + .test-row.skip .test-icon { color: #d29922; } 47 + .test-row.running .test-icon { color: #58a6ff; } 48 + .test-row.pending .test-icon { color: #484f58; } 49 + /* "Expected error" pass: still green check but with a visual indicator */ 50 + .test-row.pass-neg .test-icon { color: #3fb950; } 51 + 52 + .expect-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; margin-left: 6px; vertical-align: middle; } 53 + .expect-badge.should-pass { background: #23862633; color: #3fb950; } 54 + .expect-badge.should-error { background: #f8514933; color: #f85149; } 55 + 56 + .error-detail { background: #1c1215; border: 1px solid #f8514933; border-radius: 4px; padding: 8px; margin-top: 6px; font-family: 'SF Mono', monospace; font-size: 12px; color: #f85149; white-space: pre-wrap; word-break: break-all; } 57 + .output-detail { background: #121a16; border: 1px solid #3fb95033; border-radius: 4px; padding: 8px; margin-top: 6px; font-family: 'SF Mono', monospace; font-size: 12px; color: #3fb950; white-space: pre-wrap; word-break: break-all; } 58 + .neg-output-detail { background: #1a1520; border: 1px solid #d2a8ff33; border-radius: 4px; padding: 8px; margin-top: 6px; font-family: 'SF Mono', monospace; font-size: 12px; color: #d2a8ff; white-space: pre-wrap; word-break: break-all; } 59 + 60 + .step-transcript { margin-top: 6px; border-radius: 4px; overflow: hidden; border: 1px solid #30363d; } 61 + .step-transcript + .step-transcript { margin-top: 4px; } 62 + .step-input { padding: 4px 8px; background: #161b22; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: #d2a8ff; border-bottom: 1px solid #21262d; } 63 + .step-input::before { content: '# '; color: #484f58; } 64 + .step-output { padding: 4px 8px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; } 65 + .step-output.out-pass { background: #121a16; color: #3fb950; } 66 + .step-output.out-fail { background: #1c1215; color: #f85149; } 67 + .step-output.out-neg { background: #1a1520; color: #d2a8ff; } 68 + .step-output.out-stdout { background: #161b22; color: #c9d1d9; } 69 + 70 + @keyframes spin { to { transform: rotate(360deg); } } 71 + .spinner { display: inline-block; animation: spin 1s linear infinite; } 72 + </style> 73 + </head> 74 + <body> 75 + <h1>JTW Library Test Runner</h1> 76 + <p class="subtitle">Testing OCaml libraries across versions via ohc-built JTW output &mdash; including cross-version negative tests</p> 77 + 78 + <div class="summary"> 79 + <div class="stat pass"><div class="num" id="pass-count">0</div><div class="label">Passed</div></div> 80 + <div class="stat fail"><div class="num" id="fail-count">0</div><div class="label">Unexpected</div></div> 81 + <div class="stat skip"><div class="num" id="skip-count">0</div><div class="label">Skipped</div></div> 82 + <div class="stat run"><div class="num" id="run-count">0</div><div class="label">Running</div></div> 83 + </div> 84 + <div class="progress"><div class="progress-bar" id="progress-bar"></div></div> 85 + 86 + <div id="groups"></div> 87 + 88 + <script type="module"> 89 + import { OcamlWorker } from '/client/ocaml-worker.js'; 90 + 91 + // ── Universe mapping ────────────────────────────────────────────────── 92 + const U = { 93 + 'fmt.0.9.0': '9901393f978b0a6627c5eab595111f50', 94 + 'fmt.0.10.0': 'dc92d356407d44e1eae7e39acefce214', 95 + 'fmt.0.11.0': '5c8d38716cee871f1a6a1f164c9171e6', 96 + 'cmdliner.1.0.4': '1c3783a51f479ccb97503596896eb40b', 97 + 'cmdliner.1.3.0': 'bcb1b5485952a387d9a1a626d018fc5b', 98 + 'cmdliner.2.0.0': 'e6cf251f6257587fa235157819c1be21', 99 + 'cmdliner.2.1.0': '146d116fd47cdde3a5912f6c3c43a06c', 100 + 'mtime.1.3.0': '405772d8c1d5fcfb52a34bc074e9b2bf', 101 + 'mtime.1.4.0': 'd07582e1ae666064d4e2cf55b8f966f2', 102 + 'mtime.2.1.0': '427565ec9f440e77ea8cda7a5baf2f16', 103 + 'logs.0.7.0': '2579ce9998e74d858251a8467a2d3acc', 104 + 'logs.0.10.0': '1447d6620c603faabafd2a4af8180e64', 105 + 'uucp.14.0.0': '61994aea366afe63fbbdfbec3a6c1c17', 106 + 'uucp.15.0.0': '1676ff3253642b3d3380da595576d048', 107 + 'uucp.16.0.0': '2536abe6336b2597409378c985af206f', 108 + 'uucp.17.0.0': '9f478f56c02c6b75ad53e569576ac528', 109 + 'uunf.14.0.0': 'c49889fbf46b81974819b189749084eb', 110 + 'uunf.17.0.0': 'd41feec064e2a5ca2ca9ce644b490c35', 111 + 'astring.0.8.5': '77fa5901f826c06565dd83b8f758980c', 112 + 'jsonm.1.0.2': '331ba04a1674f61d6eb297de762940ea', 113 + 'xmlm.1.4.0': 'de0c6b460a24c08865ced16ef6a90978', 114 + 'ptime.1.2.0': '0e977ea260d75026d2cdd4a7d007b2a5', 115 + 'react.1.2.2': '8b8f1bafe428e743bbb3e9f6a24753a5', 116 + 'hmap.0.8.1': '9cbc1bea29fe2a32ff73726147a24f7f', 117 + 'gg.1.0.0': '0c7a6cc72b0eef74ddf88e8512b418e1', 118 + 'note.0.0.3': '7497fed22490d2257a6fb4ac44bb1316', 119 + 'otfm.0.4.0': 'af7a1a159d4a1c27da168df5cad06ad9', 120 + 'vg.0.9.5': '8a313572e25666862de0bc23fc09c53d', 121 + 'bos.0.2.1': '1447d6620c603faabafd2a4af8180e64', 122 + 'fpath.0.7.3': 'b034f0f4718c8842fdec8d4ff3430b97', 123 + 'uutf.1.0.4': '331ba04a1674f61d6eb297de762940ea', 124 + 'b0.0.0.6': '3125f46428fef2c0920ae254a3678000', 125 + }; 126 + 127 + // ── Test definitions ────────────────────────────────────────────────── 128 + // 129 + // Regular tests: 130 + // { group, name, universe, require, steps: [{code, expect}] } 131 + // 132 + // Cross-version (negative) tests: 133 + // { group, crossVersion: true, code: "...", 134 + // cases: [{ label, universe, require, 135 + // shouldPass: true/false, expect?, expectError? }] } 136 + 137 + const tests = [ 138 + 139 + // ══════════════════════════════════════════════════════════════════ 140 + // CROSS-VERSION NEGATIVE TESTS 141 + // ══════════════════════════════════════════════════════════════════ 142 + 143 + // ── Cmdliner.Cmd module: introduced in ~1.1, absent in 1.0.4 ── 144 + { group: 'Cmdliner: Cmd module boundary', crossVersion: true, 145 + code: 'Cmdliner.Cmd.info;;', 146 + description: 'Cmdliner.Cmd was introduced after 1.0.x. Same code errors in 1.0.4 but works in 1.3.0+.', 147 + cases: [ 148 + { label: 'cmdliner 1.0.4', universe: U['cmdliner.1.0.4'], require: ['cmdliner'], 149 + shouldPass: false, expectError: 'Unbound module' }, 150 + { label: 'cmdliner 1.3.0', universe: U['cmdliner.1.3.0'], require: ['cmdliner'], 151 + shouldPass: true, expect: 'Cmdliner.Cmd' }, 152 + { label: 'cmdliner 2.1.0', universe: U['cmdliner.2.1.0'], require: ['cmdliner'], 153 + shouldPass: true, expect: 'Cmdliner.Cmd' }, 154 + ] }, 155 + 156 + // ── Cmdliner.Term.eval: removed in 2.0 ── 157 + { group: 'Cmdliner: Term.eval removal', crossVersion: true, 158 + code: 'Cmdliner.Term.eval;;', 159 + description: 'Cmdliner.Term.eval was removed in 2.0. Present in 1.0.4 and 1.3.0 (transitional), gone in 2.x.', 160 + cases: [ 161 + { label: 'cmdliner 1.0.4', universe: U['cmdliner.1.0.4'], require: ['cmdliner'], 162 + shouldPass: true, expect: 'Cmdliner.Term' }, 163 + { label: 'cmdliner 1.3.0', universe: U['cmdliner.1.3.0'], require: ['cmdliner'], 164 + shouldPass: true, expect: 'Cmdliner.Term' }, 165 + { label: 'cmdliner 2.0.0', universe: U['cmdliner.2.0.0'], require: ['cmdliner'], 166 + shouldPass: false, expectError: 'Unbound' }, 167 + { label: 'cmdliner 2.1.0', universe: U['cmdliner.2.1.0'], require: ['cmdliner'], 168 + shouldPass: false, expectError: 'Unbound' }, 169 + ] }, 170 + 171 + // ── Uucp.unicode_version value changes ── 172 + { group: 'Uucp: unicode_version value', crossVersion: true, 173 + code: 'Uucp.unicode_version;;', 174 + description: 'Each Uucp release tracks a specific Unicode version. The returned string differs per version.', 175 + cases: [ 176 + { label: 'uucp 14.0.0', universe: U['uucp.14.0.0'], require: ['uucp'], 177 + shouldPass: true, expect: '"14.0.0"' }, 178 + { label: 'uucp 15.0.0', universe: U['uucp.15.0.0'], require: ['uucp'], 179 + shouldPass: true, expect: '"15.0.0"' }, 180 + { label: 'uucp 16.0.0', universe: U['uucp.16.0.0'], require: ['uucp'], 181 + shouldPass: true, expect: '"16.0.0"' }, 182 + { label: 'uucp 17.0.0', universe: U['uucp.17.0.0'], require: ['uucp'], 183 + shouldPass: true, expect: '"17.0.0"' }, 184 + ] }, 185 + 186 + // ── Uucp: "17.0.0" only in uucp 17 ── 187 + { group: 'Uucp: version string mismatch', crossVersion: true, 188 + code: 'assert (Uucp.unicode_version = "17.0.0");;', 189 + description: 'Asserting unicode_version = "17.0.0" passes only in uucp 17, fails in 14.', 190 + cases: [ 191 + { label: 'uucp 14.0.0', universe: U['uucp.14.0.0'], require: ['uucp'], 192 + shouldPass: false, expectError: 'Assert_failure' }, 193 + { label: 'uucp 17.0.0', universe: U['uucp.17.0.0'], require: ['uucp'], 194 + shouldPass: true, expect: '' }, 195 + ] }, 196 + 197 + // ── Uunf version boundary ── 198 + { group: 'Uunf: version boundary', crossVersion: true, 199 + code: 'assert (Uunf.unicode_version = "17.0.0");;', 200 + description: 'Uunf 14.0.0 reports Unicode 14, so asserting "17.0.0" fails. Passes in uunf 17.', 201 + cases: [ 202 + { label: 'uunf 14.0.0', universe: U['uunf.14.0.0'], require: ['uunf'], 203 + shouldPass: false, expectError: 'Assert_failure' }, 204 + { label: 'uunf 17.0.0', universe: U['uunf.17.0.0'], require: ['uunf'], 205 + shouldPass: true, expect: '' }, 206 + ] }, 207 + 208 + // ── Mtime.Span.of_float_ns: added in 2.x ── 209 + { group: 'Mtime: Span.of_float_ns boundary', crossVersion: true, 210 + code: 'Mtime.Span.of_float_ns;;', 211 + description: 'Mtime.Span.of_float_ns was added in mtime 2.0. Not present in 1.x.', 212 + cases: [ 213 + { label: 'mtime 1.3.0', universe: U['mtime.1.3.0'], require: ['mtime'], 214 + shouldPass: false, expectError: 'Unbound' }, 215 + { label: 'mtime 1.4.0', universe: U['mtime.1.4.0'], require: ['mtime'], 216 + shouldPass: false, expectError: 'Unbound' }, 217 + { label: 'mtime 2.1.0', universe: U['mtime.2.1.0'], require: ['mtime'], 218 + shouldPass: true, expect: 'Mtime.span option' }, 219 + ] }, 220 + 221 + // ══════════════════════════════════════════════════════════════════ 222 + // POSITIVE TESTS (functionality verification) 223 + // ══════════════════════════════════════════════════════════════════ 224 + 225 + // ── Fmt ── 226 + ...['0.9.0', '0.10.0', '0.11.0'].map(v => ({ 227 + group: 'Fmt', 228 + name: `${v}: Fmt.str formats integers`, 229 + universe: U[`fmt.${v}`], 230 + require: ['fmt'], 231 + steps: [{ code: 'Fmt.str "%d" 42;;', expect: '"42"' }], 232 + })), 233 + ...['0.9.0', '0.10.0', '0.11.0'].map(v => ({ 234 + group: 'Fmt', 235 + name: `${v}: Fmt.pr writes to stdout`, 236 + universe: U[`fmt.${v}`], 237 + require: ['fmt'], 238 + steps: [{ code: 'Fmt.pr "hello %s" "world";;', expectStdout: 'hello world' }], 239 + })), 240 + { group: 'Fmt', name: '0.11.0: completions for Fmt.s*', universe: U['fmt.0.11.0'], require: ['fmt'], 241 + steps: [{ complete: { source: 'Fmt.s', pos: 5 }, expectEntries: ['str'] }] }, 242 + 243 + // ── Cmdliner (positive) ── 244 + { group: 'Cmdliner', name: '2.1.0: Arg combinators', universe: U['cmdliner.2.1.0'], require: ['cmdliner'], 245 + steps: [{ code: 'let name = Cmdliner.Arg.(required & pos 0 (some string) None & info []);;', expect: 'Cmdliner.Term' }] }, 246 + 247 + // ── Uucp (positive) ── 248 + { group: 'Uucp', name: '17.0.0: general category A = Lu', universe: U['uucp.17.0.0'], require: ['uucp'], 249 + steps: [{ code: 'Uucp.Gc.general_category (Uchar.of_int 0x0041);;', expect: '`Lu' }] }, 250 + 251 + // ── Mtime (positive) ── 252 + { group: 'Mtime', name: '1.4.0: Span.to_uint64_ns', universe: U['mtime.1.4.0'], require: ['mtime'], 253 + steps: [{ code: 'Mtime.Span.to_uint64_ns;;', expect: '-> int64' }] }, 254 + { group: 'Mtime', name: '2.1.0: Span.of_uint64_ns', universe: U['mtime.2.1.0'], require: ['mtime'], 255 + steps: [{ code: 'Mtime.Span.of_uint64_ns 1_000_000_000L;;', expect: 'Mtime.span' }] }, 256 + 257 + // ── Logs ── 258 + { group: 'Logs', name: '0.10.0: Src.create and Src.name', universe: U['logs.0.10.0'], require: ['logs'], 259 + steps: [ 260 + { code: 'let src = Logs.Src.create "test" ~doc:"A test source";;', expect: 'Logs.src' }, 261 + { code: 'Logs.Src.name src;;', expect: '"test"' }, 262 + ] }, 263 + 264 + // ── Astring ── 265 + { group: 'Astring', name: '0.8.5: String.cuts', universe: U['astring.0.8.5'], require: ['astring'], 266 + steps: [{ code: 'Astring.String.cuts ~sep:"," "a,b,c";;', expect: '["a"; "b"; "c"]' }] }, 267 + { group: 'Astring', name: '0.8.5: String.concat', universe: U['astring.0.8.5'], require: ['astring'], 268 + steps: [{ code: 'Astring.String.concat ~sep:"-" ["x"; "y"; "z"];;', expect: '"x-y-z"' }] }, 269 + { group: 'Astring', name: '0.8.5: String.Sub', universe: U['astring.0.8.5'], require: ['astring'], 270 + steps: [{ code: 'Astring.String.Sub.(to_string (v "hello world" ~start:6));;', expect: '"world"' }] }, 271 + 272 + // ── Jsonm ── 273 + { group: 'Jsonm', name: '1.0.2: decode JSON', universe: U['jsonm.1.0.2'], require: ['jsonm'], 274 + steps: [{ code: 'let d = Jsonm.decoder (`String "42") in Jsonm.decode d;;', expect: '`Lexeme' }] }, 275 + 276 + // ── Xmlm ── 277 + { group: 'Xmlm', name: '1.4.0: parse XML', universe: U['xmlm.1.4.0'], require: ['xmlm'], 278 + steps: [{ code: 'let i = Xmlm.make_input (`String (0, "<root/>")) in Xmlm.input i;;', expect: '`Dtd' }] }, 279 + 280 + // ── Ptime ── 281 + { group: 'Ptime', name: '1.2.0: epoch', universe: U['ptime.1.2.0'], require: ['ptime'], 282 + steps: [{ code: 'Ptime.epoch;;', expect: 'Ptime.t' }] }, 283 + { group: 'Ptime', name: '1.2.0: date creation', universe: U['ptime.1.2.0'], require: ['ptime'], 284 + steps: [{ code: 'Ptime.of_date_time ((2024, 1, 1), ((0, 0, 0), 0));;', expect: 'Some' }] }, 285 + { group: 'Ptime', name: '1.2.0: Span round-trip', universe: U['ptime.1.2.0'], require: ['ptime'], 286 + steps: [{ code: 'Ptime.Span.of_int_s 3600 |> Ptime.Span.to_int_s;;', expect: '3600' }] }, 287 + 288 + // ── React ── 289 + { group: 'React', name: '1.2.2: signal create/read/update', universe: U['react.1.2.2'], require: ['react'], 290 + steps: [ 291 + { code: 'let s, set_s = React.S.create 0;;', expect: 'React.signal' }, 292 + { code: 'React.S.value s;;', expect: '0' }, 293 + { code: 'set_s 42;;', expect: '' }, 294 + { code: 'React.S.value s;;', expect: '42' }, 295 + ] }, 296 + 297 + // ── Hmap ── 298 + { group: 'Hmap', name: '0.8.1: heterogeneous keys + lookup', universe: U['hmap.0.8.1'], require: ['hmap'], 299 + steps: [ 300 + { code: 'let k_int : int Hmap.key = Hmap.Key.create ();;', expect: 'Hmap.key' }, 301 + { code: 'let k_str : string Hmap.key = Hmap.Key.create ();;', expect: 'Hmap.key' }, 302 + { code: 'let m = Hmap.empty |> Hmap.add k_int 42 |> Hmap.add k_str "hello";;', expect: 'Hmap.t' }, 303 + { code: 'Hmap.find k_int m;;', expect: 'Some 42' }, 304 + { code: 'Hmap.find k_str m;;', expect: 'Some "hello"' }, 305 + ] }, 306 + 307 + // ── Gg ── 308 + { group: 'Gg', name: '1.0.0: V2 vectors + addition', universe: U['gg.1.0.0'], require: ['gg'], 309 + steps: [ 310 + { code: 'Gg.V2.v 1.0 2.0;;', expect: 'Gg.v2' }, 311 + { code: 'let r = Gg.V2.add (Gg.V2.v 1.0 2.0) (Gg.V2.v 3.0 4.0) in Gg.V2.x r;;', expect: '4.' }, 312 + ] }, 313 + { group: 'Gg', name: '1.0.0: colors', universe: U['gg.1.0.0'], require: ['gg'], 314 + steps: [{ code: 'Gg.Color.red;;', expect: 'Gg.color' }] }, 315 + 316 + // ── Vg ── 317 + { group: 'Vg', name: '0.9.5: paths and images', universe: U['vg.0.9.5'], require: ['vg', 'gg'], 318 + steps: [ 319 + { code: 'let p = Vg.P.empty |> Vg.P.line (Gg.V2.v 1.0 1.0);;', expect: 'Vg.path' }, 320 + { code: 'let img = Vg.I.cut p (Vg.I.const Gg.Color.red);;', expect: 'Vg.image' }, 321 + ] }, 322 + 323 + // ── Note ── 324 + { group: 'Note', name: '0.0.3: const signal', universe: U['note.0.0.3'], require: ['note'], 325 + steps: [ 326 + { code: 'let s = Note.S.const 42;;', expect: 'Note.signal' }, 327 + { code: 'Note.S.value s;;', expect: '42' }, 328 + ] }, 329 + 330 + // ── Otfm ── 331 + { group: 'Otfm', name: '0.4.0: decoder type', universe: U['otfm.0.4.0'], require: ['otfm'], 332 + steps: [{ code: 'Otfm.decoder;;', expect: '-> Otfm.decoder' }] }, 333 + 334 + // ── Fpath ── 335 + { group: 'Fpath', name: '0.7.3: path ops', universe: U['fpath.0.7.3'], require: ['fpath'], 336 + steps: [ 337 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.to_string;;', expect: '"/usr/local/bin"' }, 338 + { code: 'Fpath.(v "/usr" / "local" / "bin") |> Fpath.to_string;;', expect: '"/usr/local/bin"' }, 339 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.parent |> Fpath.to_string;;', expect: '"/usr/local/"' }, 340 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.basename;;', expect: '"bin"' }, 341 + ] }, 342 + 343 + // ── Uutf ── 344 + { group: 'Uutf', name: '1.0.4: UTF-8 decoder', universe: U['uutf.1.0.4'], require: ['uutf'], 345 + steps: [ 346 + { code: 'let d = Uutf.decoder ~encoding:`UTF_8 (`String "ABC");;', expect: 'Uutf.decoder' }, 347 + { code: 'Uutf.decode d;;', expect: '`Uchar' }, 348 + ] }, 349 + 350 + // ── B0 ── 351 + { group: 'B0', name: '0.0.6: B0_std.Fpath', universe: U['b0.0.0.6'], require: ['b0.std'], 352 + steps: [{ code: 'B0_std.Fpath.v "/tmp";;', expect: 'B0_std.Fpath.t' }] }, 353 + 354 + // ── Bos ── 355 + { group: 'Bos (cross-library)', name: '0.2.1: Cmd construction', universe: U['bos.0.2.1'], require: ['bos'], 356 + steps: [{ code: 'Bos.Cmd.(v "echo" % "hello");;', expect: 'Bos.Cmd' }] }, 357 + ]; 358 + 359 + // ── Flatten cross-version tests into individual test items ───────── 360 + const flatTests = []; 361 + for (const t of tests) { 362 + if (t.crossVersion) { 363 + for (const c of t.cases) { 364 + flatTests.push({ 365 + group: t.group, 366 + name: c.label, 367 + universe: c.universe, 368 + require: c.require || [], 369 + crossVersion: true, 370 + crossCode: t.code, 371 + crossDescription: t.description, 372 + shouldPass: c.shouldPass, 373 + steps: c.shouldPass 374 + ? [{ code: t.code, expect: c.expect || '' }] 375 + : [{ code: t.code, expectError: c.expectError || 'Error' }], 376 + }); 377 + } 378 + } else { 379 + flatTests.push(t); 380 + } 381 + } 382 + 383 + // ── Runner ──────────────────────────────────────────────────────────── 384 + 385 + let passed = 0, failed = 0, skipped = 0, running = 0; 386 + const total = flatTests.length; 387 + const groupsEl = document.getElementById('groups'); 388 + const groupEls = {}; 389 + const testEls = []; 390 + 391 + function updateSummary() { 392 + document.getElementById('pass-count').textContent = passed; 393 + document.getElementById('fail-count').textContent = failed; 394 + document.getElementById('skip-count').textContent = skipped; 395 + document.getElementById('run-count').textContent = running; 396 + const done = passed + failed + skipped; 397 + document.getElementById('progress-bar').style.width = `${(done / total) * 100}%`; 398 + } 399 + 400 + function escHtml(s) { 401 + return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 402 + } 403 + 404 + // Track which cross-version code banners we've shown per group 405 + const shownBanners = {}; 406 + 407 + // Build DOM 408 + for (let i = 0; i < flatTests.length; i++) { 409 + const t = flatTests[i]; 410 + if (!groupEls[t.group]) { 411 + const g = document.createElement('div'); 412 + g.className = 'group'; 413 + const header = document.createElement('div'); 414 + header.className = 'group-header'; 415 + const isCross = t.crossVersion; 416 + header.innerHTML = `<span>${t.group}${isCross ? '<span class="group-badge cross">cross-version</span>' : ''}</span><span class="arrow">\u25BC</span>`; 417 + const body = document.createElement('div'); 418 + body.className = 'group-body'; 419 + header.onclick = () => { 420 + header.classList.toggle('collapsed'); 421 + body.classList.toggle('hidden'); 422 + }; 423 + g.appendChild(header); 424 + g.appendChild(body); 425 + groupsEl.appendChild(g); 426 + groupEls[t.group] = { el: g, header, body, tests: [] }; 427 + } 428 + 429 + // For cross-version tests, show the code banner once per group 430 + if (t.crossVersion && !shownBanners[t.group]) { 431 + shownBanners[t.group] = true; 432 + const banner = document.createElement('div'); 433 + banner.className = 'code-banner'; 434 + banner.innerHTML = `<code>${escHtml(t.crossCode)}</code>${t.crossDescription ? ` &mdash; <em>${escHtml(t.crossDescription)}</em>` : ''}`; 435 + groupEls[t.group].body.appendChild(banner); 436 + } 437 + 438 + const badgeHtml = t.crossVersion 439 + ? (t.shouldPass 440 + ? '<span class="expect-badge should-pass">expect: pass</span>' 441 + : '<span class="expect-badge should-error">expect: error</span>') 442 + : ''; 443 + 444 + const row = document.createElement('div'); 445 + row.className = 'test-row pending'; 446 + row.innerHTML = ` 447 + <span class="test-icon">\u25CB</span> 448 + <div class="test-name"><span class="label">${escHtml(t.name)}</span>${badgeHtml}<div class="test-detail"></div></div> 449 + <span class="test-time"></span> 450 + `; 451 + groupEls[t.group].body.appendChild(row); 452 + groupEls[t.group].tests.push(row); 453 + testEls.push(row); 454 + } 455 + 456 + // transcript: array of { code, output, outputClass } 457 + function setTestState(row, state, transcript, time) { 458 + row.className = `test-row ${state}`; 459 + const icons = { pass: '\u2714', 'pass-neg': '\u2714', fail: '\u2718', skip: '\u25CB', running: '<span class="spinner">\u25E0</span>', pending: '\u25CB' }; 460 + row.querySelector('.test-icon').innerHTML = icons[state] || '\u25CB'; 461 + if (transcript && transcript.length > 0) { 462 + const detailEl = row.querySelector('.test-detail'); 463 + let html = ''; 464 + for (const step of transcript) { 465 + html += '<div class="step-transcript">'; 466 + if (step.code) { 467 + html += `<div class="step-input">${escHtml(step.code)}</div>`; 468 + } 469 + if (step.output) { 470 + const cls = step.outputClass || 'out-pass'; 471 + html += `<div class="step-output ${cls}">${escHtml(step.output)}</div>`; 472 + } 473 + html += '</div>'; 474 + } 475 + detailEl.innerHTML = html; 476 + } 477 + if (time !== undefined) { 478 + row.querySelector('.test-time').textContent = `${time}ms`; 479 + } 480 + } 481 + 482 + // Worker cache 483 + const workerCache = new Map(); 484 + 485 + async function getWorker(universe) { 486 + if (workerCache.has(universe)) return workerCache.get(universe); 487 + const w = new OcamlWorker(`/jtw-output/compiler/5.4.0/worker.js`, { timeout: 120000 }); 488 + await w.init({ 489 + findlib_requires: [], 490 + stdlib_dcs: 'lib/ocaml/dynamic_cmis.json', 491 + findlib_index: `/jtw-output/u/${universe}/findlib_index`, 492 + }); 493 + workerCache.set(universe, w); 494 + return w; 495 + } 496 + 497 + async function runTest(t, idx) { 498 + const row = testEls[idx]; 499 + setTestState(row, 'running'); 500 + running++; 501 + updateSummary(); 502 + const start = performance.now(); 503 + 504 + try { 505 + const worker = await getWorker(t.universe); 506 + 507 + for (const pkg of (t.require || [])) { 508 + await worker.eval(`#require "${pkg}";;`); 509 + } 510 + 511 + const transcript = []; 512 + for (const step of t.steps) { 513 + if (step.complete) { 514 + const result = await worker.complete(step.complete.source, step.complete.pos); 515 + const entries = result.completions?.entries?.map(e => e.name) || []; 516 + if (step.expectEntries) { 517 + for (const exp of step.expectEntries) { 518 + if (!entries.includes(exp)) 519 + throw new Error(`Expected completion "${exp}" not found in [${entries.join(', ')}]`); 520 + } 521 + } 522 + transcript.push({ code: step.complete.source, output: `completions: [${entries.slice(0, 5).join(', ')}...]`, outputClass: 'out-pass' }); 523 + 524 + } else if (step.expectError) { 525 + // NEGATIVE TEST: we expect this to produce an error 526 + const r = await worker.eval(step.code); 527 + const ppf = r.caml_ppf || ''; 528 + const stderr = r.stderr || ''; 529 + const combined = ppf + stderr; 530 + if (combined.includes(step.expectError)) { 531 + transcript.push({ code: step.code, output: combined.trim(), outputClass: 'out-neg' }); 532 + } else if (combined === '' && ppf === '') { 533 + transcript.push({ code: step.code, output: '(empty output — error swallowed)', outputClass: 'out-neg' }); 534 + } else { 535 + transcript.push({ code: step.code, output: `Expected error "${step.expectError}" but got:\n${ppf}${stderr}`, outputClass: 'out-fail' }); 536 + throw new Error(`Expected error containing "${step.expectError}" but got success:\nppf: "${ppf}"\nstderr: "${stderr}"`); 537 + } 538 + 539 + } else { 540 + const r = await worker.eval(step.code); 541 + const ppf = r.caml_ppf || ''; 542 + const stdout = r.stdout || ''; 543 + const stderr = r.stderr || ''; 544 + 545 + if (step.expect && step.expect !== '') { 546 + if (!ppf.includes(step.expect)) { 547 + transcript.push({ code: step.code, output: ppf || stderr || '(no output)', outputClass: 'out-fail' }); 548 + throw new Error(`Expected caml_ppf to contain "${step.expect}"\nGot: "${ppf}"\nstderr: "${stderr}"`); 549 + } 550 + } 551 + if (step.expectStdout) { 552 + if (!stdout.includes(step.expectStdout)) { 553 + transcript.push({ code: step.code, output: `stdout: "${stdout}"`, outputClass: 'out-fail' }); 554 + throw new Error(`Expected stdout to contain "${step.expectStdout}"\nGot: "${stdout}"`); 555 + } 556 + } 557 + if (step.expectNotError) { 558 + if ((ppf + stderr).includes(step.expectNotError)) { 559 + transcript.push({ code: step.code, output: ppf + stderr, outputClass: 'out-fail' }); 560 + throw new Error(`Output contains unexpected "${step.expectNotError}"\nppf: "${ppf}"\nstderr: "${stderr}"`); 561 + } 562 + } 563 + // Build output line: ppf first, then stdout if present 564 + let out = ppf ? ppf.trim() : ''; 565 + if (stdout) out += (out ? '\n' : '') + '(stdout) ' + stdout.trim(); 566 + transcript.push({ code: step.code, output: out || '(unit)', outputClass: 'out-pass' }); 567 + } 568 + } 569 + 570 + const elapsed = Math.round(performance.now() - start); 571 + running--; 572 + passed++; 573 + 574 + // Use pass-neg state for negative tests that correctly errored 575 + const isNegPass = t.crossVersion && !t.shouldPass; 576 + setTestState(row, isNegPass ? 'pass-neg' : 'pass', transcript, elapsed); 577 + 578 + } catch (e) { 579 + const elapsed = Math.round(performance.now() - start); 580 + running--; 581 + failed++; 582 + // If transcript has partial results from before the error, show them 583 + if (transcript.length === 0) { 584 + transcript.push({ code: '', output: e.message, outputClass: 'out-fail' }); 585 + } 586 + setTestState(row, 'fail', transcript, elapsed); 587 + } 588 + updateSummary(); 589 + } 590 + 591 + // Run tests: parallel across universes, sequential within each universe 592 + async function runAll() { 593 + const byUniverse = new Map(); 594 + flatTests.forEach((t, i) => { 595 + if (!byUniverse.has(t.universe)) byUniverse.set(t.universe, []); 596 + byUniverse.get(t.universe).push({ test: t, idx: i }); 597 + }); 598 + 599 + const promises = []; 600 + for (const [, items] of byUniverse) { 601 + promises.push((async () => { 602 + for (const { test, idx } of items) { 603 + await runTest(test, idx); 604 + } 605 + })()); 606 + } 607 + await Promise.all(promises); 608 + } 609 + 610 + updateSummary(); 611 + runAll(); 612 + </script> 613 + </body> 614 + </html>
+100
test/ohc-integration/test.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>OHC JTW Integration Test</title> 6 + </head> 7 + <body> 8 + <h1>OHC JTW Integration Test</h1> 9 + <div id="status">Loading...</div> 10 + <div id="results"></div> 11 + <script type="module"> 12 + import { OcamlWorker } from '/client/ocaml-worker.js'; 13 + 14 + const status = document.getElementById('status'); 15 + const results = document.getElementById('results'); 16 + 17 + function log(msg) { 18 + const p = document.createElement('pre'); 19 + p.textContent = msg; 20 + results.appendChild(p); 21 + console.log(msg); 22 + } 23 + 24 + // Read config from URL params 25 + const params = new URLSearchParams(window.location.search); 26 + const universe = params.get('universe'); 27 + const compilerVersion = params.get('compiler') || '5.4.0'; 28 + 29 + if (!universe) { 30 + status.textContent = 'Error: ?universe= parameter required'; 31 + throw new Error('universe parameter required'); 32 + } 33 + 34 + const workerUrl = `/jtw-output/compiler/${compilerVersion}/worker.js`; 35 + const findlib_index = `/jtw-output/u/${universe}/findlib_index`; 36 + const stdlib_dcs = `lib/ocaml/dynamic_cmis.json`; 37 + 38 + status.textContent = 'Creating worker...'; 39 + const worker = new OcamlWorker(workerUrl, { timeout: 120000 }); 40 + 41 + try { 42 + status.textContent = 'Initializing (loading stdlib)...'; 43 + await worker.init({ 44 + findlib_requires: [], 45 + stdlib_dcs: stdlib_dcs, 46 + findlib_index: findlib_index, 47 + }); 48 + 49 + status.textContent = 'Worker ready'; 50 + document.getElementById('status').dataset.ready = 'true'; 51 + 52 + // Test 1: Basic arithmetic 53 + log('--- Test 1: Basic arithmetic ---'); 54 + const r1 = await worker.eval('let x = 1 + 2;;'); 55 + log('eval: let x = 1 + 2;;'); 56 + log('caml_ppf: ' + r1.caml_ppf); 57 + document.getElementById('results').dataset.test1 = r1.caml_ppf; 58 + 59 + // Test 2: String operations 60 + log('--- Test 2: String operations ---'); 61 + const r2 = await worker.eval('String.concat ", " ["hello"; "world"];;'); 62 + log('eval: String.concat ", " ["hello"; "world"];;'); 63 + log('caml_ppf: ' + r2.caml_ppf); 64 + document.getElementById('results').dataset.test2 = r2.caml_ppf; 65 + 66 + // Test 3: Load fmt via #require 67 + log('--- Test 3: Load fmt ---'); 68 + const r3 = await worker.eval('#require "fmt";;'); 69 + log('eval: #require "fmt";;'); 70 + log('caml_ppf: ' + r3.caml_ppf); 71 + log('stderr: ' + r3.stderr); 72 + document.getElementById('results').dataset.test3 = 'loaded'; 73 + 74 + // Test 4: Use fmt 75 + log('--- Test 4: Use Fmt ---'); 76 + const r4 = await worker.eval('Fmt.str "%a" Fmt.int 42;;'); 77 + log('eval: Fmt.str "%a" Fmt.int 42;;'); 78 + log('caml_ppf: ' + r4.caml_ppf); 79 + document.getElementById('results').dataset.test4 = r4.caml_ppf; 80 + 81 + // Test 5: Completions 82 + log('--- Test 5: Completions ---'); 83 + const r5 = await worker.complete('Fmt.i', 5); 84 + log('complete: Fmt.i at pos 5'); 85 + const entries = r5.completions?.entries || []; 86 + log('entries: ' + entries.map(e => e.name).join(', ')); 87 + document.getElementById('results').dataset.test5 = entries.length > 0 ? 'ok' : 'empty'; 88 + 89 + status.textContent = 'All tests complete'; 90 + document.getElementById('status').dataset.done = 'true'; 91 + 92 + } catch (err) { 93 + status.textContent = 'Error: ' + err.message; 94 + log('ERROR: ' + err.message); 95 + log('Stack: ' + err.stack); 96 + document.getElementById('status').dataset.error = err.message; 97 + } 98 + </script> 99 + </body> 100 + </html>
+69
test/ohc-integration/tutorials/index.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>JTW Library Tutorials</title> 6 + <style> 7 + * { box-sizing: border-box; margin: 0; padding: 0; } 8 + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #c9d1d9; padding: 40px 24px; line-height: 1.5; } 9 + .container { max-width: 960px; margin: 0 auto; } 10 + h1 { color: #f0f6fc; font-size: 28px; margin-bottom: 4px; } 11 + .subtitle { color: #8b949e; font-size: 15px; margin-bottom: 32px; } 12 + .subtitle a { color: #58a6ff; text-decoration: none; } 13 + .subtitle a:hover { text-decoration: underline; } 14 + 15 + .lib-group { margin-bottom: 32px; } 16 + .lib-group h2 { font-size: 16px; color: #f0f6fc; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #21262d; } 17 + .lib-group .desc { font-size: 13px; color: #8b949e; margin-bottom: 10px; } 18 + 19 + .version-cards { display: flex; flex-wrap: wrap; gap: 8px; } 20 + .version-card { display: block; padding: 8px 14px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; text-decoration: none; color: #c9d1d9; font-size: 14px; transition: border-color 0.2s, background 0.2s; } 21 + .version-card:hover { border-color: #58a6ff; background: #1c2128; } 22 + .version-card .ver { font-family: 'SF Mono', 'Fira Code', monospace; color: #d2a8ff; font-weight: 600; } 23 + .version-card .steps { font-size: 11px; color: #8b949e; margin-top: 2px; } 24 + </style> 25 + </head> 26 + <body> 27 + <div class="container"> 28 + <h1>JTW Library Tutorials</h1> 29 + <p class="subtitle">Interactive OCaml tutorials for Daniel B&uuml;nzli's libraries, built from ohc JTW output &mdash; <a href="../runner.html">test runner</a></p> 30 + <div id="groups"></div> 31 + </div> 32 + 33 + <script type="module"> 34 + import { TUTORIALS } from './test-defs.js'; 35 + 36 + // Group tutorials by library name 37 + const libs = {}; 38 + for (const [key, t] of Object.entries(TUTORIALS)) { 39 + if (!libs[t.name]) libs[t.name] = { name: t.name, description: t.description, versions: [] }; 40 + const stepCount = t.sections.reduce((n, s) => n + s.steps.length, 0); 41 + libs[t.name].versions.push({ key, version: t.version, steps: stepCount }); 42 + } 43 + 44 + // Sort libraries alphabetically, versions by semver 45 + const sorted = Object.values(libs).sort((a, b) => a.name.localeCompare(b.name)); 46 + for (const lib of sorted) { 47 + lib.versions.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true })); 48 + } 49 + 50 + const container = document.getElementById('groups'); 51 + for (const lib of sorted) { 52 + const g = document.createElement('div'); 53 + g.className = 'lib-group'; 54 + let html = `<h2>${lib.name}</h2>`; 55 + html += `<div class="desc">${lib.description}</div>`; 56 + html += '<div class="version-cards">'; 57 + for (const v of lib.versions) { 58 + html += `<a class="version-card" href="tutorial.html?pkg=${encodeURIComponent(v.key)}">`; 59 + html += `<div class="ver">${v.version}</div>`; 60 + html += `<div class="steps">${v.steps} steps</div>`; 61 + html += `</a>`; 62 + } 63 + html += '</div>'; 64 + g.innerHTML = html; 65 + container.appendChild(g); 66 + } 67 + </script> 68 + </body> 69 + </html>
+1312
test/ohc-integration/tutorials/test-defs.js
··· 1 + // Tutorial test definitions for all Bunzli library versions 2 + // Each entry is a self-contained interactive tutorial 3 + 4 + const U = { 5 + 'fmt.0.9.0': '9901393f978b0a6627c5eab595111f50', 6 + 'fmt.0.10.0': 'dc92d356407d44e1eae7e39acefce214', 7 + 'fmt.0.11.0': '5c8d38716cee871f1a6a1f164c9171e6', 8 + 'cmdliner.1.0.4': '1c3783a51f479ccb97503596896eb40b', 9 + 'cmdliner.1.3.0': 'bcb1b5485952a387d9a1a626d018fc5b', 10 + 'cmdliner.2.0.0': 'e6cf251f6257587fa235157819c1be21', 11 + 'cmdliner.2.1.0': '146d116fd47cdde3a5912f6c3c43a06c', 12 + 'mtime.1.3.0': '405772d8c1d5fcfb52a34bc074e9b2bf', 13 + 'mtime.1.4.0': 'd07582e1ae666064d4e2cf55b8f966f2', 14 + 'mtime.2.1.0': '427565ec9f440e77ea8cda7a5baf2f16', 15 + 'logs.0.7.0': '2579ce9998e74d858251a8467a2d3acc', 16 + 'logs.0.10.0': '1447d6620c603faabafd2a4af8180e64', 17 + 'uucp.14.0.0': '61994aea366afe63fbbdfbec3a6c1c17', 18 + 'uucp.15.0.0': '1676ff3253642b3d3380da595576d048', 19 + 'uucp.16.0.0': '2536abe6336b2597409378c985af206f', 20 + 'uucp.17.0.0': '9f478f56c02c6b75ad53e569576ac528', 21 + 'uunf.14.0.0': 'c49889fbf46b81974819b189749084eb', 22 + 'uunf.17.0.0': 'd41feec064e2a5ca2ca9ce644b490c35', 23 + 'astring.0.8.5': '77fa5901f826c06565dd83b8f758980c', 24 + 'jsonm.1.0.2': '331ba04a1674f61d6eb297de762940ea', 25 + 'xmlm.1.4.0': 'de0c6b460a24c08865ced16ef6a90978', 26 + 'ptime.1.2.0': '0e977ea260d75026d2cdd4a7d007b2a5', 27 + 'react.1.2.2': '8b8f1bafe428e743bbb3e9f6a24753a5', 28 + 'hmap.0.8.1': '9cbc1bea29fe2a32ff73726147a24f7f', 29 + 'gg.1.0.0': '0c7a6cc72b0eef74ddf88e8512b418e1', 30 + 'note.0.0.3': '7497fed22490d2257a6fb4ac44bb1316', 31 + 'otfm.0.4.0': 'af7a1a159d4a1c27da168df5cad06ad9', 32 + 'vg.0.9.5': '8a313572e25666862de0bc23fc09c53d', 33 + 'bos.0.2.1': '1447d6620c603faabafd2a4af8180e64', 34 + 'fpath.0.7.3': 'b034f0f4718c8842fdec8d4ff3430b97', 35 + 'uutf.1.0.4': '331ba04a1674f61d6eb297de762940ea', 36 + 'b0.0.0.6': '3125f46428fef2c0920ae254a3678000', 37 + }; 38 + 39 + // ── Factory: Fmt (same API across 0.9–0.11) ──────────────────────────── 40 + function fmtTutorial(version, universe) { 41 + return { 42 + name: 'Fmt', version, opam: 'fmt', 43 + description: 'OCaml Format pretty-printer combinators', 44 + universe, require: ['fmt'], 45 + sections: [ 46 + { title: 'String Formatting', 47 + description: 'Fmt.str works like Printf.sprintf, building a string from a format string.', 48 + steps: [ 49 + { code: 'Fmt.str "%d" 42;;', expect: '"42"', 50 + description: 'Format an integer into a string' }, 51 + { code: 'Fmt.str "Hello, %s!" "world";;', expect: '"Hello, world!"', 52 + description: 'Interpolate a string value' }, 53 + { code: 'Fmt.str "%d + %d = %d" 1 2 3;;', expect: '"1 + 2 = 3"', 54 + description: 'Multiple format arguments' }, 55 + { code: 'Fmt.str "%a" Fmt.int 42;;', expect: '"42"', 56 + description: 'Use a typed formatter with %a' }, 57 + ] }, 58 + { title: 'Typed Formatters', 59 + description: 'Fmt provides typed formatter values (type \'a Fmt.t = Format.formatter -> \'a -> unit) for common types.', 60 + steps: [ 61 + { code: 'Fmt.str "%a" Fmt.bool true;;', expect: '"true"', 62 + description: 'Format a boolean' }, 63 + { code: 'Fmt.str "%a" Fmt.float 3.14;;', expect: '3.14', 64 + description: 'Format a float' }, 65 + { code: 'Fmt.str "%a" Fmt.string "hi";;', expect: '"hi"', 66 + description: 'Format a string with the string formatter' }, 67 + ] }, 68 + { title: 'Collection Formatters', 69 + description: 'Fmt can format lists, options, pairs, and results with configurable separators.', 70 + steps: [ 71 + { code: 'Fmt.str "%a" Fmt.(list int) [1; 2; 3];;', expect: '1', 72 + description: 'Format a list of ints (default separator)' }, 73 + { code: 'Fmt.str "%a" Fmt.(list ~sep:comma int) [1; 2; 3];;', expect: '1, 2', 74 + description: 'Format a list with comma separators' }, 75 + { code: 'Fmt.str "%a" Fmt.(list ~sep:(any " ") int) [10; 20];;', expect: '"10 20"', 76 + description: 'Format with space separators using Fmt.any' }, 77 + { code: 'Fmt.str "%a" Fmt.(option int) (Some 5);;', expect: '5', 78 + description: 'Format an option value' }, 79 + { code: 'Fmt.str "%a" Fmt.(option int) None;;', expect: '', 80 + description: 'Format None (empty output by default)' }, 81 + { code: 'Fmt.str "%a" Fmt.(pair ~sep:comma int string) (42, "hi");;', expect: '42', 82 + description: 'Format a pair' }, 83 + ] }, 84 + { title: 'Output to stdout', 85 + description: 'Fmt.pr prints directly to stdout. Use @. for a newline flush.', 86 + steps: [ 87 + { code: 'Fmt.pr "value: %d@." 42;;', expectStdout: 'value: 42', 88 + description: 'Print formatted output to stdout' }, 89 + { code: 'Fmt.pr "%a@." Fmt.(list ~sep:sp int) [1; 2; 3];;', expectStdout: '1', 90 + description: 'Print a list to stdout' }, 91 + ] }, 92 + { title: 'Combinators', 93 + description: 'Higher-order combinators transform formatters.', 94 + steps: [ 95 + { code: 'let pp_len = Fmt.using String.length Fmt.int;;', expect: 'Fmt.t', 96 + description: 'Fmt.using transforms input before formatting' }, 97 + { code: 'Fmt.str "%a" pp_len "hello";;', expect: '"5"', 98 + description: 'pp_len formats the length of a string' }, 99 + { code: 'Fmt.str "%a" (Fmt.Dump.list Fmt.int) [1; 2; 3];;', expect: '[1; 2; 3]', 100 + description: 'Fmt.Dump formats with OCaml syntax (brackets)' }, 101 + ] }, 102 + ], 103 + }; 104 + } 105 + 106 + // ── Factory: Uucp (same API across 14–17, different Unicode version) ─── 107 + function uucpTutorial(version, universe, unicodeVer) { 108 + return { 109 + name: 'Uucp', version, opam: 'uucp', 110 + description: `Unicode character properties (Unicode ${unicodeVer})`, 111 + universe, require: ['uucp'], 112 + sections: [ 113 + { title: 'Unicode Version', 114 + description: 'Each Uucp release tracks a specific Unicode standard version.', 115 + steps: [ 116 + { code: 'Uucp.unicode_version;;', expect: `"${unicodeVer}"`, 117 + description: 'Check which Unicode version this release implements' }, 118 + ] }, 119 + { title: 'General Category', 120 + description: 'Uucp.Gc.general_category returns the Unicode General Category of a character as a polymorphic variant.', 121 + steps: [ 122 + { code: 'Uucp.Gc.general_category (Uchar.of_int 0x0041);;', expect: '`Lu', 123 + description: "'A' (U+0041) is an uppercase letter (Lu)" }, 124 + { code: 'Uucp.Gc.general_category (Uchar.of_int 0x0061);;', expect: '`Ll', 125 + description: "'a' (U+0061) is a lowercase letter (Ll)" }, 126 + { code: 'Uucp.Gc.general_category (Uchar.of_int 0x0030);;', expect: '`Nd', 127 + description: "'0' (U+0030) is a decimal digit (Nd)" }, 128 + { code: 'Uucp.Gc.general_category (Uchar.of_int 0x0020);;', expect: '`Zs', 129 + description: "Space (U+0020) is a space separator (Zs)" }, 130 + ] }, 131 + { title: 'Script Detection', 132 + description: 'Uucp.Script.script identifies which writing system a character belongs to.', 133 + steps: [ 134 + { code: 'Uucp.Script.script (Uchar.of_int 0x03B1);;', expect: '`Grek', 135 + description: "Greek alpha (U+03B1) is in the Greek script" }, 136 + { code: 'Uucp.Script.script (Uchar.of_int 0x4E16);;', expect: '`Hani', 137 + description: "CJK character (U+4E16) is in the Han script" }, 138 + { code: 'Uucp.Script.script (Uchar.of_int 0x0041);;', expect: '`Latn', 139 + description: "'A' is in the Latin script" }, 140 + ] }, 141 + { title: 'Character Properties', 142 + description: 'Uucp provides boolean property lookups for whitespace, alphabetic characters, and more.', 143 + steps: [ 144 + { code: 'Uucp.White.is_white_space (Uchar.of_int 0x0020);;', expect: 'true', 145 + description: 'Space is whitespace' }, 146 + { code: 'Uucp.White.is_white_space (Uchar.of_int 0x0041);;', expect: 'false', 147 + description: "'A' is not whitespace" }, 148 + { code: 'Uucp.White.is_white_space (Uchar.of_int 0x00A0);;', expect: 'true', 149 + description: 'Non-breaking space (U+00A0) is whitespace' }, 150 + ] }, 151 + ], 152 + }; 153 + } 154 + 155 + // ── Factory: Uunf (same API, different Unicode version) ──────────────── 156 + function uunfTutorial(version, universe, unicodeVer) { 157 + return { 158 + name: 'Uunf', version, opam: 'uunf', 159 + description: `Unicode text normalization (Unicode ${unicodeVer})`, 160 + universe, require: ['uunf'], 161 + sections: [ 162 + { title: 'Unicode Version', 163 + description: 'Each Uunf release implements normalization according to a specific Unicode version.', 164 + steps: [ 165 + { code: 'Uunf.unicode_version;;', expect: `"${unicodeVer}"`, 166 + description: 'Check the Unicode version' }, 167 + ] }, 168 + { title: 'Normalization Forms', 169 + description: 'Unicode defines four normalization forms: NFC, NFD, NFKC, and NFKD. Uunf.create selects which form to use.', 170 + steps: [ 171 + { code: 'let nfc = Uunf.create `NFC;;', expect: 'Uunf.t', 172 + description: 'Create an NFC normalizer' }, 173 + { code: 'let nfd = Uunf.create `NFD;;', expect: 'Uunf.t', 174 + description: 'Create an NFD normalizer (canonical decomposition)' }, 175 + { code: 'let nfkc = Uunf.create `NFKC;;', expect: 'Uunf.t', 176 + description: 'Create an NFKC normalizer (compatibility composition)' }, 177 + { code: 'let nfkd = Uunf.create `NFKD;;', expect: 'Uunf.t', 178 + description: 'Create an NFKD normalizer (compatibility decomposition)' }, 179 + ] }, 180 + { title: 'Adding Characters', 181 + description: 'Feed characters to the normalizer with Uunf.add. It returns `Uchar for output characters and `Await when ready for more input.', 182 + steps: [ 183 + { code: 'let n = Uunf.create `NFC;;', expect: 'Uunf.t', 184 + description: 'Create a fresh NFC normalizer' }, 185 + { code: 'Uunf.add n (`Uchar (Uchar.of_int 0x0041));;', expect: '', 186 + description: "Add 'A' to the normalizer" }, 187 + { code: 'Uunf.add n `End;;', expect: '', 188 + description: 'Signal end of input' }, 189 + ] }, 190 + ], 191 + }; 192 + } 193 + 194 + // ── Factory: Mtime 1.x (1.3 and 1.4 share the same API) ─────────────── 195 + function mtime1_3Tutorial(version, universe) { 196 + // Mtime 1.3.0: no named constants (ns/ms/s), uses float conversion functions 197 + return { 198 + name: 'Mtime', version, opam: 'mtime', 199 + description: 'Monotonic wall-clock time for OCaml', 200 + universe, require: ['mtime'], 201 + sections: [ 202 + { title: 'Time Span Basics', 203 + description: 'Mtime.Span represents monotonic time durations in nanoseconds.', 204 + steps: [ 205 + { code: 'Mtime.Span.zero;;', expect: 'Mtime.span', 206 + description: 'The zero-length span' }, 207 + { code: 'Mtime.Span.one;;', expect: 'Mtime.span', 208 + description: 'One nanosecond' }, 209 + { code: 'Mtime.Span.max_span;;', expect: 'Mtime.span', 210 + description: 'The maximum representable span' }, 211 + { code: 'Mtime.Span.of_uint64_ns 1_000_000_000L;;', expect: 'Mtime.span', 212 + description: 'Create a 1-second span from nanoseconds' }, 213 + ] }, 214 + { title: 'Span Arithmetic', 215 + description: 'Spans support addition, comparison, and absolute difference.', 216 + steps: [ 217 + { code: 'let one_sec = Mtime.Span.of_uint64_ns 1_000_000_000L;;', expect: 'Mtime.span', 218 + description: 'One second' }, 219 + { code: 'let two_sec = Mtime.Span.add one_sec one_sec;;', expect: 'Mtime.span', 220 + description: 'Add two spans: 1s + 1s = 2s' }, 221 + { code: 'Mtime.Span.to_uint64_ns two_sec;;', expect: '2000000000L', 222 + description: '2 seconds in nanoseconds' }, 223 + { code: 'Mtime.Span.equal Mtime.Span.zero Mtime.Span.zero;;', expect: 'true', 224 + description: 'Zero equals zero' }, 225 + { code: 'Mtime.Span.compare one_sec Mtime.Span.zero;;', expect: '1', 226 + description: '1 second is greater than zero' }, 227 + ] }, 228 + { title: 'Float Conversions', 229 + description: 'Convert spans to floating-point representations in various units.', 230 + steps: [ 231 + { code: 'Mtime.Span.to_ns one_sec;;', expect: '1000000000.', 232 + description: '1 second = 1e9 nanoseconds' }, 233 + { code: 'Mtime.Span.to_ms one_sec;;', expect: '1000.', 234 + description: '1 second = 1000 milliseconds' }, 235 + { code: 'Mtime.Span.to_s one_sec;;', expect: '1.', 236 + description: '1 second as a float' }, 237 + { code: 'Mtime.Span.to_us one_sec;;', expect: '1000000.', 238 + description: '1 second = 1e6 microseconds' }, 239 + ] }, 240 + ], 241 + }; 242 + } 243 + 244 + function mtime1_4Tutorial(version, universe) { 245 + // Mtime 1.4.0: has named constants (ns/ms/s) and is_shorter/is_longer 246 + return { 247 + name: 'Mtime', version, opam: 'mtime', 248 + description: 'Monotonic wall-clock time for OCaml', 249 + universe, require: ['mtime'], 250 + sections: [ 251 + { title: 'Time Span Constants', 252 + description: 'Mtime 1.4 added named constants for common time durations.', 253 + steps: [ 254 + { code: 'Mtime.Span.zero;;', expect: 'Mtime.span', 255 + description: 'The zero-length span' }, 256 + { code: 'Mtime.Span.ns;;', expect: 'Mtime.span', 257 + description: '1 nanosecond' }, 258 + { code: 'Mtime.Span.ms;;', expect: 'Mtime.span', 259 + description: '1 millisecond' }, 260 + { code: 'Mtime.Span.s;;', expect: 'Mtime.span', 261 + description: '1 second' }, 262 + { code: 'Mtime.Span.min;;', expect: 'Mtime.span', 263 + description: '1 minute' }, 264 + ] }, 265 + { title: 'Span Arithmetic', 266 + description: 'Spans support addition, scaling, and comparison.', 267 + steps: [ 268 + { code: 'let two_sec = Mtime.Span.add Mtime.Span.s Mtime.Span.s;;', expect: 'Mtime.span', 269 + description: '1s + 1s = 2s' }, 270 + { code: 'Mtime.Span.to_uint64_ns two_sec;;', expect: '2000000000L', 271 + description: '2 seconds in nanoseconds' }, 272 + { code: 'Mtime.Span.compare Mtime.Span.ms Mtime.Span.s;;', expect: '-1', 273 + description: '1ms is less than 1s' }, 274 + { code: 'Mtime.Span.equal Mtime.Span.zero Mtime.Span.zero;;', expect: 'true', 275 + description: 'Zero equals zero' }, 276 + ] }, 277 + { title: 'Conversions', 278 + description: 'Convert spans to floating-point representations in various units.', 279 + steps: [ 280 + { code: 'Mtime.Span.to_ms Mtime.Span.s;;', expect: '1000.', 281 + description: '1 second = 1000 milliseconds' }, 282 + { code: 'Mtime.Span.to_s Mtime.Span.s;;', expect: '1.', 283 + description: '1 second as a float' }, 284 + { code: 'Mtime.Span.of_uint64_ns 500_000_000L |> Mtime.Span.to_ms;;', expect: '500.', 285 + description: '500ms round-trip through nanoseconds' }, 286 + ] }, 287 + ], 288 + }; 289 + } 290 + 291 + export const TUTORIALS = { 292 + // ═══════════════════════════════════════════════════════════════════════ 293 + // Fmt 294 + // ═══════════════════════════════════════════════════════════════════════ 295 + 'fmt.0.9.0': fmtTutorial('0.9.0', U['fmt.0.9.0']), 296 + 'fmt.0.10.0': fmtTutorial('0.10.0', U['fmt.0.10.0']), 297 + 'fmt.0.11.0': fmtTutorial('0.11.0', U['fmt.0.11.0']), 298 + 299 + // ═══════════════════════════════════════════════════════════════════════ 300 + // Cmdliner 301 + // ═══════════════════════════════════════════════════════════════════════ 302 + 'cmdliner.1.0.4': { 303 + name: 'Cmdliner', version: '1.0.4', opam: 'cmdliner', 304 + description: 'Declarative definition of command line interfaces (v1 API)', 305 + universe: U['cmdliner.1.0.4'], require: ['cmdliner'], 306 + sections: [ 307 + { title: 'Argument Info', 308 + description: 'Cmdliner.Arg.info describes command-line arguments with names, docs, and metadata.', 309 + steps: [ 310 + { code: 'let verbose_info = Cmdliner.Arg.info ["v"; "verbose"] ~doc:"Be verbose";;', 311 + expect: 'Cmdliner.Arg.info', description: 'Create info for a --verbose/-v flag' }, 312 + { code: 'let name_info = Cmdliner.Arg.info [] ~docv:"NAME" ~doc:"The name";;', 313 + expect: 'Cmdliner.Arg.info', description: 'Create info for a positional argument' }, 314 + ] }, 315 + { title: 'Argument Definitions', 316 + description: 'Arguments are built from converters + info, then lifted into terms with Arg.value.', 317 + steps: [ 318 + { code: 'Cmdliner.Arg.string;;', expect: 'string Cmdliner.Arg.conv', 319 + description: 'Built-in string converter' }, 320 + { code: 'Cmdliner.Arg.int;;', expect: 'int Cmdliner.Arg.conv', 321 + description: 'Built-in int converter' }, 322 + { code: 'let verbose = Cmdliner.Arg.(value (flag (info ["v";"verbose"])));;', 323 + expect: 'bool Cmdliner.Term.t', description: 'Define a boolean flag term' }, 324 + { code: 'let count = Cmdliner.Arg.(value (opt int 0 (info ["c";"count"])));;', 325 + expect: 'int Cmdliner.Term.t', description: 'Define an optional int argument with default 0' }, 326 + ] }, 327 + { title: 'Terms (v1 API)', 328 + description: 'In Cmdliner 1.0.x, Term.const and Term.($) combine argument terms into a program term.', 329 + steps: [ 330 + { code: 'let greet = Cmdliner.Term.const (fun v n -> Printf.sprintf "%s%s" (if v then "HI " else "hi ") n);;', 331 + expect: 'Cmdliner.Term.t', description: 'A constant function lifted into a term' }, 332 + { code: 'Cmdliner.Term.info "greet" ~doc:"A greeting program";;', 333 + expect: 'Cmdliner.Term.info', description: 'Term.info describes the command (v1 API)' }, 334 + { code: 'Cmdliner.Term.eval;;', expect: 'Term.result', 335 + description: 'Term.eval runs a term — available in 1.0.x' }, 336 + ] }, 337 + ], 338 + }, 339 + 340 + 'cmdliner.1.3.0': { 341 + name: 'Cmdliner', version: '1.3.0', opam: 'cmdliner', 342 + description: 'Declarative definition of command line interfaces (transitional)', 343 + universe: U['cmdliner.1.3.0'], require: ['cmdliner'], 344 + sections: [ 345 + { title: 'Argument Building', 346 + description: 'Cmdliner 1.3 is a transitional release supporting both the old Term API and the new Cmd API.', 347 + steps: [ 348 + { code: 'let verbose = Cmdliner.Arg.(value (flag (info ["v";"verbose"] ~doc:"Be verbose")));;', 349 + expect: 'bool Cmdliner.Term.t', description: 'Define a verbose flag' }, 350 + { code: 'let greeting = Cmdliner.Arg.(value (pos 0 string "world" (info [] ~docv:"NAME")));;', 351 + expect: 'string Cmdliner.Term.t', description: 'Define a positional name argument' }, 352 + ] }, 353 + { title: 'New Cmd API (introduced in 1.1+)', 354 + description: 'The Cmd module provides a structured way to define commands, replacing Term.info + Term.eval.', 355 + steps: [ 356 + { code: 'Cmdliner.Cmd.info "hello" ~doc:"Say hello";;', 357 + expect: 'Cmdliner.Cmd.info', description: 'Cmd.info creates command metadata' }, 358 + { code: 'let hello_t = Cmdliner.Term.(const (fun v n -> ()) $ verbose $ greeting);;', 359 + expect: 'Cmdliner.Term.t', description: 'Combine arguments with Term.const and ($)' }, 360 + { code: 'Cmdliner.Cmd.v (Cmdliner.Cmd.info "hello") hello_t;;', 361 + expect: 'Cmdliner.Cmd.t', description: 'Create a command from info + term' }, 362 + ] }, 363 + { title: 'Backward Compatibility', 364 + description: 'The old Term.eval API still works in 1.3 for migration.', 365 + steps: [ 366 + { code: 'Cmdliner.Term.eval;;', expect: 'Term.result', 367 + description: 'Term.eval is still available (deprecated but functional)' }, 368 + { code: 'Cmdliner.Term.info "test";;', expect: 'Cmdliner.Term.info', 369 + description: 'Term.info still works for backward compat' }, 370 + ] }, 371 + ], 372 + }, 373 + 374 + 'cmdliner.2.0.0': { 375 + name: 'Cmdliner', version: '2.0.0', opam: 'cmdliner', 376 + description: 'Declarative definition of command line interfaces (v2 API)', 377 + universe: U['cmdliner.2.0.0'], require: ['cmdliner'], 378 + sections: [ 379 + { title: 'Arguments', 380 + description: 'Arguments are defined the same way as in earlier versions.', 381 + steps: [ 382 + { code: 'let verbose = Cmdliner.Arg.(value (flag (info ["v";"verbose"])));;', 383 + expect: 'bool Cmdliner.Term.t', description: 'A boolean flag term' }, 384 + { code: 'let name = Cmdliner.Arg.(value (pos 0 string "world" (info [])));;', 385 + expect: 'string Cmdliner.Term.t', description: 'A positional string argument' }, 386 + ] }, 387 + { title: 'Cmd Module (v2 API)', 388 + description: 'In Cmdliner 2.x, Cmd replaces Term.info/Term.eval entirely.', 389 + steps: [ 390 + { code: 'Cmdliner.Cmd.info "greet" ~doc:"Greet someone";;', 391 + expect: 'Cmdliner.Cmd.info', description: 'Create command info' }, 392 + { code: 'let t = Cmdliner.Term.(const (fun _ _ -> ()) $ verbose $ name);;', 393 + expect: 'Cmdliner.Term.t', description: 'Build the term' }, 394 + { code: 'let cmd = Cmdliner.Cmd.v (Cmdliner.Cmd.info "greet") t;;', 395 + expect: 'Cmdliner.Cmd.t', description: 'Package into a command' }, 396 + { code: 'Cmdliner.Cmd.name cmd;;', expect: '"greet"', 397 + description: 'Extract the command name' }, 398 + ] }, 399 + { title: 'Removed APIs', 400 + description: 'Term.eval was removed in 2.0. Use Cmd.eval_value instead.', 401 + steps: [ 402 + { code: 'Cmdliner.Cmd.eval_value;;', expect: 'eval_ok', 403 + description: 'Cmd.eval_value is the new entry point' }, 404 + ] }, 405 + ], 406 + }, 407 + 408 + 'cmdliner.2.1.0': { 409 + name: 'Cmdliner', version: '2.1.0', opam: 'cmdliner', 410 + description: 'Declarative definition of command line interfaces (v2 API)', 411 + universe: U['cmdliner.2.1.0'], require: ['cmdliner'], 412 + sections: [ 413 + { title: 'Arguments', 414 + description: 'Define typed command-line arguments with converters and info.', 415 + steps: [ 416 + { code: 'let verbose = Cmdliner.Arg.(value (flag (info ["v";"verbose"] ~doc:"Increase verbosity")));;', 417 + expect: 'bool Cmdliner.Term.t', description: 'A verbose flag' }, 418 + { code: 'let file = Cmdliner.Arg.(required (pos 0 (some string) None (info [] ~docv:"FILE")));;', 419 + expect: 'string Cmdliner.Term.t', description: 'A required positional file argument' }, 420 + { code: 'let count = Cmdliner.Arg.(value (opt int 1 (info ["n";"count"] ~doc:"Repeat count")));;', 421 + expect: 'int Cmdliner.Term.t', description: 'An optional integer with default' }, 422 + ] }, 423 + { title: 'Commands', 424 + description: 'Commands combine a term with metadata. Groups can nest subcommands.', 425 + steps: [ 426 + { code: 'let info = Cmdliner.Cmd.info "process" ~version:"1.0" ~doc:"Process files";;', 427 + expect: 'Cmdliner.Cmd.info', description: 'Command info with version' }, 428 + { code: 'let t = Cmdliner.Term.(const (fun _ _ _ -> ()) $ verbose $ file $ count);;', 429 + expect: 'Cmdliner.Term.t', description: 'Combine all arguments' }, 430 + { code: 'let cmd = Cmdliner.Cmd.v info t;;', 431 + expect: 'Cmdliner.Cmd.t', description: 'Create the command' }, 432 + { code: 'Cmdliner.Cmd.name cmd;;', expect: '"process"', 433 + description: 'Retrieve the command name' }, 434 + ] }, 435 + { title: 'Custom Converters', 436 + description: 'Arg.conv creates custom argument converters from a parser/printer pair.', 437 + steps: [ 438 + { code: 'let color_parser s = match s with "red" -> Ok `Red | "blue" -> Ok `Blue | _ -> Error (`Msg "unknown color");;', 439 + expect: 'val color_parser', description: 'Define a parser function' }, 440 + { code: 'let color_pp ppf c = Format.pp_print_string ppf (match c with `Red -> "red" | `Blue -> "blue");;', 441 + expect: 'val color_pp', description: 'Define a printer' }, 442 + { code: 'let color_conv = Cmdliner.Arg.conv (color_parser, color_pp);;', 443 + expect: 'Cmdliner.Arg.conv', description: 'Build a custom converter' }, 444 + ] }, 445 + ], 446 + }, 447 + 448 + // ═══════════════════════════════════════════════════════════════════════ 449 + // Mtime 450 + // ═══════════════════════════════════════════════════════════════════════ 451 + 'mtime.1.3.0': mtime1_3Tutorial('1.3.0', U['mtime.1.3.0']), 452 + 'mtime.1.4.0': mtime1_4Tutorial('1.4.0', U['mtime.1.4.0']), 453 + 454 + 'mtime.2.1.0': { 455 + name: 'Mtime', version: '2.1.0', opam: 'mtime', 456 + description: 'Monotonic wall-clock time for OCaml', 457 + universe: U['mtime.2.1.0'], require: ['mtime'], 458 + sections: [ 459 + { title: 'Time Span Constants', 460 + description: 'Mtime.Span provides named constants for common durations.', 461 + steps: [ 462 + { code: 'Mtime.Span.zero;;', expect: 'Mtime.span', 463 + description: 'Zero-length span' }, 464 + { code: 'Mtime.Span.s;;', expect: 'Mtime.span', 465 + description: '1 second' }, 466 + { code: 'Mtime.Span.min;;', expect: 'Mtime.span', 467 + description: '1 minute' }, 468 + { code: 'Mtime.Span.hour;;', expect: 'Mtime.span', 469 + description: '1 hour' }, 470 + ] }, 471 + { title: 'Span Arithmetic', 472 + description: 'Spans support addition, comparison, and predicate-based comparisons (new in 2.x).', 473 + steps: [ 474 + { code: 'let two_sec = Mtime.Span.add Mtime.Span.s Mtime.Span.s;;', expect: 'Mtime.span', 475 + description: '1s + 1s' }, 476 + { code: 'Mtime.Span.to_uint64_ns two_sec;;', expect: '2000000000L', 477 + description: '2 seconds in nanoseconds' }, 478 + { code: 'Mtime.Span.is_shorter Mtime.Span.ms ~than:Mtime.Span.s;;', expect: 'true', 479 + description: '1ms is shorter than 1s (new in 2.x)' }, 480 + { code: 'Mtime.Span.is_longer Mtime.Span.hour ~than:Mtime.Span.min;;', expect: 'true', 481 + description: '1 hour is longer than 1 minute (new in 2.x)' }, 482 + ] }, 483 + { title: 'New in 2.x: Float Conversions', 484 + description: 'Mtime 2.x adds Span.of_float_ns for creating spans from floating-point nanoseconds.', 485 + steps: [ 486 + { code: 'Mtime.Span.of_float_ns 1e9;;', expect: 'Some', 487 + description: '1e9 ns = 1 second' }, 488 + { code: 'Mtime.Span.of_float_ns (-1.);;', expect: 'None', 489 + description: 'Negative values return None' }, 490 + { code: 'Mtime.Span.of_float_ns infinity;;', expect: 'None', 491 + description: 'Non-finite values return None' }, 492 + { code: 'Mtime.Span.to_float_ns Mtime.Span.s;;', expect: '1000000000.', 493 + description: 'Convert 1 second to float nanoseconds' }, 494 + ] }, 495 + ], 496 + }, 497 + 498 + // ═══════════════════════════════════════════════════════════════════════ 499 + // Logs 500 + // ═══════════════════════════════════════════════════════════════════════ 501 + 'logs.0.7.0': { 502 + name: 'Logs', version: '0.7.0', opam: 'logs', 503 + description: 'Logging infrastructure for OCaml', 504 + universe: U['logs.0.7.0'], require: ['logs'], 505 + sections: [ 506 + { title: 'Log Sources', 507 + description: 'A Logs.Src.t identifies a log source with a name and optional documentation.', 508 + steps: [ 509 + { code: 'let src = Logs.Src.create "myapp" ~doc:"My application";;', expect: 'Logs.src', 510 + description: 'Create a named log source' }, 511 + { code: 'Logs.Src.name src;;', expect: '"myapp"', 512 + description: 'Retrieve the source name' }, 513 + { code: 'Logs.Src.doc src;;', expect: '"My application"', 514 + description: 'Retrieve the source documentation' }, 515 + ] }, 516 + { title: 'Log Levels', 517 + description: 'Logs has five levels: App, Error, Warning, Info, Debug. The global level controls what gets logged.', 518 + steps: [ 519 + { code: 'Logs.level ();;', expect: 'option', 520 + description: 'Get the current global log level' }, 521 + { code: 'Logs.set_level (Some Logs.Debug);;', expect: 'unit', 522 + description: 'Set the global level to Debug (most verbose)' }, 523 + { code: 'Logs.level ();;', expect: 'Some', 524 + description: 'Verify the level was set' }, 525 + ] }, 526 + { title: 'Error Counting', 527 + description: 'Logs tracks error and warning counts globally.', 528 + steps: [ 529 + { code: 'Logs.err_count ();;', expect: 'int', 530 + description: 'Count of errors logged so far' }, 531 + { code: 'Logs.warn_count ();;', expect: 'int', 532 + description: 'Count of warnings logged so far' }, 533 + ] }, 534 + ], 535 + }, 536 + 537 + 'logs.0.10.0': { 538 + name: 'Logs', version: '0.10.0', opam: 'logs', 539 + description: 'Logging infrastructure for OCaml', 540 + universe: U['logs.0.10.0'], require: ['logs'], 541 + sections: [ 542 + { title: 'Log Sources', 543 + description: 'Create and inspect named log sources.', 544 + steps: [ 545 + { code: 'let src = Logs.Src.create "test" ~doc:"A test source";;', expect: 'Logs.src', 546 + description: 'Create a log source' }, 547 + { code: 'Logs.Src.name src;;', expect: '"test"', 548 + description: 'Get the source name' }, 549 + { code: 'Logs.Src.doc src;;', expect: '"A test source"', 550 + description: 'Get the documentation string' }, 551 + { code: 'Logs.Src.list ();;', expect: 'Logs.src list', 552 + description: 'List all registered sources' }, 553 + ] }, 554 + { title: 'Level Management', 555 + description: 'Control log verbosity at the global and per-source levels.', 556 + steps: [ 557 + { code: 'Logs.set_level (Some Logs.Info);;', expect: 'unit', 558 + description: 'Set global level to Info' }, 559 + { code: 'Logs.level ();;', expect: 'Some', 560 + description: 'Check the global level' }, 561 + { code: 'Logs.Src.set_level src (Some Logs.Debug);;', expect: 'unit', 562 + description: 'Override the level for a specific source' }, 563 + { code: 'Logs.Src.level src;;', expect: 'Some', 564 + description: 'Check the per-source level' }, 565 + ] }, 566 + { title: 'Error Tracking', 567 + description: 'Logs maintains error and warning counters.', 568 + steps: [ 569 + { code: 'Logs.err_count ();;', expect: 'int', 570 + description: 'Number of errors logged' }, 571 + { code: 'Logs.warn_count ();;', expect: 'int', 572 + description: 'Number of warnings logged' }, 573 + ] }, 574 + ], 575 + }, 576 + 577 + // ═══════════════════════════════════════════════════════════════════════ 578 + // Uucp 579 + // ═══════════════════════════════════════════════════════════════════════ 580 + 'uucp.14.0.0': uucpTutorial('14.0.0', U['uucp.14.0.0'], '14.0.0'), 581 + 'uucp.15.0.0': uucpTutorial('15.0.0', U['uucp.15.0.0'], '15.0.0'), 582 + 'uucp.16.0.0': uucpTutorial('16.0.0', U['uucp.16.0.0'], '16.0.0'), 583 + 'uucp.17.0.0': uucpTutorial('17.0.0', U['uucp.17.0.0'], '17.0.0'), 584 + 585 + // ═══════════════════════════════════════════════════════════════════════ 586 + // Uunf 587 + // ═══════════════════════════════════════════════════════════════════════ 588 + 'uunf.14.0.0': uunfTutorial('14.0.0', U['uunf.14.0.0'], '14.0.0'), 589 + 'uunf.17.0.0': uunfTutorial('17.0.0', U['uunf.17.0.0'], '17.0.0'), 590 + 591 + // ═══════════════════════════════════════════════════════════════════════ 592 + // Astring 593 + // ═══════════════════════════════════════════════════════════════════════ 594 + 'astring.0.8.5': { 595 + name: 'Astring', version: '0.8.5', opam: 'astring', 596 + description: 'Alternative String module for OCaml', 597 + universe: U['astring.0.8.5'], require: ['astring'], 598 + sections: [ 599 + { title: 'String Splitting', 600 + description: 'Astring.String provides powerful splitting functions that work with string separators.', 601 + steps: [ 602 + { code: 'Astring.String.cuts ~sep:"," "a,b,c";;', expect: '["a"; "b"; "c"]', 603 + description: 'Split on comma' }, 604 + { code: 'Astring.String.cuts ~sep:"::" "a::b::c";;', expect: '["a"; "b"; "c"]', 605 + description: 'Split on multi-char separator' }, 606 + { code: 'Astring.String.cut ~sep:"=" "key=value";;', expect: 'Some ("key", "value")', 607 + description: 'Cut at first separator occurrence' }, 608 + { code: 'Astring.String.cut ~rev:true ~sep:"." "a.b.c";;', expect: 'Some ("a.b", "c")', 609 + description: 'Cut at last separator with ~rev:true' }, 610 + ] }, 611 + { title: 'String Building', 612 + description: 'Concatenation and transformation functions.', 613 + steps: [ 614 + { code: 'Astring.String.concat ~sep:"-" ["x"; "y"; "z"];;', expect: '"x-y-z"', 615 + description: 'Join strings with separator' }, 616 + { code: 'Astring.String.concat ~sep:", " ["hello"; "world"];;', expect: '"hello, world"', 617 + description: 'Join with comma-space' }, 618 + ] }, 619 + { title: 'String Testing', 620 + description: 'Predicate functions for string content.', 621 + steps: [ 622 + { code: 'Astring.String.is_prefix ~affix:"http" "http://example.com";;', expect: 'true', 623 + description: 'Check for a prefix' }, 624 + { code: 'Astring.String.is_suffix ~affix:".ml" "main.ml";;', expect: 'true', 625 + description: 'Check for a suffix' }, 626 + { code: 'Astring.String.is_prefix ~affix:"ftp" "http://example.com";;', expect: 'false', 627 + description: 'Prefix not found' }, 628 + { code: 'Astring.String.find_sub ~sub:"world" "hello world";;', expect: 'Some 6', 629 + description: 'Find substring position' }, 630 + ] }, 631 + { title: 'String Trimming', 632 + description: 'Remove whitespace or specific characters from strings.', 633 + steps: [ 634 + { code: 'Astring.String.trim " hello ";;', expect: '"hello"', 635 + description: 'Trim whitespace from both ends' }, 636 + { code: 'Astring.String.trim ~drop:(fun c -> c = \'/\') "/path/to/";;', expect: '"path/to"', 637 + description: 'Trim custom characters' }, 638 + ] }, 639 + { title: 'Substrings', 640 + description: 'Astring.String.Sub provides zero-copy substring operations.', 641 + steps: [ 642 + { code: 'Astring.String.Sub.(to_string (v "hello world" ~start:6));;', expect: '"world"', 643 + description: 'Extract a substring from position 6' }, 644 + { code: 'Astring.String.Sub.(to_string (v "hello world" ~stop:5));;', expect: '"hello"', 645 + description: 'Extract first 5 characters' }, 646 + ] }, 647 + ], 648 + }, 649 + 650 + // ═══════════════════════════════════════════════════════════════════════ 651 + // Jsonm 652 + // ═══════════════════════════════════════════════════════════════════════ 653 + 'jsonm.1.0.2': { 654 + name: 'Jsonm', version: '1.0.2', opam: 'jsonm', 655 + description: 'Non-blocking streaming JSON codec for OCaml', 656 + universe: U['jsonm.1.0.2'], require: ['jsonm'], 657 + sections: [ 658 + { title: 'Decoding JSON Values', 659 + description: 'Jsonm.decoder creates a streaming decoder. Each Jsonm.decode call returns one lexeme.', 660 + steps: [ 661 + { code: 'let d = Jsonm.decoder (`String "42");;', expect: 'Jsonm.decoder', 662 + description: 'Create a decoder from a JSON string' }, 663 + { code: 'Jsonm.decode d;;', expect: '`Lexeme (`Float 42.)', 664 + description: 'Decode the number 42 (JSON numbers are floats)' }, 665 + { code: 'Jsonm.decode d;;', expect: '`End', 666 + description: 'End of input' }, 667 + ] }, 668 + { title: 'Decoding Strings and Booleans', 669 + description: 'JSON strings, booleans, and null each produce a single lexeme.', 670 + steps: [ 671 + { code: 'let d2 = Jsonm.decoder (`String {|"hello"|});;', expect: 'Jsonm.decoder', 672 + description: 'Decode a JSON string' }, 673 + { code: 'Jsonm.decode d2;;', expect: '`Lexeme (`String "hello")', 674 + description: 'String lexeme' }, 675 + { code: 'let d3 = Jsonm.decoder (`String "true");;', expect: 'Jsonm.decoder', 676 + description: 'Decode a JSON boolean' }, 677 + { code: 'Jsonm.decode d3;;', expect: '`Lexeme (`Bool true)', 678 + description: 'Boolean lexeme' }, 679 + { code: 'let dn = Jsonm.decoder (`String "null");;', expect: 'Jsonm.decoder', 680 + description: 'Decode null' }, 681 + { code: 'Jsonm.decode dn;;', expect: '`Lexeme `Null', 682 + description: 'Null lexeme' }, 683 + ] }, 684 + { title: 'Decoding Arrays', 685 + description: 'Arrays produce `As (array start) and `Ae (array end) lexemes around their elements.', 686 + steps: [ 687 + { code: 'let da = Jsonm.decoder (`String "[1, 2, 3]");;', expect: 'Jsonm.decoder', 688 + description: 'Create decoder for a JSON array' }, 689 + { code: 'Jsonm.decode da;;', expect: '`Lexeme `As', 690 + description: 'Array start' }, 691 + { code: 'Jsonm.decode da;;', expect: '`Lexeme (`Float 1.)', 692 + description: 'First element' }, 693 + { code: 'Jsonm.decode da;;', expect: '`Lexeme (`Float 2.)', 694 + description: 'Second element' }, 695 + ] }, 696 + { title: 'Encoding JSON', 697 + description: 'Jsonm.encoder creates an encoder that writes lexemes to a buffer.', 698 + steps: [ 699 + { code: 'let buf = Buffer.create 64;;', expect: 'Buffer.t', 700 + description: 'Create an output buffer' }, 701 + { code: 'let e = Jsonm.encoder (`Buffer buf);;', expect: 'Jsonm.encoder', 702 + description: 'Create an encoder' }, 703 + { code: 'Jsonm.encode e (`Lexeme (`Float 42.));;', expect: '`Ok', 704 + description: 'Encode a number' }, 705 + { code: 'Jsonm.encode e `End;;', expect: '`Ok', 706 + description: 'End encoding' }, 707 + { code: 'Buffer.contents buf;;', expect: '42', 708 + description: 'The buffer contains the JSON output' }, 709 + ] }, 710 + ], 711 + }, 712 + 713 + // ═══════════════════════════════════════════════════════════════════════ 714 + // Xmlm 715 + // ═══════════════════════════════════════════════════════════════════════ 716 + 'xmlm.1.4.0': { 717 + name: 'Xmlm', version: '1.4.0', opam: 'xmlm', 718 + description: 'Streaming XML codec for OCaml', 719 + universe: U['xmlm.1.4.0'], require: ['xmlm'], 720 + sections: [ 721 + { title: 'Parsing XML Input', 722 + description: 'Xmlm.make_input creates a streaming parser. Each Xmlm.input call returns one signal.', 723 + steps: [ 724 + { code: 'let i = Xmlm.make_input (`String (0, "<root/>"));;', expect: 'Xmlm.input', 725 + description: 'Create an input from a string' }, 726 + { code: 'Xmlm.input i;;', expect: '`Dtd', 727 + description: 'First signal is the DTD (None for no doctype)' }, 728 + { code: 'Xmlm.input i;;', expect: '`El_start', 729 + description: 'Element start: <root>' }, 730 + { code: 'Xmlm.input i;;', expect: '`El_end', 731 + description: 'Element end: </root> (self-closing)' }, 732 + ] }, 733 + { title: 'Parsing with Attributes', 734 + description: 'Element start signals include the tag name and attributes.', 735 + steps: [ 736 + { code: 'let i2 = Xmlm.make_input (`String (0, {|<div class="main">text</div>|}));;', 737 + expect: 'Xmlm.input', description: 'Parse XML with attributes' }, 738 + { code: 'Xmlm.input i2;;', expect: '`Dtd', 739 + description: 'DTD signal' }, 740 + { code: 'Xmlm.input i2;;', expect: '`El_start', 741 + description: 'Element start with attributes' }, 742 + { code: 'Xmlm.input i2;;', expect: '`Data "text"', 743 + description: 'Text content' }, 744 + { code: 'Xmlm.input i2;;', expect: '`El_end', 745 + description: 'Element end' }, 746 + ] }, 747 + { title: 'XML Output', 748 + description: 'Xmlm can also write XML to a buffer.', 749 + steps: [ 750 + { code: 'let buf = Buffer.create 64;;', expect: 'Buffer.t', 751 + description: 'Create output buffer' }, 752 + { code: 'let o = Xmlm.make_output (`Buffer buf);;', expect: 'Xmlm.output', 753 + description: 'Create an XML output' }, 754 + { code: 'Xmlm.output o (`Dtd None);;', expect: 'unit', 755 + description: 'Write empty DTD' }, 756 + { code: 'Xmlm.output o (`El_start (("", "item"), []));;', expect: 'unit', 757 + description: 'Start <item> element' }, 758 + { code: 'Xmlm.output o (`Data "hello");;', expect: 'unit', 759 + description: 'Write text content' }, 760 + { code: 'Xmlm.output o `El_end;;', expect: 'unit', 761 + description: 'Close the element' }, 762 + { code: 'Buffer.contents buf;;', expect: '<item>hello</item>', 763 + description: 'The output XML' }, 764 + ] }, 765 + ], 766 + }, 767 + 768 + // ═══════════════════════════════════════════════════════════════════════ 769 + // Ptime 770 + // ═══════════════════════════════════════════════════════════════════════ 771 + 'ptime.1.2.0': { 772 + name: 'Ptime', version: '1.2.0', opam: 'ptime', 773 + description: 'POSIX time for OCaml', 774 + universe: U['ptime.1.2.0'], require: ['ptime'], 775 + sections: [ 776 + { title: 'The Epoch', 777 + description: 'Ptime.epoch represents 1970-01-01 00:00:00 UTC, the Unix epoch.', 778 + steps: [ 779 + { code: 'Ptime.epoch;;', expect: 'Ptime.t', 780 + description: 'The epoch timestamp' }, 781 + { code: 'Ptime.to_float_s Ptime.epoch;;', expect: '0.', 782 + description: 'Epoch as float seconds = 0' }, 783 + ] }, 784 + { title: 'Creating Timestamps', 785 + description: 'Ptime.of_date_time creates a timestamp from a date-time tuple.', 786 + steps: [ 787 + { code: 'let t = Ptime.of_date_time ((2024, 1, 1), ((12, 0, 0), 0));;', expect: 'Some', 788 + description: 'January 1, 2024, noon UTC' }, 789 + { code: 'let t = match t with Some t -> t | None -> assert false;;', expect: 'Ptime.t', 790 + description: 'Unwrap the option' }, 791 + { code: 'Ptime.to_date_time t;;', expect: '(2024, 1, 1)', 792 + description: 'Convert back to date-time tuple' }, 793 + { code: 'Ptime.to_rfc3339 t;;', expect: '2024-01-01', 794 + description: 'Format as RFC 3339 string' }, 795 + ] }, 796 + { title: 'Time Arithmetic', 797 + description: 'Add and subtract time spans from timestamps.', 798 + steps: [ 799 + { code: 'let one_day = Ptime.Span.of_int_s (24 * 3600);;', expect: 'Ptime.span', 800 + description: 'A span of one day (86400 seconds)' }, 801 + { code: 'let tomorrow = Ptime.add_span t one_day;;', expect: 'Some', 802 + description: 'Add one day to our timestamp' }, 803 + { code: 'Ptime.to_rfc3339 (Option.get tomorrow);;', expect: '2024-01-02', 804 + description: 'January 2nd' }, 805 + { code: 'Ptime.Span.to_int_s (Ptime.diff (Option.get tomorrow) t);;', expect: 'Some 86400', 806 + description: 'Difference is exactly 86400 seconds' }, 807 + ] }, 808 + { title: 'Time Spans', 809 + description: 'Ptime.Span represents durations in days and picoseconds.', 810 + steps: [ 811 + { code: 'Ptime.Span.zero;;', expect: 'Ptime.span', 812 + description: 'Zero duration' }, 813 + { code: 'Ptime.Span.of_int_s 3600;;', expect: 'Ptime.span', 814 + description: 'One hour in seconds' }, 815 + { code: 'Ptime.Span.to_int_s (Ptime.Span.of_int_s 3600);;', expect: 'Some 3600', 816 + description: 'Round-trip: int -> span -> int' }, 817 + { code: 'Ptime.Span.to_float_s (Ptime.Span.of_int_s 90);;', expect: '90.', 818 + description: '90 seconds as a float' }, 819 + ] }, 820 + ], 821 + }, 822 + 823 + // ═══════════════════════════════════════════════════════════════════════ 824 + // React 825 + // ═══════════════════════════════════════════════════════════════════════ 826 + 'react.1.2.2': { 827 + name: 'React', version: '1.2.2', opam: 'react', 828 + description: 'Declarative events and signals for OCaml (FRP)', 829 + universe: U['react.1.2.2'], require: ['react'], 830 + sections: [ 831 + { title: 'Creating Signals', 832 + description: 'React.S.create returns a signal and a setter function. Signals always have a current value.', 833 + steps: [ 834 + { code: 'let counter, set_counter = React.S.create 0;;', expect: 'React.signal', 835 + description: 'Create a signal with initial value 0' }, 836 + { code: 'React.S.value counter;;', expect: '0', 837 + description: 'Read the current value' }, 838 + { code: 'set_counter 42;;', expect: 'unit', 839 + description: 'Update the signal value' }, 840 + { code: 'React.S.value counter;;', expect: '42', 841 + description: 'The value has changed' }, 842 + ] }, 843 + { title: 'Derived Signals', 844 + description: 'React.S.map creates a signal that automatically updates when its source changes.', 845 + steps: [ 846 + { code: 'let doubled = React.S.map (fun x -> x * 2) counter;;', expect: 'React.signal', 847 + description: 'A signal that is always 2x the counter' }, 848 + { code: 'React.S.value doubled;;', expect: '84', 849 + description: '42 * 2 = 84' }, 850 + { code: 'set_counter 10;;', expect: 'unit', 851 + description: 'Update the counter' }, 852 + { code: 'React.S.value doubled;;', expect: '20', 853 + description: 'Doubled automatically updates: 10 * 2 = 20' }, 854 + ] }, 855 + { title: 'Combining Signals', 856 + description: 'React.S.l2 combines two signals with a function. React.S.pair creates a signal of pairs.', 857 + steps: [ 858 + { code: 'let name, set_name = React.S.create "world";;', expect: 'React.signal', 859 + description: 'A name signal' }, 860 + { code: 'let greeting = React.S.l2 (fun n c -> Printf.sprintf "Hello %s (count=%d)" n c) name counter;;', 861 + expect: 'React.signal', description: 'Combine name and counter' }, 862 + { code: 'React.S.value greeting;;', expect: '"Hello world (count=10)"', 863 + description: 'The combined value' }, 864 + { code: 'set_name "OCaml";;', expect: 'unit', 865 + description: 'Update the name' }, 866 + { code: 'React.S.value greeting;;', expect: '"Hello OCaml (count=10)"', 867 + description: 'Greeting updates automatically' }, 868 + ] }, 869 + { title: 'Events', 870 + description: 'React.E.create returns an event and a trigger. Unlike signals, events are discrete occurrences.', 871 + steps: [ 872 + { code: 'let clicks, send_click = React.E.create ();;', expect: 'React.event', 873 + description: 'Create a click event' }, 874 + { code: 'let click_count = React.S.hold 0 (React.E.map (fun _ -> React.S.value counter) clicks);;', 875 + expect: 'React.signal', description: 'Hold the counter value at each click' }, 876 + ] }, 877 + ], 878 + }, 879 + 880 + // ═══════════════════════════════════════════════════════════════════════ 881 + // Hmap 882 + // ═══════════════════════════════════════════════════════════════════════ 883 + 'hmap.0.8.1': { 884 + name: 'Hmap', version: '0.8.1', opam: 'hmap', 885 + description: 'Heterogeneous value maps for OCaml', 886 + universe: U['hmap.0.8.1'], require: ['hmap'], 887 + sections: [ 888 + { title: 'Creating Keys', 889 + description: 'Hmap keys are created with Key.create. Each key carries a type witness, allowing the map to hold values of different types.', 890 + steps: [ 891 + { code: 'let k_int : int Hmap.key = Hmap.Key.create ();;', expect: 'Hmap.key', 892 + description: 'A key for int values' }, 893 + { code: 'let k_str : string Hmap.key = Hmap.Key.create ();;', expect: 'Hmap.key', 894 + description: 'A key for string values' }, 895 + { code: 'let k_list : int list Hmap.key = Hmap.Key.create ();;', expect: 'Hmap.key', 896 + description: 'A key for int list values' }, 897 + ] }, 898 + { title: 'Building Maps', 899 + description: 'Start from Hmap.empty and add values with Hmap.add. Each key-value pair is type-safe.', 900 + steps: [ 901 + { code: 'let m = Hmap.empty;;', expect: 'Hmap.t', 902 + description: 'An empty heterogeneous map' }, 903 + { code: 'Hmap.is_empty m;;', expect: 'true', 904 + description: 'Verify it is empty' }, 905 + { code: 'let m = m |> Hmap.add k_int 42 |> Hmap.add k_str "hello" |> Hmap.add k_list [1;2;3];;', 906 + expect: 'Hmap.t', description: 'Add values of different types' }, 907 + { code: 'Hmap.cardinal m;;', expect: '3', 908 + description: 'Three bindings in the map' }, 909 + ] }, 910 + { title: 'Querying Maps', 911 + description: 'Hmap.find returns an option. The return type matches the key\'s type parameter.', 912 + steps: [ 913 + { code: 'Hmap.find k_int m;;', expect: 'Some 42', 914 + description: 'Find the int value — type-safe!' }, 915 + { code: 'Hmap.find k_str m;;', expect: 'Some "hello"', 916 + description: 'Find the string value' }, 917 + { code: 'Hmap.find k_list m;;', expect: 'Some [1; 2; 3]', 918 + description: 'Find the int list value' }, 919 + { code: 'Hmap.mem k_int m;;', expect: 'true', 920 + description: 'Check membership' }, 921 + { code: 'let m2 = Hmap.rem k_int m;;', expect: 'Hmap.t', 922 + description: 'Remove a binding' }, 923 + { code: 'Hmap.find k_int m2;;', expect: 'None', 924 + description: 'Key is no longer bound' }, 925 + ] }, 926 + ], 927 + }, 928 + 929 + // ═══════════════════════════════════════════════════════════════════════ 930 + // Gg 931 + // ═══════════════════════════════════════════════════════════════════════ 932 + 'gg.1.0.0': { 933 + name: 'Gg', version: '1.0.0', opam: 'gg', 934 + description: 'Basic types for computer graphics in OCaml', 935 + universe: U['gg.1.0.0'], require: ['gg'], 936 + sections: [ 937 + { title: '2D Vectors', 938 + description: 'Gg.V2 provides 2D vector operations. Vectors are immutable float pairs.', 939 + steps: [ 940 + { code: 'let v = Gg.V2.v 3.0 4.0;;', expect: 'Gg.v2', 941 + description: 'Create a 2D vector (3, 4)' }, 942 + { code: 'Gg.V2.x v;;', expect: '3.', 943 + description: 'X component' }, 944 + { code: 'Gg.V2.y v;;', expect: '4.', 945 + description: 'Y component' }, 946 + { code: 'Gg.V2.norm v;;', expect: '5.', 947 + description: 'Vector magnitude: sqrt(9 + 16) = 5' }, 948 + ] }, 949 + { title: 'Vector Arithmetic', 950 + description: 'Vectors support addition, subtraction, scalar multiplication, and dot products.', 951 + steps: [ 952 + { code: 'let a = Gg.V2.v 1.0 0.0;;', expect: 'Gg.v2', 953 + description: 'Unit vector along x-axis' }, 954 + { code: 'let b = Gg.V2.v 0.0 1.0;;', expect: 'Gg.v2', 955 + description: 'Unit vector along y-axis' }, 956 + { code: 'Gg.V2.add a b |> Gg.V2.x;;', expect: '1.', 957 + description: 'Addition: (1,0) + (0,1) → x = 1' }, 958 + { code: 'Gg.V2.dot a b;;', expect: '0.', 959 + description: 'Dot product of perpendicular vectors = 0' }, 960 + { code: 'Gg.V2.smul 3.0 a |> Gg.V2.x;;', expect: '3.', 961 + description: 'Scalar multiply: 3 * (1,0) → x = 3' }, 962 + ] }, 963 + { title: '3D Vectors and Cross Product', 964 + description: 'Gg.V3 adds a third dimension and the cross product operation.', 965 + steps: [ 966 + { code: 'let i = Gg.V3.v 1.0 0.0 0.0;;', expect: 'Gg.v3', 967 + description: 'X-axis unit vector' }, 968 + { code: 'let j = Gg.V3.v 0.0 1.0 0.0;;', expect: 'Gg.v3', 969 + description: 'Y-axis unit vector' }, 970 + { code: 'let k = Gg.V3.cross i j;;', expect: 'Gg.v3', 971 + description: 'Cross product i × j = k (z-axis)' }, 972 + { code: 'Gg.V3.z k;;', expect: '1.', 973 + description: 'Z component is 1 (right-hand rule)' }, 974 + ] }, 975 + { title: 'Colors', 976 + description: 'Gg.Color represents colors in linear sRGB with alpha.', 977 + steps: [ 978 + { code: 'Gg.Color.red;;', expect: 'Gg.color', 979 + description: 'Predefined red color' }, 980 + { code: 'Gg.Color.r Gg.Color.red;;', expect: '1.', 981 + description: 'Red component = 1.0' }, 982 + { code: 'Gg.Color.g Gg.Color.red;;', expect: '0.', 983 + description: 'Green component = 0.0' }, 984 + { code: 'let c = Gg.Color.v 0.5 0.8 0.2 1.0;;', expect: 'Gg.color', 985 + description: 'Create a custom RGBA color' }, 986 + { code: 'Gg.Color.a c;;', expect: '1.', 987 + description: 'Alpha component' }, 988 + ] }, 989 + ], 990 + }, 991 + 992 + // ═══════════════════════════════════════════════════════════════════════ 993 + // Vg 994 + // ═══════════════════════════════════════════════════════════════════════ 995 + 'vg.0.9.5': { 996 + name: 'Vg', version: '0.9.5', opam: 'vg', 997 + description: 'Declarative 2D vector graphics for OCaml', 998 + universe: U['vg.0.9.5'], require: ['vg', 'gg'], 999 + sections: [ 1000 + { title: 'Building Paths', 1001 + description: 'Vg.P builds immutable path values by chaining operations on P.empty.', 1002 + steps: [ 1003 + { code: 'let p = Vg.P.empty;;', expect: 'Vg.path', 1004 + description: 'Start with an empty path' }, 1005 + { code: 'let p = Vg.P.empty |> Vg.P.sub (Gg.P2.v 0. 0.) |> Vg.P.line (Gg.P2.v 1. 1.);;', 1006 + expect: 'Vg.path', description: 'A line from (0,0) to (1,1)' }, 1007 + { code: 'let circ = Vg.P.empty |> Vg.P.circle (Gg.P2.v 0.5 0.5) 0.3;;', 1008 + expect: 'Vg.path', description: 'A circle centered at (0.5, 0.5) with radius 0.3' }, 1009 + { code: 'let rect = Vg.P.empty |> Vg.P.rect (Gg.Box2.v (Gg.P2.v 0. 0.) (Gg.Size2.v 1. 1.));;', 1010 + expect: 'Vg.path', description: 'A unit rectangle' }, 1011 + ] }, 1012 + { title: 'Creating Images', 1013 + description: 'Vg.I constructs images from colors, paths, and compositing operations.', 1014 + steps: [ 1015 + { code: 'let red_fill = Vg.I.const Gg.Color.red;;', expect: 'Vg.image', 1016 + description: 'A solid red infinite image' }, 1017 + { code: 'let red_circle = Vg.I.cut circ red_fill;;', expect: 'Vg.image', 1018 + description: 'Cut the red fill to the circle path' }, 1019 + { code: 'let blue_rect = Vg.I.cut rect (Vg.I.const Gg.Color.blue);;', expect: 'Vg.image', 1020 + description: 'A blue rectangle' }, 1021 + ] }, 1022 + { title: 'Compositing Images', 1023 + description: 'Vg.I.blend composites images. I.tr applies affine transforms via Gg.M3 matrices.', 1024 + steps: [ 1025 + { code: 'let scene = Vg.I.blend red_circle blue_rect;;', expect: 'Vg.image', 1026 + description: 'Blend circle over rectangle' }, 1027 + { code: 'Vg.I.void;;', expect: 'Vg.image', 1028 + description: 'The empty (transparent) image' }, 1029 + { code: 'let moved = Vg.I.move (Gg.V2.v 0.5 0.5) red_circle;;', expect: 'Vg.image', 1030 + description: 'Translate the circle by (0.5, 0.5)' }, 1031 + ] }, 1032 + ], 1033 + }, 1034 + 1035 + // ═══════════════════════════════════════════════════════════════════════ 1036 + // Note 1037 + // ═══════════════════════════════════════════════════════════════════════ 1038 + 'note.0.0.3': { 1039 + name: 'Note', version: '0.0.3', opam: 'note', 1040 + description: 'Declarative events and signals for OCaml', 1041 + universe: U['note.0.0.3'], require: ['note'], 1042 + sections: [ 1043 + { title: 'Constant Signals', 1044 + description: 'Note.S.const creates a signal with a fixed value. Signals always have a current value.', 1045 + steps: [ 1046 + { code: 'let s = Note.S.const 42;;', expect: 'Note.signal', 1047 + description: 'A constant signal with value 42' }, 1048 + { code: 'Note.S.value s;;', expect: '42', 1049 + description: 'Read the signal value' }, 1050 + ] }, 1051 + { title: 'Mutable Signals', 1052 + description: 'Note.S.create returns a signal and a setter function for updating the value.', 1053 + steps: [ 1054 + { code: 'let counter, set_counter = Note.S.create 0;;', expect: 'Note.signal', 1055 + description: 'Create a mutable signal starting at 0' }, 1056 + { code: 'Note.S.value counter;;', expect: '0', 1057 + description: 'Initial value' }, 1058 + { code: 'set_counter 10;;', expect: 'unit', 1059 + description: 'Update the value to 10' }, 1060 + { code: 'Note.S.value counter;;', expect: '10', 1061 + description: 'Value has changed' }, 1062 + ] }, 1063 + { title: 'Signal Transformations', 1064 + description: 'Note.S.map and Note.S.l2 derive new signals from existing ones.', 1065 + steps: [ 1066 + { code: 'let doubled = Note.S.map (( * ) 2) counter;;', expect: 'Note.signal', 1067 + description: 'A derived signal: always 2x the counter' }, 1068 + { code: 'Note.S.value doubled;;', expect: '20', 1069 + description: '10 * 2 = 20' }, 1070 + { code: 'let label = Note.S.map (fun n -> Printf.sprintf "count=%d" n) counter;;', 1071 + expect: 'Note.signal', description: 'Map counter to a string label' }, 1072 + { code: 'Note.S.value label;;', expect: '"count=10"', 1073 + description: 'Label reflects the current counter value' }, 1074 + { code: 'let sum = Note.S.l2 ( + ) counter doubled;;', expect: 'Note.signal', 1075 + description: 'Combine two signals with l2' }, 1076 + { code: 'Note.S.value sum;;', expect: '30', 1077 + description: '10 + 20 = 30' }, 1078 + ] }, 1079 + ], 1080 + }, 1081 + 1082 + // ═══════════════════════════════════════════════════════════════════════ 1083 + // Otfm 1084 + // ═══════════════════════════════════════════════════════════════════════ 1085 + 'otfm.0.4.0': { 1086 + name: 'Otfm', version: '0.4.0', opam: 'otfm', 1087 + description: 'OpenType font decoder for OCaml', 1088 + universe: U['otfm.0.4.0'], require: ['otfm'], 1089 + sections: [ 1090 + { title: 'Decoder Creation', 1091 + description: 'Otfm.decoder creates a decoder from font byte data. Most operations require valid font data.', 1092 + steps: [ 1093 + { code: 'Otfm.decoder;;', expect: '-> Otfm.decoder', 1094 + description: 'The decoder constructor (takes a `String source)' }, 1095 + { code: 'let d = Otfm.decoder (`String "");;', expect: 'Otfm.decoder', 1096 + description: 'Create a decoder (with empty data for exploration)' }, 1097 + ] }, 1098 + { title: 'Querying Font Data', 1099 + description: 'With valid font data, you can query tables, glyph counts, and PostScript names.', 1100 + steps: [ 1101 + { code: 'Otfm.flavour d;;', expect: 'Error', 1102 + description: 'Flavour fails on empty data (expected)' }, 1103 + { code: 'Otfm.postscript_name d;;', expect: '', 1104 + description: 'PostScript name query (fails gracefully on empty data)' }, 1105 + { code: 'Otfm.glyph_count d;;', expect: '', 1106 + description: 'Glyph count query' }, 1107 + ] }, 1108 + ], 1109 + }, 1110 + 1111 + // ═══════════════════════════════════════════════════════════════════════ 1112 + // Fpath 1113 + // ═══════════════════════════════════════════════════════════════════════ 1114 + 'fpath.0.7.3': { 1115 + name: 'Fpath', version: '0.7.3', opam: 'fpath', 1116 + description: 'File system paths for OCaml', 1117 + universe: U['fpath.0.7.3'], require: ['fpath'], 1118 + sections: [ 1119 + { title: 'Creating Paths', 1120 + description: 'Fpath.v creates a path from a string. Paths are validated on creation.', 1121 + steps: [ 1122 + { code: 'Fpath.v "/usr/local/bin";;', expect: 'Fpath.t', 1123 + description: 'Create an absolute path' }, 1124 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.to_string;;', expect: '"/usr/local/bin"', 1125 + description: 'Convert back to string' }, 1126 + { code: 'Fpath.v "src/main.ml";;', expect: 'Fpath.t', 1127 + description: 'A relative path' }, 1128 + ] }, 1129 + { title: 'Path Composition', 1130 + description: 'Fpath.(/) appends a segment. Paths compose naturally.', 1131 + steps: [ 1132 + { code: 'Fpath.(v "/usr" / "local" / "bin") |> Fpath.to_string;;', expect: '"/usr/local/bin"', 1133 + description: 'Build paths by appending segments' }, 1134 + { code: 'Fpath.(v "src" / "lib" / "main.ml") |> Fpath.to_string;;', expect: '"src/lib/main.ml"', 1135 + description: 'Relative path composition' }, 1136 + ] }, 1137 + { title: 'Path Components', 1138 + description: 'Extract parts of a path: parent directory, basename, filename.', 1139 + steps: [ 1140 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.parent |> Fpath.to_string;;', expect: '"/usr/local/"', 1141 + description: 'Parent directory' }, 1142 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.basename;;', expect: '"bin"', 1143 + description: 'Basename (last segment)' }, 1144 + { code: 'Fpath.v "/usr/local/bin" |> Fpath.filename;;', expect: '"bin"', 1145 + description: 'Filename (last non-empty segment)' }, 1146 + { code: 'Fpath.v "/a/b/" |> Fpath.basename;;', expect: '"b"', 1147 + description: 'Basename of a directory path' }, 1148 + { code: 'Fpath.segs (Fpath.v "/a/b/c");;', expect: '[""; "a"; "b"; "c"]', 1149 + description: 'All segments (empty first = absolute)' }, 1150 + ] }, 1151 + { title: 'File Extensions', 1152 + description: 'Query and manipulate file extensions.', 1153 + steps: [ 1154 + { code: 'Fpath.has_ext ".ml" (Fpath.v "main.ml");;', expect: 'true', 1155 + description: 'Check for .ml extension' }, 1156 + { code: 'Fpath.get_ext (Fpath.v "archive.tar.gz");;', expect: '".gz"', 1157 + description: 'Get the last extension' }, 1158 + { code: 'Fpath.get_ext ~multi:true (Fpath.v "archive.tar.gz");;', expect: '".tar.gz"', 1159 + description: 'Get the full multi-extension' }, 1160 + { code: 'Fpath.rem_ext (Fpath.v "main.ml") |> Fpath.to_string;;', expect: '"main"', 1161 + description: 'Remove the extension' }, 1162 + ] }, 1163 + { title: 'Path Properties', 1164 + description: 'Test whether paths are absolute, relative, file paths, or directory paths.', 1165 + steps: [ 1166 + { code: 'Fpath.is_abs (Fpath.v "/usr/bin");;', expect: 'true', 1167 + description: 'Absolute path check' }, 1168 + { code: 'Fpath.is_rel (Fpath.v "src/main.ml");;', expect: 'true', 1169 + description: 'Relative path check' }, 1170 + { code: 'Fpath.is_dir_path (Fpath.v "/usr/bin/");;', expect: 'true', 1171 + description: 'Directory path (ends with /)' }, 1172 + { code: 'Fpath.is_file_path (Fpath.v "/usr/bin");;', expect: 'true', 1173 + description: 'File path (does not end with /)' }, 1174 + { code: 'Fpath.normalize (Fpath.v "/a/b/../c") |> Fpath.to_string;;', expect: '"/a/c"', 1175 + description: 'Normalize resolves .. components' }, 1176 + ] }, 1177 + ], 1178 + }, 1179 + 1180 + // ═══════════════════════════════════════════════════════════════════════ 1181 + // Uutf 1182 + // ═══════════════════════════════════════════════════════════════════════ 1183 + 'uutf.1.0.4': { 1184 + name: 'Uutf', version: '1.0.4', opam: 'uutf', 1185 + description: 'Non-blocking streaming Unicode codec for OCaml', 1186 + universe: U['uutf.1.0.4'], require: ['uutf'], 1187 + sections: [ 1188 + { title: 'UTF-8 Decoding', 1189 + description: 'Uutf.decoder creates a streaming decoder. Each Uutf.decode call returns one character or a signal.', 1190 + steps: [ 1191 + { code: 'let d = Uutf.decoder ~encoding:`UTF_8 (`String "ABC");;', expect: 'Uutf.decoder', 1192 + description: 'Create a UTF-8 decoder for "ABC"' }, 1193 + { code: 'Uutf.decode d;;', expect: '`Uchar', 1194 + description: 'Decode first character: A' }, 1195 + { code: 'Uutf.decode d;;', expect: '`Uchar', 1196 + description: 'Decode second character: B' }, 1197 + { code: 'Uutf.decode d;;', expect: '`Uchar', 1198 + description: 'Decode third character: C' }, 1199 + { code: 'Uutf.decode d;;', expect: '`End', 1200 + description: 'End of input' }, 1201 + ] }, 1202 + { title: 'Multi-byte Characters', 1203 + description: 'UTF-8 encodes non-ASCII characters in multiple bytes. Uutf handles this transparently.', 1204 + steps: [ 1205 + { code: 'let d2 = Uutf.decoder ~encoding:`UTF_8 (`String "caf\\xC3\\xA9");;', 1206 + expect: 'Uutf.decoder', description: 'Decode "cafe" with e-acute (U+00E9)' }, 1207 + { code: 'Uutf.decode d2;;', expect: '`Uchar', 1208 + description: 'c' }, 1209 + { code: 'Uutf.decode d2;;', expect: '`Uchar', 1210 + description: 'a' }, 1211 + { code: 'Uutf.decode d2;;', expect: '`Uchar', 1212 + description: 'f' }, 1213 + { code: 'Uutf.decode d2;;', expect: '`Uchar', 1214 + description: 'e-acute (U+00E9, decoded from 2 bytes)' }, 1215 + ] }, 1216 + { title: 'UTF-8 Encoding', 1217 + description: 'Uutf.encoder writes Unicode characters to a buffer in a specified encoding.', 1218 + steps: [ 1219 + { code: 'let buf = Buffer.create 16;;', expect: 'Buffer.t', 1220 + description: 'Create an output buffer' }, 1221 + { code: 'let e = Uutf.encoder `UTF_8 (`Buffer buf);;', expect: 'Uutf.encoder', 1222 + description: 'Create a UTF-8 encoder' }, 1223 + { code: 'Uutf.encode e (`Uchar (Uchar.of_int 0x41));;', expect: '`Ok', 1224 + description: "Encode 'A'" }, 1225 + { code: 'Uutf.encode e (`Uchar (Uchar.of_int 0xE9));;', expect: '`Ok', 1226 + description: "Encode e-acute" }, 1227 + { code: 'Uutf.encode e `End;;', expect: '`Ok', 1228 + description: 'Flush the encoder' }, 1229 + { code: 'Buffer.length buf;;', expect: '3', 1230 + description: "A (1 byte) + e-acute (2 bytes) = 3 bytes" }, 1231 + ] }, 1232 + ], 1233 + }, 1234 + 1235 + // ═══════════════════════════════════════════════════════════════════════ 1236 + // B0 1237 + // ═══════════════════════════════════════════════════════════════════════ 1238 + 'b0.0.0.6': { 1239 + name: 'B0', version: '0.0.6', opam: 'b0', 1240 + description: 'Software construction and deployment kit', 1241 + universe: U['b0.0.0.6'], require: ['b0.std'], 1242 + sections: [ 1243 + { title: 'File Paths (B0_std.Fpath)', 1244 + description: 'B0_std provides its own Fpath module for file path manipulation.', 1245 + steps: [ 1246 + { code: 'B0_std.Fpath.v "/usr/bin";;', expect: 'B0_std.Fpath.t', 1247 + description: 'Create a path' }, 1248 + { code: 'B0_std.Fpath.(v "/usr" / "local" / "bin") |> B0_std.Fpath.to_string;;', 1249 + expect: '"/usr/local/bin"', description: 'Path composition with (/)' }, 1250 + { code: 'B0_std.Fpath.basename (B0_std.Fpath.v "/usr/local/bin");;', expect: '"bin"', 1251 + description: 'Get the basename' }, 1252 + { code: 'B0_std.Fpath.parent (B0_std.Fpath.v "/usr/local/bin") |> B0_std.Fpath.to_string;;', 1253 + expect: '"/usr/local/"', description: 'Get parent directory' }, 1254 + ] }, 1255 + { title: 'Command Lines (B0_std.Cmd)', 1256 + description: 'B0_std.Cmd builds command-line invocations declaratively.', 1257 + steps: [ 1258 + { code: 'let cmd = B0_std.Cmd.(tool "ocamlfind" % "query" % "-format" % "%d" % "fmt");;', 1259 + expect: 'B0_std.Cmd.t', description: 'Build a command line' }, 1260 + { code: 'B0_std.Cmd.to_list cmd;;', expect: '["ocamlfind"', 1261 + description: 'Convert to a list of strings' }, 1262 + { code: 'B0_std.Cmd.is_empty B0_std.Cmd.empty;;', expect: 'true', 1263 + description: 'Check for empty command' }, 1264 + ] }, 1265 + ], 1266 + }, 1267 + 1268 + // ═══════════════════════════════════════════════════════════════════════ 1269 + // Bos 1270 + // ═══════════════════════════════════════════════════════════════════════ 1271 + 'bos.0.2.1': { 1272 + name: 'Bos', version: '0.2.1', opam: 'bos', 1273 + description: 'Basic OS interaction for OCaml', 1274 + universe: U['bos.0.2.1'], require: ['bos'], 1275 + sections: [ 1276 + { title: 'Command Construction', 1277 + description: 'Bos.Cmd builds shell commands declaratively with type-safe combinators.', 1278 + steps: [ 1279 + { code: 'let cmd = Bos.Cmd.(v "echo" % "hello" % "world");;', expect: 'Bos.Cmd.t', 1280 + description: 'Build: echo hello world' }, 1281 + { code: 'Bos.Cmd.to_string cmd;;', expect: 'echo', 1282 + description: 'Convert to shell string' }, 1283 + { code: 'Bos.Cmd.to_list cmd;;', expect: '["echo"; "hello"; "world"]', 1284 + description: 'Convert to argument list' }, 1285 + ] }, 1286 + { title: 'Command Combinators', 1287 + description: 'Commands support appending, conditional inclusion, and inspection.', 1288 + steps: [ 1289 + { code: 'let base = Bos.Cmd.(v "gcc" % "-O2");;', expect: 'Bos.Cmd.t', 1290 + description: 'Base compiler command' }, 1291 + { code: 'let full = Bos.Cmd.(base % "-o" % "main" %% v "main.c");;', expect: 'Bos.Cmd.t', 1292 + description: 'Append arguments and a sub-command' }, 1293 + { code: 'Bos.Cmd.to_list full;;', expect: '["gcc"', 1294 + description: 'Full argument list' }, 1295 + { code: 'Bos.Cmd.line_tool full;;', expect: 'Some "gcc"', 1296 + description: 'Extract the tool name' }, 1297 + { code: 'Bos.Cmd.is_empty Bos.Cmd.empty;;', expect: 'true', 1298 + description: 'Empty command check' }, 1299 + ] }, 1300 + { title: 'Conditional Arguments', 1301 + description: 'Bos.Cmd.on conditionally includes arguments.', 1302 + steps: [ 1303 + { code: 'let debug = true;;', expect: 'true', 1304 + description: 'A debug flag' }, 1305 + { code: 'Bos.Cmd.(v "gcc" %% on debug (v "-g") % "main.c") |> Bos.Cmd.to_list;;', 1306 + expect: '["gcc"; "-g"; "main.c"]', description: 'Debug flag is included when true' }, 1307 + { code: 'Bos.Cmd.(v "gcc" %% on false (v "-g") % "main.c") |> Bos.Cmd.to_list;;', 1308 + expect: '["gcc"; "main.c"]', description: 'Debug flag is omitted when false' }, 1309 + ] }, 1310 + ], 1311 + }, 1312 + };
+265
test/ohc-integration/tutorials/tutorial.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Loading tutorial...</title> 6 + <style> 7 + * { box-sizing: border-box; margin: 0; padding: 0; } 8 + body { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace; background: #0d1117; color: #c9d1d9; line-height: 1.6; } 9 + 10 + .top-bar { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 10; } 11 + .top-bar a { color: #58a6ff; text-decoration: none; font-size: 13px; } 12 + .top-bar a:hover { text-decoration: underline; } 13 + .top-bar .status { margin-left: auto; font-size: 13px; } 14 + .top-bar .status .pass { color: #3fb950; } 15 + .top-bar .status .fail { color: #f85149; } 16 + .top-bar .status .total { color: #8b949e; } 17 + .progress { height: 3px; background: #21262d; } 18 + .progress-bar { height: 100%; background: #58a6ff; transition: width 0.3s; width: 0%; } 19 + .progress-bar.done { background: #3fb950; } 20 + .progress-bar.has-fail { background: #f85149; } 21 + 22 + .hero { padding: 40px 24px 32px; max-width: 900px; margin: 0 auto; } 23 + .hero h1 { font-size: 28px; color: #f0f6fc; font-weight: 700; } 24 + .hero h1 .version { color: #8b949e; font-weight: 400; } 25 + .hero .desc { color: #8b949e; font-size: 15px; margin-top: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } 26 + .hero .meta { margin-top: 12px; display: flex; gap: 16px; flex-wrap: wrap; } 27 + .hero .meta .tag { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: #21262d; color: #8b949e; } 28 + .hero .meta .tag code { color: #d2a8ff; } 29 + 30 + main { max-width: 900px; margin: 0 auto; padding: 0 24px 60px; } 31 + 32 + .section { margin-bottom: 40px; } 33 + .section h2 { font-size: 16px; color: #f0f6fc; font-weight: 600; margin-bottom: 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } 34 + .section .section-desc { color: #8b949e; font-size: 14px; margin-bottom: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } 35 + 36 + .step { margin-bottom: 12px; border: 1px solid #21262d; border-radius: 8px; overflow: hidden; transition: border-color 0.3s; } 37 + .step.pass { border-color: #238636; } 38 + .step.fail { border-color: #da3633; } 39 + .step.running { border-color: #1f6feb; } 40 + 41 + .step-desc { padding: 6px 12px; font-size: 13px; color: #8b949e; background: #161b22; border-bottom: 1px solid #21262d; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } 42 + 43 + .step-code { padding: 8px 12px; background: #0d1117; display: flex; align-items: baseline; gap: 8px; } 44 + .step-code .prompt { color: #484f58; user-select: none; } 45 + .step-code code { color: #d2a8ff; white-space: pre-wrap; word-break: break-all; flex: 1; } 46 + .step-code .step-time { color: #484f58; font-size: 11px; flex-shrink: 0; } 47 + .step-code .step-icon { flex-shrink: 0; width: 16px; text-align: center; font-size: 13px; } 48 + .step-code .step-icon.pass { color: #3fb950; } 49 + .step-code .step-icon.fail { color: #f85149; } 50 + .step-code .step-icon.running { color: #58a6ff; } 51 + 52 + .step-output { padding: 8px 12px; background: #161b22; border-top: 1px solid #21262d; font-size: 13px; white-space: pre-wrap; word-break: break-all; color: #3fb950; min-height: 20px; } 53 + .step-output.empty { color: #484f58; font-style: italic; } 54 + .step-output .stderr { color: #f85149; } 55 + .step-output .stdout-line { color: #c9d1d9; } 56 + 57 + .step-error { padding: 6px 12px; background: #1c1215; border-top: 1px solid #f8514933; font-size: 12px; color: #f85149; } 58 + 59 + @keyframes spin { to { transform: rotate(360deg); } } 60 + .spinner { display: inline-block; animation: spin 1s linear infinite; } 61 + 62 + .init-status { text-align: center; padding: 40px; color: #8b949e; font-size: 14px; } 63 + .init-status.error { color: #f85149; } 64 + 65 + .error-page { text-align: center; padding: 80px 24px; } 66 + .error-page h1 { color: #f85149; font-size: 20px; margin-bottom: 8px; } 67 + .error-page p { color: #8b949e; } 68 + </style> 69 + </head> 70 + <body> 71 + <div class="top-bar"> 72 + <a href="index.html">&larr; All tutorials</a> 73 + <span id="pkg-label" style="color:#8b949e; font-size: 13px;"></span> 74 + <div class="status" id="status-bar"></div> 75 + </div> 76 + <div class="progress"><div class="progress-bar" id="progress-bar"></div></div> 77 + 78 + <div class="hero" id="hero" style="display:none;"> 79 + <h1 id="title"></h1> 80 + <p class="desc" id="description"></p> 81 + <div class="meta" id="meta"></div> 82 + </div> 83 + 84 + <div class="init-status" id="init-status"><span class="spinner">&#x25E0;</span> Loading tutorial...</div> 85 + 86 + <main id="content" style="display:none;"></main> 87 + 88 + <script type="module"> 89 + import { TUTORIALS } from './test-defs.js'; 90 + import { OcamlWorker } from '/client/ocaml-worker.js'; 91 + 92 + const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 93 + 94 + const pkg = new URLSearchParams(window.location.search).get('pkg'); 95 + const tutorial = TUTORIALS[pkg]; 96 + const initEl = document.getElementById('init-status'); 97 + 98 + if (!tutorial) { 99 + initEl.innerHTML = ''; 100 + document.getElementById('hero').style.display = 'none'; 101 + const err = document.createElement('div'); 102 + err.className = 'error-page'; 103 + err.innerHTML = `<h1>Tutorial not found</h1><p>No tutorial for <code>${esc(pkg || '(none)')}</code>.</p><p style="margin-top:12px"><a href="index.html" style="color:#58a6ff">Back to index</a></p>`; 104 + document.body.appendChild(err); 105 + throw new Error('Unknown package: ' + pkg); 106 + } 107 + 108 + document.title = `${tutorial.name} ${tutorial.version} \u2014 Tutorial`; 109 + document.getElementById('pkg-label').textContent = `${tutorial.name} ${tutorial.version}`; 110 + document.getElementById('title').innerHTML = `${esc(tutorial.name)} <span class="version">${esc(tutorial.version)}</span>`; 111 + document.getElementById('description').textContent = tutorial.description; 112 + document.getElementById('meta').innerHTML = [ 113 + `<span class="tag">require: <code>${tutorial.require.join(', ')}</code></span>`, 114 + `<span class="tag">universe: <code>${tutorial.universe.slice(0,12)}&hellip;</code></span>`, 115 + `<span class="tag">compiler: <code>${tutorial.compiler || '5.4.0'}</code></span>`, 116 + ].join(''); 117 + document.getElementById('hero').style.display = 'block'; 118 + 119 + // Count total steps 120 + const totalSteps = tutorial.sections.reduce((n, s) => n + s.steps.length, 0); 121 + let passed = 0, failed = 0, completed = 0; 122 + 123 + function updateStatus() { 124 + const bar = document.getElementById('progress-bar'); 125 + bar.style.width = `${(completed / totalSteps) * 100}%`; 126 + if (completed === totalSteps) { 127 + bar.classList.add('done'); 128 + if (failed > 0) bar.classList.add('has-fail'); 129 + } 130 + const parts = []; 131 + if (passed > 0) parts.push(`<span class="pass">${passed} passed</span>`); 132 + if (failed > 0) parts.push(`<span class="fail">${failed} failed</span>`); 133 + parts.push(`<span class="total">${completed}/${totalSteps}</span>`); 134 + document.getElementById('status-bar').innerHTML = parts.join(' &middot; '); 135 + } 136 + updateStatus(); 137 + 138 + // Build DOM 139 + const content = document.getElementById('content'); 140 + const stepRecords = []; 141 + 142 + for (const section of tutorial.sections) { 143 + const sEl = document.createElement('div'); 144 + sEl.className = 'section'; 145 + let html = `<h2>${esc(section.title)}</h2>`; 146 + if (section.description) html += `<p class="section-desc">${esc(section.description)}</p>`; 147 + sEl.innerHTML = html; 148 + 149 + for (const step of section.steps) { 150 + const div = document.createElement('div'); 151 + div.className = 'step pending'; 152 + let inner = ''; 153 + if (step.description) inner += `<div class="step-desc">${esc(step.description)}</div>`; 154 + inner += `<div class="step-code"><span class="step-icon pending">\u25CB</span><span class="prompt">#</span> <code>${esc(step.code || step.complete?.source || '')}</code><span class="step-time"></span></div>`; 155 + inner += `<div class="step-output empty">&hellip;</div>`; 156 + div.innerHTML = inner; 157 + sEl.appendChild(div); 158 + stepRecords.push({ el: div, step }); 159 + } 160 + content.appendChild(sEl); 161 + } 162 + 163 + // Init worker 164 + initEl.innerHTML = '<span class="spinner">&#x25E0;</span> Initializing OCaml worker...'; 165 + let worker; 166 + try { 167 + worker = new OcamlWorker(`/jtw-output/compiler/${tutorial.compiler || '5.4.0'}/worker.js`, { timeout: 120000 }); 168 + await worker.init({ 169 + findlib_requires: [], 170 + stdlib_dcs: 'lib/ocaml/dynamic_cmis.json', 171 + findlib_index: `/jtw-output/u/${tutorial.universe}/findlib_index`, 172 + }); 173 + 174 + initEl.innerHTML = '<span class="spinner">&#x25E0;</span> Loading packages...'; 175 + for (const req of tutorial.require) { 176 + await worker.eval(`#require "${req}";;`); 177 + } 178 + 179 + initEl.style.display = 'none'; 180 + content.style.display = 'block'; 181 + } catch (e) { 182 + initEl.className = 'init-status error'; 183 + initEl.textContent = 'Failed to initialize: ' + e.message; 184 + throw e; 185 + } 186 + 187 + // Run steps sequentially 188 + for (const { el, step } of stepRecords) { 189 + el.className = 'step running'; 190 + const iconEl = el.querySelector('.step-icon'); 191 + iconEl.className = 'step-icon running'; 192 + iconEl.innerHTML = '<span class="spinner">&#x25E0;</span>'; 193 + const outEl = el.querySelector('.step-output'); 194 + const timeEl = el.querySelector('.step-time'); 195 + const start = performance.now(); 196 + 197 + try { 198 + if (step.complete) { 199 + const result = await worker.complete(step.complete.source, step.complete.pos); 200 + const entries = result.completions?.entries?.map(e => e.name) || []; 201 + const elapsed = Math.round(performance.now() - start); 202 + outEl.className = 'step-output'; 203 + outEl.textContent = `Completions: ${entries.join(', ')}`; 204 + if (step.expectEntries) { 205 + for (const exp of step.expectEntries) { 206 + if (!entries.includes(exp)) throw new Error(`Expected completion "${exp}" not in [${entries.join(', ')}]`); 207 + } 208 + } 209 + timeEl.textContent = elapsed + 'ms'; 210 + passed++; 211 + } else { 212 + const r = await worker.eval(step.code); 213 + const ppf = r.caml_ppf || ''; 214 + const stdout = r.stdout || ''; 215 + const stderr = r.stderr || ''; 216 + const elapsed = Math.round(performance.now() - start); 217 + timeEl.textContent = elapsed + 'ms'; 218 + 219 + // Render output 220 + outEl.className = 'step-output'; 221 + let html = ''; 222 + if (ppf) html += esc(ppf.trimEnd()); 223 + if (stdout) html += (html ? '\n' : '') + `<span class="stdout-line">${esc(stdout.trimEnd())}</span>`; 224 + if (stderr && !step.expectError) html += (html ? '\n' : '') + `<span class="stderr">${esc(stderr.trimEnd())}</span>`; 225 + if (step.expectError) { 226 + // Negative test 227 + html += (html ? '\n' : '') + `<span class="stderr">${esc(stderr.trimEnd())}</span>`; 228 + } 229 + if (!html) { outEl.className = 'step-output empty'; html = 'unit'; } 230 + outEl.innerHTML = html; 231 + 232 + // Check expectations 233 + if (step.expectError) { 234 + const combined = ppf + stderr; 235 + if (!combined.includes(step.expectError)) 236 + throw new Error(`Expected error containing "${step.expectError}" but got: ${ppf || stderr || '(empty)'}`); 237 + } else if (step.expect !== undefined && step.expect !== '') { 238 + if (!ppf.includes(step.expect)) 239 + throw new Error(`Expected output containing "${step.expect}", got: "${ppf}"`); 240 + } 241 + if (step.expectStdout && !stdout.includes(step.expectStdout)) 242 + throw new Error(`Expected stdout containing "${step.expectStdout}", got: "${stdout}"`); 243 + 244 + passed++; 245 + } 246 + el.className = 'step pass'; 247 + iconEl.className = 'step-icon pass'; 248 + iconEl.textContent = '\u2714'; 249 + } catch (e) { 250 + failed++; 251 + el.className = 'step fail'; 252 + iconEl.className = 'step-icon fail'; 253 + iconEl.textContent = '\u2718'; 254 + const errDiv = document.createElement('div'); 255 + errDiv.className = 'step-error'; 256 + errDiv.textContent = e.message; 257 + el.appendChild(errDiv); 258 + } 259 + 260 + completed++; 261 + updateStatus(); 262 + } 263 + </script> 264 + </body> 265 + </html>
+241
test/ohc-integration/tutorials/tutorials.spec.js
··· 1 + // @ts-check 2 + const { test, expect } = require('@playwright/test'); 3 + 4 + const BASE = 'http://localhost:8769/test/ohc-integration/tutorials'; 5 + 6 + // Generous timeout for worker init + package loading + step execution 7 + test.describe.configure({ timeout: 180_000 }); 8 + 9 + /** 10 + * Helper: load a tutorial page, wait for all steps to finish, 11 + * return { passed, failed, total, failures[] }. 12 + */ 13 + async function runTutorial(page, pkg) { 14 + await page.goto(`${BASE}/tutorial.html?pkg=${encodeURIComponent(pkg)}`); 15 + 16 + // Wait for init status to disappear (worker loaded, packages loaded) 17 + await page.waitForSelector('#init-status', { state: 'hidden', timeout: 120_000 }); 18 + 19 + // Wait for all steps to finish: progress bar gets class 'done' 20 + await page.waitForSelector('.progress-bar.done', { timeout: 120_000 }); 21 + 22 + // Gather results 23 + const results = await page.evaluate(() => { 24 + const steps = document.querySelectorAll('.step'); 25 + const failures = []; 26 + let passed = 0, failed = 0; 27 + for (const step of steps) { 28 + if (step.classList.contains('pass')) { 29 + passed++; 30 + } else if (step.classList.contains('fail')) { 31 + failed++; 32 + const code = step.querySelector('.step-code code')?.textContent || ''; 33 + const output = step.querySelector('.step-output')?.textContent || ''; 34 + const error = step.querySelector('.step-error')?.textContent || ''; 35 + failures.push({ code, output, error }); 36 + } 37 + } 38 + return { passed, failed, total: steps.length, failures }; 39 + }); 40 + 41 + return results; 42 + } 43 + 44 + // ── Test a representative sample across all library types ────────────── 45 + 46 + test('fmt.0.11.0 tutorial', async ({ page }) => { 47 + const r = await runTutorial(page, 'fmt.0.11.0'); 48 + if (r.failures.length > 0) { 49 + console.log('Failures:', JSON.stringify(r.failures, null, 2)); 50 + } 51 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 52 + expect(r.passed).toBe(r.total); 53 + }); 54 + 55 + test('cmdliner.1.0.4 tutorial', async ({ page }) => { 56 + const r = await runTutorial(page, 'cmdliner.1.0.4'); 57 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 58 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 59 + }); 60 + 61 + test('cmdliner.2.1.0 tutorial', async ({ page }) => { 62 + const r = await runTutorial(page, 'cmdliner.2.1.0'); 63 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 64 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 65 + }); 66 + 67 + test('mtime.1.3.0 tutorial', async ({ page }) => { 68 + const r = await runTutorial(page, 'mtime.1.3.0'); 69 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 70 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 71 + }); 72 + 73 + test('mtime.2.1.0 tutorial', async ({ page }) => { 74 + const r = await runTutorial(page, 'mtime.2.1.0'); 75 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 76 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 77 + }); 78 + 79 + test('astring.0.8.5 tutorial', async ({ page }) => { 80 + const r = await runTutorial(page, 'astring.0.8.5'); 81 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 82 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 83 + }); 84 + 85 + test('jsonm.1.0.2 tutorial', async ({ page }) => { 86 + const r = await runTutorial(page, 'jsonm.1.0.2'); 87 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 88 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 89 + }); 90 + 91 + test('ptime.1.2.0 tutorial', async ({ page }) => { 92 + const r = await runTutorial(page, 'ptime.1.2.0'); 93 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 94 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 95 + }); 96 + 97 + test('hmap.0.8.1 tutorial', async ({ page }) => { 98 + const r = await runTutorial(page, 'hmap.0.8.1'); 99 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 100 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 101 + }); 102 + 103 + test('react.1.2.2 tutorial', async ({ page }) => { 104 + const r = await runTutorial(page, 'react.1.2.2'); 105 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 106 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 107 + }); 108 + 109 + test('gg.1.0.0 tutorial', async ({ page }) => { 110 + const r = await runTutorial(page, 'gg.1.0.0'); 111 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 112 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 113 + }); 114 + 115 + test('fpath.0.7.3 tutorial', async ({ page }) => { 116 + const r = await runTutorial(page, 'fpath.0.7.3'); 117 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 118 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 119 + }); 120 + 121 + test('uutf.1.0.4 tutorial', async ({ page }) => { 122 + const r = await runTutorial(page, 'uutf.1.0.4'); 123 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 124 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 125 + }); 126 + 127 + test('bos.0.2.1 tutorial', async ({ page }) => { 128 + const r = await runTutorial(page, 'bos.0.2.1'); 129 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 130 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 131 + }); 132 + 133 + test('uucp.14.0.0 tutorial', async ({ page }) => { 134 + const r = await runTutorial(page, 'uucp.14.0.0'); 135 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 136 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 137 + }); 138 + 139 + test('note.0.0.3 tutorial', async ({ page }) => { 140 + const r = await runTutorial(page, 'note.0.0.3'); 141 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 142 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 143 + }); 144 + 145 + test('vg.0.9.5 tutorial', async ({ page }) => { 146 + const r = await runTutorial(page, 'vg.0.9.5'); 147 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 148 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 149 + }); 150 + 151 + test('b0.0.0.6 tutorial', async ({ page }) => { 152 + const r = await runTutorial(page, 'b0.0.0.6'); 153 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 154 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 155 + }); 156 + 157 + test('logs.0.10.0 tutorial', async ({ page }) => { 158 + const r = await runTutorial(page, 'logs.0.10.0'); 159 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 160 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 161 + }); 162 + 163 + test('xmlm.1.4.0 tutorial', async ({ page }) => { 164 + const r = await runTutorial(page, 'xmlm.1.4.0'); 165 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 166 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 167 + }); 168 + 169 + // ── Remaining versions ──────────────────────────────────────────────── 170 + 171 + test('fmt.0.9.0 tutorial', async ({ page }) => { 172 + const r = await runTutorial(page, 'fmt.0.9.0'); 173 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 174 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 175 + }); 176 + 177 + test('fmt.0.10.0 tutorial', async ({ page }) => { 178 + const r = await runTutorial(page, 'fmt.0.10.0'); 179 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 180 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 181 + }); 182 + 183 + test('cmdliner.1.3.0 tutorial', async ({ page }) => { 184 + const r = await runTutorial(page, 'cmdliner.1.3.0'); 185 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 186 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 187 + }); 188 + 189 + test('cmdliner.2.0.0 tutorial', async ({ page }) => { 190 + const r = await runTutorial(page, 'cmdliner.2.0.0'); 191 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 192 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 193 + }); 194 + 195 + test('mtime.1.4.0 tutorial', async ({ page }) => { 196 + const r = await runTutorial(page, 'mtime.1.4.0'); 197 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 198 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 199 + }); 200 + 201 + test.skip('logs.0.7.0 tutorial — broken universe (inconsistent assumptions)', async ({ page }) => { 202 + const r = await runTutorial(page, 'logs.0.7.0'); 203 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 204 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 205 + }); 206 + 207 + test('uucp.15.0.0 tutorial', async ({ page }) => { 208 + const r = await runTutorial(page, 'uucp.15.0.0'); 209 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 210 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 211 + }); 212 + 213 + test('uucp.16.0.0 tutorial', async ({ page }) => { 214 + const r = await runTutorial(page, 'uucp.16.0.0'); 215 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 216 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 217 + }); 218 + 219 + test('uucp.17.0.0 tutorial', async ({ page }) => { 220 + const r = await runTutorial(page, 'uucp.17.0.0'); 221 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 222 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 223 + }); 224 + 225 + test('uunf.14.0.0 tutorial', async ({ page }) => { 226 + const r = await runTutorial(page, 'uunf.14.0.0'); 227 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 228 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 229 + }); 230 + 231 + test('uunf.17.0.0 tutorial', async ({ page }) => { 232 + const r = await runTutorial(page, 'uunf.17.0.0'); 233 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 234 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 235 + }); 236 + 237 + test('otfm.0.4.0 tutorial', async ({ page }) => { 238 + const r = await runTutorial(page, 'otfm.0.4.0'); 239 + if (r.failures.length > 0) console.log('Failures:', JSON.stringify(r.failures, null, 2)); 240 + expect(r.failed, `${r.failed} failures: ${JSON.stringify(r.failures)}`).toBe(0); 241 + });