this repo has no description
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 — 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': 'd8140118651d08430f933d410a909e3b',
95 'fmt.0.11.0': '2fc1d989047b6476399007c9b8b69af9',
96 'cmdliner.1.0.4': '0dd34259dc0892e543b03b3afb0a77fa',
97 'cmdliner.1.3.0': '258e7979b874502ea546e90a0742184a',
98 'cmdliner.2.0.0': '91c3d96cea9b89ddd24cf7b78786a5ca',
99 'cmdliner.2.1.0': 'bfc34a228f53ac5ced707eed285a6e5c',
100 'mtime.1.3.0': 'b6735658fd307bba23a7c5f21519b910',
101 'mtime.1.4.0': 'ebccfc43716c6da0ca4a065e60d0f875',
102 'mtime.2.1.0': '7db699c334606d6f66e65c8b515d298d',
103 'logs.0.7.0': '2c014cfbbee1d278b162002eae03eaa8',
104 'logs.0.10.0': '07a565e7588ce100ffd7c8eb8b52df07',
105 'uucp.14.0.0': '60e1409eb30c0650c4d4cbcf3c453e65',
106 'uucp.15.0.0': '6a96a3f145249f110bf14739c78e758c',
107 'uucp.16.0.0': '2bf0fbf12aa05c8f99989a759d2dc8cf',
108 'uucp.17.0.0': '58b9c48e9528ce99586b138d8f4778c2',
109 'uunf.14.0.0': 'cac36534f1bf353fd2192efd015dd0e6',
110 'uunf.17.0.0': '96704cd9810ea1ed504e4ed71cde82b0',
111 'astring.0.8.5': '1cdbe76f0ec91a6eb12bd0279a394492',
112 'jsonm.1.0.2': 'ac28e00ecd46c9464f5575c461b5d48f',
113 'xmlm.1.4.0': 'c4c22d0db3ea01343c1a868bab35e1b4',
114 'ptime.1.2.0': 'd57c69f3dd88b91454622c1841971354',
115 'react.1.2.2': 'f438ba61693a5448718c73116b228f3c',
116 'hmap.0.8.1': '753d7c421afb866e7ffe07ddea3b8349',
117 'gg.1.0.0': '02a9bababc92d6639cdbaf20233597ba',
118 'note.0.0.3': '2545f914c274aa806d29749eb96836fa',
119 'otfm.0.4.0': '4f870a70ee71e41dff878af7123b2cd6',
120 'vg.0.9.5': '0e2e71cfd8fe2e81bff124849421f662',
121 'bos.0.2.1': '2fc1d989047b6476399007c9b8b69af9',
122 'fpath.0.7.3': '2fc1d989047b6476399007c9b8b69af9',
123 'uutf.1.0.4': '4f870a70ee71e41dff878af7123b2cd6',
124 'b0.0.0.6': 'bfc34a228f53ac5ced707eed285a6e5c',
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,'&').replace(/</g,'<').replace(/>/g,'>');
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 ? ` — <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 indexUrl = `/jtw-output/u/${universe}/findlib_index.json`;
488 const { worker: w, stdlib_dcs, findlib_index } = await OcamlWorker.fromIndex(
489 indexUrl, '/jtw-output', { timeout: 120000 });
490 await w.init({
491 findlib_requires: [],
492 stdlib_dcs: stdlib_dcs,
493 findlib_index: findlib_index,
494 });
495 workerCache.set(universe, w);
496 return w;
497 }
498
499 async function runTest(t, idx) {
500 const row = testEls[idx];
501 setTestState(row, 'running');
502 running++;
503 updateSummary();
504 const start = performance.now();
505
506 try {
507 const worker = await getWorker(t.universe);
508
509 for (const pkg of (t.require || [])) {
510 await worker.eval(`#require "${pkg}";;`);
511 }
512
513 const transcript = [];
514 for (const step of t.steps) {
515 if (step.complete) {
516 const result = await worker.complete(step.complete.source, step.complete.pos);
517 const entries = result.completions?.entries?.map(e => e.name) || [];
518 if (step.expectEntries) {
519 for (const exp of step.expectEntries) {
520 if (!entries.includes(exp))
521 throw new Error(`Expected completion "${exp}" not found in [${entries.join(', ')}]`);
522 }
523 }
524 transcript.push({ code: step.complete.source, output: `completions: [${entries.slice(0, 5).join(', ')}...]`, outputClass: 'out-pass' });
525
526 } else if (step.expectError) {
527 // NEGATIVE TEST: we expect this to produce an error
528 const r = await worker.eval(step.code);
529 const ppf = r.caml_ppf || '';
530 const stderr = r.stderr || '';
531 const combined = ppf + stderr;
532 if (combined.includes(step.expectError)) {
533 transcript.push({ code: step.code, output: combined.trim(), outputClass: 'out-neg' });
534 } else if (combined === '' && ppf === '') {
535 transcript.push({ code: step.code, output: '(empty output — error swallowed)', outputClass: 'out-neg' });
536 } else {
537 transcript.push({ code: step.code, output: `Expected error "${step.expectError}" but got:\n${ppf}${stderr}`, outputClass: 'out-fail' });
538 throw new Error(`Expected error containing "${step.expectError}" but got success:\nppf: "${ppf}"\nstderr: "${stderr}"`);
539 }
540
541 } else {
542 const r = await worker.eval(step.code);
543 const ppf = r.caml_ppf || '';
544 const stdout = r.stdout || '';
545 const stderr = r.stderr || '';
546
547 if (step.expect && step.expect !== '') {
548 if (!ppf.includes(step.expect)) {
549 transcript.push({ code: step.code, output: ppf || stderr || '(no output)', outputClass: 'out-fail' });
550 throw new Error(`Expected caml_ppf to contain "${step.expect}"\nGot: "${ppf}"\nstderr: "${stderr}"`);
551 }
552 }
553 if (step.expectStdout) {
554 if (!stdout.includes(step.expectStdout)) {
555 transcript.push({ code: step.code, output: `stdout: "${stdout}"`, outputClass: 'out-fail' });
556 throw new Error(`Expected stdout to contain "${step.expectStdout}"\nGot: "${stdout}"`);
557 }
558 }
559 if (step.expectNotError) {
560 if ((ppf + stderr).includes(step.expectNotError)) {
561 transcript.push({ code: step.code, output: ppf + stderr, outputClass: 'out-fail' });
562 throw new Error(`Output contains unexpected "${step.expectNotError}"\nppf: "${ppf}"\nstderr: "${stderr}"`);
563 }
564 }
565 // Build output line: ppf first, then stdout if present
566 let out = ppf ? ppf.trim() : '';
567 if (stdout) out += (out ? '\n' : '') + '(stdout) ' + stdout.trim();
568 transcript.push({ code: step.code, output: out || '(unit)', outputClass: 'out-pass' });
569 }
570 }
571
572 const elapsed = Math.round(performance.now() - start);
573 running--;
574 passed++;
575
576 // Use pass-neg state for negative tests that correctly errored
577 const isNegPass = t.crossVersion && !t.shouldPass;
578 setTestState(row, isNegPass ? 'pass-neg' : 'pass', transcript, elapsed);
579
580 } catch (e) {
581 const elapsed = Math.round(performance.now() - start);
582 running--;
583 failed++;
584 // If transcript has partial results from before the error, show them
585 if (transcript.length === 0) {
586 transcript.push({ code: '', output: e.message, outputClass: 'out-fail' });
587 }
588 setTestState(row, 'fail', transcript, elapsed);
589 }
590 updateSummary();
591 }
592
593 // Run tests: parallel across universes, sequential within each universe
594 async function runAll() {
595 const byUniverse = new Map();
596 flatTests.forEach((t, i) => {
597 if (!byUniverse.has(t.universe)) byUniverse.set(t.universe, []);
598 byUniverse.get(t.universe).push({ test: t, idx: i });
599 });
600
601 const promises = [];
602 for (const [, items] of byUniverse) {
603 promises.push((async () => {
604 for (const { test, idx } of items) {
605 await runTest(test, idx);
606 }
607 })());
608 }
609 await Promise.all(promises);
610 }
611
612 updateSummary();
613 runAll();
614 </script>
615</body>
616</html>