objs-core 2.1.0 → 2.2.1

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.1
3
+ * @version 2.2
4
4
  * @author Roman Torshin
5
5
  * @license Apache-2.0
6
6
  */
@@ -1157,6 +1157,15 @@ const __DEV__ = true;
1157
1157
  }
1158
1158
  }, "html");
1159
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
+
1160
1169
  /**
1161
1170
  * Get or set the value property of form elements (input, textarea, select).
1162
1171
  * @param {string} [value] - Value to set. Omit to get.
@@ -2506,6 +2515,7 @@ if (__DEV__) {
2506
2515
  }
2507
2516
 
2508
2517
  /* tests function parameters */
2518
+ o.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2509
2519
  o.tLog = []; // test sessions and results
2510
2520
  o.tRes = []; // test results
2511
2521
  o.tStatus = []; // test statuses
@@ -2514,6 +2524,8 @@ o.tShowOk = o.F; // show success tests or only errors
2514
2524
  o.tStyled = o.F; // styled HTML results or plain style
2515
2525
  o.tTime = 2000; // timeout for async tests
2516
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
2517
2529
  o.tAutolog = o.F; // auto log to console
2518
2530
  o.tBeforeEach = undefined; // called before each test case
2519
2531
  o.tAfterEach = undefined; // called after each test case
@@ -2665,10 +2677,21 @@ o.test = (title = "", ...tests) => {
2665
2677
  }
2666
2678
  };
2667
2679
 
2680
+ // Extract callback and options
2681
+ let opts = {};
2668
2682
  if (typeof tests[num - 1] === "function") {
2669
2683
  o.tFns[testN] = tests[num - 1];
2670
2684
  num--;
2671
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
+ }
2672
2695
 
2673
2696
  // get tLog from sessionStorage
