eyeling 1.10.13 → 1.10.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDBOOK.md CHANGED
@@ -1831,3 +1831,21 @@ If you depend on Eyeling as a library, the package exposes:
1831
1831
 
1832
1832
  See:
1833
1833
  - [Chapter 14 — Entry points: CLI, bundle exports, and npm API](#ch14)
1834
+
1835
+ ### A.9 Further reading
1836
+ If you want to go deeper into N3 itself and the logic/programming ideas behind Eyeling, these are good starting points:
1837
+
1838
+ N3 / Semantic Web specs and reports:
1839
+ - https://w3c.github.io/N3/spec/
1840
+ - https://w3c.github.io/N3/reports/20230703/semantics.html
1841
+ - https://w3c.github.io/N3/reports/20230703/builtins.html
1842
+
1843
+ Logic & reasoning background (Wikipedia):
1844
+ - https://en.wikipedia.org/wiki/Mathematical_logic
1845
+ - https://en.wikipedia.org/wiki/Automated_reasoning
1846
+ - https://en.wikipedia.org/wiki/Forward_chaining
1847
+ - https://en.wikipedia.org/wiki/Backward_chaining
1848
+ - https://en.wikipedia.org/wiki/Unification_%28computer_science%29
1849
+ - https://en.wikipedia.org/wiki/Prolog
1850
+ - https://en.wikipedia.org/wiki/Datalog
1851
+ - https://en.wikipedia.org/wiki/Skolem_normal_form
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.10.13",
3
+ "version": "1.10.15",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -41,8 +41,10 @@
41
41
  "test:n3gen": "node test/n3gen.test.js",
42
42
  "test:examples": "node test/examples.test.js",
43
43
  "test:manifest": "node test/manifest.test.js",
44
+ "test:playground": "node test/playground.test.js",
44
45
  "test:package": "node test/package.test.js",
45
- "test": "npm run build && npm run test:packlist && npm run test:api && npm run test:n3gen && npm run test:examples",
46
+ "test:all": "npm run test:packlist && npm run test:api && npm run test:n3gen && npm run test:examples && npm run test:manifest && npm run test:playground && npm run test:package",
47
+ "test": "npm run build && npm run test:all",
46
48
  "preversion": "npm test",
47
49
  "postversion": "git push --follow-tags"
48
50
  }
