this repo has no description

js_top_worker: add Playwright integration tests for preloaded package detection

Tests the full end-to-end flow of runtime preloaded package detection:
- Preloaded package (yojson): succeeds without fetching .cma.js
- Normal package (stringext): fetches and loads from universe
- CRC mismatch (crc_conflict): raises Crc_mismatch with bogus CRC

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

+542
+1
test/integration/.gitignore
··· 1 + node_modules/
+2
test/integration/crc_conflict/META
··· 1 + description = "Fake package for CRC mismatch test" 2 + version = "0.1"
+6
test/integration/crc_conflict/dynamic_cmis.json
··· 1 + { 2 + "dcs_url": "crc_conflict", 3 + "dcs_toplevel_modules": ["Yojson"], 4 + "dcs_file_prefixes": [], 5 + "dcs_module_crcs": {"Yojson": "00000000000000000000000000000000"} 6 + }
+45
test/integration/dune
··· 1 + ; Integration tests for preloaded package detection 2 + ; Run with: dune build @runintegration 3 + 4 + (executable 5 + (name integ_worker) 6 + (modes byte js) 7 + (modules integ_worker) 8 + (link_flags (-linkall)) 9 + (preprocess (pps js_of_ocaml-ppx)) 10 + (js_of_ocaml 11 + (javascript_files ../../lib/stubs.js) 12 + (flags --effects=disabled --toplevel +toplevel.js +dynlink.js)) 13 + (libraries js_top_worker-web)) 14 + 15 + ; Generate universe with yojson (preloaded) and stringext (not preloaded) 16 + (rule 17 + (targets (dir _universe)) 18 + (action 19 + (run jtw opam -o _universe yojson stringext))) 20 + 21 + ; Build alias - compiles everything needed for integration tests 22 + (alias 23 + (name integration) 24 + (deps 25 + integ_worker.bc.js 26 + integ_test.html 27 + test_findlib_index.json 28 + run_integ.js 29 + _universe 30 + (source_tree crc_conflict))) 31 + 32 + ; Run alias - executes the Playwright tests 33 + (rule 34 + (alias runintegration) 35 + (deps 36 + integ_worker.bc.js 37 + integ_test.html 38 + test_findlib_index.json 39 + run_integ.js 40 + _universe 41 + (source_tree crc_conflict)) 42 + (action 43 + (progn 44 + (echo "Running integration tests with Playwright...\n") 45 + (run node run_integ.js))))
+242
test/integration/integ_test.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>Preloaded Package Integration Test</title> 5 + <style> 6 + body { font-family: monospace; padding: 20px; } 7 + #status { margin-bottom: 20px; } 8 + .pass { color: green; } 9 + .fail { color: red; } 10 + </style> 11 + </head> 12 + <body> 13 + <h1>Preloaded Package Integration Test</h1> 14 + <div id="status">Running tests...</div> 15 + <pre id="log"></pre> 16 + 17 + <script> 18 + // Capture console.log for display 19 + const logEl = document.getElementById('log'); 20 + const originalLog = console.log; 21 + console.log = function(...args) { 22 + originalLog.apply(console, args); 23 + const text = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); 24 + logEl.textContent += text + '\n'; 25 + }; 26 + </script> 27 + <script> 28 + (async function() { 29 + const results = { total: 3, passed: 0, failed: 0, done: false }; 30 + window.testResults = results; 31 + 32 + // Helper: send JSON message to worker 33 + function send(worker, msg) { 34 + worker.postMessage(JSON.stringify(msg)); 35 + } 36 + 37 + // Helper: wait for a specific message type from worker 38 + function waitFor(worker, predicate, timeoutMs) { 39 + timeoutMs = timeoutMs || 30000; 40 + return new Promise(function(resolve, reject) { 41 + var timer = setTimeout(function() { 42 + reject(new Error('Timeout waiting for message')); 43 + }, timeoutMs); 44 + function handler(ev) { 45 + var msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; 46 + if (predicate(msg)) { 47 + clearTimeout(timer); 48 + worker.removeEventListener('message', handler); 49 + resolve(msg); 50 + } 51 + } 52 + worker.addEventListener('message', handler); 53 + }); 54 + } 55 + 56 + // Helper: evaluate code and wait for output or eval_error 57 + function evalCode(worker, cellId, code) { 58 + return new Promise(function(resolve, reject) { 59 + var accumulated = ''; 60 + var timer = setTimeout(function() { 61 + reject(new Error('Timeout evaluating: ' + code)); 62 + }, 30000); 63 + 64 + function handler(ev) { 65 + var msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; 66 + if (msg.type === 'output_at' && msg.cell_id === cellId) { 67 + accumulated += (msg.caml_ppf || ''); 68 + } else if (msg.type === 'output' && msg.cell_id === cellId) { 69 + clearTimeout(timer); 70 + worker.removeEventListener('message', handler); 71 + // Merge accumulated output 72 + if (accumulated && (!msg.caml_ppf || msg.caml_ppf === '')) { 73 + msg.caml_ppf = accumulated; 74 + } 75 + resolve(msg); 76 + } else if (msg.type === 'eval_error' && msg.cell_id === cellId) { 77 + clearTimeout(timer); 78 + worker.removeEventListener('message', handler); 79 + resolve(msg); 80 + } 81 + } 82 + worker.addEventListener('message', handler); 83 + send(worker, { type: 'eval', cell_id: cellId, env_id: 'default', code: code }); 84 + }); 85 + } 86 + 87 + try { 88 + // Create worker via blob URL (same pattern as ocaml-worker.js) 89 + var baseUrl = new URL('integ_worker.bc.js', window.location.href).href; 90 + var baseDir = baseUrl.replace(/\/integ_worker\.bc\.js$/, ''); 91 + var blobContent = 'globalThis.__global_rel_url="' + baseDir + '";\nimportScripts("' + baseUrl + '");'; 92 + var blobUrl = URL.createObjectURL(new Blob([blobContent], { type: 'text/javascript' })); 93 + var worker = new Worker(blobUrl); 94 + 95 + worker.onerror = function(e) { 96 + console.log('WORKER ERROR: ' + e.message); 97 + }; 98 + 99 + // Listen for all messages for debugging 100 + worker.addEventListener('message', function(ev) { 101 + var msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; 102 + console.log('worker msg: ' + msg.type + (msg.cell_id !== undefined ? ' cell=' + msg.cell_id : '')); 103 + }); 104 + 105 + // Send init 106 + console.log('Sending init...'); 107 + var readyPromise = waitFor(worker, function(msg) { 108 + return msg.type === 'ready' || msg.type === 'init_error'; 109 + }, 60000); 110 + 111 + send(worker, { 112 + type: 'init', 113 + findlib_requires: [], 114 + findlib_index: 'test_findlib_index.json', 115 + stdlib_dcs: '_universe/lib/ocaml/dynamic_cmis.json' 116 + }); 117 + 118 + var initMsg = await readyPromise; 119 + if (initMsg.type === 'init_error') { 120 + throw new Error('Init failed: ' + initMsg.message); 121 + } 122 + console.log('Worker ready!'); 123 + 124 + // ========================================================= 125 + // Test 1: Preloaded package (yojson) 126 + // yojson is linked into the worker binary via js_top_worker-web. 127 + // #require "yojson" should succeed WITHOUT fetching yojson.cma.js. 128 + // ========================================================= 129 + console.log('\n--- Test 1: Preloaded package (yojson) ---'); 130 + var r1 = await evalCode(worker, 1, '#require "yojson";; Yojson.Safe.from_string "[1,2,3]";;'); 131 + console.log('Result type: ' + r1.type); 132 + console.log('stdout: ' + (r1.stdout || '')); 133 + console.log('stderr: ' + (r1.stderr || '')); 134 + console.log('caml_ppf: ' + (r1.caml_ppf || '')); 135 + if (r1.type === 'output') { 136 + console.log('TEST 1 PASSED: yojson loaded as preloaded package'); 137 + results.passed++; 138 + } else { 139 + console.log('TEST 1 FAILED: expected output, got ' + r1.type + ': ' + (r1.message || '')); 140 + results.failed++; 141 + } 142 + 143 + // ========================================================= 144 + // Test 2: Normal package (stringext) 145 + // stringext is NOT in the worker binary. 146 + // #require "stringext" should fetch and load stringext.cma.js. 147 + // ========================================================= 148 + console.log('\n--- Test 2: Normal package (stringext) ---'); 149 + var r2 = await evalCode(worker, 2, '#require "stringext";; Stringext.string_after "hello world" 6;;'); 150 + console.log('Result type: ' + r2.type); 151 + console.log('stdout: ' + (r2.stdout || '')); 152 + console.log('stderr: ' + (r2.stderr || '')); 153 + console.log('caml_ppf: ' + (r2.caml_ppf || '')); 154 + if (r2.type === 'output') { 155 + console.log('TEST 2 PASSED: stringext loaded from universe'); 156 + results.passed++; 157 + } else { 158 + console.log('TEST 2 FAILED: expected output, got ' + r2.type + ': ' + (r2.message || '')); 159 + results.failed++; 160 + } 161 + 162 + // ========================================================= 163 + // Test 3: CRC mismatch (crc_conflict) 164 + // crc_conflict claims module Yojson with a bogus CRC. 165 + // Since Yojson IS in the binary but CRCs differ, 166 + // is_package_preloaded should raise Crc_mismatch. 167 + // 168 + // The exception propagates to Lwt.async's unhandled handler 169 + // (logged to console) rather than returning as eval_error. 170 + // We detect this by: (a) the #require produces no response, 171 + // and (b) a subsequent eval confirms the worker is still alive. 172 + // ========================================================= 173 + console.log('\n--- Test 3: CRC mismatch (crc_conflict) ---'); 174 + 175 + // Race: wait for eval response OR 5s timeout 176 + var test3result = await Promise.race([ 177 + evalCode(worker, 3, '#require "crc_conflict";;').then(function(r) { return { kind: 'response', data: r }; }), 178 + new Promise(function(resolve) { setTimeout(function() { resolve({ kind: 'timeout' }); }, 5000); }) 179 + ]); 180 + 181 + var test3passed = false; 182 + if (test3result.kind === 'response') { 183 + var r3 = test3result.data; 184 + console.log('Got response type: ' + r3.type); 185 + // If we got an eval_error with Crc_mismatch, that's a pass 186 + if (r3.type === 'eval_error' && r3.message && r3.message.indexOf('Crc_mismatch') >= 0) { 187 + test3passed = true; 188 + } 189 + // If output contains the error, that's also a pass 190 + if (r3.type === 'output') { 191 + var allOutput = (r3.stderr || '') + (r3.caml_ppf || '') + (r3.stdout || ''); 192 + if (allOutput.indexOf('Crc_mismatch') >= 0 || allOutput.indexOf('CRC mismatch') >= 0) { 193 + test3passed = true; 194 + } 195 + } 196 + } else { 197 + // Timeout: exception escaped to Lwt.async (expected behavior). 198 + // The Crc_mismatch exception was logged to the worker's console 199 + // (verified by Playwright in run_integ.js). From the page, we verify 200 + // the worker is still alive — proving the error was non-fatal. 201 + console.log('No response (exception escaped to Lwt.async). Probing worker...'); 202 + try { 203 + var probe = await evalCode(worker, 99, '1 + 1;;'); 204 + if (probe.type === 'output') { 205 + console.log('Worker still alive after Crc_mismatch (probe returned: ' + (probe.caml_ppf || '') + ')'); 206 + test3passed = true; 207 + } 208 + } catch (probeErr) { 209 + console.log('Worker probe failed: ' + probeErr.message); 210 + } 211 + } 212 + 213 + if (test3passed) { 214 + console.log('TEST 3 PASSED: CRC mismatch correctly detected'); 215 + results.passed++; 216 + } else { 217 + console.log('TEST 3 FAILED: expected Crc_mismatch error'); 218 + results.failed++; 219 + } 220 + 221 + } catch(e) { 222 + console.log('FATAL ERROR: ' + e.message); 223 + console.log(e.stack); 224 + results.failed = results.total - results.passed; 225 + } 226 + 227 + // Report final results 228 + results.done = true; 229 + console.log('\n========================================'); 230 + console.log('Results: ' + results.passed + '/' + results.total + ' passed'); 231 + console.log('========================================'); 232 + 233 + var status = document.getElementById('status'); 234 + if (results.failed === 0) { 235 + status.innerHTML = '<span class="pass">All ' + results.passed + ' tests passed!</span>'; 236 + } else { 237 + status.innerHTML = '<span class="fail">' + results.failed + ' tests failed</span> (' + results.passed + ' passed)'; 238 + } 239 + })(); 240 + </script> 241 + </body> 242 + </html>
+1
test/integration/integ_worker.ml
··· 1 + let _ = Js_top_worker_web.Worker.run ()
+62
test/integration/package-lock.json
··· 1 + { 2 + "name": "js_top_worker_integration_tests", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "js_top_worker_integration_tests", 9 + "version": "1.0.0", 10 + "devDependencies": { 11 + "playwright": "^1.40.0" 12 + } 13 + }, 14 + "node_modules/fsevents": { 15 + "version": "2.3.2", 16 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 17 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 18 + "dev": true, 19 + "hasInstallScript": true, 20 + "license": "MIT", 21 + "optional": true, 22 + "os": [ 23 + "darwin" 24 + ], 25 + "engines": { 26 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 27 + } 28 + }, 29 + "node_modules/playwright": { 30 + "version": "1.58.2", 31 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", 32 + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", 33 + "dev": true, 34 + "license": "Apache-2.0", 35 + "dependencies": { 36 + "playwright-core": "1.58.2" 37 + }, 38 + "bin": { 39 + "playwright": "cli.js" 40 + }, 41 + "engines": { 42 + "node": ">=18" 43 + }, 44 + "optionalDependencies": { 45 + "fsevents": "2.3.2" 46 + } 47 + }, 48 + "node_modules/playwright-core": { 49 + "version": "1.58.2", 50 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", 51 + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", 52 + "dev": true, 53 + "license": "Apache-2.0", 54 + "bin": { 55 + "playwright-core": "cli.js" 56 + }, 57 + "engines": { 58 + "node": ">=18" 59 + } 60 + } 61 + } 62 + }
+13
test/integration/package.json
··· 1 + { 2 + "name": "js_top_worker_integration_tests", 3 + "version": "1.0.0", 4 + "description": "Integration tests for preloaded package detection", 5 + "private": true, 6 + "scripts": { 7 + "test": "node run_integ.js", 8 + "test:headed": "node run_integ.js --headed" 9 + }, 10 + "devDependencies": { 11 + "playwright": "^1.40.0" 12 + } 13 + }
+166
test/integration/run_integ.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Playwright integration test runner for preloaded package detection. 4 + * 5 + * Usage: 6 + * node run_integ.js [--headed] 7 + * 8 + * Starts an HTTP server, runs tests in a browser, reports results. 9 + */ 10 + 11 + const http = require('http'); 12 + const fs = require('fs'); 13 + const path = require('path'); 14 + const { createRequire } = require('module'); 15 + 16 + const PORT = 8766; 17 + const TIMEOUT = 60000; // 60 seconds max test time 18 + 19 + // When run by dune, __filename is in _build/default/js_top_worker/test/integration/. 20 + // We serve files from that build directory. For node_modules (playwright), we 21 + // compute the source directory by stripping _build/default/ from the path. 22 + const buildDir = path.dirname(__filename); 23 + 24 + // Compute source directory: replace _build/default/ segment with empty 25 + // e.g. /path/_build/default/js_top_worker/test/integration -> /path/js_top_worker/test/integration 26 + function computeSourceDir(dir) { 27 + const marker = path.sep + '_build' + path.sep + 'default' + path.sep; 28 + const idx = dir.indexOf(marker); 29 + if (idx >= 0) { 30 + return dir.substring(0, idx) + path.sep + dir.substring(idx + marker.length); 31 + } 32 + return dir; 33 + } 34 + 35 + const sourceDir = computeSourceDir(buildDir); 36 + 37 + // Load playwright from source directory's node_modules 38 + const sourceRequire = createRequire(path.join(sourceDir, 'package.json')); 39 + const { chromium } = sourceRequire('playwright'); 40 + 41 + // MIME types for serving files 42 + const mimeTypes = { 43 + '.html': 'text/html', 44 + '.js': 'application/javascript', 45 + '.css': 'text/css', 46 + '.json': 'application/json', 47 + }; 48 + 49 + function startServer() { 50 + return new Promise((resolve, reject) => { 51 + const server = http.createServer((req, res) => { 52 + let filePath = req.url === '/' ? '/integ_test.html' : req.url; 53 + 54 + // Strip query string 55 + filePath = filePath.split('?')[0]; 56 + 57 + // Try build directory first, then source directory 58 + let fullPath = path.join(buildDir, filePath); 59 + if (!fs.existsSync(fullPath)) { 60 + fullPath = path.join(sourceDir, filePath); 61 + } 62 + 63 + if (!fs.existsSync(fullPath)) { 64 + console.log(`404: ${filePath}`); 65 + res.writeHead(404); 66 + res.end('Not found: ' + filePath); 67 + return; 68 + } 69 + 70 + const ext = path.extname(fullPath); 71 + const contentType = mimeTypes[ext] || 'application/octet-stream'; 72 + 73 + fs.readFile(fullPath, (err, content) => { 74 + if (err) { 75 + res.writeHead(500); 76 + res.end('Error reading file'); 77 + return; 78 + } 79 + res.writeHead(200, { 'Content-Type': contentType }); 80 + res.end(content); 81 + }); 82 + }); 83 + 84 + server.listen(PORT, () => { 85 + console.log(`Integration test server running at http://localhost:${PORT}/`); 86 + resolve(server); 87 + }); 88 + 89 + server.on('error', reject); 90 + }); 91 + } 92 + 93 + async function runTests(headed = false) { 94 + let server; 95 + let browser; 96 + let exitCode = 0; 97 + 98 + try { 99 + // Start the HTTP server 100 + server = await startServer(); 101 + 102 + // Launch browser 103 + browser = await chromium.launch({ headless: !headed }); 104 + const page = await browser.newPage(); 105 + 106 + // Collect console messages 107 + const logs = []; 108 + page.on('console', msg => { 109 + const text = msg.text(); 110 + logs.push(text); 111 + console.log(`[browser] ${text}`); 112 + }); 113 + 114 + // Navigate to test page 115 + console.log('Loading integration test page...'); 116 + await page.goto(`http://localhost:${PORT}/`); 117 + 118 + // Wait for tests to complete 119 + console.log('Waiting for tests to complete...'); 120 + await page.waitForFunction( 121 + () => window.testResults && window.testResults.done, 122 + { timeout: TIMEOUT } 123 + ); 124 + 125 + // Get final results 126 + const testResults = await page.evaluate(() => ({ 127 + total: window.testResults.total, 128 + passed: window.testResults.passed, 129 + failed: window.testResults.failed, 130 + })); 131 + 132 + console.log('\n========================================'); 133 + console.log(`Integration Test Results: ${testResults.passed}/${testResults.total} passed`); 134 + console.log('========================================\n'); 135 + 136 + // Additional check: verify Crc_mismatch appeared in browser console 137 + // (worker console.log is captured by Playwright but not by the page's JS) 138 + const hasCrcMismatch = logs.some(line => line.includes('Crc_mismatch')); 139 + if (!hasCrcMismatch) { 140 + console.log('WARNING: Crc_mismatch not found in console logs'); 141 + } 142 + 143 + if (testResults.failed > 0) { 144 + console.log('FAILED: Some integration tests did not pass'); 145 + exitCode = 1; 146 + } else if (!hasCrcMismatch) { 147 + console.log('FAILED: Crc_mismatch exception was not raised'); 148 + exitCode = 1; 149 + } else { 150 + console.log('SUCCESS: All integration tests passed'); 151 + } 152 + 153 + } catch (err) { 154 + console.error('Error running integration tests:', err.message); 155 + exitCode = 1; 156 + } finally { 157 + if (browser) await browser.close(); 158 + if (server) server.close(); 159 + } 160 + 161 + process.exit(exitCode); 162 + } 163 + 164 + // Parse command line args 165 + const headed = process.argv.includes('--headed'); 166 + runTests(headed);
+4
test/integration/test_findlib_index.json
··· 1 + { 2 + "meta_files": ["crc_conflict/META"], 3 + "universes": ["./_universe"] 4 + }