2674
2697
  if (testSession) {
@@ -2702,6 +2725,172 @@ o.test = (title = "", ...tests) => {
2702
2725
  o.tRes[testN] = o.F;
2703
2726
  o.tStatus[testN] = [];
2704
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
+ }
2705
2894
 
2706
2895
  for (let i = o.tStatus[testN].length; i < num; i++) {
2707
2896
  const testInfo = {
@@ -2801,42 +2990,7 @@ o.test = (title = "", ...tests) => {
2801
2990
  }
2802
2991
  }
2803
2992
 
2804
- o.tRes[testN] = done === num;
2805
- row = waits ? "├ " : "╘ ";
2806
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2807
- log(row, done + waits !== num);
2808
- if (!waits) {
2809
- log();
2810
- }
2811
-
2812
- if (o.tStyled) {
2813
- o.tLog[testN] +=
2814
- o.tPre +
2815
- '<div style="color:' +
2816
- (done + waits !== num ? "red" : "green") +
2817
- ';"><b>DONE ' +
2818
- done +
2819
- "/" +
2820
- num +
2821
- (waits ? ", waiting: " + waits : "") +
2822
- "</b>" +
2823
- o.tDc +
2824
- o.tDc;
2825
- } else {
2826
- o.tLog[testN] += row + "\n";
2827
- }
2828
-
2829
- // Save test results to sessionStorage
2830
- if (testSession) {
2831
- sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2832
- sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2833
- sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2834
- }
2835
-
2836
- if (!waits && typeof o.tFns[testN] === "function") {
2837
- o.tFns[testN](testN);
2838
- }
2839
-
2993
+ finalize();
2840
2994
  return testN;
2841
2995
  };
2842
2996
 
@@ -2902,16 +3056,23 @@ o.testUpdate = (info, res = o.F, suff = "") => {
2902
3056
  n++;
2903
3057
  }
2904
3058
 
2905
- // 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;
2906
3072
  if (sessionStorage?.getItem("oTest-Run") === testN) {
2907
- // save test results to sessionStorage
2908
3073
  sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
2909
3074
  sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2910
3075
  sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2911
-
2912
- if (n < o.tests[testN].tests.length) {
2913
- return;
2914
- }
2915
3076
  }
2916
3077
 
2917
3078
  o.tRes[testN] = !fails;
@@ -3067,6 +3228,8 @@ o.recorder = {
3067
3228
  _listeners: [],
3068
3229
  _observer: null,
3069
3230
  };
3231
+ /** When true, log assertion flow (recording + playback) for debugging. */
3232
+ o.recordingAssertionDebug = false;
3070
3233
 
3071
3234
  /**
3072
3235
  * Start recording user interactions
@@ -3098,6 +3261,7 @@ o.startRecording = (observe, events, timeouts) => {
3098
3261
 
3099
3262
  rec.observeRoot = observe || null;
3100
3263
  rec.assertions = [];
3264
+ rec.removedElements = [];
3101
3265
 
3102
3266
  // snapshot current o.inits data
3103
3267
  o.inits.forEach((inst, idx) => {
@@ -3192,6 +3356,16 @@ o.startRecording = (observe, events, timeouts) => {
3192
3356
  rec._observer = new MutationObserver((mutations) => {
3193
3357
  const actionIdx = rec.actions.length - 1;
3194
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
+ }
3195
3369
  mutations.forEach((m) => {
3196
3370
  const addAssertionIndex = (sel, node) => {
3197
3371
  let listSelector;
@@ -3233,16 +3407,57 @@ o.startRecording = (observe, events, timeouts) => {
3233
3407
  )
3234
3408
  )
3235
3409
  return;
3236
- // Prefer stable content (e.g. .task-text) so assertions survive reorder/restore
3237
- const textEl = node.querySelector?.(".task-text") || node;
3238
3410
  const text =
3239
- (textEl.textContent?.trim() || node.textContent?.trim() || "").slice(0, 80) ||
3240
- undefined;
3411
+ (node.textContent?.trim() || "").slice(0, 80) || undefined;
3241
3412
  const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, node);
3242
3413
  const a = { actionIdx, type: "visible", selector: sel, text };
3243
3414
  if (aListSel != null) a.listSelector = aListSel;
3244
3415
  if (aIdx != null) a.index = aIdx;
3245
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
+ }
3246
3461
  });
3247
3462
  }
3248
3463
  if (m.type === "attributes") {
@@ -3264,6 +3479,16 @@ o.startRecording = (observe, events, timeouts) => {
3264
3479
  if (aListSel != null) a.listSelector = aListSel;
3265
3480
  if (aIdx != null) a.index = aIdx;
3266
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
+ }
3267
3492
  }
3268
3493
  });
3269
3494
  });
@@ -3348,8 +3573,14 @@ o.startRecording = (observe, events, timeouts) => {
3348
3573
  ? target?.checked
3349
3574
  : undefined;
3350
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)
3351
3578
  const delay =
3352
- 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;
3353
3584
  const pushAction = () => {
3354
3585
  const action = { type: ev, target: selector, time: Date.now() };
3355
3586
  if (targetType) action.targetType = targetType;
@@ -3397,6 +3628,7 @@ o.stopRecording = () => {
3397
3628
  initialData: { ...rec.initialData },
3398
3629
  stepDelays: { ...rec.stepDelays },
3399
3630
  assertions: [...(rec.assertions || [])],
3631
+ removedElements: [...(rec.removedElements || [])],
3400
3632
  observeRoot: rec.observeRoot || null,
3401
3633
  };
3402
3634
  };
@@ -3419,46 +3651,264 @@ o.clearRecording = (id) => {
3419
3651
  };
3420
3652
 
3421
3653
  /**
3422
- * Export a recording as a ready-to-commit o.addTest() code string.
3423
- * Available in all builds so QA testers can export tests from staging.
3424
- * @param {{actions: Array, mocks: Object, initialData: Object}} recording
3425
- * @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}> }}
3426
3659
  */
3427
- o.exportTest = (recording) => {
3428
- const cases = recording.actions
3429
- .map((a) => {
3430
- let body;
3431
- if (a.type === "scroll") {
3432
- body = ` window.scrollTo(0, ${a.scrollY || 0}); return true;\n`;
3433
- } else if (a.type === "input" || a.type === "change") {
3434
- body =
3435
- (a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
3436
- (a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
3437
- ` 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
+ }
3760
+ } else {
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;
3438
3783
  } else {
3439
- const useNativeClick = a.type === "click";
3440
- body = useNativeClick
3441
- ? ` el.click(); return true;\n`
3442
- : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true})); return true;\n`;
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
+ }
3443
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
+ }
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);
3444
3858
  return (
3445
- ` ['${a.type} on ${a.target}', () => {\n` +
3446
- ` const el = document.querySelector('${a.target}');\n` +
3447
- ` 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` +
3448
3890
  body +
3449
- ` }],`
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
+ ` }]`,
3450
3900
  );
3451
- })
3452
- .join("\n");
3453
-
3454
- const mocksStr = Object.keys(recording.mocks).length
3901
+ }
3902
+ }
3903
+ const mocksStr = Object.keys(recording.mocks || {}).length
3455
3904
  ? JSON.stringify(recording.mocks, null, 2)
