commit 70fa81eaad92b7a378ebad596667606d245c84e1
parent fccaf54e5f351d9969753f0c91c2e3e61ca3796a
Author: James Eversole <james@eversole.co>
Date: Tue, 20 Oct 2026 11:39:12 -0500
Add full-page Playground recovery and Firefox idle soak coverage
Keep Reload Playground available during stuck or failed runtime operations. Confirm before discarding local drafts or opened modules; perform a full browser-page reload rather than relying on Worker cooperation. Describe the live tricu content store serving this page's demo, library and compiler bundles.
Add real-navigation recovery checks and a reproducible Firefox WebDriver BiDi soak with independent published/compiled Haskell sessions in genuinely hidden tabs. Capture visibility, UI/diagnostic state and console/network errors without polling or rebuilding sessions during idle.
Validation: nix flake check, JS parity, 136 Node tests, desktop/mobile recovery checks on Haskell and Zig, and IDE regressions. Firefox 150.0.2 passed all six 10/20/30-minute idle sessions with no unexpected console/network errors or read-only failure. This does not exclude production networking, OS suspend or memory-pressure conditions.
Diffstat:
9 files changed, 318 insertions(+), 3 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md
@@ -1464,3 +1464,52 @@ Chromium harness above; add `TEST_BACKEND=zig-wasm` for Zig and
inspection, interpretation failure/recovery, revision invalidation, cancellable
observation, anchored cards, definition search, draft-preserving try, source-free
exploration, and the existing import safety/undo controls.
+
+### Recovering an unresponsive Playground
+
+**Reload Playground** in the always-present toolbar performs a full page reload,
+replacing all page state and runtime/compiler/validation Workers. It remains
+available during loading, compilation, evaluation and errors. A local draft or
+opened local module triggers a confirmation before navigation; cancelling leaves
+that work and the current operation untouched. Copy anything needed before
+confirming: drafts, expressions, results and the in-memory local-module collection
+are not restored. Arrival follows the normal URL/default selection rules and
+never automatically evaluates a program. The runtime selector returns to its
+normal page default. This is a recovery path, not a diagnosis or prevention of
+long-idle runtime/browser failures.
+
+Add `TEST_RELOAD=1` to a Chromium backend harness invocation to exercise warning
+cancellation, recovery during divergent execution, full navigation, cleared local
+state, fresh WASM evaluation and demo switching after recovery.
+
+### Firefox background-idle soak
+
+The opt-in WebDriver BiDi runner uses real Firefox tabs and packaged runtime code,
+without a CUA stack or mocked evaluator. Each checkpoint has independent published
+and locally compiled Haskell sessions; later checkpoints are never exercised or
+rebuilt by earlier ones. A neutral tab stays active. The driver verifies the site
+tabs are hidden and does not poll them during the requested idle interval. On
+resumption it checks the original retained binding before replacing any session,
+then exercises inspection, demo switching, editing/compilation, restore, and
+ordinary expression/compile diagnostics without leaving the editor read-only.
+
+```sh
+SITE=$(nix build .#arboricxSite --no-link --print-out-paths)
+ARBORICX_SITE_ROOT="$SITE" IDLE_REPORT=/tmp/tricu-firefox-idle.json \
+ nix shell --inputs-from . nixpkgs#nodejs nixpkgs#firefox -c \
+ python3 ext/demo-site/test/run-bounded.py --seconds 2100 --memory-mib 8192 -- \
+ node --max-old-space-size=512 ext/demo-site/test/firefox-idle.mjs
+```
+
+Default intervals are 10, 20 and 30 minutes. `IDLE_MINUTES=0.02,0.04,0.06`
+provides a short harness smoke, not evidence about long-idle behavior. The JSON
+report is saved after setup and every checkpoint; it includes actual hidden
+intervals, browser version, visibility changes, UI/diagnostic snapshots, unexpected
+console/network errors, and the immutable tested site path. A checkpoint failure
+also attempts a screenshot beside the report. The runner does not reload or
+silently recover a failure. Temporary browser profiles are removed on exit.
+
+Headless background tabs do not reproduce every desktop lifecycle condition.
+This check does not inject OS suspend, memory pressure, tab unloading, extensions,
+or production network/deployment changes. A clean run is not proof that an
+intermittent user-reported failure cannot occur.
diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js
@@ -124,6 +124,14 @@ const localModules = localModulesUI(() => {
}
queueCompile();
});
+// A full navigation replaces all runtime/compiler/validation Workers and page
+// state, including failures outside the active session. Never gate recovery on
+// the busy/loaded flags: it must work while a runtime is stuck or loading fails.
+el('reload-playground').onclick = () => {
+ if ((draft || localModules.entries().length > 0) && !window.confirm('Reload the Playground and restart all runtimes? Local source edits, results and opened local modules will be cleared. Copy any work you want to keep before continuing.'))
+ return;
+ location.reload();
+};
const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value,
available: () => !busy,
editable: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly,
diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html
@@ -43,7 +43,7 @@
<div class="editor-toolbar" data-inspector-background>
<div class="toolbar-selection"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading Names and values…</option></select></div>
<div class="toolbar-runtime"><label for="runtime-backend">Runtime</label><select id="runtime-backend"><option value="haskell-wasm" selected>Haskell</option><option value="zig-wasm">Zig</option></select></div>
- <div class="toolbar-actions"><button id="add-import" disabled>Explore libraries…</button><button id="stop" disabled hidden>Stop</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div>
+ <div class="toolbar-actions"><button id="add-import" disabled>Explore libraries…</button><button id="stop" disabled hidden>Stop</button><button id="reload-playground" title="Reload the page to restart all runtimes. Local drafts, output and opened modules are cleared.">Reload Playground</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div>
<span id="status" role="status">Loading Names and values</span>
</div>
<div id="editor-body" class="editor-body">
@@ -112,7 +112,7 @@
<div id="settings-backdrop" hidden></div>
<section id="ecosystem" class="explanation wrap" data-inspector-background><p class="eyebrow">One calculus, a growing ecosystem</p><h2>Trees all the way down.</h2><div class="explanation-grid">
<article><h3>Portable Arboricx packages.</h3><p>Arboricx bundles hold named exports and shared tree structure. Content-addressed storage gives trees and modules stable identities. Package a function, move it to another compatible host and apply it there.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/modules.md.html">Explore modules and packages</a></p></article>
- <article><h3>A web server made of trees.</h3><p>The ecosystem includes HTTP support and an Arboricx registry written in tricu. Tree programs handle requests, build responses and work with the content store through host IO. The same language used for the examples can serve its own packages.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/arboricx/server.tri.html">Explore the registry server</a></p></article>
+ <article><h3>A web server made of trees.</h3><p>This Playground gets its demo, library and compiler bundles from a live Arboricx content store server written in tricu. When you load a demo, import a published library or compile your own source, this page fetches the pinned bundles from that server and verifies their hashes before using them. Tree programs are serving the packages that power the examples on this very page.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/arboricx/server.tri.html">Explore the registry server</a></p></article>
<article><h3>A compiler that compiles itself.</h3><p>Lexing, parsing, name resolution and lambda elimination are tricu programs. The compiler can compile its own source into Arboricx packages. The editor runs that packaged compiler so you can follow the journey from source to tree.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/compiler.md.html">Explore the self-hosting compiler</a></p></article>
<article><h3>Programs that inspect programs.</h3><p>Tree Calculus combines computation with structural inspection. Leaf, Stem and Fork represent both functions and data. A program can examine another program's shape, count its nodes or compare its structure. Try the tree microscope in the editor.</p><p><a href="https://treecalcul.us/">Explore Tree Calculus</a></p></article>
<article><h3>IO as an interaction tree.</h3><p>A tricu program describes actions and their continuations as trees. The host responds to each request and execution proceeds with that response. The conversation example puts text output, line input and composition into practice.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/io.tri.html">Explore the IO library</a></p></article>
diff --git a/ext/demo-site/reference-input/getting-started.md b/ext/demo-site/reference-input/getting-started.md
@@ -150,3 +150,13 @@ execution mode. Derive expected results explicitly and distinguish a proposed
program from one you have actually run. Published interfaces and these static
references are readable through ordinary HTTP; playground execution is optional
for looking up names, imports and conventions.
+
+## Recover a stuck runtime
+
+Use **Reload Playground** in the toolbar if the runtime stops responding or
+repeatedly errors after an idle period. This reloads the whole page, including
+all WASM sessions. It stays available while other work is running. If you have a
+local draft or opened modules, confirm before discarding them; cancel to copy your
+work first. Reload also clears expressions, results and the local-module collection,
+resets the runtime selector, and loads the normal URL/default selection without
+automatically evaluating it.
diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts
@@ -108,6 +108,15 @@ const localModules = localModulesUI(() => {
if (!busy) { dispose(); el('status').textContent = 'Changes pending'; }
queueCompile();
});
+// A full navigation replaces all runtime/compiler/validation Workers and page
+// state, including failures outside the active session. Never gate recovery on
+// the busy/loaded flags: it must work while a runtime is stuck or loading fails.
+el<HTMLButtonElement>('reload-playground').onclick = () => {
+ if ((draft || localModules.entries().length > 0) && !window.confirm(
+ 'Reload the Playground and restart all runtimes? Local source edits, results and opened local modules will be cleared. Copy any work you want to keep before continuing.',
+ )) return;
+ location.reload();
+};
const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value,
available: () => !busy,
editable: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly,
diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css
@@ -1,7 +1,7 @@
:root{color-scheme:light;--paper:#f5f2ea;--ink:#202d29;--muted:#626d65;--green:#235a43;--line:#d8dcd0;--panel:#fffef9;--code:#172a24;--toolbar:#eaf0e6;--dock:#eef2e8;--output:#f3f4ee;--error:#9b3422;--warning:#8b581b;--focus:#b65b28;--primary:var(--green);--on-primary:white;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button,.button{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:10px 14px;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px}button:hover:not(:disabled),.button:hover{border-color:var(--green)}button:disabled{cursor:default;opacity:.45}button.primary,.button.primary{background:var(--primary);color:var(--on-primary);border-color:var(--green)}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.wrap{max-width:1120px;margin:auto;padding-inline:28px}header{display:flex;justify-content:space-between;align-items:center;padding-block:28px;border-bottom:1px solid var(--line);gap:20px}.wordmark{font-weight:800;font-size:25px;text-decoration:none}.wordmark span{font-size:13px;font-weight:400;color:var(--muted)}nav{display:flex;gap:22px;font-size:14px}.hero{padding-block:64px;position:relative}.eyebrow{font:600 12px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:var(--green)}.pronunciation{text-transform:none;letter-spacing:normal}h1{font-size:clamp(48px,6vw,78px);line-height:1.03;letter-spacing:-.055em;font-weight:650;margin:22px 0}h1 span{color:var(--green)}.intro{max-width:570px;font-size:19px;line-height:1.7;color:var(--muted)}.shapes{display:flex;gap:35px;margin:28px 0 35px}.shapes div{width:100px;text-align:center}.shapes svg{width:100px;height:80px;display:block;fill:var(--green)}.shapes>div:first-child circle{transform:translateY(18.5px)}.shapes path{stroke:var(--green);stroke-width:2;fill:none}.shapes strong,.shapes code{display:block;font-size:13px;margin-top:5px}.shapes code{color:var(--muted)}h2{font-size:36px;letter-spacing:-.035em;font-weight:600;margin:8px 0 16px}h3{font-size:15px;font-weight:650;margin:0 0 12px}.hint{font-size:12px;line-height:1.7;color:var(--muted);overflow-wrap:anywhere}label{display:block;font-size:12px;font-weight:600;margin-bottom:6px}select,textarea,input:not([type=checkbox]){font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:9px;width:100%;min-width:0}select{min-height:44px}textarea{resize:vertical;font:14px/1.6 ui-monospace,monospace}pre{font:14px/1.65 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{margin-top:14px;font-size:13px}summary{cursor:pointer;color:var(--green);min-height:32px}button,input,select,textarea{touch-action:manipulation}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}.error{color:var(--error)}.input-row{display:flex;gap:8px;margin-bottom:12px}.input-row button{white-space:nowrap}.check{display:flex;align-items:center;gap:8px;font-weight:400;margin-block:14px}.check input{flex:none}.demo-picker{display:flex;flex-wrap:wrap;gap:8px}.demo-picker button{font-size:12px}
/* One workspace, one output surface. No nested cards or inactive panels. */
-.workspace{max-width:1064px;margin:0 auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:clip;scroll-margin-top:16px}.editor-toolbar{position:sticky;top:0;z-index:5;display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--line);background:var(--toolbar)}.toolbar-selection{flex:1 1 220px;min-width:0}.toolbar-runtime{flex:0 1 150px;min-width:115px}.toolbar-actions{display:flex;gap:8px;align-items:center}.editor-toolbar #status{flex-basis:100%;font-size:12px;color:var(--muted);min-height:18px}.editor-body{display:grid;grid-template-columns:minmax(0,1fr)}.editor-body.settings-open{grid-template-columns:minmax(0,1fr) 320px}.editor-main{min-width:0}.source-panel{background:var(--code);color:#e3edde;min-width:0}.source-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px 8px}.source-heading h2{font-size:14px;letter-spacing:0;margin:0}.source-heading span{font:11px ui-monospace,monospace;color:#b1c1b7}#authorship{font-size:12px;color:#b1c1b7;margin:0;padding:0 20px 12px}#authorship.modified{color:#ffda98}#source-editor{display:block;width:100%;height:460px;min-height:240px;max-height:none;border:0;border-radius:0;padding:18px 20px;margin:0;color:inherit;background:transparent;font:14px/24px ui-monospace,monospace;tab-size:2}#source-editor.modified{background:#273c32;box-shadow:inset 4px 0 #e5b96b}#source-editor:disabled{opacity:.75}#source-notes{margin:0;padding:18px 20px;max-height:460px;overflow:auto;white-space:pre;tab-size:2}#source-availability,#compile-diagnostic,#compile-activity{margin:0;padding:10px 20px;color:#e2d5af}#compile-diagnostic{color:#ffc5b9}.source-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #385045}.source-actions .hint{color:#b1c1b7;margin:0}.source-actions button{font-size:12px;padding:6px 10px;background:transparent;color:#e3edde;border-color:#385045}.expression-dock{padding:16px 20px;background:var(--dock);border-top:1px solid var(--line)}#expression-inputs label{font-size:13px}#expression-inputs label span{font-weight:400;color:var(--muted)}#expression-editor{font:16px/26px ui-monospace,monospace;min-height:92px;background:var(--panel)}#expression-help{margin:6px 0 0}#draft-run-note{color:var(--warning)}.argument-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.argument-heading h3{margin:0}#argument-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.argument-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 10px;margin-top:12px}.argument-row label{grid-column:1/-1}.argument-row textarea{font-size:16px}.argument-row button{font-size:12px;align-self:start}
+.workspace{max-width:1064px;margin:0 auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:clip;scroll-margin-top:16px}.editor-toolbar{position:sticky;top:0;z-index:5;display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--line);background:var(--toolbar)}.toolbar-selection{flex:1 1 220px;min-width:0}.toolbar-runtime{flex:0 1 150px;min-width:115px}.toolbar-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;max-width:100%}.editor-toolbar #status{flex-basis:100%;font-size:12px;color:var(--muted);min-height:18px}.editor-body{display:grid;grid-template-columns:minmax(0,1fr)}.editor-body.settings-open{grid-template-columns:minmax(0,1fr) 320px}.editor-main{min-width:0}.source-panel{background:var(--code);color:#e3edde;min-width:0}.source-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px 8px}.source-heading h2{font-size:14px;letter-spacing:0;margin:0}.source-heading span{font:11px ui-monospace,monospace;color:#b1c1b7}#authorship{font-size:12px;color:#b1c1b7;margin:0;padding:0 20px 12px}#authorship.modified{color:#ffda98}#source-editor{display:block;width:100%;height:460px;min-height:240px;max-height:none;border:0;border-radius:0;padding:18px 20px;margin:0;color:inherit;background:transparent;font:14px/24px ui-monospace,monospace;tab-size:2}#source-editor.modified{background:#273c32;box-shadow:inset 4px 0 #e5b96b}#source-editor:disabled{opacity:.75}#source-notes{margin:0;padding:18px 20px;max-height:460px;overflow:auto;white-space:pre;tab-size:2}#source-availability,#compile-diagnostic,#compile-activity{margin:0;padding:10px 20px;color:#e2d5af}#compile-diagnostic{color:#ffc5b9}.source-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #385045}.source-actions .hint{color:#b1c1b7;margin:0}.source-actions button{font-size:12px;padding:6px 10px;background:transparent;color:#e3edde;border-color:#385045}.expression-dock{padding:16px 20px;background:var(--dock);border-top:1px solid var(--line)}#expression-inputs label{font-size:13px}#expression-inputs label span{font-weight:400;color:var(--muted)}#expression-editor{font:16px/26px ui-monospace,monospace;min-height:92px;background:var(--panel)}#expression-help{margin:6px 0 0}#draft-run-note{color:var(--warning)}.argument-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.argument-heading h3{margin:0}#argument-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.argument-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 10px;margin-top:12px}.argument-row label{grid-column:1/-1}.argument-row textarea{font-size:16px}.argument-row button{font-size:12px;align-self:start}
/* Docked settings; mobile turns the same inspector into a modal sheet. */
#settings-panel{min-width:0;padding:18px;background:var(--paper);border-left:1px solid var(--line);font-size:13px}#settings-panel section{padding-block:20px;border-bottom:1px solid var(--line)}#settings-panel section:last-child{border:0}.inspector-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.inspector-heading h2{font-size:16px;margin:0;letter-spacing:0}#settings-panel button{font-size:12px}#settings-panel .hint{margin-bottom:0}#settings-panel details>p{margin-top:8px}#package-info,#release-info,#compiler-stats{overflow-wrap:anywhere}#download-bundle{margin-top:12px;width:100%}#bundle-download-status:empty{display:none}
.output-panel{border-top:1px solid var(--line);padding:20px;min-width:0}.output-toolbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.output-toolbar>div{min-width:0}#output-provenance{margin-block:16px 8px}#result{min-height:64px;max-height:520px;overflow:auto;margin:0;padding:16px;background:var(--output);border:1px solid var(--line);border-radius:4px}#result:empty::before{content:"Your output appears here.";color:var(--muted)}#result-stale{border-left:3px solid #b28132;padding-left:10px}.output-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}#io-console{margin-top:20px}#terminal{max-height:260px;overflow:auto;padding:12px;border-left:3px solid var(--green);background:var(--dock)}#terminal:empty{display:none}#trace{max-height:260px;overflow:auto;list-style:none;padding:0;font:12px/1.6 ui-monospace,monospace}#trace li{padding-block:8px;border-bottom:1px solid var(--line);overflow-wrap:anywhere;white-space:pre-wrap}#trace li.pending{color:var(--green)}.trace-panel{border-top:1px solid var(--line);padding-top:12px}
diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs
@@ -55,6 +55,55 @@ try {
};
for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));}
const report=await evaluate(`import(${JSON.stringify(process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`);
+ if(process.env.TEST_RELOAD){
+ // Exercise a real navigation, not a mocked reload callback. Runtime state is
+ // deliberately non-returning when recovery is accepted.
+ const waitFor=async(expression,label)=>{
+ for(let i=0;i<3600;i++){
+ try{if(await evaluate(expression))return;}catch(error){if(!/context|navigat|Cannot find/i.test(error.message))throw error;}
+ await new Promise(r=>setTimeout(r,25));
+ }
+ throw Error('Recovery timeout: '+label);
+ };
+ const sourceFreeLocals=await evaluate("document.getElementById('source-editor').hidden&&document.getElementById('local-module-list').children.length>0");
+ if(sourceFreeLocals){
+ await evaluate("window.__localRecoveryPrompts=0;const savedConfirm=window.confirm;window.confirm=()=>{window.__localRecoveryPrompts++;return false;};document.getElementById('reload-playground').click();window.confirm=savedConfirm");
+ assert.equal(await evaluate('window.__localRecoveryPrompts'),1,'Opened modules warn even without an editable draft');
+ }
+ await evaluate(`(()=>{
+ document.querySelector('[data-compiler-example=identity]').click();
+ const source=document.getElementById('source-editor');
+ source.value='answer = 42\\nloop = (x : x x) (x : x x)';source.dispatchEvent(new Event('input'));
+ })()`);
+ await waitFor("document.getElementById('status').textContent==='Compiled'",'scratch compile');
+ assert.equal(await evaluate("document.getElementById('reload-playground').disabled"),false,'Recovery available for local drafts');
+ const draftBefore=await evaluate("document.getElementById('source-editor').value");
+ const localsBefore=await evaluate("document.getElementById('local-module-list').children.length");
+ await evaluate("window.__reloadProof=true;window.__recoveryPrompts=0;window.confirm=()=>{window.__recoveryPrompts++;return false;};document.getElementById('reload-playground').click()");
+ await new Promise(r=>setTimeout(r,100));
+ assert.equal(await evaluate('window.__recoveryPrompts'),1,'Draft recovery asks before clearing work');
+ assert.equal(await evaluate("document.getElementById('source-editor').value"),draftBefore,'Cancelled recovery preserves authored source');
+ assert.equal(await evaluate("document.getElementById('local-module-list').children.length"),localsBefore,'Cancelled recovery preserves local files');
+ await evaluate(`(()=>{
+ const expression=document.getElementById('expression-editor');expression.value='loop';expression.dispatchEvent(new Event('input'));
+ const output=document.getElementById('output-type');output.value='tricu';output.dispatchEvent(new Event('change'));
+ document.getElementById('mode').value='pure';document.getElementById('run').click();
+ })()`);
+ await waitFor("document.getElementById('status').textContent==='Running'",'divergent WASM execution');
+ assert.equal(await evaluate("document.getElementById('reload-playground').disabled"),false,'Recovery remains available while WASM is stuck');
+ await evaluate("window.confirm=()=>true;document.getElementById('reload-playground').click()");
+ await waitFor("!window.__reloadProof&&document.getElementById('status')?.textContent==='Ready'",'new page/runtime');
+ assert.equal(await evaluate("document.getElementById('source-editor').readOnly||document.getElementById('source-editor').hidden"),false,'Reload restores editable demo source');
+ assert.equal(await evaluate("document.getElementById('local-module-list').children.length"),0,'Reload clears in-memory local modules');
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'','Reload never automatically evaluates');
+ assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Recovery toolbar fits viewport');
+ await evaluate("document.getElementById('program-picker').value='demo:arithmetic';document.getElementById('program-picker').dispatchEvent(new Event('change'))");
+ await waitFor("document.getElementById('status').textContent==='Ready'",'demo switch after recovery');
+ await evaluate("document.getElementById('run').click()");
+ await waitFor("document.getElementById('status').textContent==='Finished'",'fresh WASM evaluation');
+ assert.equal(await evaluate("document.getElementById('result').textContent"),'12','Fresh runtime works after recovery and demo switch');
+ report.checks.push('full-page toolbar recovery: confirmation/cancellation; available during divergent WASM execution; new page/runtime; cleared local state; editable demos and successful evaluation');
+ }
assert.deepEqual(exceptions,[]);
if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){
for(const width of [320,390,1440]){
diff --git a/ext/demo-site/test/firefox-idle.mjs b/ext/demo-site/test/firefox-idle.mjs
@@ -0,0 +1,174 @@
+// Real Firefox/WebDriver BiDi soak against the packaged page. No evaluator/UI mocks.
+// Each published/compiled session is left untouched in a genuinely hidden tab
+// for its entire requested interval; earlier checkpoints never rebuild later ones.
+import assert from 'node:assert/strict';
+import {spawn} from 'node:child_process';
+import {mkdtemp,rm,writeFile,realpath} from 'node:fs/promises';
+import {resolve} from 'node:path';
+import {startSiteServer} from './backend-server.mjs';
+
+const minutes=(process.env.IDLE_MINUTES??'10,20,30').split(',').map(Number);
+assert(minutes.length>0&&minutes.length<=6&&minutes.every(n=>Number.isFinite(n)&&n>0),'Positive IDLE_MINUTES, at most six checkpoints');
+const output=resolve(process.env.IDLE_REPORT??'/tmp/tricu-firefox-idle.json');
+const site=resolve(process.env.ARBORICX_SITE_ROOT??'result-site');
+const {server,url}=await startSiteServer(resolve(site,'runtime/haskell'));
+const profile=await mkdtemp('/tmp/tricu-firefox-idle-');
+const report={schema:1,started:new Date().toISOString(),site:await realpath(site),minutes,
+ scope:'Headless Firefox on Linux; packaged page and both WASM/compiler bundles served by the existing loopback fixture server. Real inactive tabs, no page polling during idle. Each interval has independent published and compiled Haskell sessions. No OS suspend, memory-pressure injection, production network or extensions.',
+ events:[],checkpoints:[],ok:false};
+let browser,socket,next=0,neutral,stderr='',failure;
+const pending=new Map();
+const pause=ms=>new Promise(r=>setTimeout(r,ms));
+const save=()=>writeFile(output,JSON.stringify(report,null,2)+'\n');
+function send(method,params={},timeout=90_000){
+ const id=++next;
+ return new Promise((resolve,reject)=>{
+ const timer=setTimeout(()=>{pending.delete(id);reject(Error('BiDi timeout: '+method));},timeout);
+ pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params}));
+ });
+}
+async function evaluate(context,expression){
+ const reply=await send('script.evaluate',{target:{context},awaitPromise:true,
+ expression:`Promise.resolve((${expression})).then(value=>JSON.stringify(value??null))`});
+ if(reply.type!=='success')throw Error('Page evaluation: '+JSON.stringify(reply));
+ return JSON.parse(reply.result.value);
+}
+const snapshot=context=>evaluate(context,`(()=>{
+ const get=id=>document.getElementById(id);
+ return {at:new Date().toISOString(),visibility:document.visibilityState,status:get('status')?.textContent,
+ backend:get('runtime-backend')?.value,program:get('program-picker')?.value,
+ sourceHidden:get('source-editor')?.hidden,sourceReadOnly:get('source-editor')?.readOnly,
+ sourceDisabled:get('source-editor')?.disabled,source:get('source-editor')?.value,
+ expression:get('expression-editor')?.value,result:get('result')?.textContent,
+ diagnostic:get('compile-diagnostic')?.textContent,runDisabled:get('run')?.disabled,
+ errors:window.__idleErrors,visibilityChanges:window.__idleVisibility};
+})()`);
+async function wait(context,condition,label){
+ const until=Date.now()+90_000;
+ while(Date.now()<until){if(await evaluate(context,condition))return;await pause(50);}
+ throw Error('Timeout '+label+': '+JSON.stringify(await snapshot(context)));
+}
+const status=(context,text)=>wait(context,`document.getElementById('status')?.textContent===${JSON.stringify(text)}`,text);
+const input=(context,id,value)=>evaluate(context,`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input'));})()`);
+const change=(context,id,value)=>evaluate(context,`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change'));})()`);
+const click=(context,id)=>evaluate(context,`document.getElementById(${JSON.stringify(id)}).click()`);
+async function editable(context){
+ const state=await snapshot(context);
+ assert.equal(state.sourceHidden,false,JSON.stringify(state));
+ assert.equal(state.sourceReadOnly,false,JSON.stringify(state));
+ assert.equal(state.sourceDisabled,false,JSON.stringify(state));
+ assert.deepEqual(state.errors??[],[],JSON.stringify(state));
+ return state;
+}
+async function run(context,expression,expected,codec='number'){
+ await input(context,'expression-editor',expression);await change(context,'output-type',codec);
+ assert.equal(await evaluate(context,"document.getElementById('run').disabled"),false,'Evaluate enabled');
+ await click(context,'run');await status(context,'Finished');
+ assert.equal(await evaluate(context,"document.getElementById('result').textContent"),expected,expression);
+ await editable(context);
+}
+async function select(context,name){await change(context,'program-picker','demo:'+name);await status(context,'Ready');await editable(context);}
+async function exercise(job){
+ const {context,mode}=job;
+ job.resumedAt=new Date().toISOString();job.idleSeconds=(Date.now()-job.backgroundAt)/1000;
+ job.before=await snapshot(context);
+ assert.equal(job.before.visibility,'hidden','Tab remained inactive before resuming');
+ assert(job.idleSeconds>=job.minutes*60,'Complete idle interval');
+ assert.equal(job.before.visibilityChanges.filter(e=>e.visibility==='visible').length,0,'No accidental activation during idle');
+ await send('browsingContext.activate',{context});
+ assert.equal(await evaluate(context,'document.visibilityState'),'visible','Real foreground transition');
+ await editable(context);
+ // First operation uses the original retained session, before any replacement.
+ await run(context,'answer',mode==='compiled'?'43':'42');
+ await evaluate(context,`document.querySelector('[aria-label="Inspect compiled definition answer"]').click()`);
+ await wait(context,`document.querySelector('.definition-card pre')?.textContent===${JSON.stringify(mode==='compiled'?'43':'42')}`,'retained inline inspection');
+ await evaluate(context,"document.querySelector('.definition-card button').click()");
+ for(let cycle=0;cycle<2;cycle++){
+ await select(context,'arithmetic');await run(context,'total','12');
+ const source=await evaluate(context,"document.getElementById('source-editor').value");
+ assert(source.includes('Math.add 5 7'),'Known independently checked arithmetic specimen');
+ await input(context,'source-editor',source.replace('Math.add 5 7','Math.add 8 9'));
+ await status(context,'Compiled');await run(context,'total','17');
+ await click(context,'restore-source');await status(context,'Ready');await run(context,'total','12');
+ await select(context,'functions');await run(context,'double 6','12');
+ await select(context,'language-reference');await run(context,'identity 23','23');
+ }
+ // Ordinary diagnostics must not strand the published/draft editor read-only.
+ await input(context,'expression-editor','unknownIdleName');await click(context,'run');await status(context,'Stopped');await editable(context);
+ await run(context,'identity 24','24');
+ await input(context,'source-editor','!export answer\nanswer = (');await status(context,'Compilation failed');await editable(context);
+ await click(context,'restore-source');await status(context,'Ready');await run(context,'identity 25','25');
+ job.after=await snapshot(context);job.ok=true;
+ console.log(`PASS ${job.minutes} min ${mode}: ${job.idleSeconds.toFixed(1)}s hidden; retained evaluation/inspection, switching, compilation, restoration and diagnostic recovery`);
+ await send('browsingContext.activate',{context:neutral});
+ await send('browsingContext.close',{context});
+ await save();
+}
+try{
+ browser=spawn(process.argv[2]??'firefox',['--headless','--no-remote','--new-instance','--remote-debugging-port','0','--profile',profile,'about:blank'],{
+ stdio:['ignore','ignore','pipe'],env:{...process.env,HOME:profile,XDG_CONFIG_HOME:resolve(profile,'config'),XDG_CACHE_HOME:resolve(profile,'cache'),XDG_DATA_HOME:resolve(profile,'data'),MOZ_CRASHREPORTER_DISABLE:'1',GSETTINGS_BACKEND:'memory'},
+ });
+ const endpoint=await new Promise((resolve,reject)=>{
+ const timer=setTimeout(()=>reject(Error('Firefox BiDi startup: '+stderr)),20_000);
+ browser.on('error',error=>{clearTimeout(timer);reject(error);});
+ browser.on('exit',code=>{clearTimeout(timer);reject(Error('Firefox exited '+code));});
+ browser.stderr.on('data',chunk=>{stderr=(stderr+chunk).slice(-32_000);const match=stderr.match(/WebDriver BiDi listening on (ws:\/\/[^\s]+)/);if(match){clearTimeout(timer);resolve(match[1]);}});
+ });
+ socket=new WebSocket(endpoint+'/session');await new Promise((r,j)=>{socket.onopen=r;socket.onerror=j;});
+ socket.onmessage=({data})=>{
+ const message=JSON.parse(data);
+ if(message.id){const waiter=pending.get(message.id);if(!waiter)return;clearTimeout(waiter.timer);pending.delete(message.id);
+ if(message.type==='error')waiter.reject(Error(JSON.stringify(message)));else waiter.resolve(message.result);
+ }else if((message.method==='log.entryAdded'&&message.params.level==='error')||message.method==='network.fetchError'||(message.method==='network.responseCompleted'&&message.params.response.status>=400)){
+ report.events.push({at:new Date().toISOString(),...message});
+ }
+ };
+ const session=await send('session.new',{capabilities:{}});report.capabilities=session.capabilities;
+ await send('session.subscribe',{events:['log.entryAdded','network.fetchError','network.responseCompleted']});
+ neutral=(await send('browsingContext.getTree')).contexts[0].context;
+ for(const interval of minutes)for(const mode of ['published','compiled']){
+ const context=(await send('browsingContext.create',{type:'tab',background:true})).context;
+ await send('browsingContext.activate',{context});
+ await send('browsingContext.navigate',{context,url,wait:'complete'});
+ await evaluate(context,`(()=>{
+ window.__idleErrors=[];window.__idleVisibility=[];
+ window.addEventListener('error',event=>window.__idleErrors.push({type:'error',message:event.message,filename:event.filename,line:event.lineno}));
+ window.addEventListener('unhandledrejection',event=>window.__idleErrors.push({type:'rejection',message:String(event.reason)}));
+ document.addEventListener('visibilitychange',()=>window.__idleVisibility.push({visibility:document.visibilityState,at:Date.now()}));
+ })()`);
+ await status(context,'Ready');
+ if(mode==='compiled'){
+ const source=await evaluate(context,"document.getElementById('source-editor').value");
+ assert(source.includes('answer = 42'));await input(context,'source-editor',source.replace('answer = 42','answer = 43'));
+ await status(context,'Compiled');
+ }
+ await run(context,'answer',mode==='compiled'?'43':'42');
+ const job={context,mode,minutes:interval};report.checkpoints.push(job);
+ await send('browsingContext.activate',{context:neutral});
+ }
+ // Start all independent intervals only after every session has been warmed.
+ for(const job of report.checkpoints){
+ assert.equal(await evaluate(job.context,'document.visibilityState'),'hidden');
+ await evaluate(job.context,'window.__idleVisibility=[]');job.backgroundAt=Date.now();
+ }
+ await save();console.log(`Warmed ${report.checkpoints.length} independent Haskell sessions in Firefox ${report.capabilities.browserVersion}. Background waits: ${minutes.join(', ')} minutes.`);
+ for(const job of [...report.checkpoints].sort((a,b)=>a.backgroundAt+a.minutes*60_000-b.backgroundAt-b.minutes*60_000)){
+ await pause(Math.max(0,job.backgroundAt+job.minutes*60_000-Date.now()));
+ try{await exercise(job);}catch(error){job.error=String(error);try{job.failureState=await snapshot(job.context);}catch(capture){job.captureError=String(capture);}
+ try{const shot=await send('browsingContext.captureScreenshot',{context:job.context});await writeFile(output+'.failure.png',Buffer.from(shot.data,'base64'));}catch{}
+ throw error;
+ }
+ }
+ assert.deepEqual(report.events,[],'Unexpected browser console/network errors');
+ report.ok=true;
+}catch(error){failure=error;report.error=error.stack??String(error);}
+finally{
+ report.finished=new Date().toISOString();report.stderr=stderr;await save();
+ if(socket?.readyState===WebSocket.OPEN){try{await send('browser.close',{},5000);}catch{}socket.close();}
+ if(browser?.exitCode===null&&browser.signalCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill('SIGKILL');await exited;}
+ for(const waiter of pending.values()){clearTimeout(waiter.timer);waiter.reject(Error('Browser closed'));}pending.clear();
+ server.closeAllConnections();await new Promise(r=>server.close(r));
+ await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100});
+}
+if(failure)throw failure;
+console.log(`PASS Firefox idle soak. Report: ${output}`);
diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs
@@ -83,3 +83,19 @@ test('import picker and static agent entrypoint are discoverable and accessible'
assert.match(html,/<footer[^>]*>.*For AI assistants.*<\/footer>/);
const source=await read('src/import-picker.ts');assert.match(source,/textContent/);assert.doesNotMatch(source,/innerHTML/);
});
+
+test('full Playground recovery stays in the toolbar and warns before losing local work',async()=>{
+ const html=await read('index.html'),app=await read('src/app.ts');
+ const button=html.match(/<button id="reload-playground"[^>]*>Reload Playground<\/button>/)?.[0];
+ assert.ok(button);assert.doesNotMatch(button,/disabled|hidden/);
+ assert.ok(html.indexOf('id="reload-playground"')<html.indexOf('id="editor-body"'));
+ assert.match(app,/draft \|\| localModules\.entries\(\)\.length > 0/);
+ assert.match(app,/window\.confirm\(/);assert.match(app,/location\.reload\(\)/);
+ assert.doesNotMatch(app,/reload-playground'\)\.disabled/);
+});
+test('Ecosystem describes the live content store serving this Playground',async()=>{
+ const html=await read('index.html');
+ assert.match(html,/This Playground gets its demo, library and compiler bundles from a live Arboricx content store server written in tricu/);
+ assert.match(html,/verifies their hashes before using them/);
+ assert.doesNotMatch(html,/same language used for the examples can serve its own packages/);
+});