eyeling 1.24.13 → 1.24.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
@@ -3682,7 +3682,7 @@ That is exactly the sort of explanation that N3, and Eyeling in particular, can
3682
3682
 
3683
3683
  ## Appendix I — The Eyeling Playground
3684
3684
 
3685
- The **Eyeling Playground** is the browser-based front end for experimenting with Eyeling without a local install or command-line workflow. It is meant for teaching, quick debugging, live demos, and shareable reasoning examples. Rather than treating reasoning as an offline batch process, the playground makes it interactive: users can edit N3 directly in the browser, load remote N3 from a URL, run reasoning, inspect streamed output, and share the current state through a link.
3685
+ The **Eyeling Playground** is the browser-based front end for experimenting with Eyeling without a local install or command-line workflow. It is meant for teaching, quick debugging, live demos, and shareable reasoning examples. Rather than treating reasoning as an offline batch process, the playground makes it interactive: users can edit N3 directly in the browser, load remote N3 from a URL, run reasoning, inspect streamed or rendered output, autosave local state, and create a compact share link when needed.
3686
3686
 
3687
3687
  This appendix explains what the playground is for, how it is structured, and why it matters in practice.
3688
3688
 
@@ -3696,7 +3696,8 @@ The playground exists to lower that initial friction. It lets a user:
3696
3696
  - edit or paste a small N3 program,
3697
3697
  - run reasoning immediately,
3698
3698
  - inspect output and errors in place,
3699
- - and share the exact setup with a URL.
3699
+ - autosave local work between reloads,
3700
+ - and copy a compact share link when the setup should be shared.
3700
3701
 
3701
3702
  That makes the playground useful not only for newcomers, but also for experienced users who want a fast feedback loop for small examples.
3702
3703
 
@@ -3714,6 +3715,8 @@ A key recent addition is **background knowledge mode**. When enabled, the N3 loa
3714
3715
 
3715
3716
  That separation is helpful both pedagogically and practically. It mirrors real reasoning work, where a user often reasons _over_ a fixed body of data rather than constantly rewriting it.
3716
3717
 
3718
+ For repository examples, the playground also follows the same sidecar-input convention as the example test runner. When a loaded URL looks like `.../examples/name.n3`, the playground probes for `.../examples/input/name.trig`. If that companion TriG file exists, it is loaded automatically as background evidence and the run uses RDF/TriG compatibility mode, matching the command-line `-r` behavior used by `npm run test:examples`.
3719
+
3717
3720
  ### I.3 Execution behavior
3718
3721
 
3719
3722
  The playground is designed to feel responsive even when reasoning is not trivial. To do that, it uses a browser execution model that can run inference in a worker rather than blocking the main UI thread. Output is then surfaced back into the page.
@@ -3728,6 +3731,10 @@ This matters because the playground is not just a text box plus a submit button.
3728
3731
 
3729
3732
  The output behavior also adapts to the kind of N3 program being run. In some cases the natural result is a streamed list of derived triples. In others, such as programs using output-oriented constructs like `log:outputString`, a rendered text result is more appropriate. The playground supports both styles.
3730
3733
 
3734
+ For Markdown-oriented `log:outputString` examples, the output pane has two views: a rendered Markdown view and a Markdown source view. The rendered view is selected by default when the output is text intended for presentation, while the source view keeps the exact generated Markdown available for copying, inspection, or comparison.
3735
+
3736
+ Repository example reports often contain relative source links that are written for the checked-in files under `examples/output/*.md`. When such an example is loaded into the playground from a raw URL, the rendered Markdown view resolves those relative links against the corresponding static output page rather than against `/playground`, so links like `../name.n3` and `../input/name.trig` continue to point to the intended example files.
3737
+
3731
3738
  ### I.4 Error handling and explainability
3732
3739
 
3733
3740
  For an interactive reasoning environment, error behavior matters almost as much as successful output. The playground therefore gives particular attention to syntax and runtime feedback.
@@ -3741,21 +3748,21 @@ The playground also exposes two configuration toggles that are especially useful
3741
3748
 
3742
3749
  Together these choices make the playground better suited to live explanation, teaching, and debugging than a minimal browser wrapper would be.
3743
3750
 