3456
3905
  : "{}";
3457
3906
 
3458
3907
  return (
3459
3908
  `// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
3460
- `const recordingMocks = ${mocksStr};\n\n` +
3461
- `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` +
3462
3912
  ` // teardown\n});\n`
3463
3913
  );
3464
3914
  };
@@ -3591,13 +4041,25 @@ o.exportPlaywrightTest = (recording, options = {}) => {
3591
4041
  // Available in all builds so assessors can replay and see results (testOverlay) on staging.
3592
4042
  /**
3593
4043
  * Play back a recording as an automated test sequence
3594
- * @param {{actions: Array, mocks: Object}} recording
3595
- * @param {Object} [mockOverrides] - Additional mock overrides (anonymized data)
3596
- * @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}}
3597
4047
  */
3598
- 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
+
3599
4062
  const allMocks = Object.assign({}, recording.mocks, mockOverrides);
3600
- // install mock fetch
3601
4063
  const origFetch = window.fetch;
3602
4064
  window.fetch = (url, opts = {}) => {
3603
4065
  const method = (opts.method || "GET").toUpperCase();
@@ -3611,51 +4073,154 @@ o.playRecording = (recording, mockOverrides = {}) => {
3611
4073
  return origFetch(url, opts);
3612
4074
  };
3613
4075
 
3614
- const testCases = recording.actions.map((action) => [
3615
- `${action.type} on ${action.target}`,
3616
- () => {
3617
- let el = null;
3618
- if (action.target) {
3619
- if (action.listSelector != null && action.targetIndex != null) {
3620
- const items = o.D.querySelectorAll(action.listSelector);
3621
- const item = items[action.targetIndex];
3622
- if (item) {
3623
- el =
3624
- action.target !== action.listSelector
3625
- ? item.querySelector(action.target)
3626
- : item;
3627
- 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);
3628
4138
  }
3629
- } else {
3630
- el = o.D.querySelector(action.target);
3631
4139
  }
3632
- }
3633
- if (!el && action.type !== "scroll") {
3634
- return `element not found: ${action.target}`;
3635
- }
3636
- if (action.type === "scroll") {
3637
- window.scrollTo(0, action.scrollY || 0);
3638
- } else if (action.type === "input" || action.type === "change") {
3639
- if (action.value !== undefined) el.value = action.value;
3640
- if (action.checked !== undefined) el.checked = action.checked;
3641
- el.dispatchEvent(new Event(action.type, { bubbles: true }));
3642
- } else {
3643
- if (action.type === "click") {
3644
- 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 }));
3645
4149
  } else {
3646
- el.dispatchEvent(
3647
- new MouseEvent(action.type, { bubbles: true, cancelable: true }),
3648
- );
4150
+ if (action.type === "click") {
4151
+ el.click();
4152
+ } else {
4153
+ el.dispatchEvent(
4154
+ new MouseEvent(action.type, { bubbles: true, cancelable: true }),
4155
+ );
4156
+ }
3649
4157
  }
3650
- }
3651
- return true;
3652
- },
3653
- ]);
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
+ }
3654
4205
 
3655
- const testId = o.test("Recorded playback", ...testCases, () => {
4206
+ const onComplete = isOptions && opts.onComplete;
4207
+ const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId) => {
3656
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);
3657
4221
  });
3658
- return testId;
4222
+
4223
+ return runAssertions ? { testId } : testId;
3659
4224
  };
3660
4225
 
3661
4226
  // ─── Test results overlay (all builds — for assessors to see auto + manual results) ───
@@ -3694,6 +4259,78 @@ o.testOverlay = () => {
3694
4259
  });
3695
4260
  };