@@ -0,0 +1,555 @@
1
+ 'use strict';
2
+
3
+ // Smoke-test the browser playground (demo.html).
4
+ //
5
+ // Goal: ensure demo.html loads without runtime exceptions and that the default
6
+ // Socrates program can be executed to completion ("Done") with non-empty output.
7
+ //
8
+ // This test is dependency-free: it drives Chromium directly via the Chrome
9
+ // DevTools Protocol (CDP) over WebSocket.
10
+
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const http = require('node:http');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const { spawn } = require('node:child_process');
17
+ const { setTimeout: sleep } = require('node:timers/promises');
18
+
19
+ const ROOT = path.resolve(__dirname, '..');
20
+
21
+ const TTY = process.stdout.isTTY;
22
+ const C = TTY
23
+ ? { g: '\x1b[32m', r: '\x1b[31m', y: '\x1b[33m', dim: '\x1b[2m', n: '\x1b[0m' }
24
+ : { g: '', r: '', y: '', dim: '', n: '' };
25
+ function ok(msg) {
26
+ console.log(`${C.g}OK${C.n} ${msg}`);
27
+ }
28
+ function info(msg) {
29
+ console.log(`${C.y}==${C.n} ${msg}`);
30
+ }
31
+ function fail(msg) {
32
+ console.error(`${C.r}FAIL${C.n} ${msg}`);
33
+ }
34
+
35
+ function guessContentType(p) {
36
+ const ext = path.extname(p).toLowerCase();
37
+ if (ext === '.html') return 'text/html; charset=utf-8';
38
+ if (ext === '.js') return 'application/javascript; charset=utf-8';
39
+ if (ext === '.css') return 'text/css; charset=utf-8';
40
+ if (ext === '.json') return 'application/json; charset=utf-8';
41
+ if (ext === '.ttl' || ext === '.n3') return 'text/plain; charset=utf-8';
42
+ if (ext === '.txt' || ext === '.md') return 'text/plain; charset=utf-8';
43
+ return 'application/octet-stream';
44
+ }
45
+
46
+ function startStaticServer(rootDir) {
47
+ const server = http.createServer((req, res) => {
48
+ try {
49
+ const url = new URL(req.url || '/', 'http://localhost');
50
+ let pathname = decodeURIComponent(url.pathname);
51
+
52
+ // Avoid noisy browser console errors.
53
+ if (pathname === '/favicon.ico') {
54
+ res.writeHead(204);
55
+ res.end();
56
+ return;
57
+ }
58
+
59
+ if (pathname === '/' || pathname === '') pathname = '/demo.html';
60
+ // Prevent directory traversal.
61
+ const fsPath = path.resolve(rootDir, '.' + pathname);
62
+ if (!fsPath.startsWith(rootDir)) {
63
+ res.writeHead(403);
64
+ res.end('Forbidden');
65
+ return;
66
+ }
67
+
68
+ const st = fs.statSync(fsPath);
69
+ if (st.isDirectory()) {
70
+ res.writeHead(301, { Location: pathname.replace(/\/$/, '') + '/demo.html' });
71
+ res.end();
72
+ return;
73
+ }
74
+
75
+ res.writeHead(200, { 'Content-Type': guessContentType(fsPath), 'Cache-Control': 'no-store' });
76
+ fs.createReadStream(fsPath).pipe(res);
77
+ } catch (e) {
78
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
79
+ res.end('Not found');
80
+ }
81
+ });
82
+
83
+ return new Promise((resolve, reject) => {
84
+ server.on('error', reject);
85
+ server.listen(0, '127.0.0.1', () => {
86
+ const addr = server.address();
87
+ resolve({
88
+ server,
89
+ port: addr.port,
90
+ baseUrl: `http://127.0.0.1:${addr.port}`,
91
+ });
92
+ });
93
+ });
94
+ }
95
+
96
+ function which(cmd) {
97
+ try {
98
+ // Avoid spawnSync (keeps this file in the same style as other tests: lightweight).
99
+ const paths = String(process.env.PATH || '').split(path.delimiter);
100
+ for (const p of paths) {
101
+ const fp = path.join(p, cmd);
102
+ if (fs.existsSync(fp)) return fp;
103
+ }
104
+ } catch (_) {}
105
+ return null;
106
+ }
107
+
108
+ function findChromium() {
109
+ // Allow overrides.
110
+ const env = process.env.EYELING_BROWSER || process.env.CHROME_BIN || process.env.PUPPETEER_EXECUTABLE_PATH;
111
+ if (env && fs.existsSync(env)) return env;
112
+
113
+ // Common binaries.
114
+ const candidates = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'chrome'];
115
+ for (const c of candidates) {
116
+ const p = which(c);
117
+ if (p) return p;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ // Minimal CodeMirror stub for the playground.
123
+ // The real demo loads CodeMirror from a CDN. In CI/offline tests we intercept
124
+ // those script requests and provide this stub to prevent runtime failures.
125
+ const CODEMIRROR_STUB = String.raw`(function(){
126
+ if (window.CodeMirror) return;
127
+
128
+ function posToIndex(text, line, ch){
129
+ line = Math.max(0, line|0);
130
+ ch = Math.max(0, ch|0);
131
+ const norm = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
132
+ const lines = norm.split('\n');
133
+ if (lines.length === 0) return 0;
134
+ if (line >= lines.length) line = lines.length - 1;
135
+ if (ch > lines[line].length) ch = lines[line].length;
136
+ let idx = 0;
137
+ for (let i = 0; i < line; i++) idx += lines[i].length + 1;
138
+ return idx + ch;
139
+ }
140
+
141
+ function mkWrapper(textarea){
142
+ var wrapper = document.createElement('div');
143
+ wrapper.className = 'CodeMirror';
144
+
145
+ var scroll = document.createElement('div');
146
+ scroll.className = 'CodeMirror-scroll';
147
+ scroll.style.overflow = 'auto';
148
+
149
+ var sizer = document.createElement('div');
150
+ sizer.className = 'CodeMirror-sizer';
151
+
152
+ var code = document.createElement('div');
153
+ code.className = 'CodeMirror-code';
154
+
155
+ var pre = document.createElement('pre');
156
+ pre.textContent = textarea.value || '';
157
+
158
+ code.appendChild(pre);
159
+ sizer.appendChild(code);
160
+ scroll.appendChild(sizer);
161
+ wrapper.appendChild(scroll);
162
+
163
+ return { wrapper: wrapper, scroll: scroll, pre: pre };
164
+ }
165
+
166
+ window.CodeMirror = {
167
+ fromTextArea: function(textarea/*, opts*/){
168
+ var obj = mkWrapper(textarea);
169
+ try {
170
+ textarea.style.display = 'none';
171
+ textarea.parentNode.insertBefore(obj.wrapper, textarea.nextSibling);
172
+ } catch(_) {}
173
+
174
+ function sync(){ obj.pre.textContent = textarea.value || ''; }
175
+ function getLines(){
176
+ return String(textarea.value || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
177
+ }
178
+
179
+ const doc = {
180
+ posFromIndex: function(i){
181
+ i = Math.max(0, i|0);
182
+ const lines = getLines();
183
+ let acc = 0;
184
+ for (let ln = 0; ln < lines.length; ln++){
185
+ const len = lines[ln].length;
186
+ if (i <= acc + len) return { line: ln, ch: i - acc };
187
+ acc += len + 1;
188
+ }
189
+ return { line: Math.max(0, lines.length - 1), ch: (lines[lines.length-1] || '').length };
190
+ }
191
+ };
192
+
193
+ return {
194
+ getValue: function(){ return textarea.value || ''; },
195
+ setValue: function(v){ textarea.value = String(v == null ? '' : v); sync(); },
196
+
197
+ // Methods used by demo.html's streaming appender
198
+ getScrollerElement: function(){ return obj.scroll; },
199
+ lastLine: function(){ const ls = getLines(); return Math.max(0, ls.length - 1); },
200
+ getLine: function(n){ const ls = getLines(); return ls[n] == null ? '' : ls[n]; },
201
+ replaceRange: function(text, pos){
202
+ const cur = String(textarea.value || '');
203
+ const idx = posToIndex(cur, pos && pos.line, pos && pos.ch);
204
+ textarea.value = cur.slice(0, idx) + String(text == null ? '' : text) + cur.slice(idx);
205
+ sync();
206
+ },
207
+
208
+ // Misc methods used by layout / resizing code
209
+ refresh: function(){},
210
+ setSize: function(){},
211
+ setOption: function(){},
212
+ on: function(){},
213
+ operation: function(fn){ try{ fn(); } catch(_){} },
214
+ getWrapperElement: function(){ return obj.wrapper; },
215
+ getScrollInfo: function(){ return { height: 0, clientHeight: 0, top: 0 }; },
216
+ defaultTextHeight: function(){ return 17; },
217
+
218
+ // Error highlighting hooks (no-op in stub)
219
+ addLineClass: function(){},
220
+ removeLineClass: function(){},
221
+ clearGutter: function(){},
222
+ setGutterMarker: function(){},
223
+
224
+ // Minimal doc access for error helpers (if ever invoked)
225
+ getDoc: function(){ return doc; },
226
+ doc: doc
227
+ };
228
+ }
229
+ };
230
+ })();`;
231
+
232
+ function b64(s) {
233
+ return Buffer.from(String(s), 'utf8').toString('base64');
234
+ }
235
+
236
+ class CDP {
237
+ constructor(ws) {
238
+ this.ws = ws;
239
+ this.nextId = 0;
240
+ this.pending = new Map();
241
+ this.handlers = new Map();
242
+ ws.onmessage = (ev) => {
243
+ const msg = JSON.parse(ev.data);
244
+ if (msg.id) {
245
+ const p = this.pending.get(msg.id);
246
+ if (!p) return;
247
+ this.pending.delete(msg.id);
248
+ if (msg.error) {
249
+ const e = new Error(msg.error.message || 'CDP error');
250
+ e.data = msg.error;
251
+ p.reject(e);
252
+ } else {
253
+ p.resolve(msg.result);
254
+ }
255
+ return;
256
+ }
257
+ const key = `${msg.sessionId || ''}:${msg.method}`;
258
+ const hs = this.handlers.get(key);
259
+ if (hs) {
260
+ for (const h of hs) {
261
+ try {
262
+ h(msg.params);
263
+ } catch (_) {}
264
+ }
265
+ }
266
+ };
267
+ }
268
+
269
+ send(method, params = {}, sessionId, timeoutMs = 15000) {
270
+ const id = ++this.nextId;
271
+ const payload = { id, method, params };
272
+ if (sessionId) payload.sessionId = sessionId;
273
+ this.ws.send(JSON.stringify(payload));
274
+ return new Promise((resolve, reject) => {
275
+ const t = setTimeout(() => {
276
+ this.pending.delete(id);
277
+ reject(new Error(`CDP timeout (${timeoutMs}ms): ${method}`));
278
+ }, timeoutMs);
279
+ this.pending.set(id, {
280
+ resolve: (v) => {
281
+ clearTimeout(t);
282
+ resolve(v);
283
+ },
284
+ reject: (e) => {
285
+ clearTimeout(t);
286
+ reject(e);
287
+ },
288
+ });
289
+ });
290
+ }
291
+
292
+ on(method, sessionId, fn) {
293
+ const key = `${sessionId || ''}:${method}`;
294
+ let hs = this.handlers.get(key);
295
+ if (!hs) this.handlers.set(key, (hs = []));
296
+ hs.push(fn);
297
+ }
298
+
299
+ once(method, sessionId, timeoutMs = 15000, predicate = null) {
300
+ return new Promise((resolve, reject) => {
301
+ const t = setTimeout(() => {
302
+ cleanup();
303
+ reject(new Error(`Timeout waiting for ${method}`));
304
+ }, timeoutMs);
305
+ const handler = (params) => {
306
+ if (predicate && !predicate(params)) return;
307
+ cleanup();
308
+ resolve(params);
309
+ };
310
+ const cleanup = () => {
311
+ clearTimeout(t);
312
+ const key = `${sessionId || ''}:${method}`;
313
+ const hs = this.handlers.get(key) || [];
314
+ const idx = hs.indexOf(handler);
315
+ if (idx >= 0) hs.splice(idx, 1);
316
+ };
317
+ this.on(method, sessionId, handler);
318
+ });
319
+ }
320
+ }
321
+
322
+ async function main() {
323
+ const browserPath = findChromium();
324
+ assert.ok(browserPath, 'No Chromium/Chrome binary found. Set EYELING_BROWSER to override.');
325
+
326
+ let server = null;
327
+ let chrome = null;
328
+ let ws = null;
329
+
330
+ const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eyeling-playground-'));
331
+
332
+ async function cleanup() {
333
+ try {
334
+ if (ws) ws.close();
335
+ } catch (_) {}
336
+ try {
337
+ if (chrome) chrome.kill('SIGKILL');
338
+ } catch (_) {}
339
+ try {
340
+ if (server) server.close();
341
+ } catch (_) {}
342
+ try {
343
+ fs.rmSync(profileDir, { recursive: true, force: true });
344
+ } catch (_) {}
345
+ }
346
+
347
+ try {
348
+ const started = await startStaticServer(ROOT);
349
+ server = started.server;
350
+ const demoUrl = `${started.baseUrl}/demo.html`;
351
+ info(`Static server: ${demoUrl}`);
352
+
353
+ const chromeArgs = [
354
+ '--headless=new',
355
+ '--disable-gpu',
356
+ '--no-sandbox',
357
+ '--disable-dev-shm-usage',
358
+ '--remote-debugging-port=0',
359
+ `--user-data-dir=${profileDir}`,
360
+ 'about:blank',
361
+ ];
362
+
363
+ chrome = spawn(browserPath, chromeArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
364
+
365
+ let wsUrl = null;
366
+ const wsRe = /DevTools listening on (ws:\/\/[^\s]+)/;
367
+ const stderrChunks = [];
368
+
369
+ chrome.stderr.on('data', (buf) => {
370
+ const s = String(buf);
371
+ stderrChunks.push(s);
372
+ const m = wsRe.exec(s);
373
+ if (m && m[1]) wsUrl = m[1];
374
+ });
375
+
376
+ // Wait for DevTools endpoint.
377
+ const start = Date.now();
378
+ while (!wsUrl) {
379
+ if (chrome.exitCode != null) {
380
+ throw new Error(`Chromium exited early: ${chrome.exitCode}\n${stderrChunks.join('')}`);
381
+ }
382
+ if (Date.now() - start > 15000) {
383
+ throw new Error(`Timed out waiting for DevTools URL.\n${stderrChunks.join('')}`);
384
+ }
385
+ await sleep(50);
386
+ }
387
+
388
+ info(`Chromium: ${browserPath}`);
389
+ info(`CDP: ${wsUrl}`);
390
+
391
+ ws = new WebSocket(wsUrl);
392
+ await new Promise((resolve, reject) => {
393
+ ws.onopen = resolve;
394
+ ws.onerror = reject;
395
+ });
396
+ const cdp = new CDP(ws);
397
+
398
+ // Create and attach to a new page target.
399
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
400
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
401
+
402
+ // Capture exceptions and console errors.
403
+ const exceptions = [];
404
+ const consoleErrors = [];
405
+ cdp.on('Runtime.exceptionThrown', sessionId, (p) => exceptions.push(p));
406
+ cdp.on('Log.entryAdded', sessionId, (p) => {
407
+ if (p && p.entry && p.entry.level === 'error') consoleErrors.push(p.entry);
408
+ });
409
+ cdp.on('Runtime.consoleAPICalled', sessionId, (p) => {
410
+ if (p && p.type === 'error') consoleErrors.push({ source: 'console', text: JSON.stringify(p.args || []) });
411
+ });
412
+
413
+ await cdp.send('Page.enable', {}, sessionId);
414
+ await cdp.send('Runtime.enable', {}, sessionId);
415
+ await cdp.send('Log.enable', {}, sessionId);
416
+ await cdp.send('Network.enable', {}, sessionId);
417
+
418
+ // Intercept CodeMirror + remote GitHub raw URLs (keep test deterministic).
419
+ const localPkg = fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8');
420
+ const localEyeling = fs.readFileSync(path.join(ROOT, 'eyeling.js'), 'utf8');
421
+
422
+ const intercept = new Map([
423
+ // CodeMirror assets (CDN)
424
+ ['https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js', { ct: 'application/javascript', body: CODEMIRROR_STUB }],
425
+ ['https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/turtle/turtle.min.js', { ct: 'application/javascript', body: '' }],
426
+ ['https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/sparql/sparql.min.js', { ct: 'application/javascript', body: '' }],
427
+ ['https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css', { ct: 'text/css', body: '/* stub */\n' }],
428
+
429
+ // GitHub raw references used for "latest" version display
430
+ ['https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/package.json', { ct: 'application/json', body: localPkg }],
431
+ ['https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/eyeling.js', { ct: 'application/javascript', body: localEyeling }],
432
+ ]);
433
+
434
+ await cdp.send(
435
+ 'Fetch.enable',
436
+ {
437
+ patterns: [
438
+ { urlPattern: 'https://cdn.jsdelivr.net/npm/codemirror@5.65.16/*', requestStage: 'Request' },
439
+ { urlPattern: 'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/*', requestStage: 'Request' },
440
+ ],
441
+ },
442
+ sessionId
443
+ );
444
+
445
+ cdp.on('Fetch.requestPaused', sessionId, async (p) => {
446
+ const url = p && p.request && p.request.url ? p.request.url : '';
447
+ const hit = intercept.get(url);
448
+ try {
449
+ if (hit) {
450
+ await cdp.send(
451
+ 'Fetch.fulfillRequest',
452
+ {
453
+ requestId: p.requestId,
454
+ responseCode: 200,
455
+ responseHeaders: [
456
+ { name: 'Content-Type', value: `${hit.ct}; charset=utf-8` },
457
+ { name: 'Cache-Control', value: 'no-store' },
458
+ // Avoid CORS surprises for fetch() from the page.
459
+ { name: 'Access-Control-Allow-Origin', value: '*' },
460
+ ],
461
+ body: b64(hit.body),
462
+ },
463
+ sessionId
464
+ );
465
+ } else {
466
+ await cdp.send('Fetch.continueRequest', { requestId: p.requestId }, sessionId);
467
+ }
468
+ } catch (_) {
469
+ // Best-effort: if interception fails, just continue.
470
+ try {
471
+ await cdp.send('Fetch.continueRequest', { requestId: p.requestId }, sessionId);
472
+ } catch (_) {}
473
+ }
474
+ });
475
+
476
+ const loadFired = cdp.once('Page.loadEventFired', sessionId, 30000);
477
+ const nav = await cdp.send('Page.navigate', { url: demoUrl }, sessionId);
478
+ assert.ok(!nav.errorText, `demo.html navigation failed: ${nav.errorText}`);
479
+ await loadFired;
480
+
481
+ // Click the Run button.
482
+ await cdp.send(
483
+ 'Runtime.evaluate',
484
+ { expression: `document.getElementById('run-btn') && document.getElementById('run-btn').click();`, returnByValue: true },
485
+ sessionId
486
+ );
487
+
488
+ // Wait for completion and capture output.
489
+ // The demo reports completion with status strings like:
490
+ // "Done. Derived: …", "Done (paused). …", or "Done. (Run N)".
491
+ let last = { status: '', output: '' };
492
+ const deadline = Date.now() + 60000;
493
+
494
+ while (Date.now() < deadline) {
495
+ // Fail fast on runtime exceptions (often indicates a broken CodeMirror stub or worker init).
496
+ if (exceptions.length) {
497
+ throw new Error(`Uncaught exception in demo.html: ${JSON.stringify(exceptions[0] || {})}`);
498
+ }
499
+
500
+ const r = await cdp.send(
501
+ 'Runtime.evaluate',
502
+ {
503
+ expression: `(() => {
504
+ const s = document.getElementById('status');
505
+ const o = document.getElementById('output-editor');
506
+ return { status: s ? String(s.textContent || '') : '', output: o ? String(o.value || '') : '' };
507
+ })()`,
508
+ returnByValue: true,
509
+ },
510
+ sessionId
511
+ );
512
+ last = r && r.result ? r.result.value : last;
513
+
514
+ const st = (last && typeof last.status === 'string') ? last.status : '';
515
+
516
+ // Treat any "Reasoning error" as failure.
517
+ if (/Reasoning error/i.test(st)) {
518
+ throw new Error(`Playground reported error: ${st}
519
+ Output:
520
+ ${last.output || ''}`);
521
+ }
522
+
523
+ // Success conditions: status starts with "Done" (covers "Done." and "Done (paused).")
524
+ if (String(st || '').trim().startsWith('Done')) break;
525
+
526
+ await sleep(100);
527
+ }
528
+
529
+ assert.ok(last && typeof last.status === 'string' && String(last.status || '').trim().startsWith('Done'), `Expected Done. Got: ${last.status}`);
530
+ assert.ok(last && typeof last.output === 'string' && last.output.length > 0, 'Expected non-empty output');
531
+ assert.match(last.output, /Socrates/i, 'Expected output to mention Socrates');
532
+ assert.match(last.output, /Mortal/i, 'Expected output to mention Mortal');
533
+
534
+ // Ensure no uncaught runtime exceptions.
535
+ assert.equal(exceptions.length, 0, `Uncaught exceptions in demo.html: ${JSON.stringify(exceptions[0] || {})}`);
536
+
537
+ // Console errors are noisy and often indicate a broken UI.
538
+ // (We suppress known noise like /favicon.ico on the server.)
539
+ assert.equal(consoleErrors.length, 0, `Console errors in demo.html: ${JSON.stringify(consoleErrors[0] || {})}`);
540
+
541
+ // Cleanup.
542
+ try {
543
+ await cdp.send('Browser.close');
544
+ } catch (_) {}
545
+
546
+ ok('demo.html loads and runs the default program');
547
+ } finally {
548
+ await cleanup();
549
+ }
550
+ }
551
+
552
+ main().catch((e) => {
553
+ fail(e && e.stack ? e.stack : String(e));
554
+ process.exit(1);
555
+ });