3744
- ### I.5 Shareable state through URLs
3751
+ ### I.5 Local state and compact share links
3752
+
3753
+ The playground deliberately separates ordinary editing from link sharing.
3745
3754
 
3746
- One of the most practical features of the playground is that its state can be encoded in the page URL.
3755
+ During normal use, the live browser URL is kept short. Editor content and UI state are autosaved in `localStorage`, so reloading the page can restore local work without continuously rewriting the address bar with a large encoded N3 program.
3747
3756
 
3748
- The canonical query parameters are:
3757
+ When a user does want a portable link, the **Copy share link** button creates one on demand:
3749
3758
 
3750
- - `edit` sets the editor content,
3751
- - `url` fills the URL field,
3752
- - `loadbg` determines whether the URL should be loaded as background knowledge,
3753
- - `proofcomments` — initializes the proof-comments checkbox,
3754
- - `httpsderef` — initializes the HTTPS dereferencing checkbox.
3759
+ - unedited examples that were loaded from a URL can be shared as short `?url=...` links,
3760
+ - edited programs are shared with a compact compressed `?state=...` payload,
3761
+ - default option values are omitted from that payload to keep links small.
3755
3762
 
3756
- This makes the playground particularly strong for tutorials and demos. A link can specify not just a program, but a whole configuration: an imported resource, whether it belongs in background knowledge, a small editable overlay, and the relevant runtime toggles.
3763
+ This keeps everyday use pleasant while preserving the important tutorial and issue-reporting workflow: a link can still capture the imported resource, the local editable overlay, background-knowledge mode, proof-comments mode, and HTTPS-dereferencing mode.
3757
3764
 
3758
- Older hash-based links are still accepted as a fallback, but new state updates are written using query parameters because they scale better as the UI grows beyond a single editor field.
3765
+ For compatibility, older `?edit=`, `?program=`, `?url=`, compact `?state=`, and hash-based links are still accepted when opened. The old `/demo` entry point is also kept as a redirect to the canonical `/playground` page.
3759
3766
 
3760
3767
  ### I.6 What the playground is good for
3761
3768
 
@@ -3775,7 +3782,7 @@ For short reasoning tasks, the playground can be a faster debugging surface than
3775
3782
 
3776
3783
  #### I.6.4 Sharing examples
3777
3784
 
3778
- A single link can capture enough context for another person to reproduce an example quickly. This is valuable in issue reports, discussions, teaching material, and public-facing demonstrations.
3785
+ A copied share link can capture enough context for another person to reproduce an example quickly, without forcing the live browser URL to carry the full editor content during normal use. This is valuable in issue reports, discussions, teaching material, and public-facing demonstrations.
3779
3786
 
3780
3787
  ### I.7 Limits of the playground
3781
3788
 
@@ -3787,7 +3794,7 @@ In short: the playground is best thought of as a compact interactive front end f
3787
3794
 
3788
3795
  ### I.8 Why it matters
3789
3796
 
3790
- The Eyeling Playground shows that N3 reasoning can be made substantially more approachable without flattening the underlying logic into a toy interface. A relatively small set of features — an editor, a URL loader, background knowledge mode, responsive execution, proof toggles, and shareable query parameters — is enough to support serious educational and exploratory work.
3797
+ The Eyeling Playground shows that N3 reasoning can be made substantially more approachable without flattening the underlying logic into a toy interface. A relatively small set of features — an editor, a URL loader, background knowledge mode, responsive execution, proof toggles, rendered Markdown output, local autosave, and compact share links — is enough to support serious educational and exploratory work.
3791
3798
 
3792
3799
  That is the main value of the playground. It gives Eyeling a public-facing, browser-native environment where reasoning is not hidden behind setup overhead, and where examples can move easily between author, teacher, student, and reviewer.
3793
3800
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.24.13",
3
+ "version": "1.24.15",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -22,8 +22,18 @@ const TTY = process.stdout.isTTY;
22
22
  const C = TTY
23
23
  ? { g: '\x1b[32m', r: '\x1b[31m', y: '\x1b[33m', dim: '\x1b[2m', n: '\x1b[0m' }
24
24
  : { g: '', r: '', y: '', dim: '', n: '' };