3696
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);
3697
4334
  const overlayStyle = {
3698
4335
  position: "fixed",
3699
4336
  left: "50%",
@@ -3703,31 +4340,27 @@ o.testOverlay = () => {
3703
4340
  width: "fit-content",
3704
4341
  "max-width": "min(90vw, 420px)",
3705
4342
  "font-family": "system-ui,sans-serif",
3706
- cursor: "grab",
3707
4343
  "user-select": "text",
3708
4344
  };
3709
-
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>";
3710
4353
  const box = o
3711
4354
  .initState({
3712
4355
  tag: "div",
3713
- id: btnId,
3714
- className: "o-test-overlay",
4356
+ className,
4357
+ id: id || undefined,
3715
4358
  style:
3716
- "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;",
3717
- html:
3718
- `<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;">` +
3719
- `<div style="display:flex;align-items:center;gap:12px;">` +
3720
- `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;">Tests: 0/0</span>` +
3721
- `<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>` +
3722
- `<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>` +
3723
- `</div></div>` +
3724
- `<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,
3725
4361
  })
3726
4362
  .appendInside("body");
3727
-
3728
- const applyOverlayStyle = () => {
3729
- box.css(overlayStyle);
3730
- };
4363
+ const applyStyle = () => box.css(overlayStyle);
3731
4364
  let drag = null;
3732
4365
  const onMove = (e) => {
3733
4366
  if (!drag) return;
@@ -3735,71 +4368,49 @@ o.testOverlay = () => {
3735
4368
  overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3736
4369
  delete overlayStyle.bottom;
3737
4370
  overlayStyle.transform = "none";
3738
- applyOverlayStyle();
4371
+ applyStyle();
3739
4372
  };
3740
4373
  const onUp = () => {
3741
4374
  if (drag) {
3742
- overlayStyle.cursor = "grab";
3743
- applyOverlayStyle();
4375
+ delete overlayStyle.cursor;
4376
+ applyStyle();
3744
4377
  }
3745
4378
  drag = null;
3746
4379
  };
3747
4380
  box.on("mousedown", (e) => {
3748
- if (
3749
- e.target.closest(".o-test-overlay-close") ||
3750
- e.target.closest(".o-test-overlay-toggle") ||
3751
- e.target.closest("#" + panelId)
3752
- )
3753
- return;
4381
+ if (excludeDragSelector && e.target.closest(excludeDragSelector)) return;
3754
4382
  const r = box.el.getBoundingClientRect();
3755
4383
  drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3756
4384
  overlayStyle.cursor = "grabbing";
3757
- applyOverlayStyle();
4385
+ applyStyle();
3758
4386
  });
3759
4387
  o.D.addEventListener("mousemove", onMove);
3760
4388
  o.D.addEventListener("mouseup", onUp);
3761
-
3762
- const refreshSummary = () => {
3763
- const summary = o(".o-test-overlay-summary");
3764
- if (summary.els.length)
3765
- summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
3766
- };
3767
-
3768
- box.first(".o-test-overlay-toggle").on("click", () => {
3769
- const panel = o("#" + panelId);
3770
- if (!panel.el) return;
3771
- const isOpen = panel.el.style.display !== "none";
3772
- panel.css({ display: isOpen ? "none" : "block" });
3773
- if (!isOpen) updatePanel();
3774
- });
3775
-
3776
- box.first(".o-test-overlay-close").on("click", () => {
4389
+ let timerId;
4390
+ const cleanup = () => {
3777
4391
  o.D.removeEventListener("mousemove", onMove);
3778
4392
  o.D.removeEventListener("mouseup", onUp);
4393
+ if (timerId) clearInterval(timerId);
3779
4394
  box.remove();
3780
- });
3781
-
3782
- o.testOverlay.showPanel = () => {
3783
- const panel = o("#" + panelId);
3784
- if (!panel.el) return;
3785
- panel.css({ display: "block" });
3786
- updatePanel();
3787
- refreshSummary();
3788
- };
3789
-
3790
- // Single patch of o.test to refresh panel when tests complete (use base so we don't stack)
3791
- if (!o._testOverlayBase) o._testOverlayBase = o.test;
3792
- o.test = (...args) => {
3793
- const id = o._testOverlayBase(...args);
3794
- const origFn = o.tFns[id];
3795
- o.tFns[id] = (n) => {
3796
- if (typeof origFn === "function") origFn(n);
3797
- const panel = o("#" + panelId);
3798
- if (panel.el && panel.el.style.display !== "none") updatePanel();
3799
- refreshSummary();
3800
- };
3801
- return id;
3802
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;
3803
4414
  };
3804
4415
 
3805
4416
  /**
@@ -3807,12 +4418,11 @@ o.testOverlay = () => {
3807
4418
  * Only available in dev builds. NOT referenced in exportPlaywrightTest.
3808
4419
  * @param {string} label - Test title (shown as "Test title: Paused")
3809
4420
  * @param {string[]} [items] - Optional checklist for the operator (e.g. hover effects to verify); use labels so clicking text toggles checkbox
3810
- * @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
3811
4422
  * @returns {Promise<{ ok: boolean, errors?: string[] }>} ok true if all items checked; errors = list of unchecked item texts when ok false
3812
4423
  */
3813
4424
  o.testConfirm = (label, items = [], opts = {}) =>
3814
4425
  new Promise((resolve) => {
3815
- o(".o-tc-overlay").remove();
3816
4426
  const btnLabel = opts.confirm || "Continue";
3817
4427
  const hasCheckboxes = items.length > 0;
3818
4428
  const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
@@ -3821,7 +4431,7 @@ o.testConfirm = (label, items = [], opts = {}) =>
3821
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;}" +
3822
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;}";
3823
4433
  const itemsHtml = hasCheckboxes
3824
- ? `<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;">` +
3825
4435
  items
3826
4436
  .map(
3827
4437
  (i, idx) =>
@@ -3830,23 +4440,18 @@ o.testConfirm = (label, items = [], opts = {}) =>
3830
4440
  .join("") +
3831
4441
  "</ul>"
3832
4442
  : "";
3833
- const box = o
3834
- .initState({
3835
- tag: "div",
3836
- className: "o-tc-overlay",
3837
- style:
3838
- "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;",
3839
- html:
3840
- `<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;">` +
3841
- `<div style="display:flex;align-items:center;gap:12px;">` +
3842
- `<span class="o-tc-label" style="flex:1;">${label}: Paused</span>` +
3843
- `<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>` +
3844
- `</div>` +
3845
- itemsHtml +
3846
- `</div>`,
3847
- })
3848
- .appendInside("body");
3849
-
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
+ });
3850
4455
  const okBtnStyles = {
3851
4456
  padding: "6px 14px",
3852
4457
  background: hasCheckboxes ? "#dc2626" : "#2563eb",
@@ -3860,70 +4465,23 @@ o.testConfirm = (label, items = [], opts = {}) =>
3860
4465
  };
3861
4466
  if (hasCheckboxes) {
3862
4467
  const okBtn = box.first(".o-tc-ok");
3863
- const cbs = o(".o-tc-overlay .o-tc-item-cb");
4468
+ const cbs = o(".o-overlay-common .o-tc-item-cb");
3864
4469
  const updateBtn = () => {
3865
4470
  const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
3866
4471
  okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
3867
4472
  };
3868
4473
  cbs.on("change", updateBtn);
3869
4474
  }
3870
-
3871
- let drag = null;
3872
- const overlayStyle = {
3873
- position: "fixed",
3874
- left: "50%",
3875
- bottom: "50px",
3876
- transform: "translateX(-50%)",
3877
- "z-index": "999999",
3878
- width: "fit-content",
3879
- "max-width": "min(90vw, 400px)",
3880
- "font-family": "system-ui,sans-serif",
3881
- cursor: "grab",
3882
- "user-select": "text",
3883
- };
3884
- const applyOverlayStyle = () => {
3885
- box.css(overlayStyle);
3886
- };
3887
- const onMove = (e) => {
3888
- if (!drag) return;
3889
- overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
3890
- overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
3891
- delete overlayStyle.bottom;
3892
- overlayStyle.transform = "none";
3893
- applyOverlayStyle();
3894
- };
3895
- const onUp = () => {
3896
- if (drag) {
3897
- overlayStyle.cursor = "grab";
3898
- applyOverlayStyle();
3899
- }
3900
- drag = null;
3901
- };
3902
- box.on("mousedown", (e) => {
3903
- if (e.target.closest(".o-tc-ok")) return;
3904
- const r = box.el.getBoundingClientRect();
3905
- drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
3906
- overlayStyle.cursor = "grabbing";
3907
- applyOverlayStyle();
3908
- });
3909
- o.D.addEventListener("mousemove", onMove);
3910
- o.D.addEventListener("mouseup", onUp);
3911
-
3912
4475
  box.first(".o-tc-ok").on("click", () => {
3913
- o.D.removeEventListener("mousemove", onMove);
3914
- o.D.removeEventListener("mouseup", onUp);
3915
4476
  let unchecked = [];
3916
4477
  if (hasCheckboxes) {
3917
- const cbsList = o(".o-tc-overlay .o-tc-item-cb");
3918
- cbsList.els.forEach((el, idx) => {
3919
- if (!el.checked && items[idx] !== undefined) unchecked.push(items[idx]);
3920
- });
3921
- }
3922
- box.remove();
3923
- if (unchecked.length === 0) {
3924
- resolve({ ok: true });
3925
- } else {
3926
- 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
+ });
3927
4483
  }
4484
+ box._overlayCleanup();
4485
+ resolve(unchecked.length === 0 ? { ok: true } : { ok: false, errors: unchecked });
3928
4486
  });
3929
4487
  });