/** * DOM-walk + forms-module scenarios for the Statum client. * * Each scenario runs in a FRESH QuickJS context (run.sh invokes this script * once per scenario) with a minimal DOM shim (shim.js) that supports the * render walk and the forms module end to end, including capture-phase * events, form reset defaults and a scriptable fetch stub. * * State enters through the real machinery: a `set` directive returned by the * fetch stub and applied by bridge.loadEntrypoint() (which re-renders through * applyDirectives -> renderAll). Signatures vary per step so the idempotency * guard never short-circuits a deliberate re-render. * * 1 walk: stm-if/stm-else chain, stm-text, stm-class.* * 2 stm-for keyed: node identity preserved per key, removal honored * 3 stm-for unkeyed: instances rebuilt (all identities change) * 4 stm-prefill: input + textarea property writes (no attribute) * 5 stm-prefill: select single + multiple (array) * 6 stm-prefill: checkbox truthy/falsy/null * 7 stm-prefill: radio group, same expression per member * 8 dirty skip: dirty input keeps user value, clean sibling re-fills * 9 dirty is per element within a form * 10 reset event un-dirties; prefill re-applies after reset * 11 stm-clear-on-success: success resets + un-dirties the form * 12 stm-clear-on-success: failure keeps values and dirty state * 13 keyed loop of forms: clearing instance 2 leaves 1 and 3 alone * 14 two sibling forms: clearing A leaves B alone * 15 statum.document.reset: resets exactly the given form * 16 handler order: stm-prefill registered before stm-class.* * 17 reenter: action triggers an in-place entrypoint refresh (fetched twice) * 18 reenter ordering: refresh before navigate, both execute * 19 reenter failure: logged only; navigate still runs; promise resolves * 20 stm-on-success: applied on success, cleared on the next attempt * 21 stm-on-error/on-success mirroring: failure clears success + vice versa * 22 stm-clear-on-error: a failing action resets the form * 23 declined stm-confirm does NOT clear (clear-on-error present) * 24 headless core: stubbed fetch applies state, no walk, no crash * * Usage: qjs tests/dom/scenarios.js <1..24> */ import * as std from 'std'; import { installShim } from './shim.js'; // Repo root as a CWD-relative path; run.sh cds to the repo root first (same // convention as tests/markdown/spec-test.js). var ROOT = '.'; var scenario = scriptArgs.length > 1 ? scriptArgs[1] : ''; function fail(msg) { std.err.puts('FAIL [' + scenario + '] ' + msg + '\n'); std.exit(1); } function check(cond, msg) { if (!cond) fail(msg); } function load(name) { (0, eval)(std.loadFile(ROOT + '/js/' + name)); } function loadAll() { load('statum-core.js'); load('statum-document.js'); load('statum-markdown.js'); } /** Yield to the job queue n times so promise chains settle. */ async function spin(n) { for (var i = 0; i < (n || 30); i++) await Promise.resolve(); } var sigCounter = 0; /** * Put `public` data into a slot type through a set directive, then re-render. * Uses loadEntrypoint (public bridge machinery) so the exact production path * (fetch -> handleResponse -> applyDirectives -> renderAll) runs. */ async function setState(typeName, publicData) { sigCounter++; var snapshot = { slot: { key: 'k-' + typeName, type: typeName, scope: 'page' }, public: publicData }; globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([{ type: 'set', snapshot_frame: { content: JSON.stringify(snapshot), signature: 'sig-' + sigCounter } }]); }); await globalThis.statum._bridge.loadEntrypoint(); await spin(); } /** Fire a real (bubbling, non-cancelable-by-default) event on an element. */ function fire(el, type) { var evt = globalThis.__shim.makeEvent(type); el.dispatchEvent(evt); return evt; } function el(tag) { return globalThis.__shim.makeElement(tag); } function input(type, name, value) { var i = el('input'); if (type) i.setAttribute('type', type); if (name) i.setAttribute('name', name); if (value !== undefined) i.setAttribute('value', value); return i; } async function main() { installShim(); var log = globalThis.__shim.log; var body = globalThis.__shim.body; // Scenario 24 runs HEADLESS: core alone, no document module. if (scenario !== '24') loadAll(); else load('statum-core.js'); var s = globalThis.statum; check(s && s._bridge, 'statum did not load'); if (scenario === '24') { // Headless core: stub the entrypoint fetch, boot, and prove state was // applied with no walk and no crash. check(s.registerAttribute === undefined, 'headless core must not expose registerAttribute (it is document API)'); check(s.document === undefined, 'headless core must not expose statum.document'); sigCounter++; var snap = { slot: { key: 'k-h', type: 'h', scope: 'page' }, public: { hello: 'world' } }; globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([{ type: 'set', snapshot_frame: { content: JSON.stringify(snap), signature: 'sig-h-' + sigCounter } }]); }); await s.init(); await spin(50); check(s.read('h') && s.read('h').hello === 'world', 'headless boot did not apply the entrypoint state: ' + JSON.stringify(s.state())); check(JSON.stringify(s.state()) === '{"h":{"hello":"world"}}', 'unexpected state: ' + JSON.stringify(s.state())); // A render cycle with no driver and no hooks must not crash or warn. s._bridge.renderAll(); await spin(20); check(log.errors.length === 0, 'headless core errored: ' + JSON.stringify(log.errors)); check(log.warns.length === 0, 'headless core warned: ' + JSON.stringify(log.warns)); print('scenario ' + scenario + ': OK'); return; } check(s.document && typeof s.document.reset === 'function', 'statum.document.reset missing'); check(typeof s.document.markDirty === 'function' && typeof s.document.isDirty === 'function', 'statum.document.markDirty/isDirty missing'); check(s.forms === undefined, 'statum.forms must be gone (merged into statum.document)'); check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing'); check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function', 'bridge.markDirty/isDirty missing'); if (scenario === '1') { // if/else-if/else chain + stm-text + stm-class.* var w = el('div'); var a = el('p'); a.setAttribute('stm-if', "m.mood === 'up'"); a.textContent = 'up'; var b = el('p'); b.setAttribute('stm-else-if', "m.mood === 'mid'"); b.textContent = 'mid'; var c = el('p'); c.setAttribute('stm-else', ''); c.textContent = 'down'; w.appendChild(a); w.appendChild(b); w.appendChild(c); var t = el('span'); t.setAttribute('stm-text', 'm.mood'); var cl = el('span'); cl.setAttribute('stm-class.hi', "m.mood === 'up'"); w.appendChild(t); w.appendChild(cl); body.appendChild(w); await setState('m', { mood: 'up' }); check(!a.hasAttribute('hidden') && b.hasAttribute('hidden') && c.hasAttribute('hidden'), 'branch up not exclusively shown'); check(t.textContent === 'up', 'stm-text wrote ' + JSON.stringify(t.textContent)); check(cl.classList.contains('hi'), 'stm-class.hi not applied'); await setState('m', { mood: 'mid' }); check(a.hasAttribute('hidden') && !b.hasAttribute('hidden') && c.hasAttribute('hidden'), 'branch mid not exclusively shown'); check(!cl.classList.contains('hi'), 'stm-class.hi not removed'); check(t.textContent === 'mid', 'stm-text not updated'); await setState('m', { mood: 'zzz' }); check(a.hasAttribute('hidden') && b.hasAttribute('hidden') && !c.hasAttribute('hidden'), 'else branch not shown'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '2') { // Keyed loop: identity per key preserved; removal honored. var holder = el('div'); holder.setAttribute('stm-for-item-in', 'list.items'); holder.setAttribute('stm-key', 'item.id'); var row = el('p'); row.setAttribute('stm-text', 'item.name'); holder.appendChild(row); body.appendChild(holder); await setState('list', { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }] }); var insts = holder.parentNode.children.filter(function (n) { return n !== holder; }); // holder is children[0]; instances follow it in order. var kids = body.children.filter(function (n) { return n !== holder; }); check(kids.length === 3, 'expected 3 instances, got ' + kids.length); var k1 = kids[0], k2 = kids[1], k3 = kids[2]; // The stm-text binding is on the template's child, so read the child. check(k1.children[0].textContent === 'a' && k2.children[0].textContent === 'b' && k3.children[0].textContent === 'c', 'instance texts: ' + kids.map(function (n) { return n.children[0] && n.children[0].textContent; }).join(',')); await setState('list', { items: [{ id: 1, name: 'a' }, { id: 3, name: 'c3' }] }); var kids2 = body.children.filter(function (n) { return n !== holder; }); check(kids2.length === 2, 'expected 2 instances after removal, got ' + kids2.length); check(kids2[0] === k1, 'key 1 node identity not preserved'); check(kids2[1] === k3, 'key 3 node identity not preserved'); check(kids2[1].children[0].textContent === 'c3', 'key 3 text not refreshed'); check(k2.parentNode === null, 'removed instance still attached'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '3') { // Unkeyed loop: rebuild wholesale. var holder = el('div'); holder.setAttribute('stm-for-item-in', 'list.items'); var row = el('p'); row.setAttribute('stm-text', 'item.name'); holder.appendChild(row); body.appendChild(holder); await setState('list', { items: [{ name: 'a' }, { name: 'b' }] }); var kids = body.children.filter(function (n) { return n !== holder; }); check(kids.length === 2 && kids[0].children[0].textContent === 'a' && kids[1].children[0].textContent === 'b', 'initial render'); await setState('list', { items: [{ name: 'a' }, { name: 'b' }] }); var kids2 = body.children.filter(function (n) { return n !== holder; }); check(kids2.length === 2, 'instance count changed'); check(kids2[0] !== kids[0] && kids2[1] !== kids[1], 'unkeyed loop reused nodes'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '4') { // Prefill writes the value PROPERTY, never the value attribute. var f = el('form'); var ti = input('text', 'who'); ti.setAttribute('stm-prefill', 'p.who'); var ta = el('textarea'); ta.setAttribute('name', 'bio'); ta.setAttribute('stm-prefill', 'p.bio'); f.appendChild(ti); f.appendChild(ta); body.appendChild(f); await setState('p', { who: 'ada', bio: 'hi' }); check(ti.value === 'ada', 'text input value = ' + JSON.stringify(ti.value)); check(ta.value === 'hi', 'textarea value = ' + JSON.stringify(ta.value)); check(ti.getAttribute('value') === null, 'text input value ATTRIBUTE was written'); check(ta.getAttribute('stm-prefill') === 'p.bio', 'stm-prefill attribute consumed'); await setState('p', { who: null, bio: undefined }); check(ti.value === '', 'null should prefill as empty string, got ' + JSON.stringify(ti.value)); check(ta.value === 'hi', 'undefined must leave the element untouched'); await setState('p', { who: 42, bio: 'x' }); check(ti.value === '42', 'number stringified: ' + JSON.stringify(ti.value)); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '5') { // Select single + multiple. var f = el('form'); var sel = el('select'); sel.setAttribute('stm-prefill', 'p.pick'); var o1 = el('option'); o1.setAttribute('value', 'a'); o1.textContent = 'A'; var o2 = el('option'); o2.setAttribute('value', 'b'); o2.textContent = 'B'; sel.appendChild(o1); sel.appendChild(o2); var multi = el('select'); multi.setAttribute('multiple', ''); multi.setAttribute('stm-prefill', 'p.set'); var m1 = el('option'); m1.setAttribute('value', 'x'); var m2 = el('option'); m2.setAttribute('value', 'y'); var m3 = el('option'); m3.setAttribute('value', 'z'); multi.appendChild(m1); multi.appendChild(m2); multi.appendChild(m3); f.appendChild(sel); f.appendChild(multi); body.appendChild(f); await setState('p', { pick: 'b', set: ['x', 'z'] }); check(sel.value === 'b', 'single select value = ' + JSON.stringify(sel.value)); check(o2.selected === true && o1.selected === false, 'single select option states wrong'); check(m1.selected === true && m2.selected === false && m3.selected === true, 'multiple select option states wrong'); await setState('p', { pick: 'nope', set: 'y' }); check(sel.value === '', 'unmatched single select should yield empty value'); check(m1.selected === false && m2.selected === true && m3.selected === false, 'non-array multiple value must behave like single'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '6') { // Checkbox: truthy -> checked, falsy/null -> unchecked. var f = el('form'); var cb = input('checkbox', 'ok'); cb.setAttribute('stm-prefill', 'p.ok'); f.appendChild(cb); body.appendChild(f); await setState('p', { ok: true }); check(cb.checked === true, 'truthy did not check'); await setState('p', { ok: false }); check(cb.checked === false, 'falsy did not uncheck'); await setState('p', { ok: null }); check(cb.checked === false, 'null did not uncheck'); await setState('p', { ok: 'yes' }); check(cb.checked === true, 'non-empty string should check'); await setState('p', {}); // ok undefined -> untouched check(cb.checked === true, 'undefined must leave checked untouched'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '7') { // Radio group: every member carries the same expression; the member whose // own value matches the resolved value gets checked. var f = el('form'); var mk = function (v) { var r = input('radio', 'g', v); r.setAttribute('stm-prefill', 'p.choice'); f.appendChild(r); return r; }; var ra = mk('a'), rb = mk('b'), rc = mk('c'); body.appendChild(f); await setState('p', { choice: 'b' }); check(ra.checked === false && rb.checked === true && rc.checked === false, 'state b checked the wrong member(s)'); await setState('p', { choice: 'd' }); check(ra.checked === false && rb.checked === false && rc.checked === false, 'state d (no match) must leave all unchecked'); await setState('p', { choice: null }); check(rb.checked === false, 'null must uncheck (String(null) matches nothing)'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '8') { // A dirty element is never overwritten; a clean sibling still fills. var f = el('form'); var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.one'); var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.two'); f.appendChild(i1); f.appendChild(i2); body.appendChild(f); await setState('p', { one: 'x', two: 'x' }); check(i1.value === 'x' && i2.value === 'x', 'initial fill failed'); fire(i1, 'input'); // user edits input 1 check(s.document.isDirty(i1), 'input event did not mark the element dirty'); await setState('p', { one: 'y', two: 'y' }); check(i1.value === 'x', 'dirty element was overwritten: ' + JSON.stringify(i1.value)); check(i2.value === 'y', 'clean sibling not re-filled'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '9') { // Dirtiness is per element. var f = el('form'); var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.v'); var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.v'); f.appendChild(i1); f.appendChild(i2); body.appendChild(f); await setState('p', { v: 'a' }); fire(i1, 'change'); await setState('p', { v: 'b' }); check(i1.value === 'a', 'dirty element overwritten'); check(i2.value === 'b', 'clean element not filled'); check(s.document.isDirty(i1) && !s.document.isDirty(i2), 'dirty flags wrong'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '10') { // Reset restores defaults, un-dirties, and prefill re-applies. var f = el('form'); var i1 = input('text', 'one', 'init'); i1.setAttribute('stm-prefill', 'p.v'); f.appendChild(i1); body.appendChild(f); await setState('p', { v: 'state' }); check(i1.value === 'state', 'prefill did not apply'); fire(i1, 'input'); check(s.document.isDirty(i1), 'not dirty after edit'); f.reset(); check(i1.value === 'init', 'reset did not restore the attribute default: ' + JSON.stringify(i1.value)); check(!s.document.isDirty(i1), 'reset did not un-dirty'); await setState('p', { v: 'zzz' }); check(i1.value === 'zzz', 'prefill did not re-apply after reset'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '11') { // Clear-on-success: action resolves -> form resets and un-dirties. var f = el('form'); f.setAttribute('stm-action', 'p.act'); f.setAttribute('stm-clear-on-success', ''); var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v'); f.appendChild(i1); body.appendChild(f); await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } }); check(i1.value === 'typed', 'prefill did not apply'); fire(i1, 'input'); // user edits -> dirty; reset must still win via clear globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(f, 'submit'); await spin(50); check(globalThis.__shim.fetchCalls.some(function (c) { return c.url === '/save'; }), 'the action request never fired'); check(i1.value === 'dflt', 'form not reset after success: ' + JSON.stringify(i1.value)); check(!s.document.isDirty(i1), 'form not un-dirtied after success'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '12') { // Clear-on-success: failure keeps values and dirty state. var f = el('form'); f.setAttribute('stm-action', 'p.act'); f.setAttribute('stm-clear-on-success', ''); var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v'); f.appendChild(i1); body.appendChild(f); await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } }); fire(i1, 'input'); i1.value = 'edited'; globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); }); fire(f, 'submit'); await spin(50); check(i1.value === 'edited', 'form was reset despite failure: ' + JSON.stringify(i1.value)); check(s.document.isDirty(i1), 'dirty state lost on failure'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); // Error-directive variant (statum response carrying an error directive). globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]); }); fire(f, 'submit'); await spin(50); check(i1.value === 'edited', 'form was reset after an error directive'); // Core logs error directives by design; nothing else may error. check(log.errors.length === 1 && log.errors[0].indexOf('error directive') !== -1, 'unexpected console.error: ' + JSON.stringify(log.errors)); log.errors.length = 0; } else if (scenario === '13') { // Keyed loop of forms: clearing one instance leaves the others intact. var holder = el('div'); holder.setAttribute('stm-for-item-in', 'p.rows'); holder.setAttribute('stm-key', 'item.id'); var row = el('form'); row.setAttribute('stm-action', 'item.act'); row.setAttribute('stm-clear-on-success', ''); var ri = input('text', 'val', 'blank'); ri.setAttribute('stm-prefill', 'item.val'); row.appendChild(ri); holder.appendChild(row); body.appendChild(holder); var mkAct = function (uri) { return { uri: uri, method: 'POST' }; }; await setState('p', { rows: [ { id: 1, val: 'one', act: mkAct('/save1') }, { id: 2, val: 'two', act: mkAct('/save2') }, { id: 3, val: 'three', act: mkAct('/save3') } ] }); var forms = body.querySelectorAll('form').filter(function (n) { // The loop template lives inside the (hidden) holder; instances sit in // their own DIVs outside it. var p = n.parentNode; return p !== holder && p.tagName === 'DIV'; }); check(forms.length === 3, 'expected 3 instance forms, got ' + forms.length); check(forms[0].children[0].value === 'one', 'instance 1 prefill failed'); // Dirty instance 2's input by editing it. var i2 = forms[1].children[0]; i2.value = 'user2'; fire(i2, 'input'); // Instance 1 and 3 inputs are clean (state-filled). globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(forms[1], 'submit'); await spin(50); check(forms[1].children[0].value === 'blank', 'instance 2 not reset: ' + JSON.stringify(forms[1].children[0].value)); check(!s.document.isDirty(forms[1].children[0]), 'instance 2 not un-dirtied'); check(forms[0].children[0].value === 'one', 'instance 1 was touched'); check(forms[2].children[0].value === 'three', 'instance 3 was touched'); var saved = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; }) .map(function (c) { return c.url; }); check(saved.indexOf('/save2') !== -1 && saved.indexOf('/save1') === -1 && saved.indexOf('/save3') === -1, 'wrong action fired: ' + JSON.stringify(saved)); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '14') { // Two sibling forms: clear-on-success on A only. var fa = el('form'); fa.setAttribute('stm-action', 'p.act'); fa.setAttribute('stm-clear-on-success', ''); var ia = input('text', 'a', 'dflt-a'); ia.setAttribute('stm-prefill', 'p.va'); fa.appendChild(ia); var fb = el('form'); fb.setAttribute('stm-action', 'p.act'); var ib = input('text', 'b', 'dflt-b'); ib.setAttribute('stm-prefill', 'p.vb'); fb.appendChild(ib); body.appendChild(fa); body.appendChild(fb); await setState('p', { va: 'A', vb: 'B', act: { uri: '/save', method: 'POST' } }); fire(ib, 'input'); // B becomes dirty globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(fa, 'submit'); await spin(50); check(ia.value === 'dflt-a', 'form A not reset'); check(!s.document.isDirty(ia), 'form A not un-dirtied'); check(ib.value === 'B', 'form B value was touched'); check(s.document.isDirty(ib), 'form B dirty state was touched'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '15') { // statum.forms.reset resets exactly the given form. var fa = el('form'); var ia = input('text', 'a', 'dflt-a'); fa.appendChild(ia); var fb = el('form'); var ib = input('text', 'b', 'dflt-b'); fb.appendChild(ib); body.appendChild(fa); body.appendChild(fb); ia.value = 'x'; fire(ia, 'input'); ib.value = 'y'; fire(ib, 'input'); s.document.reset(fa); check(ia.value === 'dflt-a', 'form A not reset'); check(!s.document.isDirty(ia), 'form A not un-dirtied'); check(ib.value === 'y', 'form B was touched'); check(s.document.isDirty(ib), 'form B dirty state was touched'); // Passing a control inside a form resolves the owning form. ib.value = 'z'; s.document.reset(ib); check(ib.value === 'dflt-b', 'forms.reset(el) did not resolve the owning form'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '16') { // Handler order: stm-prefill first (property writes feed later predicates). var names = s._bridge.attributeHandlers.map(function (h) { return h.name; }); check(names.indexOf('stm-prefill') === 0, 'stm-prefill is not first: ' + JSON.stringify(names)); check(names.indexOf('stm-class.') === 1, 'stm-class. is not second: ' + JSON.stringify(names)); check(names.indexOf('stm-markdown') === names.indexOf('stm-html') + 1, 'markdown ordering broke'); check(names[names.length - 1] === 'stm-h-demotion', 'last handler changed: ' + JSON.stringify(names)); check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns)); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '17') { // reenter: an action response re-fetches the entrypoint in place. var t = el('span'); t.setAttribute('stm-text', 'm.mood'); body.appendChild(t); sigCounter++; var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } }; var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } }; var entrypointCount = 0; globalThis.__shim.setFetch(function (url) { if (String(url).indexOf('/_statum/entrypoint') !== -1) { entrypointCount++; return globalThis.__shim.okResponse([{ type: 'set', snapshot_frame: { content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2), signature: 'sig-r17-' + entrypointCount } }]); } // The action request: reply with a reenter directive. return globalThis.__shim.okResponse([{ type: 'reenter' }]); }); await s._bridge.loadEntrypoint(); await spin(); check(t.textContent === 'first', 'initial entrypoint state not applied: ' + JSON.stringify(t.textContent)); var initialFetches = globalThis.__shim.fetchCalls.length; await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}); await spin(50); var entryFetches = globalThis.__shim.fetchCalls.filter(function (c) { return c.url.indexOf('/_statum/entrypoint') !== -1; }); check(entryFetches.length === 2, 'entrypoint fetched ' + entryFetches.length + ' time(s), expected 2 (initial + reenter)'); check(globalThis.__shim.fetchCalls.length === initialFetches + 2, 'exactly two new requests were expected (action + refresh), got ' + (globalThis.__shim.fetchCalls.length - initialFetches)); check(t.textContent === 'second', 'reentered entrypoint state not applied: ' + JSON.stringify(t.textContent)); check(globalThis.location.href === 'http://localhost/', 'a bare reenter must not navigate'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '18') { // reenter + navigate in one response: refresh first, then navigate wins. var t = el('span'); t.setAttribute('stm-text', 'm.mood'); body.appendChild(t); sigCounter++; var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } }; var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } }; var entrypointCount = 0; var order = []; // shared log: reenter's fetch vs the location change globalThis.__shim.setFetch(function (url) { if (String(url).indexOf('/_statum/entrypoint') !== -1) { entrypointCount++; order.push('entrypoint:' + entrypointCount); return globalThis.__shim.okResponse([{ type: 'set', snapshot_frame: { content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2), signature: 'sig-r18-' + entrypointCount } }]); } return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]); }); // Record the navigation in the shared order log. var originalHref = globalThis.location.href; Object.defineProperty(globalThis.location, 'href', { configurable: true, get: function () { return originalHref; }, set: function (v) { order.push('navigate:' + v); } }); await s._bridge.loadEntrypoint(); await spin(); check(t.textContent === 'first', 'initial entrypoint state not applied'); await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}); await spin(50); check(globalThis.__shim.fetchCalls.filter(function (c) { return c.url.indexOf('/_statum/entrypoint') !== -1; }).length === 2, 'reenter did not refresh the entrypoint'); check(order.indexOf('navigate:/after') !== -1, 'navigate did not run'); check(order.indexOf('entrypoint:2') < order.indexOf('navigate:/after'), 'reenter must run before navigate, order was ' + JSON.stringify(order)); check(t.textContent === 'second', 'refresh state not applied before navigating'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '19') { // A failing reenter refresh: logged, swallowed, navigate still runs, and // the action promise resolves. var t = el('span'); t.setAttribute('stm-text', 'm.mood'); body.appendChild(t); sigCounter++; var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } }; var entrypointCount = 0; globalThis.__shim.setFetch(function (url) { if (String(url).indexOf('/_statum/entrypoint') !== -1) { entrypointCount++; if (entrypointCount === 1) { return globalThis.__shim.okResponse([{ type: 'set', snapshot_frame: { content: JSON.stringify(snap1), signature: 'sig-r19-1' } }]); } return Promise.reject(new Error('network down')); } return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]); }); var navigatedTo = null; Object.defineProperty(globalThis.location, 'href', { configurable: true, get: function () { return 'http://localhost/'; }, set: function (v) { navigatedTo = v; } }); await s._bridge.loadEntrypoint(); await spin(); var resolved = false; var rejected = null; await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}).then(function () { resolved = true; }, function (e) { rejected = e; }); await spin(50); check(resolved && rejected === null, 'action promise should resolve after a failed reenter, rejected=' + String(rejected)); check(log.errors.some(function (m) { return m.indexOf('[statum] reenter: entrypoint refresh failed') !== -1; }), 'reenter failure was not logged: ' + JSON.stringify(log.errors)); check(navigatedTo === '/after', 'navigate did not run after the failed reenter, navigatedTo=' + JSON.stringify(navigatedTo)); check(log.errors.length === 1, 'exactly one console.error expected: ' + JSON.stringify(log.errors)); // Core logs the swallowed reenter failure by design; nothing else may error. log.errors.length = 0; } else if (scenario === '20') { // stm-on-success: applied on success, cleared on the next attempt. var b = el('button'); b.setAttribute('stm-action', 'p.act'); b.setAttribute('stm-on-success', 'saved ok'); body.appendChild(b); await setState('p', { act: { uri: '/save', method: 'POST' } }); globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(b, 'click'); await spin(50); check(b.classList.contains('saved') && b.classList.contains('ok'), 'success classes not applied: ' + JSON.stringify(b.classList)); // A new attempt clears them at attempt start. The fetch stub below never // resolves, freezing the attempt mid-flight so the cleared state (before // any outcome re-applies classes) is observable. var hang = new Promise(function () {}); globalThis.__shim.setFetch(function () { return hang; }); fire(b, 'click'); await spin(20); check(!b.classList.contains('saved') && !b.classList.contains('ok'), 'success classes not cleared on a new attempt'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '21') { // Mirroring: a failure applies stm-on-error and clears stm-on-success; // a later success applies stm-on-success and clears stm-on-error. var b = el('button'); b.setAttribute('stm-action', 'p.act'); b.setAttribute('stm-on-error', 'boom'); b.setAttribute('stm-on-success', 'yay'); body.appendChild(b); await setState('p', { act: { uri: '/save', method: 'POST' } }); globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); }); fire(b, 'click'); await spin(50); check(b.classList.contains('boom'), 'error classes not applied'); check(!b.classList.contains('yay'), 'success classes survived a failure'); log.errors.length = 0; globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(b, 'click'); await spin(50); check(b.classList.contains('yay'), 'success classes not applied after a failure'); check(!b.classList.contains('boom'), 'error classes survived a success'); // And an error-directive failure mirrors the HTTP failure exactly. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]); }); fire(b, 'click'); await spin(50); check(b.classList.contains('boom'), 'error classes not applied (error directive)'); check(!b.classList.contains('yay'), 'success classes survived an error directive'); log.errors.length = 0; check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '22') { // stm-clear-on-error: a failing action resets the form (values and dirty). var f = el('form'); f.setAttribute('stm-action', 'p.act'); f.setAttribute('stm-clear-on-error', ''); var i1 = input('text', 'a', 'dflt'); i1.setAttribute('stm-prefill', 'p.v'); f.appendChild(i1); body.appendChild(f); await setState('p', { v: 'from state', act: { uri: '/save', method: 'POST' } }); i1.value = 'user typed'; fire(i1, 'input'); check(s.document.isDirty(i1), 'input not dirty after edit'); globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); }); fire(f, 'submit'); await spin(50); check(i1.value === 'dflt', 'form was not reset on failure: ' + JSON.stringify(i1.value)); check(!s.document.isDirty(i1), 'form was not un-dirtied on failure'); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); // A success must NOT reset a stm-clear-on-error form. i1.value = 'typed again'; fire(i1, 'input'); globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); }); fire(f, 'submit'); await spin(50); check(i1.value === 'typed again', 'stm-clear-on-error fired on success: ' + JSON.stringify(i1.value)); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); } else if (scenario === '23') { // A declined stm-confirm never clears: decline is cancellation, not error. var f = el('form'); f.setAttribute('stm-action', 'p.act'); f.setAttribute('stm-confirm', 'sure?'); f.setAttribute('stm-clear-on-error', ''); var i1 = input('text', 'a', 'dflt'); f.appendChild(i1); body.appendChild(f); await setState('p', { act: { uri: '/save', method: 'POST' } }); i1.value = 'user typed'; fire(i1, 'input'); // Decline the confirm (custom handler returning false). s.onConfirmHandler = function () { return false; }; fire(f, 'submit'); await spin(50); check(i1.value === 'user typed', 'declined confirm cleared the form: ' + JSON.stringify(i1.value)); check(s.document.isDirty(i1), 'declined confirm un-dirtied the form'); var posts = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; }); check(posts.length === 0, 'a declined confirm still fired a request: ' + JSON.stringify(posts)); check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors)); // The error-directive path of clear-on-error: failure with a Statum body. s.onConfirmHandler = null; // window.confirm fallback: shim returns true i1.value = 'typed more'; fire(i1, 'input'); globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]); }); fire(f, 'submit'); await spin(50); check(i1.value === 'dflt', 'error directive did not reset a clear-on-error form: ' + JSON.stringify(i1.value)); log.errors.length = 0; } else { fail('unknown scenario: ' + scenario); } check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors)); print('scenario ' + scenario + ': OK'); } main().catch(function (e) { std.err.puts('FAIL [' + scenario + '] ' + (e && e.stack || e) + '\n'); std.exit(1); });