25
+ const msTag = (ms) => `${C.dim}(${ms} ms)${C.n}`;
26
+
27
+ const TOTAL_TESTS = 11;
28
+ const idxWidth = String(TOTAL_TESTS).length;
29
+ let passed = 0;
30
+ let failed = 0;
31
+ let currentTest = null;
32
+ let nonTestFailure = false;
33
+ const suiteStart = Date.now();
34
+
25
35
  function ok(msg) {
26
- console.log(`${C.g}OK ${C.n} ${msg}`);
36
+ console.log(`${C.g}OK${C.n} ${msg}`);
27
37
  }
28
38
  function info(msg) {
29
39
  console.log(`${C.y}==${C.n} ${msg}`);
@@ -31,6 +41,36 @@ function info(msg) {
31
41
  function fail(msg) {
32
42
  console.error(`${C.r}FAIL${C.n} ${msg}`);
33
43
  }
44
+ function beginTest(msg) {
45
+ currentTest = { msg, start: Date.now() };
46
+ }
47
+ function endTest() {
48
+ const tc = currentTest;
49
+ if (!tc) return;
50
+ const idx = String(passed + failed + 1).padStart(idxWidth, '0');
51
+ ok(`${idx} ${tc.msg} ${msTag(Date.now() - tc.start)}`);
52
+ passed += 1;
53
+ currentTest = null;
54
+ }
55
+ function recordCurrentFailure() {
56
+ const tc = currentTest;
57
+ if (!tc) return false;
58
+ const idx = String(passed + failed + 1).padStart(idxWidth, '0');
59
+ fail(`${idx} ${tc.msg} ${msTag(Date.now() - tc.start)}`);
60
+ failed += 1;
61
+ currentTest = null;
62
+ return true;
63
+ }
64
+ function printSummary() {
65
+ console.log('');
66
+ const suiteMs = Date.now() - suiteStart;
67
+ info(`Total elapsed: ${suiteMs} ms (${(suiteMs / 1000).toFixed(2)} s)`);
68
+ if (failed === 0 && !nonTestFailure) {
69
+ ok(`All playground tests passed (${passed}/${TOTAL_TESTS})`);
70
+ } else {
71
+ fail(`Some playground tests failed (${passed}/${TOTAL_TESTS})`);
72
+ }
73
+ }
34
74
 
35
75
  function guessContentType(p) {
36
76
  const ext = path.extname(p).toLowerCase();
@@ -495,6 +535,8 @@ async function main() {
495
535
  // Intercept CodeMirror + remote GitHub raw URLs (keep test deterministic).
496
536
  const localPkg = fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8');
497
537
  const localEyeling = fs.readFileSync(path.join(ROOT, 'eyeling.js'), 'utf8');
538
+ const localSmokeArithmetic = fs.readFileSync(path.join(ROOT, 'examples', 'smoke-arithmetic.n3'), 'utf8');
539
+ const localSmokeArithmeticTrig = fs.readFileSync(path.join(ROOT, 'examples', 'input', 'smoke-arithmetic.trig'), 'utf8');
498
540
  const localSudoku = fs.readFileSync(path.join(ROOT, 'examples', 'sudoku.n3'), 'utf8');
499
541
  const localSudokuBuiltin = fs.readFileSync(path.join(ROOT, 'examples', 'builtin', 'sudoku.js'), 'utf8');
500
542
 
@@ -526,6 +568,14 @@ async function main() {
526
568
  'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/eyeling.js',
527
569
  { ct: 'application/javascript', body: localEyeling },
528
570
  ],
571
+ [
572
+ 'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/smoke-arithmetic.n3',
573
+ { ct: 'text/plain', body: localSmokeArithmetic },
574
+ ],
575
+ [
576
+ 'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/input/smoke-arithmetic.trig',
577
+ { ct: 'text/plain', body: localSmokeArithmeticTrig },
578
+ ],
529
579
  [
530
580
  'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/sudoku.n3',
531
581
  { ct: 'text/plain', body: localSudoku },
@@ -534,6 +584,10 @@ async function main() {
534
584
  'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/builtin/sudoku.js',
535
585
  { ct: 'application/javascript', body: localSudokuBuiltin },
536
586
  ],
587
+ [
588
+ 'https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/input/sudoku.trig',
589
+ { code: 404, ct: 'text/plain', body: 'not found' },
590
+ ],
537
591
  ]);
538
592
 
539
593
  async function getText(url) {
@@ -551,13 +605,17 @@ async function main() {
551
605
  });
552
606
  }
553
607
 
608
+ beginTest('clean /playground URL serves the playground');
554
609
  const cleanRes = await getText(cleanPlaygroundUrl);
555
610
  assert.equal(cleanRes.statusCode, 200, 'clean /playground URL should serve the playground');
556
611
  assert.match(cleanRes.body, /Eyeling N3 Playground/, 'clean /playground URL should load the playground');
612
+ endTest();
557
613
 
614
+ beginTest('legacy /demo URL serves the redirect page');
558
615
  const legacyRes = await getText(legacyDemoUrl);
559
616
  assert.equal(legacyRes.statusCode, 200, 'legacy /demo URL should serve redirect page');
560
617
  assert.match(legacyRes.body, /playground/, 'legacy /demo URL should point to the playground');
618
+ endTest();
561
619
 
562
620
  await cdp.send(
563
621
  'Fetch.enable',
@@ -582,7 +640,7 @@ async function main() {
582
640
  'Fetch.fulfillRequest',
583
641
  {
584
642
  requestId: p.requestId,
585
- responseCode: 200,
643
+ responseCode: hit.code || 200,
586
644
  responseHeaders: [
587
645
  { name: 'Content-Type', value: `${hit.ct}; charset=utf-8` },
588
646
  { name: 'Cache-Control', value: 'no-store' },
@@ -647,6 +705,7 @@ async function main() {
647
705
  })
648
706
  : [];
649
707
  const renderedPanel = document.getElementById('output-rendered');
708
+ const outputTabs = document.querySelector('.output-tabs');
650
709
  const renderedTab = document.getElementById('output-rendered-tab');
651
710
  const sourceTab = document.getElementById('output-source-tab');
652
711
  const sourceWrapper = document.getElementById('output-source');
@@ -659,9 +718,11 @@ async function main() {
659
718
  renderedHtml: renderedPanel ? String(renderedPanel.innerHTML || '') : '',
660
719
  renderedHidden: renderedPanel ? !!renderedPanel.hidden : true,
661
720
  sourceHidden: sourceWrapper ? sourceWrapper.classList.contains('markdown-source-hidden') : true,
721
+ outputTabsHidden: outputTabs ? !!outputTabs.hidden : true,
662
722
  renderedTabSelected: renderedTab ? renderedTab.getAttribute('aria-selected') === 'true' : false,
663
723
  sourceTabSelected: sourceTab ? sourceTab.getAttribute('aria-selected') === 'true' : false,
664
724
  shareStatus: document.getElementById('share-status') ? String(document.getElementById('share-status').textContent || '') : '',
725
+ backgroundStatus: document.getElementById('background-status') ? String(document.getElementById('background-status').textContent || '') : '',
665
726
  href: String(window.location.href || ''),
666
727
  highlighted,
667
728
  };
@@ -758,6 +819,7 @@ ${JSON.stringify(last, null, 2)}`);
758
819
  `;
759
820
 
760
821
  // 1) Baseline smoke test: the default program runs to completion.
822
+ beginTest('playground runs the default Socrates program');
761
823
  await clickRun();
762
824
  const baseline = await waitForState(
763
825
  'default program completion',
@@ -769,9 +831,13 @@ ${JSON.stringify(last, null, 2)}`);
769
831
  );
770
832
  assert.ok(typeof baseline.output === 'string' && baseline.output.length > 0, 'Expected non-empty output');
771
833
  for (const [re, msg] of DEFAULT_PROGRAM_EXPECTS) assert.match(baseline.output, re, msg);
772
- ok('playground runs the default Socrates program');
834
+ assert.equal(baseline.outputTabsHidden, true, 'Expected plain Turtle output to hide Markdown tabs');
835
+ assert.equal(baseline.renderedHidden, true, 'Expected plain Turtle output to skip rendered Markdown panel');
836
+ assert.equal(baseline.sourceHidden, false, 'Expected plain Turtle output to show source directly');
837
+ endTest();
773
838
 
774
839
  // 2) N3 syntax errors should be shown in Output and highlight the offending line.
840
+ beginTest('playground shows syntax errors in Output and highlights the offending line');
775
841
  await setProgram(syntaxErrorProgram);
776
842
  await clickRun();
777
843
  const syntaxErr = await waitForState(
@@ -783,9 +849,10 @@ ${JSON.stringify(last, null, 2)}`);
783
849
  assert.match(syntaxErr.output, /\n\^\s*$/m, 'Expected caret line in syntax error output');
784
850
  assert.equal(syntaxErr.highlighted[0].line, 3, 'Expected line 3 to be highlighted');
785
851
  assert.equal(syntaxErr.highlighted[0].text, '^', 'Expected highlighted line text to match the broken line');
786
- ok('playground shows syntax errors in Output and highlights the offending line');
852
+ endTest();
787
853
 
788
854
  // 3) Inference fuse output should be visible in the Output pane.
855
+ beginTest('playground clearly shows inference fuse output');
789
856
  await setProgram(fuseProgram);
790
857
  await clickRun();
791
858
  const fuse = await waitForState(
@@ -799,9 +866,10 @@ ${JSON.stringify(last, null, 2)}`);
799
866
  assert.match(fuse.output, /Inference fuse triggered\./i, 'Expected fuse message in Output');
800
867
  assert.match(fuse.output, /Fired rule:/i, 'Expected fired rule explanation in Output');
801
868
  assert.match(fuse.output, /Matched instance:/i, 'Expected matched instance in Output');
802
- ok('playground clearly shows inference fuse output');
869
+ endTest();
803
870
 
804
871
  // 4) log:outputString should render as clean text, not raw triples.
872
+ beginTest('playground renders log:outputString Markdown with Rendered/Markdown source tabs');
805
873
  await setProgram(outputStringProgram);
806
874
  await clickRun();
807
875
  const rendered = await waitForState(
@@ -818,6 +886,7 @@ ${JSON.stringify(last, null, 2)}`);
818
886
  /:report\s+log:outputString\s+"|# Derived triples/i,
819
887
  'Expected clean rendered output without raw triples',
820
888
  );
889
+ assert.equal(rendered.outputTabsHidden, false, 'Expected Markdown output tabs to be visible for log:outputString');
821
890
  assert.equal(rendered.renderedHidden, false, 'Expected rendered Markdown tab to be visible by default');
822
891
  assert.equal(rendered.sourceHidden, true, 'Expected Markdown source tab to be hidden by default');
823
892
  assert.equal(rendered.renderedTabSelected, true, 'Expected Rendered tab to be selected by default');
@@ -828,6 +897,7 @@ ${JSON.stringify(last, null, 2)}`);
828
897
 
829
898
  await clickOutputSourceTab();
830
899
  const sourceView = await getPlaygroundState();
900
+ assert.equal(sourceView.outputTabsHidden, false, 'Expected Markdown output tabs to stay visible in source view');
831
901
  assert.equal(sourceView.sourceTabSelected, true, 'Expected Markdown source tab to be selectable');
832
902
  assert.equal(sourceView.renderedHidden, true, 'Expected rendered Markdown panel to hide after selecting source');
833
903
  assert.equal(sourceView.sourceHidden, false, 'Expected source editor to show after selecting source');
@@ -836,17 +906,50 @@ ${JSON.stringify(last, null, 2)}`);
836
906
  await clickOutputRenderedTab();
837
907
  const renderedAgain = await getPlaygroundState();
838
908
  assert.equal(renderedAgain.renderedTabSelected, true, 'Expected Rendered tab to be selectable again');
839
- ok('playground renders log:outputString Markdown with Rendered/Markdown source tabs');
909
+ endTest();
840
910
 
841
911
  // 5) Normal editing should not keep rewriting the browser URL with raw N3 content.
912
+ beginTest('playground keeps the live URL short and creates compact share links on demand');
842
913
  assert.doesNotMatch(renderedAgain.href, /[?&](?:edit|program)=/, 'Expected live URL to avoid raw editor content');
843
914
  const compactShareUrl = await makeShareUrlInPage();
844
915
  assert.match(compactShareUrl, /[?&]state=/, 'Expected an on-demand compact state parameter');
845
916
  assert.doesNotMatch(compactShareUrl, /[?&](?:edit|program)=/, 'Expected share link to avoid raw edit/program params');
846
917
  assert.ok(compactShareUrl.length < playgroundUrl.length + encodeURIComponent(outputStringProgram).length, 'Expected compact share URL to be shorter than raw editor URL');
847
- ok('playground keeps the live URL short and creates compact share links on demand');
918
+ endTest();
919
+
920
+ // 6) URL-loaded examples should auto-load matching examples/input/<stem>.trig and run in RDF/TriG mode.
921
+ beginTest('playground auto-loads companion TriG sidecars and uses RDF/TriG mode');
922
+ await loadUrlIntoEditor('https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/smoke-arithmetic.n3');
923
+ const smokeLoaded = await waitForState(
924
+ 'smoke-arithmetic URL loaded with companion TriG input',
925
+ (st) => /companion RDF\/TriG input/i.test(String(st.status || '')) && /input\/smoke-arithmetic\.trig/i.test(String(st.backgroundStatus || '')),
926
+ 20000,
927
+ );
928
+ assert.match(smokeLoaded.backgroundStatus, /smoke-arithmetic\.trig/i, 'Expected companion TriG sidecar in background status');
929
+ await clickRun();
930
+ const smoke = await waitForState(
931
+ 'URL-loaded smoke-arithmetic example completion with sidecar input',
932
+ (st) =>
933
+ String(st.status || '')
934
+ .trim()
935
+ .startsWith('Done') && /product = 42/i.test(String(st.output || '')),
936
+ 30000,
937
+ );
938
+ assert.match(smoke.output, /product = 42/i, 'Expected result derived from companion TriG evidence');
939
+ assert.match(
940
+ smoke.renderedHtml,
941
+ new RegExp('href="' + started.baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/examples/smoke-arithmetic\\.n3"'),
942
+ 'Expected relative Markdown source links to resolve against the static output page, not /playground',
943
+ );
944
+ assert.match(
945
+ smoke.renderedHtml,
946
+ new RegExp('href="' + started.baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/examples/input/smoke-arithmetic\\.trig"'),
947
+ 'Expected relative Markdown TriG links to resolve against the static output page, not /playground',
948
+ );
949
+ endTest();
848
950
 
849
- // 6) URL-loaded repository examples should auto-load matching examples/builtin/<stem>.js.
951
+ // 7) URL-loaded repository examples should auto-load matching examples/builtin/<stem>.js.
952
+ beginTest('playground auto-loads a companion example builtin for URL-loaded Sudoku');
850
953
  await loadUrlIntoEditor('https://raw.githubusercontent.com/eyereasoner/eyeling/refs/heads/main/examples/sudoku.n3');
851
954
  await waitForState(
852
955
  'sudoku URL loaded with companion builtin',
@@ -867,14 +970,18 @@ ${JSON.stringify(last, null, 2)}`);
867
970
  );
868
971
  assert.match(sudoku.output, /Completed grid/i, 'Expected Sudoku rendered output');
869
972
  assert.match(sudoku.output, /unique valid Sudoku solution/i, 'Expected Sudoku builtin-backed result');
870
- ok('playground auto-loads a companion example builtin for URL-loaded Sudoku');
973
+ endTest();
871
974
 
872
975
  // Ensure no uncaught runtime exceptions.
976
+ beginTest('playground has no uncaught runtime exceptions');
873
977
  assert.equal(exceptions.length, 0, `Uncaught exceptions in playground.html: ${JSON.stringify(exceptions[0] || {})}`);
978
+ endTest();
874
979
 
875
980
  // Console errors are noisy and often indicate a broken UI.
876
981
  // (We suppress known noise like /favicon.ico on the server.)
982
+ beginTest('playground has no console errors');
877
983
  assert.equal(consoleErrors.length, 0, `Console errors in playground.html: ${JSON.stringify(consoleErrors[0] || {})}`);
984
+ endTest();
878
985
 
879
986
  // Cleanup.
880
987
  try {
@@ -883,9 +990,13 @@ ${JSON.stringify(last, null, 2)}`);
883
990
  } finally {
884
991
  await cleanup();
885
992
  }
993
+
994
+ printSummary();
886
995
  }
887
996
 
888
997
  main().catch((e) => {
998
+ if (!recordCurrentFailure()) nonTestFailure = true;
999
+ printSummary();
889
1000
  fail(e && e.stack ? e.stack : String(e));
890
1001
  process.exit(1);
891
1002
  });