scenarios.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. /**
  2. * DOM-walk + forms-module scenarios for the Statum client.
  3. *
  4. * Each scenario runs in a FRESH QuickJS context (run.sh invokes this script
  5. * once per scenario) with a minimal DOM shim (shim.js) that supports the
  6. * render walk and the forms module end to end, including capture-phase
  7. * events, form reset defaults and a scriptable fetch stub.
  8. *
  9. * State enters through the real machinery: a `set` directive returned by the
  10. * fetch stub and applied by bridge.loadEntrypoint() (which re-renders through
  11. * applyDirectives -> renderAll). Signatures vary per step so the idempotency
  12. * guard never short-circuits a deliberate re-render.
  13. *
  14. * 1 walk: stm-if/stm-else chain, stm-text, stm-class.*
  15. * 2 stm-for keyed: node identity preserved per key, removal honored
  16. * 3 stm-for unkeyed: instances rebuilt (all identities change)
  17. * 4 stm-prefill: input + textarea property writes (no attribute)
  18. * 5 stm-prefill: select single + multiple (array)
  19. * 6 stm-prefill: checkbox truthy/falsy/null
  20. * 7 stm-prefill: radio group, same expression per member
  21. * 8 dirty skip: dirty input keeps user value, clean sibling re-fills
  22. * 9 dirty is per element within a form
  23. * 10 reset event un-dirties; prefill re-applies after reset
  24. * 11 stm-clear-on-success: success resets + un-dirties the form
  25. * 12 stm-clear-on-success: failure keeps values and dirty state
  26. * 13 keyed loop of forms: clearing instance 2 leaves 1 and 3 alone
  27. * 14 two sibling forms: clearing A leaves B alone
  28. * 15 statum.document.reset: resets exactly the given form
  29. * 16 handler order: stm-prefill registered before stm-class.*
  30. * 17 reenter: action triggers an in-place entrypoint refresh (fetched twice)
  31. * 18 reenter ordering: refresh before navigate, both execute
  32. * 19 reenter failure: logged only; navigate still runs; promise resolves
  33. * 20 stm-on-success: applied on success, cleared on the next attempt
  34. * 21 stm-on-error/on-success mirroring: failure clears success + vice versa
  35. * 22 stm-clear-on-error: a failing action resets the form
  36. * 23 declined stm-confirm does NOT clear (clear-on-error present)
  37. * 24 headless core: stubbed fetch applies state, no walk, no crash
  38. *
  39. * Usage: qjs tests/dom/scenarios.js <1..24>
  40. */
  41. import * as std from 'std';
  42. import { installShim } from './shim.js';
  43. // Repo root as a CWD-relative path; run.sh cds to the repo root first (same
  44. // convention as tests/markdown/spec-test.js).
  45. var ROOT = '.';
  46. var scenario = scriptArgs.length > 1 ? scriptArgs[1] : '';
  47. function fail(msg) {
  48. std.err.puts('FAIL [' + scenario + '] ' + msg + '\n');
  49. std.exit(1);
  50. }
  51. function check(cond, msg) {
  52. if (!cond) fail(msg);
  53. }
  54. function load(name) {
  55. (0, eval)(std.loadFile(ROOT + '/js/' + name));
  56. }
  57. function loadAll() {
  58. load('statum-core.js');
  59. load('statum-document.js');
  60. load('statum-markdown.js');
  61. }
  62. /** Yield to the job queue n times so promise chains settle. */
  63. async function spin(n) {
  64. for (var i = 0; i < (n || 30); i++) await Promise.resolve();
  65. }
  66. var sigCounter = 0;
  67. /**
  68. * Put `public` data into a slot type through a set directive, then re-render.
  69. * Uses loadEntrypoint (public bridge machinery) so the exact production path
  70. * (fetch -> handleResponse -> applyDirectives -> renderAll) runs.
  71. */
  72. async function setState(typeName, publicData) {
  73. sigCounter++;
  74. var snapshot = { slot: { key: 'k-' + typeName, type: typeName, scope: 'page' }, public: publicData };
  75. globalThis.__shim.setFetch(function () {
  76. return globalThis.__shim.okResponse([{
  77. type: 'set',
  78. snapshot_frame: { content: JSON.stringify(snapshot), signature: 'sig-' + sigCounter }
  79. }]);
  80. });
  81. await globalThis.statum._bridge.loadEntrypoint();
  82. await spin();
  83. }
  84. /** Fire a real (bubbling, non-cancelable-by-default) event on an element. */
  85. function fire(el, type) {
  86. var evt = globalThis.__shim.makeEvent(type);
  87. el.dispatchEvent(evt);
  88. return evt;
  89. }
  90. function el(tag) { return globalThis.__shim.makeElement(tag); }
  91. function input(type, name, value) {
  92. var i = el('input');
  93. if (type) i.setAttribute('type', type);
  94. if (name) i.setAttribute('name', name);
  95. if (value !== undefined) i.setAttribute('value', value);
  96. return i;
  97. }
  98. async function main() {
  99. installShim();
  100. var log = globalThis.__shim.log;
  101. var body = globalThis.__shim.body;
  102. // Scenario 24 runs HEADLESS: core alone, no document module.
  103. if (scenario !== '24') loadAll();
  104. else load('statum-core.js');
  105. var s = globalThis.statum;
  106. check(s && s._bridge, 'statum did not load');
  107. if (scenario === '24') {
  108. // Headless core: stub the entrypoint fetch, boot, and prove state was
  109. // applied with no walk and no crash.
  110. check(s.registerAttribute === undefined,
  111. 'headless core must not expose registerAttribute (it is document API)');
  112. check(s.document === undefined, 'headless core must not expose statum.document');
  113. sigCounter++;
  114. var snap = { slot: { key: 'k-h', type: 'h', scope: 'page' }, public: { hello: 'world' } };
  115. globalThis.__shim.setFetch(function () {
  116. return globalThis.__shim.okResponse([{
  117. type: 'set',
  118. snapshot_frame: { content: JSON.stringify(snap), signature: 'sig-h-' + sigCounter }
  119. }]);
  120. });
  121. await s.init();
  122. await spin(50);
  123. check(s.read('h') && s.read('h').hello === 'world',
  124. 'headless boot did not apply the entrypoint state: ' + JSON.stringify(s.state()));
  125. check(JSON.stringify(s.state()) === '{"h":{"hello":"world"}}',
  126. 'unexpected state: ' + JSON.stringify(s.state()));
  127. // A render cycle with no driver and no hooks must not crash or warn.
  128. s._bridge.renderAll();
  129. await spin(20);
  130. check(log.errors.length === 0, 'headless core errored: ' + JSON.stringify(log.errors));
  131. check(log.warns.length === 0, 'headless core warned: ' + JSON.stringify(log.warns));
  132. print('scenario ' + scenario + ': OK');
  133. return;
  134. }
  135. check(s.document && typeof s.document.reset === 'function', 'statum.document.reset missing');
  136. check(typeof s.document.markDirty === 'function' && typeof s.document.isDirty === 'function',
  137. 'statum.document.markDirty/isDirty missing');
  138. check(s.forms === undefined, 'statum.forms must be gone (merged into statum.document)');
  139. check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
  140. check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function',
  141. 'bridge.markDirty/isDirty missing');
  142. if (scenario === '1') {
  143. // if/else-if/else chain + stm-text + stm-class.*
  144. var w = el('div');
  145. var a = el('p'); a.setAttribute('stm-if', "m.mood === 'up'"); a.textContent = 'up';
  146. var b = el('p'); b.setAttribute('stm-else-if', "m.mood === 'mid'"); b.textContent = 'mid';
  147. var c = el('p'); c.setAttribute('stm-else', ''); c.textContent = 'down';
  148. w.appendChild(a); w.appendChild(b); w.appendChild(c);
  149. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  150. var cl = el('span'); cl.setAttribute('stm-class.hi', "m.mood === 'up'");
  151. w.appendChild(t); w.appendChild(cl);
  152. body.appendChild(w);
  153. await setState('m', { mood: 'up' });
  154. check(!a.hasAttribute('hidden') && b.hasAttribute('hidden') && c.hasAttribute('hidden'),
  155. 'branch up not exclusively shown');
  156. check(t.textContent === 'up', 'stm-text wrote ' + JSON.stringify(t.textContent));
  157. check(cl.classList.contains('hi'), 'stm-class.hi not applied');
  158. await setState('m', { mood: 'mid' });
  159. check(a.hasAttribute('hidden') && !b.hasAttribute('hidden') && c.hasAttribute('hidden'),
  160. 'branch mid not exclusively shown');
  161. check(!cl.classList.contains('hi'), 'stm-class.hi not removed');
  162. check(t.textContent === 'mid', 'stm-text not updated');
  163. await setState('m', { mood: 'zzz' });
  164. check(a.hasAttribute('hidden') && b.hasAttribute('hidden') && !c.hasAttribute('hidden'),
  165. 'else branch not shown');
  166. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  167. } else if (scenario === '2') {
  168. // Keyed loop: identity per key preserved; removal honored.
  169. var holder = el('div');
  170. holder.setAttribute('stm-for-item-in', 'list.items');
  171. holder.setAttribute('stm-key', 'item.id');
  172. var row = el('p');
  173. row.setAttribute('stm-text', 'item.name');
  174. holder.appendChild(row);
  175. body.appendChild(holder);
  176. await setState('list', { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }] });
  177. var insts = holder.parentNode.children.filter(function (n) { return n !== holder; });
  178. // holder is children[0]; instances follow it in order.
  179. var kids = body.children.filter(function (n) { return n !== holder; });
  180. check(kids.length === 3, 'expected 3 instances, got ' + kids.length);
  181. var k1 = kids[0], k2 = kids[1], k3 = kids[2];
  182. // The stm-text binding is on the template's child, so read the child.
  183. check(k1.children[0].textContent === 'a' && k2.children[0].textContent === 'b' &&
  184. k3.children[0].textContent === 'c',
  185. 'instance texts: ' + kids.map(function (n) { return n.children[0] && n.children[0].textContent; }).join(','));
  186. await setState('list', { items: [{ id: 1, name: 'a' }, { id: 3, name: 'c3' }] });
  187. var kids2 = body.children.filter(function (n) { return n !== holder; });
  188. check(kids2.length === 2, 'expected 2 instances after removal, got ' + kids2.length);
  189. check(kids2[0] === k1, 'key 1 node identity not preserved');
  190. check(kids2[1] === k3, 'key 3 node identity not preserved');
  191. check(kids2[1].children[0].textContent === 'c3', 'key 3 text not refreshed');
  192. check(k2.parentNode === null, 'removed instance still attached');
  193. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  194. } else if (scenario === '3') {
  195. // Unkeyed loop: rebuild wholesale.
  196. var holder = el('div');
  197. holder.setAttribute('stm-for-item-in', 'list.items');
  198. var row = el('p');
  199. row.setAttribute('stm-text', 'item.name');
  200. holder.appendChild(row);
  201. body.appendChild(holder);
  202. await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
  203. var kids = body.children.filter(function (n) { return n !== holder; });
  204. check(kids.length === 2 && kids[0].children[0].textContent === 'a' &&
  205. kids[1].children[0].textContent === 'b', 'initial render');
  206. await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
  207. var kids2 = body.children.filter(function (n) { return n !== holder; });
  208. check(kids2.length === 2, 'instance count changed');
  209. check(kids2[0] !== kids[0] && kids2[1] !== kids[1], 'unkeyed loop reused nodes');
  210. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  211. } else if (scenario === '4') {
  212. // Prefill writes the value PROPERTY, never the value attribute.
  213. var f = el('form');
  214. var ti = input('text', 'who'); ti.setAttribute('stm-prefill', 'p.who');
  215. var ta = el('textarea'); ta.setAttribute('name', 'bio'); ta.setAttribute('stm-prefill', 'p.bio');
  216. f.appendChild(ti); f.appendChild(ta);
  217. body.appendChild(f);
  218. await setState('p', { who: 'ada', bio: 'hi' });
  219. check(ti.value === 'ada', 'text input value = ' + JSON.stringify(ti.value));
  220. check(ta.value === 'hi', 'textarea value = ' + JSON.stringify(ta.value));
  221. check(ti.getAttribute('value') === null, 'text input value ATTRIBUTE was written');
  222. check(ta.getAttribute('stm-prefill') === 'p.bio', 'stm-prefill attribute consumed');
  223. await setState('p', { who: null, bio: undefined });
  224. check(ti.value === '', 'null should prefill as empty string, got ' + JSON.stringify(ti.value));
  225. check(ta.value === 'hi', 'undefined must leave the element untouched');
  226. await setState('p', { who: 42, bio: 'x' });
  227. check(ti.value === '42', 'number stringified: ' + JSON.stringify(ti.value));
  228. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  229. } else if (scenario === '5') {
  230. // Select single + multiple.
  231. var f = el('form');
  232. var sel = el('select'); sel.setAttribute('stm-prefill', 'p.pick');
  233. var o1 = el('option'); o1.setAttribute('value', 'a'); o1.textContent = 'A';
  234. var o2 = el('option'); o2.setAttribute('value', 'b'); o2.textContent = 'B';
  235. sel.appendChild(o1); sel.appendChild(o2);
  236. var multi = el('select'); multi.setAttribute('multiple', ''); multi.setAttribute('stm-prefill', 'p.set');
  237. var m1 = el('option'); m1.setAttribute('value', 'x');
  238. var m2 = el('option'); m2.setAttribute('value', 'y');
  239. var m3 = el('option'); m3.setAttribute('value', 'z');
  240. multi.appendChild(m1); multi.appendChild(m2); multi.appendChild(m3);
  241. f.appendChild(sel); f.appendChild(multi);
  242. body.appendChild(f);
  243. await setState('p', { pick: 'b', set: ['x', 'z'] });
  244. check(sel.value === 'b', 'single select value = ' + JSON.stringify(sel.value));
  245. check(o2.selected === true && o1.selected === false, 'single select option states wrong');
  246. check(m1.selected === true && m2.selected === false && m3.selected === true,
  247. 'multiple select option states wrong');
  248. await setState('p', { pick: 'nope', set: 'y' });
  249. check(sel.value === '', 'unmatched single select should yield empty value');
  250. check(m1.selected === false && m2.selected === true && m3.selected === false,
  251. 'non-array multiple value must behave like single');
  252. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  253. } else if (scenario === '6') {
  254. // Checkbox: truthy -> checked, falsy/null -> unchecked.
  255. var f = el('form');
  256. var cb = input('checkbox', 'ok'); cb.setAttribute('stm-prefill', 'p.ok');
  257. f.appendChild(cb);
  258. body.appendChild(f);
  259. await setState('p', { ok: true });
  260. check(cb.checked === true, 'truthy did not check');
  261. await setState('p', { ok: false });
  262. check(cb.checked === false, 'falsy did not uncheck');
  263. await setState('p', { ok: null });
  264. check(cb.checked === false, 'null did not uncheck');
  265. await setState('p', { ok: 'yes' });
  266. check(cb.checked === true, 'non-empty string should check');
  267. await setState('p', {}); // ok undefined -> untouched
  268. check(cb.checked === true, 'undefined must leave checked untouched');
  269. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  270. } else if (scenario === '7') {
  271. // Radio group: every member carries the same expression; the member whose
  272. // own value matches the resolved value gets checked.
  273. var f = el('form');
  274. var mk = function (v) {
  275. var r = input('radio', 'g', v);
  276. r.setAttribute('stm-prefill', 'p.choice');
  277. f.appendChild(r);
  278. return r;
  279. };
  280. var ra = mk('a'), rb = mk('b'), rc = mk('c');
  281. body.appendChild(f);
  282. await setState('p', { choice: 'b' });
  283. check(ra.checked === false && rb.checked === true && rc.checked === false,
  284. 'state b checked the wrong member(s)');
  285. await setState('p', { choice: 'd' });
  286. check(ra.checked === false && rb.checked === false && rc.checked === false,
  287. 'state d (no match) must leave all unchecked');
  288. await setState('p', { choice: null });
  289. check(rb.checked === false, 'null must uncheck (String(null) matches nothing)');
  290. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  291. } else if (scenario === '8') {
  292. // A dirty element is never overwritten; a clean sibling still fills.
  293. var f = el('form');
  294. var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.one');
  295. var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.two');
  296. f.appendChild(i1); f.appendChild(i2);
  297. body.appendChild(f);
  298. await setState('p', { one: 'x', two: 'x' });
  299. check(i1.value === 'x' && i2.value === 'x', 'initial fill failed');
  300. fire(i1, 'input'); // user edits input 1
  301. check(s.document.isDirty(i1), 'input event did not mark the element dirty');
  302. await setState('p', { one: 'y', two: 'y' });
  303. check(i1.value === 'x', 'dirty element was overwritten: ' + JSON.stringify(i1.value));
  304. check(i2.value === 'y', 'clean sibling not re-filled');
  305. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  306. } else if (scenario === '9') {
  307. // Dirtiness is per element.
  308. var f = el('form');
  309. var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.v');
  310. var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.v');
  311. f.appendChild(i1); f.appendChild(i2);
  312. body.appendChild(f);
  313. await setState('p', { v: 'a' });
  314. fire(i1, 'change');
  315. await setState('p', { v: 'b' });
  316. check(i1.value === 'a', 'dirty element overwritten');
  317. check(i2.value === 'b', 'clean element not filled');
  318. check(s.document.isDirty(i1) && !s.document.isDirty(i2), 'dirty flags wrong');
  319. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  320. } else if (scenario === '10') {
  321. // Reset restores defaults, un-dirties, and prefill re-applies.
  322. var f = el('form');
  323. var i1 = input('text', 'one', 'init'); i1.setAttribute('stm-prefill', 'p.v');
  324. f.appendChild(i1);
  325. body.appendChild(f);
  326. await setState('p', { v: 'state' });
  327. check(i1.value === 'state', 'prefill did not apply');
  328. fire(i1, 'input');
  329. check(s.document.isDirty(i1), 'not dirty after edit');
  330. f.reset();
  331. check(i1.value === 'init', 'reset did not restore the attribute default: ' + JSON.stringify(i1.value));
  332. check(!s.document.isDirty(i1), 'reset did not un-dirty');
  333. await setState('p', { v: 'zzz' });
  334. check(i1.value === 'zzz', 'prefill did not re-apply after reset');
  335. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  336. } else if (scenario === '11') {
  337. // Clear-on-success: action resolves -> form resets and un-dirties.
  338. var f = el('form');
  339. f.setAttribute('stm-action', 'p.act');
  340. f.setAttribute('stm-clear-on-success', '');
  341. var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
  342. f.appendChild(i1);
  343. body.appendChild(f);
  344. await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
  345. check(i1.value === 'typed', 'prefill did not apply');
  346. fire(i1, 'input'); // user edits -> dirty; reset must still win via clear
  347. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  348. fire(f, 'submit');
  349. await spin(50);
  350. check(globalThis.__shim.fetchCalls.some(function (c) { return c.url === '/save'; }),
  351. 'the action request never fired');
  352. check(i1.value === 'dflt', 'form not reset after success: ' + JSON.stringify(i1.value));
  353. check(!s.document.isDirty(i1), 'form not un-dirtied after success');
  354. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  355. } else if (scenario === '12') {
  356. // Clear-on-success: failure keeps values and dirty state.
  357. var f = el('form');
  358. f.setAttribute('stm-action', 'p.act');
  359. f.setAttribute('stm-clear-on-success', '');
  360. var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
  361. f.appendChild(i1);
  362. body.appendChild(f);
  363. await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
  364. fire(i1, 'input');
  365. i1.value = 'edited';
  366. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  367. fire(f, 'submit');
  368. await spin(50);
  369. check(i1.value === 'edited', 'form was reset despite failure: ' + JSON.stringify(i1.value));
  370. check(s.document.isDirty(i1), 'dirty state lost on failure');
  371. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  372. // Error-directive variant (statum response carrying an error directive).
  373. globalThis.__shim.setFetch(function () {
  374. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  375. });
  376. fire(f, 'submit');
  377. await spin(50);
  378. check(i1.value === 'edited', 'form was reset after an error directive');
  379. // Core logs error directives by design; nothing else may error.
  380. check(log.errors.length === 1 && log.errors[0].indexOf('error directive') !== -1,
  381. 'unexpected console.error: ' + JSON.stringify(log.errors));
  382. log.errors.length = 0;
  383. } else if (scenario === '13') {
  384. // Keyed loop of forms: clearing one instance leaves the others intact.
  385. var holder = el('div');
  386. holder.setAttribute('stm-for-item-in', 'p.rows');
  387. holder.setAttribute('stm-key', 'item.id');
  388. var row = el('form');
  389. row.setAttribute('stm-action', 'item.act');
  390. row.setAttribute('stm-clear-on-success', '');
  391. var ri = input('text', 'val', 'blank');
  392. ri.setAttribute('stm-prefill', 'item.val');
  393. row.appendChild(ri);
  394. holder.appendChild(row);
  395. body.appendChild(holder);
  396. var mkAct = function (uri) { return { uri: uri, method: 'POST' }; };
  397. await setState('p', {
  398. rows: [
  399. { id: 1, val: 'one', act: mkAct('/save1') },
  400. { id: 2, val: 'two', act: mkAct('/save2') },
  401. { id: 3, val: 'three', act: mkAct('/save3') }
  402. ]
  403. });
  404. var forms = body.querySelectorAll('form').filter(function (n) {
  405. // The loop template lives inside the (hidden) holder; instances sit in
  406. // their own DIVs outside it.
  407. var p = n.parentNode;
  408. return p !== holder && p.tagName === 'DIV';
  409. });
  410. check(forms.length === 3, 'expected 3 instance forms, got ' + forms.length);
  411. check(forms[0].children[0].value === 'one', 'instance 1 prefill failed');
  412. // Dirty instance 2's input by editing it.
  413. var i2 = forms[1].children[0];
  414. i2.value = 'user2';
  415. fire(i2, 'input');
  416. // Instance 1 and 3 inputs are clean (state-filled).
  417. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  418. fire(forms[1], 'submit');
  419. await spin(50);
  420. check(forms[1].children[0].value === 'blank', 'instance 2 not reset: ' + JSON.stringify(forms[1].children[0].value));
  421. check(!s.document.isDirty(forms[1].children[0]), 'instance 2 not un-dirtied');
  422. check(forms[0].children[0].value === 'one', 'instance 1 was touched');
  423. check(forms[2].children[0].value === 'three', 'instance 3 was touched');
  424. var saved = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; })
  425. .map(function (c) { return c.url; });
  426. check(saved.indexOf('/save2') !== -1 && saved.indexOf('/save1') === -1 && saved.indexOf('/save3') === -1,
  427. 'wrong action fired: ' + JSON.stringify(saved));
  428. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  429. } else if (scenario === '14') {
  430. // Two sibling forms: clear-on-success on A only.
  431. var fa = el('form');
  432. fa.setAttribute('stm-action', 'p.act');
  433. fa.setAttribute('stm-clear-on-success', '');
  434. var ia = input('text', 'a', 'dflt-a');
  435. ia.setAttribute('stm-prefill', 'p.va');
  436. fa.appendChild(ia);
  437. var fb = el('form');
  438. fb.setAttribute('stm-action', 'p.act');
  439. var ib = input('text', 'b', 'dflt-b');
  440. ib.setAttribute('stm-prefill', 'p.vb');
  441. fb.appendChild(ib);
  442. body.appendChild(fa); body.appendChild(fb);
  443. await setState('p', { va: 'A', vb: 'B', act: { uri: '/save', method: 'POST' } });
  444. fire(ib, 'input'); // B becomes dirty
  445. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  446. fire(fa, 'submit');
  447. await spin(50);
  448. check(ia.value === 'dflt-a', 'form A not reset');
  449. check(!s.document.isDirty(ia), 'form A not un-dirtied');
  450. check(ib.value === 'B', 'form B value was touched');
  451. check(s.document.isDirty(ib), 'form B dirty state was touched');
  452. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  453. } else if (scenario === '15') {
  454. // statum.forms.reset resets exactly the given form.
  455. var fa = el('form');
  456. var ia = input('text', 'a', 'dflt-a');
  457. fa.appendChild(ia);
  458. var fb = el('form');
  459. var ib = input('text', 'b', 'dflt-b');
  460. fb.appendChild(ib);
  461. body.appendChild(fa); body.appendChild(fb);
  462. ia.value = 'x'; fire(ia, 'input');
  463. ib.value = 'y'; fire(ib, 'input');
  464. s.document.reset(fa);
  465. check(ia.value === 'dflt-a', 'form A not reset');
  466. check(!s.document.isDirty(ia), 'form A not un-dirtied');
  467. check(ib.value === 'y', 'form B was touched');
  468. check(s.document.isDirty(ib), 'form B dirty state was touched');
  469. // Passing a control inside a form resolves the owning form.
  470. ib.value = 'z';
  471. s.document.reset(ib);
  472. check(ib.value === 'dflt-b', 'forms.reset(el) did not resolve the owning form');
  473. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  474. } else if (scenario === '16') {
  475. // Handler order: stm-prefill first (property writes feed later predicates).
  476. var names = s._bridge.attributeHandlers.map(function (h) { return h.name; });
  477. check(names.indexOf('stm-prefill') === 0, 'stm-prefill is not first: ' + JSON.stringify(names));
  478. check(names.indexOf('stm-class.') === 1, 'stm-class. is not second: ' + JSON.stringify(names));
  479. check(names.indexOf('stm-markdown') === names.indexOf('stm-html') + 1, 'markdown ordering broke');
  480. check(names[names.length - 1] === 'stm-h-demotion', 'last handler changed: ' + JSON.stringify(names));
  481. check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns));
  482. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  483. } else if (scenario === '17') {
  484. // reenter: an action response re-fetches the entrypoint in place.
  485. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  486. body.appendChild(t);
  487. sigCounter++;
  488. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  489. var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
  490. var entrypointCount = 0;
  491. globalThis.__shim.setFetch(function (url) {
  492. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  493. entrypointCount++;
  494. return globalThis.__shim.okResponse([{
  495. type: 'set',
  496. snapshot_frame: {
  497. content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
  498. signature: 'sig-r17-' + entrypointCount
  499. }
  500. }]);
  501. }
  502. // The action request: reply with a reenter directive.
  503. return globalThis.__shim.okResponse([{ type: 'reenter' }]);
  504. });
  505. await s._bridge.loadEntrypoint();
  506. await spin();
  507. check(t.textContent === 'first', 'initial entrypoint state not applied: ' + JSON.stringify(t.textContent));
  508. var initialFetches = globalThis.__shim.fetchCalls.length;
  509. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
  510. await spin(50);
  511. var entryFetches = globalThis.__shim.fetchCalls.filter(function (c) {
  512. return c.url.indexOf('/_statum/entrypoint') !== -1;
  513. });
  514. check(entryFetches.length === 2, 'entrypoint fetched ' + entryFetches.length + ' time(s), expected 2 (initial + reenter)');
  515. check(globalThis.__shim.fetchCalls.length === initialFetches + 2,
  516. 'exactly two new requests were expected (action + refresh), got ' +
  517. (globalThis.__shim.fetchCalls.length - initialFetches));
  518. check(t.textContent === 'second', 'reentered entrypoint state not applied: ' + JSON.stringify(t.textContent));
  519. check(globalThis.location.href === 'http://localhost/', 'a bare reenter must not navigate');
  520. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  521. } else if (scenario === '18') {
  522. // reenter + navigate in one response: refresh first, then navigate wins.
  523. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  524. body.appendChild(t);
  525. sigCounter++;
  526. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  527. var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
  528. var entrypointCount = 0;
  529. var order = []; // shared log: reenter's fetch vs the location change
  530. globalThis.__shim.setFetch(function (url) {
  531. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  532. entrypointCount++;
  533. order.push('entrypoint:' + entrypointCount);
  534. return globalThis.__shim.okResponse([{
  535. type: 'set',
  536. snapshot_frame: {
  537. content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
  538. signature: 'sig-r18-' + entrypointCount
  539. }
  540. }]);
  541. }
  542. return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
  543. });
  544. // Record the navigation in the shared order log.
  545. var originalHref = globalThis.location.href;
  546. Object.defineProperty(globalThis.location, 'href', {
  547. configurable: true,
  548. get: function () { return originalHref; },
  549. set: function (v) { order.push('navigate:' + v); }
  550. });
  551. await s._bridge.loadEntrypoint();
  552. await spin();
  553. check(t.textContent === 'first', 'initial entrypoint state not applied');
  554. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
  555. await spin(50);
  556. check(globalThis.__shim.fetchCalls.filter(function (c) {
  557. return c.url.indexOf('/_statum/entrypoint') !== -1;
  558. }).length === 2, 'reenter did not refresh the entrypoint');
  559. check(order.indexOf('navigate:/after') !== -1, 'navigate did not run');
  560. check(order.indexOf('entrypoint:2') < order.indexOf('navigate:/after'),
  561. 'reenter must run before navigate, order was ' + JSON.stringify(order));
  562. check(t.textContent === 'second', 'refresh state not applied before navigating');
  563. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  564. } else if (scenario === '19') {
  565. // A failing reenter refresh: logged, swallowed, navigate still runs, and
  566. // the action promise resolves.
  567. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  568. body.appendChild(t);
  569. sigCounter++;
  570. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  571. var entrypointCount = 0;
  572. globalThis.__shim.setFetch(function (url) {
  573. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  574. entrypointCount++;
  575. if (entrypointCount === 1) {
  576. return globalThis.__shim.okResponse([{
  577. type: 'set',
  578. snapshot_frame: { content: JSON.stringify(snap1), signature: 'sig-r19-1' }
  579. }]);
  580. }
  581. return Promise.reject(new Error('network down'));
  582. }
  583. return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
  584. });
  585. var navigatedTo = null;
  586. Object.defineProperty(globalThis.location, 'href', {
  587. configurable: true,
  588. get: function () { return 'http://localhost/'; },
  589. set: function (v) { navigatedTo = v; }
  590. });
  591. await s._bridge.loadEntrypoint();
  592. await spin();
  593. var resolved = false;
  594. var rejected = null;
  595. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}).then(function () {
  596. resolved = true;
  597. }, function (e) {
  598. rejected = e;
  599. });
  600. await spin(50);
  601. check(resolved && rejected === null, 'action promise should resolve after a failed reenter, rejected=' + String(rejected));
  602. check(log.errors.some(function (m) { return m.indexOf('[statum] reenter: entrypoint refresh failed') !== -1; }),
  603. 'reenter failure was not logged: ' + JSON.stringify(log.errors));
  604. check(navigatedTo === '/after', 'navigate did not run after the failed reenter, navigatedTo=' + JSON.stringify(navigatedTo));
  605. check(log.errors.length === 1, 'exactly one console.error expected: ' + JSON.stringify(log.errors));
  606. // Core logs the swallowed reenter failure by design; nothing else may error.
  607. log.errors.length = 0;
  608. } else if (scenario === '20') {
  609. // stm-on-success: applied on success, cleared on the next attempt.
  610. var b = el('button');
  611. b.setAttribute('stm-action', 'p.act');
  612. b.setAttribute('stm-on-success', 'saved ok');
  613. body.appendChild(b);
  614. await setState('p', { act: { uri: '/save', method: 'POST' } });
  615. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  616. fire(b, 'click');
  617. await spin(50);
  618. check(b.classList.contains('saved') && b.classList.contains('ok'),
  619. 'success classes not applied: ' + JSON.stringify(b.classList));
  620. // A new attempt clears them at attempt start. The fetch stub below never
  621. // resolves, freezing the attempt mid-flight so the cleared state (before
  622. // any outcome re-applies classes) is observable.
  623. var hang = new Promise(function () {});
  624. globalThis.__shim.setFetch(function () { return hang; });
  625. fire(b, 'click');
  626. await spin(20);
  627. check(!b.classList.contains('saved') && !b.classList.contains('ok'),
  628. 'success classes not cleared on a new attempt');
  629. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  630. } else if (scenario === '21') {
  631. // Mirroring: a failure applies stm-on-error and clears stm-on-success;
  632. // a later success applies stm-on-success and clears stm-on-error.
  633. var b = el('button');
  634. b.setAttribute('stm-action', 'p.act');
  635. b.setAttribute('stm-on-error', 'boom');
  636. b.setAttribute('stm-on-success', 'yay');
  637. body.appendChild(b);
  638. await setState('p', { act: { uri: '/save', method: 'POST' } });
  639. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  640. fire(b, 'click');
  641. await spin(50);
  642. check(b.classList.contains('boom'), 'error classes not applied');
  643. check(!b.classList.contains('yay'), 'success classes survived a failure');
  644. log.errors.length = 0;
  645. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  646. fire(b, 'click');
  647. await spin(50);
  648. check(b.classList.contains('yay'), 'success classes not applied after a failure');
  649. check(!b.classList.contains('boom'), 'error classes survived a success');
  650. // And an error-directive failure mirrors the HTTP failure exactly.
  651. globalThis.__shim.setFetch(function () {
  652. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  653. });
  654. fire(b, 'click');
  655. await spin(50);
  656. check(b.classList.contains('boom'), 'error classes not applied (error directive)');
  657. check(!b.classList.contains('yay'), 'success classes survived an error directive');
  658. log.errors.length = 0;
  659. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  660. } else if (scenario === '22') {
  661. // stm-clear-on-error: a failing action resets the form (values and dirty).
  662. var f = el('form');
  663. f.setAttribute('stm-action', 'p.act');
  664. f.setAttribute('stm-clear-on-error', '');
  665. var i1 = input('text', 'a', 'dflt');
  666. i1.setAttribute('stm-prefill', 'p.v');
  667. f.appendChild(i1);
  668. body.appendChild(f);
  669. await setState('p', { v: 'from state', act: { uri: '/save', method: 'POST' } });
  670. i1.value = 'user typed';
  671. fire(i1, 'input');
  672. check(s.document.isDirty(i1), 'input not dirty after edit');
  673. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  674. fire(f, 'submit');
  675. await spin(50);
  676. check(i1.value === 'dflt', 'form was not reset on failure: ' + JSON.stringify(i1.value));
  677. check(!s.document.isDirty(i1), 'form was not un-dirtied on failure');
  678. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  679. // A success must NOT reset a stm-clear-on-error form.
  680. i1.value = 'typed again';
  681. fire(i1, 'input');
  682. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  683. fire(f, 'submit');
  684. await spin(50);
  685. check(i1.value === 'typed again', 'stm-clear-on-error fired on success: ' + JSON.stringify(i1.value));
  686. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  687. } else if (scenario === '23') {
  688. // A declined stm-confirm never clears: decline is cancellation, not error.
  689. var f = el('form');
  690. f.setAttribute('stm-action', 'p.act');
  691. f.setAttribute('stm-confirm', 'sure?');
  692. f.setAttribute('stm-clear-on-error', '');
  693. var i1 = input('text', 'a', 'dflt');
  694. f.appendChild(i1);
  695. body.appendChild(f);
  696. await setState('p', { act: { uri: '/save', method: 'POST' } });
  697. i1.value = 'user typed';
  698. fire(i1, 'input');
  699. // Decline the confirm (custom handler returning false).
  700. s.onConfirmHandler = function () { return false; };
  701. fire(f, 'submit');
  702. await spin(50);
  703. check(i1.value === 'user typed', 'declined confirm cleared the form: ' + JSON.stringify(i1.value));
  704. check(s.document.isDirty(i1), 'declined confirm un-dirtied the form');
  705. var posts = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; });
  706. check(posts.length === 0, 'a declined confirm still fired a request: ' + JSON.stringify(posts));
  707. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  708. // The error-directive path of clear-on-error: failure with a Statum body.
  709. s.onConfirmHandler = null; // window.confirm fallback: shim returns true
  710. i1.value = 'typed more';
  711. fire(i1, 'input');
  712. globalThis.__shim.setFetch(function () {
  713. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  714. });
  715. fire(f, 'submit');
  716. await spin(50);
  717. check(i1.value === 'dflt', 'error directive did not reset a clear-on-error form: ' + JSON.stringify(i1.value));
  718. log.errors.length = 0;
  719. } else {
  720. fail('unknown scenario: ' + scenario);
  721. }
  722. check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
  723. print('scenario ' + scenario + ': OK');
  724. }
  725. main().catch(function (e) {
  726. std.err.puts('FAIL [' + scenario + '] ' + (e && e.stack || e) + '\n');
  727. std.exit(1);
  728. });