objs-core 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/objs.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Objs-core library
3
- * @version 2.0
3
+ * @version 2.2
4
4
  * @author Roman Torshin
5
5
  * @license Apache-2.0
6
6
  */
@@ -14,7 +14,7 @@ const __DEV__ = true;
14
14
  * @param {any} query - Selector, DOM element to use, an array of elements, inited ID or nothing for creating an element
15
15
  * @returns {Object} Objs instance with DOM manipulation methods
16
16
  */
17
- const o = (query) => {
17
+ const o = (query) => {
18
18
  let result = {
19
19
  els: [],
20
20
  ie: {},
@@ -22,6 +22,7 @@ const o = (query) => {
22
22
  parented: {},
23
23
  store: {},
24
24
  refs: {},
25
+ _refsByIndex: [],
25
26
  states: [],
26
27
  isDebug: false,
27
28
  currentState: "",
@@ -133,10 +134,49 @@ const o = (query) => {
133
134
  result.states = [];
134
135
  result.ie = {};
135
136
  }
137
+ if (Array.isArray(result._refsByIndex)) {
138
+ const currentLen = result._refsByIndex.length;
139
+ if (currentLen > ln) {
140
+ cycleObj(result._refsByIndex, (k) => {
141
+ const idx = +k;
142
+ if (idx >= ln) {
143
+ delete result._refsByIndex[idx];
144
+ }
145
+ });
146
+ result._refsByIndex.length = ln;
147
+ } else if (currentLen < ln) {
148
+ for (let idx = currentLen; idx < ln; idx++) {
149
+ result._refsByIndex[idx] = {};
150
+ }
151
+ }
152
+ }
136
153
  };
137
154
  // sets new objects to operate
138
155
  result.reset = o;
139
156
 
157
+ /**
158
+ * Auto-hydrate: after innerHTML is set, bind inited instances (e.g. those
159
+ * created and stored in the parent render) to the DOM nodes that came from
160
+ * the container's HTML, so the parent can control them via store/refs.
161
+ * Scopes by container so the elements from HTML are the Objs instances.
162
+ */
163
+ const hydrateDataOInitIn = (containerEl) => {
164
+ if (ssr || !containerEl.querySelectorAll) return;
165
+ const nodes = containerEl.querySelectorAll("[data-o-init]");
166
+ const byId = {};
167
+ nodes.forEach((node) => {
168
+ const id = node.getAttribute("data-o-init");
169
+ if (id === null) return;
170
+ if (!byId[id]) byId[id] = [];
171
+ byId[id].push(node);
172
+ });
173
+ cycleObj(byId, (id) => {
174
+ const inst = o.inits[id];
175
+ if (!inst) return;
176
+ inst.getSSR(Number(id), byId[id]);
177
+ });
178
+ };
179
+
140
180
  /**
141
181
  * Transform DOM elements based on state and props
142
182
  * @param {Element} el - DOM element to transform
@@ -186,7 +226,7 @@ const o = (query) => {
186
226
  ) {
187
227
  // insert html
188
228
  ["html", "innerHTML"].includes(s)
189
- ? (el.innerHTML = value)
229
+ ? (el.innerHTML = value, !ssr && hydrateDataOInitIn(el))
190
230
  : // className alias
191
231
  s === "className"
192
232
  ? el.setAttribute("class", value)
@@ -326,23 +366,23 @@ const o = (query) => {
326
366
 
327
367
  // creation elements for prop in props
328
368
  const newEl = (n, prop = {}) => {
329
- if (type(data) === objectType) {
330
- return D.createElement(data.tag || data.tagName || "div");
331
- } else {
332
- const newElem = D.createElement("div");
333
- newElem.innerHTML = type(data) === functionType ? data(prop) : data;
334
- if (newElem.children.length > ONE || !newElem.firstElementChild) {
335
- newElem.dataset.oInit = n;
336
- return newElem;
337
- } else {
338
- newElem.firstElementChild.dataset.oInit = n;
339
- return newElem.firstElementChild;
340
- }
369
+ const resolved = type(data) === functionType ? data(prop) : data;
370
+ if (type(resolved) === objectType) {
371
+ return D.createElement(resolved.tag || resolved.tagName || "div");
341
372
  }
373
+ const newElem = D.createElement("div");
374
+ newElem.innerHTML = resolved;
375
+ if (newElem.children.length > ONE || !newElem.firstElementChild) {
376
+ newElem.dataset.oInit = n;
377
+ return newElem;
378
+ }
379
+ newElem.firstElementChild.dataset.oInit = n;
380
+ return newElem.firstElementChild;
342
381
  };
343
382
 
344
383
  // properties creation
345
384
  const rawData = props; // raw argument before array-wrapping
385
+ if (!Array.isArray(props)) props = [props];
346
386
  !props.length ? (props = [props]) : props;
347
387
 
348
388
  // creating elements if no one was selected
@@ -383,19 +423,45 @@ const o = (query) => {
383
423
  if (creation) {
384
424
  buff["data-o-init"] = initN;
385
425
  buff["data-o-init-i"] = i;
426
+ if (buff.events) {
427
+ result._hydrateEvents = result._hydrateEvents || [];
428
+ result._hydrateEvents[i] = buff.events;
429
+ }
386
430
  }
387
431
  transform(el, buff, props[j ? i : 0]);
388
432
  }
389
433
  });
390
434
  if (creation) {
435
+ result._refsByIndex = [];
391
436
  result.refs = {};
392
- result.els.forEach((el) => {
437
+ result.els.forEach((el, idx) => {
393
438
  if (!el.querySelectorAll) return;
439
+ const refsForEl = {};
394
440
  el.querySelectorAll("[ref]").forEach((refEl) => {
395
- result.refs[refEl.getAttribute("ref")] = o(refEl);
396
- refEl.removeAttribute("ref");
441
+ const refName = refEl.getAttribute("ref");
442
+ const refInstance = o(refEl);
443
+ refsForEl[refName] = refInstance;
444
+ if (idx === 0) result.refs[refName] = refInstance;
397
445
  });
446
+ result._refsByIndex[idx] = refsForEl;
398
447
  });
448
+ if (!ssr && result._hydrateEvents) {
449
+ result._hydrateEvents.forEach((evts, idx) => {
450
+ if (!evts) return;
451
+ result.select(idx);
452
+ cycleObj(evts, (event) => {
453
+ const spec = evts[event];
454
+ if (type(spec) === objectType && spec.targetRef && type(spec.handler) === functionType) {
455
+ const refsForIdx = result._refsByIndex?.[idx] ?? result.refs;
456
+ const ref = refsForIdx?.[spec.targetRef];
457
+ if (ref) ref.on(event, spec.handler);
458
+ } else if (type(spec) === functionType) {
459
+ result.on(event, spec);
460
+ }
461
+ });
462
+ });
463
+ result.all();
464
+ }
399
465
  }
400
466
  }
401
467
 
@@ -409,24 +475,47 @@ const o = (query) => {
409
475
  });
410
476
  });
411
477
  const renderState = states.render || states;
412
- if (
413
- !ssr &&
414
- type(renderState) === objectType &&
415
- renderState.events &&
416
- renderState.ssr
417
- ) {
478
+ const hasStateEvents = !ssr && type(renderState) === objectType && renderState.events;
479
+ const hasHydrateEvents = !ssr && result._hydrateEvents && result._hydrateEvents.length;
480
+ if (hasStateEvents || hasHydrateEvents) {
418
481
  result.initSSRAfterGettingSSR = () => {
482
+ result._refsByIndex = [];
419
483
  result.refs = {};
420
- result.els.forEach((el) => {
484
+ result.els.forEach((el, idx) => {
421
485
  if (!el.querySelectorAll) return;
486
+ const refsForEl = {};
422
487
  el.querySelectorAll("[ref]").forEach((refEl) => {
423
- result.refs[refEl.getAttribute("ref")] = o(refEl);
488
+ const refName = refEl.getAttribute("ref");
489
+ const refInstance = o(refEl);
490
+ refsForEl[refName] = refInstance;
491
+ if (idx === 0) result.refs[refName] = refInstance;
424
492
  refEl.removeAttribute("ref");
425
493
  });
494
+ result._refsByIndex[idx] = refsForEl;
495
+ if (idx === 0) result.refs = refsForEl;
426
496
  });
427
- cycleObj(renderState.events, (event) => {
428
- result.on(event, renderState.events[event]);
429
- });
497
+ if (hasStateEvents) {
498
+ cycleObj(renderState.events, (event) => {
499
+ result.on(event, renderState.events[event]);
500
+ });
501
+ }
502
+ if (result._hydrateEvents) {
503
+ result._hydrateEvents.forEach((evts, idx) => {
504
+ if (!evts) return;
505
+ result.select(idx);
506
+ cycleObj(evts, (event) => {
507
+ const spec = evts[event];
508
+ if (type(spec) === objectType && spec.targetRef && type(spec.handler) === functionType) {
509
+ const refsForIdx = result._refsByIndex?.[idx] ?? result.refs;
510
+ const ref = refsForIdx?.[spec.targetRef];
511
+ if (ref) ref.on(event, spec.handler);
512
+ } else if (type(spec) === functionType) {
513
+ result.on(event, spec);
514
+ }
515
+ });
516
+ });
517
+ result.all();
518
+ }
430
519
  };
431
520
  }
432
521
  }, "init");
@@ -447,10 +536,13 @@ const o = (query) => {
447
536
  }, "connect");
448
537
 
449
538
  /**
450
- * Get SSR elements
539
+ * Get SSR elements: bind this instance to DOM nodes (by initId or from a list).
540
+ * When called from auto-hydration, fromEls are the nodes from the parent's HTML
541
+ * so the inited instance stored in the parent can control those elements.
451
542
  * @param {number} initId - Initialization ID
543
+ * @param {Element[]} [fromEls] - Optional list of elements to bind to (e.g. from containerEl)
452
544
  */
453
- result.getSSR = returner((initId) => {
545
+ result.getSSR = returner((initId, fromEls) => {
454
546
  typeVerify([[initId, [numberType, undefinedType]]]);
455
547
  const effectiveId = initId !== undefined ? initId : result.initID;
456
548
  if (
@@ -459,17 +551,35 @@ const o = (query) => {
459
551
  ) {
460
552
  return;
461
553
  }
462
- const ssrEls = o.D.querySelectorAll(`[data-o-init="${effectiveId}"]`);
554
+ const ssrEls =
555
+ fromEls && fromEls.length
556
+ ? fromEls
557
+ : o.D.querySelectorAll(`[data-o-init="${effectiveId}"]`);
463
558
 
464
- if (ssrEls.length && !result.els.length) {
559
+ if (ssrEls.length) {
465
560
  result.els = Array.from(ssrEls);
466
- result.initID = initId;
467
- o.inits[initId] = result;
561
+ if (initId !== undefined) {
562
+ result.initID = initId;
563
+ o.inits[initId] = result;
564
+ }
468
565
  setResultVals(false);
469
-
470
566
  if (type(result.initSSRAfterGettingSSR) === functionType) {
471
567
  result.initSSRAfterGettingSSR();
472
- delete result.initSSRAfterGettingSSR;
568
+ } else if (fromEls && fromEls.length) {
569
+ result._refsByIndex = [];
570
+ result.refs = {};
571
+ result.els.forEach((el, idx) => {
572
+ if (!el.querySelectorAll) return;
573
+ const refsForEl = {};
574
+ el.querySelectorAll("[ref]").forEach((refEl) => {
575
+ const refName = refEl.getAttribute("ref");
576
+ refsForEl[refName] = o(refEl);
577
+ if (idx === 0) result.refs[refName] = refsForEl[refName];
578
+ refEl.removeAttribute("ref");
579
+ });
580
+ result._refsByIndex[idx] = refsForEl;
581
+ if (idx === 0) result.refs = refsForEl;
582
+ });
473
583
  }
474
584
  }
475
585
  }, "getSSR");
@@ -648,18 +758,26 @@ const o = (query) => {
648
758
  }, "sample");
649
759
 
650
760
  /**
651
- * Select element to control
652
- * @param {number} i - Index of element to select
761
+ * Select element to control. Accepts index (number) or event: select(e) selects the element in this instance that contains e.target (e.g. the row that had the event).
762
+ * @param {number|Event} i - Index of element, or event object (uses e.target to find containing element)
653
763
  */
654
764
  result.select = returner((i) => {
655
- typeVerify([[i, [numberType, undefinedType]]]);
656
- if (i === u) {
657
- i = result.length - ONE;
765
+ let idx = i;
766
+ if (idx != null && type(idx) === objectType && idx.target && result.els.length) {
767
+ idx = result.els.findIndex((el) => el === idx.target || el.contains(idx.target));
768
+ if (idx < 0) idx = 0;
658
769
  }
659
- start = i;
660
- finish = i;
661
- result.el = result.els[i];
770
+ typeVerify([[idx, [numberType, undefinedType]]]);
771
+ if (idx === u) {
772
+ idx = result.length - ONE;
773
+ }
774
+ start = idx;
775
+ finish = idx;
776
+ result.el = result.els[idx];
662
777
  select = ONE;
778
+ if (Array.isArray(result._refsByIndex) && result._refsByIndex[idx]) {
779
+ result.refs = result._refsByIndex[idx];
780
+ }
663
781
  }, "select");
664
782
 
665
783
  /**
@@ -670,6 +788,9 @@ const o = (query) => {
670
788
  finish = 0;
671
789
  result.el = result.els[0];
672
790
  select = 0;
791
+ if (Array.isArray(result._refsByIndex) && result._refsByIndex.length) {
792
+ result.refs = result._refsByIndex[0] || {};
793
+ }
673
794
  }, "all");
674
795
 
675
796
  /**
@@ -708,7 +829,10 @@ const o = (query) => {
708
829
  j = finish;
709
830
  }
710
831
 
711
- result.els.splice(i, ONE);
832
+ result.els.splice(j, ONE);
833
+ if (Array.isArray(result._refsByIndex)) {
834
+ result._refsByIndex.splice(j, ONE);
835
+ }
712
836
  setResultVals();
713
837
  }, "skip");
714
838
 
@@ -1033,6 +1157,15 @@ const o = (query) => {
1033
1157
  }
1034
1158
  }, "html");
1035
1159
 
1160
+ result.toString = function () {
1161
+ return result.html();
1162
+ };
1163
+ result[Symbol.toPrimitive] = function (hint) {
1164
+ if (hint === "string" || hint === "default") return result.html();
1165
+ if (hint === "number") return result.els?.length ?? 0;
1166
+ return result.html();
1167
+ };
1168
+
1036
1169
  /**
1037
1170
  * Get or set the value property of form elements (input, textarea, select).
1038
1171
  * @param {string} [value] - Value to set. Omit to get.
@@ -2382,6 +2515,7 @@ if (__DEV__) {
2382
2515
  }
2383
2516
 
2384
2517
  /* tests function parameters */
2518
+ o.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2385
2519
  o.tLog = []; // test sessions and results
2386
2520
  o.tRes = []; // test results
2387
2521
  o.tStatus = []; // test statuses
@@ -2390,6 +2524,8 @@ o.tShowOk = o.F; // show success tests or only errors
2390
2524
  o.tStyled = o.F; // styled HTML results or plain style
2391
2525
  o.tTime = 2000; // timeout for async tests
2392
2526
  o.tests = []; // tests with storage
2527
+ o.tExpectedSteps = {}; // expected step count per test (for playRecording when o.tests not used)
2528
+ o.tFinalized = {}; // prevent duplicate finalization
2393
2529
  o.tAutolog = o.F; // auto log to console
2394
2530
  o.tBeforeEach = undefined; // called before each test case
2395
2531
  o.tAfterEach = undefined; // called after each test case
@@ -2541,10 +2677,21 @@ o.test = (title = "", ...tests) => {
2541
2677
  }
2542
2678
  };
2543
2679
 
2680
+ // Extract callback and options
2681
+ let opts = {};
2544
2682
  if (typeof tests[num - 1] === "function") {
2545
2683
  o.tFns[testN] = tests[num - 1];
2546
2684
  num--;
2547
2685
  }
2686
+ if (
2687
+ num > 0 &&
2688
+ typeof tests[num - 1] === "object" &&
2689
+ !Array.isArray(tests[num - 1]) &&
2690
+ (tests[num - 1].sync !== undefined || tests[num - 1].confirmOnFailure !== undefined)
2691
+ ) {
2692
+ opts = tests[num - 1];
2693
+ num--;
2694
+ }
2548
2695
 
2549
2696
  // get tLog from sessionStorage
2550
2697
  if (testSession) {
@@ -2578,6 +2725,172 @@ o.test = (title = "", ...tests) => {
2578
2725
  o.tRes[testN] = o.F;
2579
2726
  o.tStatus[testN] = [];
2580
2727
  }
2728
+ o.tExpectedSteps[testN] = num;
2729
+ o.tFinalized[testN] = false;
2730
+
2731
+ const showConfirmOnFailureOverlay = (stepIdx, msg) =>
2732
+ new Promise((resolve) => {
2733
+ const box = o.overlay({
2734
+ innerHTML:
2735
+ `<div style="display:flex;flex-direction:column;gap:8px;">` +
2736
+ `<div style="cursor:grab;">Step ${stepIdx + 1} failed: ${msg || "error"}. Continue testing?</div>` +
2737
+ `<div style="display:flex;gap:8px;">` +
2738
+ `<button class="o-cf-continue" style="padding:6px 12px;background:#2563eb;color:#fff;border:none;border-radius:6px;cursor:pointer;">Continue</button>` +
2739
+ `<button class="o-cf-stop" style="padding:6px 12px;background:#dc2626;color:#fff;border:none;border-radius:6px;cursor:pointer;">Stop</button>` +
2740
+ `</div></div>`,
2741
+ timeout: opts.confirmOnFailureTimeout || undefined,
2742
+ onClose: (r) => resolve(r || { continue: false }),
2743
+ excludeDragSelector: ".o-cf-continue, .o-cf-stop",
2744
+ });
2745
+ box.first(".o-cf-continue").on("click", () => {
2746
+ box._overlayCleanup();
2747
+ resolve({ continue: true });
2748
+ });
2749
+ box.first(".o-cf-stop").on("click", () => {
2750
+ box._overlayCleanup();
2751
+ resolve({ continue: false });
2752
+ });
2753
+ });
2754
+
2755
+ const finalize = () => {
2756
+ if (o.tFinalized[testN]) return;
2757
+ o.tFinalized[testN] = true;
2758
+ const anyFailed = o.tStatus[testN].some((s) => s === false);
2759
+ o.tRes[testN] = !anyFailed && done === num;
2760
+ row = waits ? "├ " : "╘ ";
2761
+ row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2762
+ log(row, done + waits !== num);
2763
+ if (!waits) {
2764
+ log();
2765
+ }
2766
+ if (o.tStyled) {
2767
+ o.tLog[testN] +=
2768
+ o.tPre +
2769
+ '<div style="color:' +
2770
+ (done + waits !== num ? "red" : "green") +
2771
+ ';"><b>DONE ' +
2772
+ done +
2773
+ "/" +
2774
+ num +
2775
+ (waits ? ", waiting: " + waits : "") +
2776
+ "</b>" +
2777
+ o.tDc +
2778
+ o.tDc;
2779
+ } else {
2780
+ o.tLog[testN] += row + "\n";
2781
+ }
2782
+ if (testSession) {
2783
+ sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2784
+ sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2785
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2786
+ }
2787
+ if (!waits && typeof o.tFns[testN] === "function") {
2788
+ o.tFns[testN](testN);
2789
+ }
2790
+ };
2791
+
2792
+ if (opts.sync || opts.confirmOnFailure) {
2793
+ (async () => {
2794
+ for (let i = o.tStatus[testN].length; i < num; i++) {
2795
+ const testInfo = {
2796
+ n: testN,
2797
+ i,
2798
+ title: tests[i][0],
2799
+ tShowOk: o.tShowOk,
2800
+ tStyled: o.tStyled,
2801
+ };
2802
+ let res = tests[i][1];
2803
+ if (typeof res === "undefined") {
2804
+ if (o.tStyled) {
2805
+ o.tLog[testN] += "<div>" + testInfo.title + "</div>";
2806
+ } else {
2807
+ o.tLog[testN] += testInfo.title + "\n";
2808
+ }
2809
+ log("├ " + testInfo.title, false, true);
2810
+ o.tStatus[testN][i] = true;
2811
+ done++;
2812
+ continue;
2813
+ }
2814
+ if (typeof o.tBeforeEach === "function") {
2815
+ o.tBeforeEach(testInfo);
2816
+ }
2817
+ if (typeof res === "function") {
2818
+ try {
2819
+ res = res(testInfo);
2820
+ } catch (error) {
2821
+ res = error.message;
2822
+ if (o.onError) {
2823
+ o.onError(error);
2824
+ }
2825
+ }
2826
+ }
2827
+ if (typeof o.tAfterEach === "function") {
2828
+ o.tAfterEach(testInfo, res);
2829
+ }
2830
+ if (res && typeof res.then === "function") {
2831
+ try {
2832
+ const value = await res;
2833
+ const ok =
2834
+ value === true ||
2835
+ value == null ||
2836
+ (value && typeof value === "object" && value.ok === true);
2837
+ const msg =
2838
+ value && value.errors && value.errors.length
2839
+ ? value.errors.join("; ")
2840
+ : typeof value === "string"
2841
+ ? value
2842
+ : "";
2843
+ o.testUpdate(testInfo, ok, ok ? "" : msg ? ": " + msg : "");
2844
+ done++;
2845
+ if (!ok && opts.confirmOnFailure) {
2846
+ const choice = await showConfirmOnFailureOverlay(i, msg);
2847
+ if (!choice.continue) break;
2848
+ }
2849
+ } catch (err) {
2850
+ o.testUpdate(testInfo, false, err.message || "Promise rejected");
2851
+ if (opts.confirmOnFailure) {
2852
+ const choice = await showConfirmOnFailureOverlay(i, err.message || "Promise rejected");
2853
+ if (!choice.continue) break;
2854
+ }
2855
+ }
2856
+ continue;
2857
+ }
2858
+ if (typeof o.tStatus[testN][i] === "undefined") {
2859
+ o.tStatus[testN][i] = typeof res === "string" ? o.F : res;
2860
+ } else {
2861
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2862
+ return;
2863
+ }
2864
+ if (res === true) {
2865
+ done++;
2866
+ if (o.tShowOk) {
2867
+ o.tLog[testN] += preOk + tests[i][0] + posOk;
2868
+ log("├ OK: " + tests[i][0]);
2869
+ }
2870
+ } else if (res !== o.U) {
2871
+ o.tLog[testN] += preXx + tests[i][0] + (res !== o.F ? ": " + res : "") + posXx;
2872
+ log("├ ✘ " + tests[i][0] + (res !== o.F ? ": " + res : ""), true);
2873
+ if (opts.confirmOnFailure) {
2874
+ const choice = await showConfirmOnFailureOverlay(i, typeof res === "string" ? res : "");
2875
+ if (!choice.continue) break;
2876
+ }
2877
+ } else {
2878
+ waits++;
2879
+ setTimeout(
2880
+ (info) => {
2881
+ info.title += " (timeout)";
2882
+ o.testUpdate(info);
2883
+ },
2884
+ o.tTime,
2885
+ testInfo,
2886
+ );
2887
+ return;
2888
+ }
2889
+ }
2890
+ finalize();
2891
+ })();
2892
+ return testN;
2893
+ }
2581
2894
 
2582
2895
  for (let i = o.tStatus[testN].length; i < num; i++) {
2583
2896
  const testInfo = {
@@ -2677,42 +2990,7 @@ o.test = (title = "", ...tests) => {
2677
2990
  }
2678
2991
  }
2679
2992
 
2680
- o.tRes[testN] = done === num;
2681
- row = waits ? "├ " : "╘ ";
2682
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2683
- log(row, done + waits !== num);
2684
- if (!waits) {
2685
- log();
2686
- }
2687
-
2688
- if (o.tStyled) {
2689
- o.tLog[testN] +=
2690
- o.tPre +
2691
- '<div style="color:' +
2692
- (done + waits !== num ? "red" : "green") +
2693
- ';"><b>DONE ' +
2694
- done +
2695
- "/" +
2696
- num +
2697
- (waits ? ", waiting: " + waits : "") +
2698
- "</b>" +
2699
- o.tDc +
2700
- o.tDc;
2701
- } else {
2702
- o.tLog[testN] += row + "\n";
2703
- }
2704
-
2705
- // Save test results to sessionStorage
2706
- if (testSession) {
2707
- sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2708
- sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2709
- sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2710
- }
2711
-
2712
- if (!waits && typeof o.tFns[testN] === "function") {
2713
- o.tFns[testN](testN);
2714
- }
2715
-
2993
+ finalize();
2716
2994
  return testN;
2717
2995
  };
2718
2996
 
@@ -2778,16 +3056,23 @@ o.testUpdate = (info, res = o.F, suff = "") => {
2778
3056
  n++;
2779
3057
  }
2780
3058
 
2781
- // if test is in progress and not completed
3059
+ const expectedSteps =
3060
+ o.tests[testN]?.tests?.length ?? o.tExpectedSteps[testN] ?? Number.MAX_SAFE_INTEGER;
3061
+ if (n < expectedSteps) {
3062
+ if (sessionStorage?.getItem("oTest-Run") === testN) {
3063
+ sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
3064
+ sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
3065
+ sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
3066
+ }
3067
+ return;
3068
+ }
3069
+
3070
+ if (o.tFinalized[testN]) return;
3071
+ o.tFinalized[testN] = true;
2782
3072
  if (sessionStorage?.getItem("oTest-Run") === testN) {
2783
- // save test results to sessionStorage
2784
3073
  sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2785
3074
  sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2786
3075
  sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2787
-
2788
- if (n < o.tests[testN].tests.length) {
2789
- return;
2790
- }
2791
3076
  }
2792
3077
 
2793
3078
  o.tRes[testN] = !fails;
@@ -2943,6 +3228,8 @@ o.recorder = {
2943
3228
  _listeners: [],
2944
3229
  _observer: null,
2945
3230
  };
3231
+ /** When true, log assertion flow (recording + playback) for debugging. */
3232
+ o.recordingAssertionDebug = false;
2946
3233
 
2947
3234
  /**
2948
3235
  * Start recording user interactions
@@ -2974,6 +3261,7 @@ o.startRecording = (observe, events, timeouts) => {
2974
3261
 
2975
3262
  rec.observeRoot = observe || null;
2976
3263
  rec.assertions = [];
3264
+ rec.removedElements = [];
2977
3265
 
2978
3266
  // snapshot current o.inits data
2979
3267
  o.inits.forEach((inst, idx) => {
@@ -3068,6 +3356,16 @@ o.startRecording = (observe, events, timeouts) => {
3068
3356
  rec._observer = new MutationObserver((mutations) => {
3069
3357
  const actionIdx = rec.actions.length - 1;
3070
3358
  if (actionIdx < 0) return;
3359
+ const lastAction = rec.actions[actionIdx];
3360
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3361
+ console.log("[recording] MutationObserver batch:", {
3362
+ actionIdx,
3363
+ lastAction: lastAction ? { type: lastAction.type, target: lastAction.target } : null,
3364
+ mutationTypes: mutations.map((x) => x.type),
3365
+ addedCount: mutations.reduce((n, x) => n + (x.addedNodes?.length || 0), 0),
3366
+ removedCount: mutations.reduce((n, x) => n + (x.removedNodes?.length || 0), 0),
3367
+ });
3368
+ }
3071
3369
  mutations.forEach((m) => {
3072
3370
  const addAssertionIndex = (sel, node) => {
3073
3371
  let listSelector;
@@ -3109,16 +3407,57 @@ o.startRecording = (observe, events, timeouts) => {
3109
3407
  )
3110
3408
  )
3111
3409
  return;
3112
- // Prefer stable content (e.g. .task-text) so assertions survive reorder/restore
3113
- const textEl = node.querySelector?.(".task-text") || node;
3114
3410
  const text =
3115
- (textEl.textContent?.trim() || node.textContent?.trim() || "").slice(0, 80) ||
3116
- undefined;
3411
+ (node.textContent?.trim() || "").slice(0, 80) || undefined;
3117
3412
  const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, node);
3118
3413
  const a = { actionIdx, type: "visible", selector: sel, text };
3119
3414
  if (aListSel != null) a.listSelector = aListSel;
3120
3415
  if (aIdx != null) a.index = aIdx;
3121
3416
  rec.assertions.push(a);
3417
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3418
+ console.log("[recording] +visible assertion:", {
3419
+ actionIdx,
3420
+ lastAction: lastAction?.type + " " + lastAction?.target,
3421
+ selector: sel,
3422
+ text: (text || "").slice(0, 40),
3423
+ index: aIdx,
3424
+ listSelector: aListSel,
3425
+ });
3426
+ }
3427
+ });
3428
+ m.removedNodes.forEach((node) => {
3429
+ if (node.nodeType !== 1) return;
3430
+ const sel = buildSelector(node);
3431
+ if (!sel) return;
3432
+ const text = (node.textContent?.trim() || "").slice(0, 80) || undefined;
3433
+ const parent = m.target;
3434
+ let index;
3435
+ if (node.previousSibling) {
3436
+ index = Array.from(parent.children).indexOf(node.previousSibling) + 1;
3437
+ } else if (node.nextSibling) {
3438
+ index = Array.from(parent.children).indexOf(node.nextSibling);
3439
+ } else {
3440
+ index = 0;
3441
+ }
3442
+ let listSelector;
3443
+ if (o.autotag && node.dataset?.[o.autotag]) {
3444
+ const qaVal = node.dataset[o.autotag];
3445
+ listSelector = `[data-${o.autotag}="${qaVal}"]`;
3446
+ }
3447
+ const entry = { actionIdx, type: "removed", selector: sel, text };
3448
+ if (listSelector) entry.listSelector = listSelector;
3449
+ entry.index = index;
3450
+ rec.removedElements.push(entry);
3451
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3452
+ console.log("[recording] +removed element:", {
3453
+ actionIdx,
3454
+ lastAction: lastAction?.type + " " + lastAction?.target,
3455
+ selector: sel,
3456
+ text: (text || "").slice(0, 40),
3457
+ index,
3458
+ listSelector,
3459
+ });
3460
+ }
3122
3461
  });
3123
3462
  }
3124
3463
  if (m.type === "attributes") {
@@ -3140,6 +3479,16 @@ o.startRecording = (observe, events, timeouts) => {
3140
3479
  if (aListSel != null) a.listSelector = aListSel;
3141
3480
  if (aIdx != null) a.index = aIdx;
3142
3481
  rec.assertions.push(a);
3482
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3483
+ console.log("[recording] +class assertion:", {
3484
+ actionIdx,
3485
+ lastAction: lastAction?.type + " " + lastAction?.target,
3486
+ selector: sel,
3487
+ className: m.target.className,
3488
+ index: aIdx,
3489
+ listSelector: aListSel,
3490
+ });
3491
+ }
3143
3492
  }
3144
3493
  });
3145
3494
  });
@@ -3224,8 +3573,14 @@ o.startRecording = (observe, events, timeouts) => {
3224
3573
  ? target?.checked
3225
3574
  : undefined;
3226
3575
 
3576
+ // Push click/change immediately so MutationObserver sees correct actionIdx
3577
+ // (mutations fire sync after target handler; debounce would attach assertions to wrong action)
3227
3578
  const delay =
3228
- stepDelays[ev] !== undefined ? stepDelays[ev] : (captureDebounce[ev] ?? 0);
3579
+ ev === "click" || ev === "change"
3580
+ ? 0
3581
+ : stepDelays[ev] !== undefined
3582
+ ? stepDelays[ev]
3583
+ : captureDebounce[ev] ?? 0;
3229
3584
  const pushAction = () => {
3230
3585
  const action = { type: ev, target: selector, time: Date.now() };
3231
3586
  if (targetType) action.targetType = targetType;
@@ -3273,6 +3628,7 @@ o.stopRecording = () => {
3273
3628
  initialData: { ...rec.initialData },
3274
3629
  stepDelays: { ...rec.stepDelays },
3275
3630
  assertions: [...(rec.assertions || [])],
3631
+ removedElements: [...(rec.removedElements || [])],
3276
3632
  observeRoot: rec.observeRoot || null,
3277
3633
  };
3278
3634
  };
@@ -3295,46 +3651,264 @@ o.clearRecording = (id) => {
3295
3651
  };
3296
3652
 
3297
3653
  /**
3298
- * Export a recording as a ready-to-commit o.addTest() code string.
3299
- * Available in all builds so QA testers can export tests from staging.
3300
- * @param {{actions: Array, mocks: Object, initialData: Object}} recording
3301
- * @returns {string}
3654
+ * Run recording assertions in the current DOM.
3655
+ * @param {{actions: Array, assertions: Array, observeRoot?: string}} recording
3656
+ * @param {Element|string} [root] - Root element or selector; defaults to recording.observeRoot or document.body
3657
+ * @param {number} [actionIdx] - When provided, run only assertions for this action index
3658
+ * @returns {{ passed: number, total: number, failures: Array<{selector: string, message: string}> }}
3302
3659
  */
3303
- o.exportTest = (recording) => {
3304
- const cases = recording.actions
3305
- .map((a) => {
3306
- let body;
3307
- if (a.type === "scroll") {
3308
- body = ` window.scrollTo(0, ${a.scrollY || 0}); return true;\n`;
3309
- } else if (a.type === "input" || a.type === "change") {
3310
- body =
3311
- (a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
3312
- (a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
3313
- ` el.dispatchEvent(new Event('${a.type}', {bubbles:true})); return true;\n`;
3660
+ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3661
+ const preFiltered = opts && opts.assertions;
3662
+ const assertions =
3663
+ preFiltered != null
3664
+ ? preFiltered
3665
+ : (recording.assertions || []).filter(
3666
+ (a) => actionIdx == null || a.actionIdx === actionIdx,
3667
+ );
3668
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3669
+ console.log("[runRecordingAssertions] run:", {
3670
+ actionIdx,
3671
+ scope: actionIdx == null ? "teardown (all)" : "per-action",
3672
+ assertionsCount: assertions.length,
3673
+ assertions: assertions.map((a) => ({
3674
+ actionIdx: a.actionIdx,
3675
+ type: a.type,
3676
+ selector: a.selector,
3677
+ index: a.index,
3678
+ text: (a.text || "").slice(0, 40),
3679
+ })),
3680
+ });
3681
+ }
3682
+ const seen = new Set();
3683
+ const deduped = assertions.filter((a) => {
3684
+ const key = `${a.selector}|${a.type}|${a.actionIdx}|${a.index ?? ""}`;
3685
+ if (seen.has(key)) return false;
3686
+ seen.add(key);
3687
+ return true;
3688
+ });
3689
+ const resolveRoot = () => {
3690
+ if (root != null) {
3691
+ return typeof root === "string" ? o.D.querySelector(root) || o.D.body : root;
3692
+ }
3693
+ const sel = recording.observeRoot;
3694
+ return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
3695
+ };
3696
+ const r = resolveRoot();
3697
+ const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
3698
+ const getText = (el) => (el ? norm(el.textContent || "") : "");
3699
+ const removedElements = opts?.removedElements || [];
3700
+ const isRemoved = (a) => {
3701
+ if (!removedElements.length || actionIdx == null) return false;
3702
+ const expText = norm(a.text || "");
3703
+ for (const r of removedElements) {
3704
+ if (r.actionIdx > actionIdx) continue;
3705
+ if (norm(r.text || "") !== expText) continue;
3706
+ if (r.selector !== a.selector) continue;
3707
+ if (a.listSelector != null && r.listSelector !== a.listSelector) continue;
3708
+ if (a.index != null && r.index !== a.index) continue;
3709
+ return true;
3710
+ }
3711
+ return false;
3712
+ };
3713
+ let passed = 0;
3714
+ const failures = [];
3715
+ for (const a of deduped) {
3716
+ if (isRemoved(a)) {
3717
+ passed += 1;
3718
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3719
+ console.log("[runRecordingAssertions] skip (explicit removed):", {
3720
+ actionIdx: a.actionIdx,
3721
+ selector: a.selector,
3722
+ text: (a.text || "").slice(0, 40),
3723
+ });
3724
+ }
3725
+ continue;
3726
+ }
3727
+ let el = null;
3728
+ let indexOutOfBounds = false;
3729
+ if (a.listSelector != null && a.index != null) {
3730
+ const items = r.querySelectorAll(a.listSelector);
3731
+ const expectedText = norm(a.text || "");
3732
+ const tryItem = (idx) => {
3733
+ const it = items[idx];
3734
+ if (!it) return null;
3735
+ const e =
3736
+ a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
3737
+ return (e || (a.selector !== a.listSelector ? it : null));
3738
+ };
3739
+ let item = items[a.index];
3740
+ if (!item && a.index > 0) item = items[a.index - 1];
3741
+ if (item) {
3742
+ el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
3743
+ if (!el && a.selector !== a.listSelector) el = item;
3744
+ if (a.type === "visible" && expectedText && el) {
3745
+ const actualText = getText(el);
3746
+ const textMismatch =
3747
+ actualText.indexOf(expectedText) === -1 &&
3748
+ expectedText.indexOf(actualText) === -1;
3749
+ if (textMismatch) {
3750
+ for (let j = 0; j < items.length; j++) {
3751
+ const candEl = tryItem(j);
3752
+ if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
3753
+ el = candEl;
3754
+ item = items[j];
3755
+ break;
3756
+ }
3757
+ }
3758
+ }
3759
+ }
3314
3760
  } else {
3315
- const useNativeClick = a.type === "click";
3316
- body = useNativeClick
3317
- ? ` el.click(); return true;\n`
3318
- : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true})); return true;\n`;
3761
+ indexOutOfBounds = true;
3762
+ }
3763
+ } else {
3764
+ const matches = r.querySelectorAll(a.selector);
3765
+ el = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
3766
+ }
3767
+ if (a.type === "visible") {
3768
+ const visible =
3769
+ el &&
3770
+ el.nodeType === 1 &&
3771
+ (el.offsetParent !== null ||
3772
+ (el.getBoundingClientRect && el.getBoundingClientRect().width > 0));
3773
+ const expectedText = norm(a.text || "");
3774
+ const actualText = getText(el);
3775
+ const fullActual = actualText;
3776
+ const textOk =
3777
+ !expectedText ||
3778
+ actualText.indexOf(expectedText) !== -1 ||
3779
+ fullActual.indexOf(expectedText) !== -1 ||
3780
+ (expectedText.length > 0 && expectedText.indexOf(actualText) !== -1);
3781
+ if (visible && textOk) {
3782
+ passed += 1;
3783
+ } else {
3784
+ const message = indexOutOfBounds
3785
+ ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector || a.selector).length} items, assertion expected index ${a.index})`
3786
+ : !el
3787
+ ? "element not found"
3788
+ : !visible
3789
+ ? "not visible"
3790
+ : !textOk
3791
+ ? "text mismatch"
3792
+ : "fail";
3793
+ failures.push({ selector: a.selector, message });
3794
+ if (typeof console !== "undefined" && console.warn) {
3795
+ console.warn("[runRecordingAssertions] visible failed:", {
3796
+ actionIdx: a.actionIdx,
3797
+ selector: a.selector,
3798
+ listSelector: a.listSelector,
3799
+ index: a.index,
3800
+ expectedText: a.text || "(any)",
3801
+ actualText: actualText.slice(0, 80),
3802
+ message,
3803
+ });
3804
+ }
3805
+ }
3806
+ } else if (a.type === "class") {
3807
+ const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
3808
+ const hasClass =
3809
+ el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
3810
+ if (hasClass) {
3811
+ passed += 1;
3812
+ } else {
3813
+ const msg = indexOutOfBounds
3814
+ ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})`
3815
+ : !el
3816
+ ? "element not found"
3817
+ : `expected class "${a.className}"`;
3818
+ failures.push({ selector: a.selector, message: msg });
3819
+ if (typeof console !== "undefined" && console.warn) {
3820
+ console.warn("[runRecordingAssertions] failed:", {
3821
+ type: a.type,
3822
+ selector: a.selector,
3823
+ actionIdx: a.actionIdx,
3824
+ listSelector: a.listSelector,
3825
+ index: a.index,
3826
+ itemsInRoot: a.listSelector ? r.querySelectorAll(a.listSelector).length : "-",
3827
+ message: msg,
3828
+ });
3829
+ }
3319
3830
  }
3831
+ }
3832
+ }
3833
+ return { passed, total: deduped.length, failures };
3834
+ };
3835
+
3836
+ /**
3837
+ * Export a recording as a ready-to-commit o.addTest() code string.
3838
+ * Includes assertions interleaved with actions (Playwright parity).
3839
+ * @param {{actions: Array, assertions: Array, mocks: Object, initialData: Object, observeRoot?: string}} recording
3840
+ * @param {{delay?: number}} [options] - delay in ms at end of each action (default 16 for recorded actions)
3841
+ * @returns {string}
3842
+ */
3843
+ o.exportTest = (recording, options = {}) => {
3844
+ const delay = options.delay !== undefined ? options.delay : 16;
3845
+ const recordingData = {
3846
+ actions: recording.actions,
3847
+ assertions: recording.assertions || [],
3848
+ observeRoot: recording.observeRoot || null,
3849
+ };
3850
+ const rootVar = recording.observeRoot
3851
+ ? `(o.D.querySelector('${recording.observeRoot.replace(/'/g, "\\'")}') || o.D.body)`
3852
+ : "o.D.body";
3853
+ const getEl = (a) => {
3854
+ if (a.listSelector != null && a.targetIndex != null) {
3855
+ const listSel = JSON.stringify(a.listSelector);
3856
+ const useItem = a.target === a.listSelector;
3857
+ const targetSel = useItem ? listSel : JSON.stringify(a.target);
3320
3858
  return (
3321
- ` ['${a.type} on ${a.target}', () => {\n` +
3322
- ` const el = document.querySelector('${a.target}');\n` +
3323
- ` if (!el) return 'element not found';\n` +
3859
+ ` const items = o.D.querySelectorAll(${listSel});\n` +
3860
+ ` const item = items[${a.targetIndex}];\n` +
3861
+ ` let el = null;\n` +
3862
+ ` if (item) { el = ${useItem ? "item" : `item.querySelector(${targetSel}) || item`}; }`
3863
+ );
3864
+ }
3865
+ return ` const el = o.D.querySelector(${JSON.stringify(a.target)});`;
3866
+ };
3867
+ const endSuffix = delay > 0 ? `\n await o.sleep(${delay});\n return true;\n` : ` return true;\n`;
3868
+ const stepFn = delay > 0 ? "async () =>" : "() =>";
3869
+ const steps = [];
3870
+ for (let i = 0; i < recording.actions.length; i++) {
3871
+ const a = recording.actions[i];
3872
+ let body;
3873
+ if (a.type === "scroll") {
3874
+ body = ` window.scrollTo(0, ${a.scrollY || 0});${endSuffix}`;
3875
+ } else if (a.type === "input" || a.type === "change") {
3876
+ body =
3877
+ (a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
3878
+ (a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
3879
+ ` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
3880
+ } else {
3881
+ const useNativeClick = a.type === "click";
3882
+ body = useNativeClick
3883
+ ? ` el.click();${endSuffix}`
3884
+ : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
3885
+ }
3886
+ steps.push(
3887
+ ` ['${a.type} on ${a.target}', ${stepFn} {\n` +
3888
+ getEl(a) +
3889
+ `\n if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';\n` +
3324
3890
  body +
3325
- ` }],`
3891
+ ` }]`,
3892
+ );
3893
+ const assertsForAction = (recording.assertions || []).filter((x) => x.actionIdx === i);
3894
+ if (assertsForAction.length > 0) {
3895
+ steps.push(
3896
+ ` ['assert after ${a.type}', () => {\n` +
3897
+ ` const r = o.runRecordingAssertions(recordingData, ${rootVar}, ${i});\n` +
3898
+ ` return r.passed === r.total ? true : r.failures.map(f => f.selector + ': ' + f.message).join('; ');\n` +
3899
+ ` }]`,
3326
3900
  );
3327
- })
3328
- .join("\n");
3329
-
3330
- const mocksStr = Object.keys(recording.mocks).length
3901
+ }
3902
+ }
3903
+ const mocksStr = Object.keys(recording.mocks || {}).length
3331
3904
  ? JSON.stringify(recording.mocks, null, 2)
3332
3905
  : "{}";
3333
3906
 
3334
3907
  return (
3335
3908
  `// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
3336
- `const recordingMocks = ${mocksStr};\n\n` +
3337
- `o.addTest('Recorded test', [\n${cases}\n], () => {\n` +
3909
+ `const recordingMocks = ${mocksStr};\n` +
3910
+ `const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };\n\n` +
3911
+ `o.addTest('Recorded test', [\n${steps.join(",\n")}\n // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],\n], () => {\n` +
3338
3912
  ` // teardown\n});\n`
3339
3913
  );
3340
3914
  };
@@ -3467,13 +4041,25 @@ o.exportPlaywrightTest = (recording, options = {}) => {
3467
4041
  // Available in all builds so assessors can replay and see results (testOverlay) on staging.
3468
4042
  /**
3469
4043
  * Play back a recording as an automated test sequence
3470
- * @param {{actions: Array, mocks: Object}} recording
3471
- * @param {Object} [mockOverrides] - Additional mock overrides (anonymized data)
3472
- * @returns {number} testId
4044
+ * @param {{actions: Array, mocks: Object, assertions?: Array, observeRoot?: string}} recording
4045
+ * @param {Object} [opts] - mockOverrides or { runAssertions?, root?, manualChecks?, mockOverrides? }
4046
+ * @returns {number|{testId: number, assertionResult?: Object}}
3473
4047
  */
3474
- o.playRecording = (recording, mockOverrides = {}) => {
4048
+ o.playRecording = (recording, opts = {}) => {
4049
+ const isOptions =
4050
+ opts &&
4051
+ typeof opts === "object" &&
4052
+ (opts.runAssertions !== undefined ||
4053
+ opts.root !== undefined ||
4054
+ opts.manualChecks !== undefined ||
4055
+ opts.actionDelay !== undefined);
4056
+ const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
4057
+ const runAssertions = isOptions && opts.runAssertions;
4058
+ const rootOpt = isOptions ? opts.root : undefined;
4059
+ const manualChecks = (isOptions && opts.manualChecks) || [];
4060
+ const actionDelay = isOptions && opts.actionDelay !== undefined ? opts.actionDelay : 16;
4061
+
3475
4062
  const allMocks = Object.assign({}, recording.mocks, mockOverrides);
3476
- // install mock fetch
3477
4063
  const origFetch = window.fetch;
3478
4064
  window.fetch = (url, opts = {}) => {
3479
4065
  const method = (opts.method || "GET").toUpperCase();
@@ -3487,51 +4073,154 @@ o.playRecording = (recording, mockOverrides = {}) => {
3487
4073
  return origFetch(url, opts);
3488
4074
  };
3489
4075
 
3490
- const testCases = recording.actions.map((action) => [
3491
- `${action.type} on ${action.target}`,
3492
- () => {
3493
- let el = null;
3494
- if (action.target) {
3495
- if (action.listSelector != null && action.targetIndex != null) {
3496
- const items = o.D.querySelectorAll(action.listSelector);
3497
- const item = items[action.targetIndex];
3498
- if (item) {
3499
- el =
3500
- action.target !== action.listSelector
3501
- ? item.querySelector(action.target)
3502
- : item;
3503
- if (!el && action.target !== action.listSelector) el = item;
4076
+ const resolveRoot = () => {
4077
+ if (rootOpt != null) {
4078
+ return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
4079
+ }
4080
+ const sel = recording.observeRoot;
4081
+ return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
4082
+ };
4083
+ const rootEl = runAssertions ? resolveRoot() : null;
4084
+ const actionScope = rootOpt != null ? resolveRoot() : o.D;
4085
+
4086
+ const actions = recording.actions;
4087
+ const assertions = recording.assertions || [];
4088
+
4089
+ const assertionsByAction = {};
4090
+ for (const a of assertions) {
4091
+ const k = a.actionIdx;
4092
+ if (!assertionsByAction[k]) assertionsByAction[k] = [];
4093
+ assertionsByAction[k].push(a);
4094
+ }
4095
+ if (o.recordingAssertionDebug && runAssertions && typeof console !== "undefined" && console.log) {
4096
+ const summary = actions.map((act, i) => ({
4097
+ i,
4098
+ action: act.type + " " + (act.target || ""),
4099
+ assertions: (assertionsByAction[i] || []).length,
4100
+ assertionDetails: (assertionsByAction[i] || []).map((x) => ({
4101
+ type: x.type,
4102
+ index: x.index,
4103
+ text: (x.text || "").slice(0, 30),
4104
+ })),
4105
+ }));
4106
+ console.log("[playRecording] assertions by action:", summary);
4107
+ }
4108
+ const manualByAction = {};
4109
+ for (const mc of manualChecks) {
4110
+ const k = mc.afterAction;
4111
+ if (!manualByAction[k]) manualByAction[k] = [];
4112
+ manualByAction[k].push(mc);
4113
+ }
4114
+
4115
+ const testCases = [];
4116
+ let assertionAccum = { passed: 0, total: 0, failures: [] };
4117
+
4118
+ for (let i = 0; i < actions.length; i++) {
4119
+ const action = actions[i];
4120
+ testCases.push([
4121
+ `${action.type} on ${action.target}`,
4122
+ async () => {
4123
+ let el = null;
4124
+ const scope = actionScope;
4125
+ if (action.target) {
4126
+ if (action.listSelector != null && action.targetIndex != null) {
4127
+ const items = scope.querySelectorAll(action.listSelector);
4128
+ const item = items[action.targetIndex];
4129
+ if (item) {
4130
+ el =
4131
+ action.target !== action.listSelector
4132
+ ? item.querySelector(action.target)
4133
+ : item;
4134
+ if (!el && action.target !== action.listSelector) el = item;
4135
+ }
4136
+ } else {
4137
+ el = scope.querySelector(action.target);
3504
4138
  }
3505
- } else {
3506
- el = o.D.querySelector(action.target);
3507
4139
  }
3508
- }
3509
- if (!el && action.type !== "scroll") {
3510
- return `element not found: ${action.target}`;
3511
- }
3512
- if (action.type === "scroll") {
3513
- window.scrollTo(0, action.scrollY || 0);
3514
- } else if (action.type === "input" || action.type === "change") {
3515
- if (action.value !== undefined) el.value = action.value;
3516
- if (action.checked !== undefined) el.checked = action.checked;
3517
- el.dispatchEvent(new Event(action.type, { bubbles: true }));
3518
- } else {
3519
- if (action.type === "click") {
3520
- el.click();
4140
+ if (!el && action.type !== "scroll") {
4141
+ return `element not found: ${action.target}`;
4142
+ }
4143
+ if (action.type === "scroll") {
4144
+ window.scrollTo(0, action.scrollY || 0);
4145
+ } else if (action.type === "input" || action.type === "change") {
4146
+ if (action.value !== undefined) el.value = action.value;
4147
+ if (action.checked !== undefined) el.checked = action.checked;
4148
+ el.dispatchEvent(new Event(action.type, { bubbles: true }));
3521
4149
  } else {
3522
- el.dispatchEvent(
3523
- new MouseEvent(action.type, { bubbles: true, cancelable: true }),
3524
- );
4150
+ if (action.type === "click") {
4151
+ el.click();
4152
+ } else {
4153
+ el.dispatchEvent(
4154
+ new MouseEvent(action.type, { bubbles: true, cancelable: true }),
4155
+ );
4156
+ }
3525
4157
  }
3526
- }
3527
- return true;
3528
- },
3529
- ]);
4158
+ if (actionDelay > 0) await o.sleep(actionDelay);
4159
+ return true;
4160
+ },
4161
+ ]);
4162
+ const asserted = assertionsByAction[i];
4163
+ if (runAssertions && asserted && asserted.length > 0) {
4164
+ testCases.push([
4165
+ `assert after ${action.type}`,
4166
+ () =>
4167
+ new Promise((resolve) => {
4168
+ const run = () => {
4169
+ const r = o.runRecordingAssertions(recording, rootEl, i, {
4170
+ assertions: asserted,
4171
+ removedElements: recording.removedElements,
4172
+ });
4173
+ assertionAccum.passed += r.passed;
4174
+ assertionAccum.total += r.total;
4175
+ assertionAccum.failures.push(...r.failures);
4176
+ resolve(
4177
+ r.passed === r.total
4178
+ ? true
4179
+ : r.failures.map((f) => f.selector + ": " + f.message).join("; "),
4180
+ );
4181
+ };
4182
+ requestAnimationFrame(() => requestAnimationFrame(run));
4183
+ }),
4184
+ ]);
4185
+ }
4186
+ for (const mc of manualByAction[i] || []) {
4187
+ testCases.push([
4188
+ `Manual: ${mc.label}`,
4189
+ () =>
4190
+ typeof o.testConfirm === "function"
4191
+ ? o.testConfirm(mc.label, mc.items || [])
4192
+ : { ok: true },
4193
+ ]);
4194
+ }
4195
+ }
4196
+ for (const mc of manualByAction["end"] || []) {
4197
+ testCases.push([
4198
+ `Manual: ${mc.label}`,
4199
+ () =>
4200
+ typeof o.testConfirm === "function"
4201
+ ? o.testConfirm(mc.label, mc.items || [])
4202
+ : { ok: true },
4203
+ ]);
4204
+ }
3530
4205
 
3531
- const testId = o.test("Recorded playback", ...testCases, () => {
4206
+ const onComplete = isOptions && opts.onComplete;
4207
+ const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId) => {
3532
4208
  window.fetch = origFetch;
4209
+ const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : undefined;
4210
+ if (assertionResult?.failures?.length > 0) {
4211
+ o.tRes[testId] = false;
4212
+ const failLines = assertionResult.failures
4213
+ .map((f) => `${f.selector}: ${f.message}`)
4214
+ .join("; ");
4215
+ const suffix = o.tStyled
4216
+ ? o.tPre + o.tXx + "Assertions failed: " + failLines + o.tDc
4217
+ : "\n✘ Assertions failed: " + failLines;
4218
+ o.tLog[testId] = (o.tLog[testId] || "") + suffix;
4219
+ }
4220
+ if (typeof onComplete === "function") onComplete(assertionResult);
3533
4221
  });
3534
- return testId;
4222
+
4223
+ return runAssertions ? { testId } : testId;
3535
4224
  };
3536
4225
 
3537
4226
  // ─── Test results overlay (all builds — for assessors to see auto + manual results) ───
@@ -3570,6 +4259,78 @@ o.testOverlay = () => {
3570
4259
  });
3571
4260
  };
3572
4261
 
4262
+ const innerHTML =
4263
+ `<div style="display:flex;align-items:center;gap:12px;">` +
4264
+ `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span>` +
4265
+ `<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
4266
+ `<button type="button" class="o-test-overlay-close" style="padding:4px 8px;background:transparent;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;font-size:16px;line-height:1;" title="Close">×</button>` +
4267
+ `</div>` +
4268
+ `<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:240px;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`;
4269
+ const box = o.overlay({
4270
+ innerHTML,
4271
+ removeExisting: false,
4272
+ className: "o-test-overlay",
4273
+ id: btnId,
4274
+ excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId,
4275
+ });
4276
+
4277
+ const refreshSummary = () => {
4278
+ const summary = o(".o-test-overlay-summary");
4279
+ if (summary.els.length)
4280
+ summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
4281
+ };
4282
+
4283
+ box.first(".o-test-overlay-toggle").on("click", () => {
4284
+ const panel = o("#" + panelId);
4285
+ if (!panel.el) return;
4286
+ const isOpen = panel.el.style.display !== "none";
4287
+ panel.css({ display: isOpen ? "none" : "block" });
4288
+ if (!isOpen) updatePanel();
4289
+ });
4290
+
4291
+ box.first(".o-test-overlay-close").on("click", () => {
4292
+ box._overlayCleanup();
4293
+ });
4294
+
4295
+ o.testOverlay.showPanel = () => {
4296
+ const panel = o("#" + panelId);
4297
+ if (!panel.el) return;
4298
+ panel.css({ display: "block" });
4299
+ updatePanel();
4300
+ refreshSummary();
4301
+ };
4302
+
4303
+ if (!o._testOverlayBase) o._testOverlayBase = o.test;
4304
+ o.test = (...args) => {
4305
+ const id = o._testOverlayBase(...args);
4306
+ const origFn = o.tFns[id];
4307
+ o.tFns[id] = (n) => {
4308
+ if (typeof origFn === "function") origFn(n);
4309
+ const panel = o("#" + panelId);
4310
+ if (panel.el && panel.el.style.display !== "none") updatePanel();
4311
+ refreshSummary();
4312
+ };
4313
+ return id;
4314
+ };
4315
+ };
4316
+
4317
+ /**
4318
+ * Common draggable overlay — shared by testConfirm, testOverlay, confirmOnFailure.
4319
+ * @param {{ innerHTML: string, onClose?: (result?: any) => void, timeout?: number, excludeDragSelector?: string }} opts
4320
+ * @returns {Object} box instance (Objs element)
4321
+ */
4322
+ o.overlay = (opts = {}) => {
4323
+ const {
4324
+ innerHTML,
4325
+ onClose,
4326
+ timeout,
4327
+ excludeDragSelector,
4328
+ removeExisting = true,
4329
+ className = "o-overlay-common",
4330
+ id,
4331
+ } = opts;
4332
+ if (removeExisting) o("." + className).remove();
4333
+ else if (id && o("#" + id).el) return o("#" + id);
3573
4334
  const overlayStyle = {
3574
4335
  position: "fixed",
3575
4336
  left: "50%",
@@ -3579,31 +4340,27 @@ o.testOverlay = () => {
3579
4340
  width: "fit-content",
3580
4341
  "max-width": "min(90vw, 420px)",
3581
4342
  "font-family": "system-ui,sans-serif",
3582
- cursor: "grab",
3583
4343
  "user-select": "text",
3584
4344
  };
3585
-
4345
+ const countdownId = "o-overlay-countdown";
4346
+ const barHtml =
4347
+ `<div class="o-overlay-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;min-width:200px;max-height:90vh;overflow-y:auto;">` +
4348
+ innerHTML +
4349
+ (timeout
4350
+ ? `<div id="${countdownId}" style="margin-top:6px;font-size:11px;color:#94a3b8;"></div>`
4351
+ : "") +
4352
+ "</div>";
3586
4353
  const box = o
3587
4354
  .initState({
3588
4355
  tag: "div",
3589
- id: btnId,
3590
- className: "o-test-overlay",
4356
+ className,
4357
+ id: id || undefined,
3591
4358
  style:
3592
- "position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,420px);font-family:system-ui,sans-serif;cursor:grab;user-select:text;",
3593
- html:
3594
- `<div class="o-test-overlay-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;cursor:grab;min-width:200px;">` +
3595
- `<div style="display:flex;align-items:center;gap:12px;">` +
3596
- `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;">Tests: 0/0</span>` +
3597
- `<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
3598
- `<button type="button" class="o-test-overlay-close" style="padding:4px 8px;background:transparent;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;font-size:16px;line-height:1;" title="Close">×</button>` +
3599
- `</div></div>` +
3600
- `<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:60vh;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`,
4359
+ "position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,420px);font-family:system-ui,sans-serif;user-select:text;",
4360
+ html: barHtml,
3601
4361
  })
3602
4362
  .appendInside("body");
3603
-
3604
- const applyOverlayStyle = () => {
3605
- box.css(overlayStyle);
3606
- };
4363
+ const applyStyle = () => box.css(overlayStyle);
3607
4364
  let drag = null;
3608
4365
  const onMove = (e) => {
3609
4366
  if (!drag) return;
@@ -3611,71 +4368,49 @@ o.testOverlay = () => {
3611
4368
  overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3612
4369
  delete overlayStyle.bottom;
3613
4370
  overlayStyle.transform = "none";
3614
- applyOverlayStyle();
4371
+ applyStyle();
3615
4372
  };
3616
4373
  const onUp = () => {
3617
4374
  if (drag) {
3618
- overlayStyle.cursor = "grab";
3619
- applyOverlayStyle();
4375
+ delete overlayStyle.cursor;
4376
+ applyStyle();
3620
4377
  }
3621
4378
  drag = null;
3622
4379
  };
3623
4380
  box.on("mousedown", (e) => {
3624
- if (
3625
- e.target.closest(".o-test-overlay-close") ||
3626
- e.target.closest(".o-test-overlay-toggle") ||
3627
- e.target.closest("#" + panelId)
3628
- )
3629
- return;
4381
+ if (excludeDragSelector && e.target.closest(excludeDragSelector)) return;
3630
4382
  const r = box.el.getBoundingClientRect();
3631
4383
  drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3632
4384
  overlayStyle.cursor = "grabbing";
3633
- applyOverlayStyle();
4385
+ applyStyle();
3634
4386
  });
3635
4387
  o.D.addEventListener("mousemove", onMove);
3636
4388
  o.D.addEventListener("mouseup", onUp);
3637
-
3638
- const refreshSummary = () => {
3639
- const summary = o(".o-test-overlay-summary");
3640
- if (summary.els.length)
3641
- summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
3642
- };
3643
-
3644
- box.first(".o-test-overlay-toggle").on("click", () => {
3645
- const panel = o("#" + panelId);
3646
- if (!panel.el) return;
3647
- const isOpen = panel.el.style.display !== "none";
3648
- panel.css({ display: isOpen ? "none" : "block" });
3649
- if (!isOpen) updatePanel();
3650
- });
3651
-
3652
- box.first(".o-test-overlay-close").on("click", () => {
4389
+ let timerId;
4390
+ const cleanup = () => {
3653
4391
  o.D.removeEventListener("mousemove", onMove);
3654
4392
  o.D.removeEventListener("mouseup", onUp);
4393
+ if (timerId) clearInterval(timerId);
3655
4394
  box.remove();
3656
- });
3657
-
3658
- o.testOverlay.showPanel = () => {
3659
- const panel = o("#" + panelId);
3660
- if (!panel.el) return;
3661
- panel.css({ display: "block" });
3662
- updatePanel();
3663
- refreshSummary();
3664
- };
3665
-
3666
- // Single patch of o.test to refresh panel when tests complete (use base so we don't stack)
3667
- if (!o._testOverlayBase) o._testOverlayBase = o.test;
3668
- o.test = (...args) => {
3669
- const id = o._testOverlayBase(...args);
3670
- const origFn = o.tFns[id];
3671
- o.tFns[id] = (n) => {
3672
- if (typeof origFn === "function") origFn(n);
3673
- const panel = o("#" + panelId);
3674
- if (panel.el && panel.el.style.display !== "none") updatePanel();
3675
- refreshSummary();
3676
- };
3677
- return id;
3678
4395
  };
4396
+ if (timeout && timeout > 0) {
4397
+ let remaining = Math.ceil(timeout / 1000);
4398
+ const cd = o("#" + countdownId);
4399
+ if (cd.el) cd.el.textContent = remaining ? `Continue in ${remaining}s` : "";
4400
+ timerId = setInterval(() => {
4401
+ remaining -= 1;
4402
+ if (cd.el) cd.el.textContent = remaining > 0 ? `Continue in ${remaining}s` : "";
4403
+ if (remaining <= 0) {
4404
+ clearInterval(timerId);
4405
+ timerId = null;
4406
+ cleanup();
4407
+ if (typeof onClose === "function") onClose({ ok: false, errors: ["timeout"] });
4408
+ }
4409
+ }, 1000);
4410
+ }
4411
+ box._overlayCleanup = cleanup;
4412
+ box._overlayOnClose = onClose;
4413
+ return box;
3679
4414
  };
3680
4415
 
3681
4416
  /**
@@ -3683,12 +4418,11 @@ o.testOverlay = () => {
3683
4418
  * Only available in dev builds. NOT referenced in exportPlaywrightTest.
3684
4419
  * @param {string} label - Test title (shown as "Test title: Paused")
3685
4420
  * @param {string[]} [items] - Optional checklist for the operator (e.g. hover effects to verify); use labels so clicking text toggles checkbox
3686
- * @param {{ confirm?: string }} [opts] - Continue button label (default "Continue")
4421
+ * @param {{ confirm?: string, timeout?: number }} [opts] - Continue button label (default "Continue"); timeout in ms for countdown
3687
4422
  * @returns {Promise<{ ok: boolean, errors?: string[] }>} ok true if all items checked; errors = list of unchecked item texts when ok false
3688
4423
  */
3689
4424
  o.testConfirm = (label, items = [], opts = {}) =>
3690
4425
  new Promise((resolve) => {
3691
- o(".o-tc-overlay").remove();
3692
4426
  const btnLabel = opts.confirm || "Continue";
3693
4427
  const hasCheckboxes = items.length > 0;
3694
4428
  const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
@@ -3697,7 +4431,7 @@ o.testConfirm = (label, items = [], opts = {}) =>
3697
4431
  ".o-tc-item-cb{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:2px solid #ef4444;border-radius:3px;background:#fef2f2;flex-shrink:0;cursor:pointer;}" +
3698
4432
  ".o-tc-item-cb:checked{border-color:#22c55e;background:#22c55e;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E\");background-size:12px 12px;background-position:center;}";
3699
4433
  const itemsHtml = hasCheckboxes
3700
- ? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;">` +
4434
+ ? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;cursor:grab;">` +
3701
4435
  items
3702
4436
  .map(
3703
4437
  (i, idx) =>
@@ -3706,23 +4440,18 @@ o.testConfirm = (label, items = [], opts = {}) =>
3706
4440
  .join("") +
3707
4441
  "</ul>"
3708
4442
  : "";
3709
- const box = o
3710
- .initState({
3711
- tag: "div",
3712
- className: "o-tc-overlay",
3713
- style:
3714
- "position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,400px);font-family:system-ui,sans-serif;cursor:grab;user-select:text;",
3715
- html:
3716
- `<div class="o-tc-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;cursor:grab;min-width:280px;">` +
3717
- `<div style="display:flex;align-items:center;gap:12px;">` +
3718
- `<span class="o-tc-label" style="flex:1;">${label}: Paused</span>` +
3719
- `<button type="button" class="o-tc-ok" style="padding:6px 14px;background:${btnBg};color:#fff;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;flex-shrink:0;">${btnLabel}</button>` +
3720
- `</div>` +
3721
- itemsHtml +
3722
- `</div>`,
3723
- })
3724
- .appendInside("body");
3725
-
4443
+ const innerHTML =
4444
+ `<div style="display:flex;align-items:center;gap:12px;">` +
4445
+ `<span class="o-tc-label" style="flex:1;cursor:grab;">${label}: Paused</span>` +
4446
+ `<button type="button" class="o-tc-ok" style="padding:6px 14px;background:${btnBg};color:#fff;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;flex-shrink:0;">${btnLabel}</button>` +
4447
+ `</div>` +
4448
+ itemsHtml;
4449
+ const box = o.overlay({
4450
+ innerHTML,
4451
+ timeout: opts.timeout,
4452
+ excludeDragSelector: ".o-tc-ok",
4453
+ onClose: (r) => resolve(r || { ok: true }),
4454
+ });
3726
4455
  const okBtnStyles = {
3727
4456
  padding: "6px 14px",
3728
4457
  background: hasCheckboxes ? "#dc2626" : "#2563eb",
@@ -3736,70 +4465,23 @@ o.testConfirm = (label, items = [], opts = {}) =>
3736
4465
  };
3737
4466
  if (hasCheckboxes) {
3738
4467
  const okBtn = box.first(".o-tc-ok");
3739
- const cbs = o(".o-tc-overlay .o-tc-item-cb");
4468
+ const cbs = o(".o-overlay-common .o-tc-item-cb");
3740
4469
  const updateBtn = () => {
3741
4470
  const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
3742
4471
  okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
3743
4472
  };
3744
4473
  cbs.on("change", updateBtn);
3745
4474
  }
3746
-
3747
- let drag = null;
3748
- const overlayStyle = {
3749
- position: "fixed",
3750
- left: "50%",
3751
- bottom: "50px",
3752
- transform: "translateX(-50%)",
3753
- "z-index": "999999",
3754
- width: "fit-content",
3755
- "max-width": "min(90vw, 400px)",
3756
- "font-family": "system-ui,sans-serif",
3757
- cursor: "grab",
3758
- "user-select": "text",
3759
- };
3760
- const applyOverlayStyle = () => {
3761
- box.css(overlayStyle);
3762
- };
3763
- const onMove = (e) => {
3764
- if (!drag) return;
3765
- overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
3766
- overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3767
- delete overlayStyle.bottom;
3768
- overlayStyle.transform = "none";
3769
- applyOverlayStyle();
3770
- };
3771
- const onUp = () => {
3772
- if (drag) {
3773
- overlayStyle.cursor = "grab";
3774
- applyOverlayStyle();
3775
- }
3776
- drag = null;
3777
- };
3778
- box.on("mousedown", (e) => {
3779
- if (e.target.closest(".o-tc-ok")) return;
3780
- const r = box.el.getBoundingClientRect();
3781
- drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3782
- overlayStyle.cursor = "grabbing";
3783
- applyOverlayStyle();
3784
- });
3785
- o.D.addEventListener("mousemove", onMove);
3786
- o.D.addEventListener("mouseup", onUp);
3787
-
3788
4475
  box.first(".o-tc-ok").on("click", () => {
3789
- o.D.removeEventListener("mousemove", onMove);
3790
- o.D.removeEventListener("mouseup", onUp);
3791
4476
  let unchecked = [];
3792
4477
  if (hasCheckboxes) {
3793
- const cbsList = o(".o-tc-overlay .o-tc-item-cb");
3794
- cbsList.els.forEach((el, idx) => {
3795
- if (!el.checked && items[idx] !== undefined) unchecked.push(items[idx]);
3796
- });
3797
- }
3798
- box.remove();
3799
- if (unchecked.length === 0) {
3800
- resolve({ ok: true });
3801
- } else {
3802
- resolve({ ok: false, errors: unchecked });
4478
+ const cbsList = o(".o-overlay-common .o-tc-item-cb");
4479
+ if (cbsList.els.length)
4480
+ cbsList.els.forEach((el, idx) => {
4481
+ if (!el.checked && items[idx] !== undefined) unchecked.push(items[idx]);
4482
+ });
3803
4483
  }
4484
+ box._overlayCleanup();
4485
+ resolve(unchecked.length === 0 ? { ok: true } : { ok: false, errors: unchecked });
3804
4486
  });
3805
4487
  });