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.built.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
  */
@@ -788,6 +788,14 @@ const o = (query) => {
788
788
  return html;
789
789
  }
790
790
  }, "html");
791
+ result.toString = function() {
792
+ return result.html();
793
+ };
794
+ result[Symbol.toPrimitive] = function(hint) {
795
+ if (hint === "string" || hint === "default") return result.html();
796
+ if (hint === "number") return result.els?.length ?? 0;
797
+ return result.html();
798
+ };
791
799
  result.val = returner((value) => {
792
800
  if (value === void 0) return result.el?.value;
793
801
  iterator(() => {
@@ -1700,6 +1708,7 @@ o.withReactContext = (React, Context, selector, component, state = "render") =>
1700
1708
  if (__DEV__) {
1701
1709
  o.debug = false;
1702
1710
  }
1711
+ o.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1703
1712
  o.tLog = [];
1704
1713
  o.tRes = [];
1705
1714
  o.tStatus = [];
@@ -1708,6 +1717,8 @@ o.tShowOk = o.F;
1708
1717
  o.tStyled = o.F;
1709
1718
  o.tTime = 2e3;
1710
1719
  o.tests = [];
1720
+ o.tExpectedSteps = {};
1721
+ o.tFinalized = {};
1711
1722
  o.tAutolog = o.F;
1712
1723
  o.tBeforeEach = void 0;
1713
1724
  o.tAfterEach = void 0;
@@ -1808,10 +1819,15 @@ o.test = (title = "", ...tests) => {
1808
1819
  }
1809
1820
  }
1810
1821
  };
1822
+ let opts = {};
1811
1823
  if (typeof tests[num - 1] === "function") {
1812
1824
  o.tFns[testN2] = tests[num - 1];
1813
1825
  num--;
1814
1826
  }
1827
+ if (num > 0 && typeof tests[num - 1] === "object" && !Array.isArray(tests[num - 1]) && (tests[num - 1].sync !== void 0 || tests[num - 1].confirmOnFailure !== void 0)) {
1828
+ opts = tests[num - 1];
1829
+ num--;
1830
+ }
1815
1831
  if (testSession) {
1816
1832
  o.tLog[testN2] = sessionStorage.getItem(`oTest-Log-${testN2}`) || "";
1817
1833
  o.tRes[testN2] = sessionStorage.getItem(`oTest-Res-${testN2}`) || false;
@@ -1839,6 +1855,143 @@ o.test = (title = "", ...tests) => {
1839
1855
  o.tRes[testN2] = o.F;
1840
1856
  o.tStatus[testN2] = [];
1841
1857
  }
1858
+ o.tExpectedSteps[testN2] = num;
1859
+ o.tFinalized[testN2] = false;
1860
+ const showConfirmOnFailureOverlay = (stepIdx, msg) => new Promise((resolve) => {
1861
+ const box = o.overlay({
1862
+ innerHTML: `<div style="display:flex;flex-direction:column;gap:8px;"><div style="cursor:grab;">Step ${stepIdx + 1} failed: ${msg || "error"}. Continue testing?</div><div style="display:flex;gap:8px;"><button class="o-cf-continue" style="padding:6px 12px;background:#2563eb;color:#fff;border:none;border-radius:6px;cursor:pointer;">Continue</button><button class="o-cf-stop" style="padding:6px 12px;background:#dc2626;color:#fff;border:none;border-radius:6px;cursor:pointer;">Stop</button></div></div>`,
1863
+ timeout: opts.confirmOnFailureTimeout || void 0,
1864
+ onClose: (r) => resolve(r || { continue: false }),
1865
+ excludeDragSelector: ".o-cf-continue, .o-cf-stop"
1866
+ });
1867
+ box.first(".o-cf-continue").on("click", () => {
1868
+ box._overlayCleanup();
1869
+ resolve({ continue: true });
1870
+ });
1871
+ box.first(".o-cf-stop").on("click", () => {
1872
+ box._overlayCleanup();
1873
+ resolve({ continue: false });
1874
+ });
1875
+ });
1876
+ const finalize = () => {
1877
+ if (o.tFinalized[testN2]) return;
1878
+ o.tFinalized[testN2] = true;
1879
+ const anyFailed = o.tStatus[testN2].some((s) => s === false);
1880
+ o.tRes[testN2] = !anyFailed && done === num;
1881
+ row = waits ? "\u251C " : "\u2558 ";
1882
+ row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
1883
+ log(row, done + waits !== num);
1884
+ if (!waits) {
1885
+ log();
1886
+ }
1887
+ if (o.tStyled) {
1888
+ o.tLog[testN2] += o.tPre + '<div style="color:' + (done + waits !== num ? "red" : "green") + ';"><b>DONE ' + done + "/" + num + (waits ? ", waiting: " + waits : "") + "</b>" + o.tDc + o.tDc;
1889
+ } else {
1890
+ o.tLog[testN2] += row + "\n";
1891
+ }
1892
+ if (testSession) {
1893
+ sessionStorage.setItem(`oTest-Log-${testN2}`, o.tLog[testN2]);
1894
+ sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
1895
+ sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
1896
+ }
1897
+ if (!waits && typeof o.tFns[testN2] === "function") {
1898
+ o.tFns[testN2](testN2);
1899
+ }
1900
+ };
1901
+ if (opts.sync || opts.confirmOnFailure) {
1902
+ (async () => {
1903
+ for (let i = o.tStatus[testN2].length; i < num; i++) {
1904
+ const testInfo = {
1905
+ n: testN2,
1906
+ i,
1907
+ title: tests[i][0],
1908
+ tShowOk: o.tShowOk,
1909
+ tStyled: o.tStyled
1910
+ };
1911
+ let res = tests[i][1];
1912
+ if (typeof res === "undefined") {
1913
+ if (o.tStyled) {
1914
+ o.tLog[testN2] += "<div>" + testInfo.title + "</div>";
1915
+ } else {
1916
+ o.tLog[testN2] += testInfo.title + "\n";
1917
+ }
1918
+ log("\u251C " + testInfo.title, false, true);
1919
+ o.tStatus[testN2][i] = true;
1920
+ done++;
1921
+ continue;
1922
+ }
1923
+ if (typeof o.tBeforeEach === "function") {
1924
+ o.tBeforeEach(testInfo);
1925
+ }
1926
+ if (typeof res === "function") {
1927
+ try {
1928
+ res = res(testInfo);
1929
+ } catch (error) {
1930
+ res = error.message;
1931
+ if (o.onError) {
1932
+ o.onError(error);
1933
+ }
1934
+ }
1935
+ }
1936
+ if (typeof o.tAfterEach === "function") {
1937
+ o.tAfterEach(testInfo, res);
1938
+ }
1939
+ if (res && typeof res.then === "function") {
1940
+ try {
1941
+ const value = await res;
1942
+ const ok = value === true || value == null || value && typeof value === "object" && value.ok === true;
1943
+ const msg = value && value.errors && value.errors.length ? value.errors.join("; ") : typeof value === "string" ? value : "";
1944
+ o.testUpdate(testInfo, ok, ok ? "" : msg ? ": " + msg : "");
1945
+ done++;
1946
+ if (!ok && opts.confirmOnFailure) {
1947
+ const choice = await showConfirmOnFailureOverlay(i, msg);
1948
+ if (!choice.continue) break;
1949
+ }
1950
+ } catch (err) {
1951
+ o.testUpdate(testInfo, false, err.message || "Promise rejected");
1952
+ if (opts.confirmOnFailure) {
1953
+ const choice = await showConfirmOnFailureOverlay(i, err.message || "Promise rejected");
1954
+ if (!choice.continue) break;
1955
+ }
1956
+ }
1957
+ continue;
1958
+ }
1959
+ if (typeof o.tStatus[testN2][i] === "undefined") {
1960
+ o.tStatus[testN2][i] = typeof res === "string" ? o.F : res;
1961
+ } else {
1962
+ sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
1963
+ return;
1964
+ }
1965
+ if (res === true) {
1966
+ done++;
1967
+ if (o.tShowOk) {
1968
+ o.tLog[testN2] += preOk + tests[i][0] + posOk;
1969
+ log("\u251C OK: " + tests[i][0]);
1970
+ }
1971
+ } else if (res !== o.U) {
1972
+ o.tLog[testN2] += preXx + tests[i][0] + (res !== o.F ? ": " + res : "") + posXx;
1973
+ log("\u251C \u2718 " + tests[i][0] + (res !== o.F ? ": " + res : ""), true);
1974
+ if (opts.confirmOnFailure) {
1975
+ const choice = await showConfirmOnFailureOverlay(i, typeof res === "string" ? res : "");
1976
+ if (!choice.continue) break;
1977
+ }
1978
+ } else {
1979
+ waits++;
1980
+ setTimeout(
1981
+ (info) => {
1982
+ info.title += " (timeout)";
1983
+ o.testUpdate(info);
1984
+ },
1985
+ o.tTime,
1986
+ testInfo
1987
+ );
1988
+ return;
1989
+ }
1990
+ }
1991
+ finalize();
1992
+ })();
1993
+ return testN2;
1994
+ }
1842
1995
  for (let i = o.tStatus[testN2].length; i < num; i++) {
1843
1996
  const testInfo = {
1844
1997
  n: testN2,
@@ -1919,26 +2072,7 @@ o.test = (title = "", ...tests) => {
1919
2072
  );
1920
2073
  }
1921
2074
  }
1922
- o.tRes[testN2] = done === num;
1923
- row = waits ? "\u251C " : "\u2558 ";
1924
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
1925
- log(row, done + waits !== num);
1926
- if (!waits) {
1927
- log();
1928
- }
1929
- if (o.tStyled) {
1930
- o.tLog[testN2] += o.tPre + '<div style="color:' + (done + waits !== num ? "red" : "green") + ';"><b>DONE ' + done + "/" + num + (waits ? ", waiting: " + waits : "") + "</b>" + o.tDc + o.tDc;
1931
- } else {
1932
- o.tLog[testN2] += row + "\n";
1933
- }
1934
- if (testSession) {
1935
- sessionStorage.setItem(`oTest-Log-${testN2}`, o.tLog[testN2]);
1936
- sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
1937
- sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
1938
- }
1939
- if (!waits && typeof o.tFns[testN2] === "function") {
1940
- o.tFns[testN2](testN2);
1941
- }
2075
+ finalize();
1942
2076
  return testN2;
1943
2077
  };
1944
2078
  o.testUpdate = (info, res = o.F, suff = "") => {
@@ -1990,13 +2124,21 @@ o.testUpdate = (info, res = o.F, suff = "") => {
1990
2124
  }
1991
2125
  n++;
1992
2126
  }
2127
+ const expectedSteps = o.tests[testN2]?.tests?.length ?? o.tExpectedSteps[testN2] ?? Number.MAX_SAFE_INTEGER;
2128
+ if (n < expectedSteps) {
2129
+ if (sessionStorage?.getItem("oTest-Run") === testN2) {
2130
+ sessionStorage.setItem(`oTest-Log-${testN2}`, o.tLog[testN2]);
2131
+ sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
2132
+ sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
2133
+ }
2134
+ return;
2135
+ }
2136
+ if (o.tFinalized[testN2]) return;
2137
+ o.tFinalized[testN2] = true;
1993
2138
  if (sessionStorage?.getItem("oTest-Run") === testN2) {
1994
2139
  sessionStorage.setItem(`oTest-Log-${testN2}`, o.tLog[testN2]);
1995
2140
  sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
1996
2141
  sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
1997
- if (n < o.tests[testN2].tests.length) {
1998
- return;
1999
- }
2000
2142
  }
2001
2143
  o.tRes[testN2] = !fails;
2002
2144
  row = fails ? "FAILED " + fails + "/" + n : "DONE " + n + "/" + n;
@@ -2101,6 +2243,7 @@ o.recorder = {
2101
2243
  _listeners: [],
2102
2244
  _observer: null
2103
2245
  };
2246
+ o.recordingAssertionDebug = false;
2104
2247
  o.startRecording = (observe, events, timeouts) => {
2105
2248
  if (o.recorder.active) {
2106
2249
  return;
@@ -2124,6 +2267,7 @@ o.startRecording = (observe, events, timeouts) => {
2124
2267
  rec.initialData = { url: window.location.href, timestamp: Date.now() };
2125
2268
  rec.observeRoot = observe || null;
2126
2269
  rec.assertions = [];
2270
+ rec.removedElements = [];
2127
2271
  o.inits.forEach((inst, idx) => {
2128
2272
  if (inst?.store) {
2129
2273
  rec.initialData["init_" + idx] = JSON.parse(JSON.stringify(inst.store));
@@ -2204,6 +2348,16 @@ o.startRecording = (observe, events, timeouts) => {
2204
2348
  rec._observer = new MutationObserver((mutations) => {
2205
2349
  const actionIdx = rec.actions.length - 1;
2206
2350
  if (actionIdx < 0) return;
2351
+ const lastAction = rec.actions[actionIdx];
2352
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2353
+ console.log("[recording] MutationObserver batch:", {
2354
+ actionIdx,
2355
+ lastAction: lastAction ? { type: lastAction.type, target: lastAction.target } : null,
2356
+ mutationTypes: mutations.map((x) => x.type),
2357
+ addedCount: mutations.reduce((n, x) => n + (x.addedNodes?.length || 0), 0),
2358
+ removedCount: mutations.reduce((n, x) => n + (x.removedNodes?.length || 0), 0)
2359
+ });
2360
+ }
2207
2361
  mutations.forEach((m) => {
2208
2362
  const addAssertionIndex = (sel, node) => {
2209
2363
  let listSelector;
@@ -2242,13 +2396,56 @@ o.startRecording = (observe, events, timeouts) => {
2242
2396
  (a2) => a2.actionIdx === actionIdx && a2.selector === sel && a2.type === "visible"
2243
2397
  ))
2244
2398
  return;
2245
- const textEl = node.querySelector?.(".task-text") || node;
2246
- const text = (textEl.textContent?.trim() || node.textContent?.trim() || "").slice(0, 80) || void 0;
2399
+ const text = (node.textContent?.trim() || "").slice(0, 80) || void 0;
2247
2400
  const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, node);
2248
2401
  const a = { actionIdx, type: "visible", selector: sel, text };
2249
2402
  if (aListSel != null) a.listSelector = aListSel;
2250
2403
  if (aIdx != null) a.index = aIdx;
2251
2404
  rec.assertions.push(a);
2405
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2406
+ console.log("[recording] +visible assertion:", {
2407
+ actionIdx,
2408
+ lastAction: lastAction?.type + " " + lastAction?.target,
2409
+ selector: sel,
2410
+ text: (text || "").slice(0, 40),
2411
+ index: aIdx,
2412
+ listSelector: aListSel
2413
+ });
2414
+ }
2415
+ });
2416
+ m.removedNodes.forEach((node) => {
2417
+ if (node.nodeType !== 1) return;
2418
+ const sel = buildSelector(node);
2419
+ if (!sel) return;
2420
+ const text = (node.textContent?.trim() || "").slice(0, 80) || void 0;
2421
+ const parent = m.target;
2422
+ let index;
2423
+ if (node.previousSibling) {
2424
+ index = Array.from(parent.children).indexOf(node.previousSibling) + 1;
2425
+ } else if (node.nextSibling) {
2426
+ index = Array.from(parent.children).indexOf(node.nextSibling);
2427
+ } else {
2428
+ index = 0;
2429
+ }
2430
+ let listSelector;
2431
+ if (o.autotag && node.dataset?.[o.autotag]) {
2432
+ const qaVal = node.dataset[o.autotag];
2433
+ listSelector = `[data-${o.autotag}="${qaVal}"]`;
2434
+ }
2435
+ const entry = { actionIdx, type: "removed", selector: sel, text };
2436
+ if (listSelector) entry.listSelector = listSelector;
2437
+ entry.index = index;
2438
+ rec.removedElements.push(entry);
2439
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2440
+ console.log("[recording] +removed element:", {
2441
+ actionIdx,
2442
+ lastAction: lastAction?.type + " " + lastAction?.target,
2443
+ selector: sel,
2444
+ text: (text || "").slice(0, 40),
2445
+ index,
2446
+ listSelector
2447
+ });
2448
+ }
2252
2449
  });
2253
2450
  }
2254
2451
  if (m.type === "attributes") {
@@ -2268,6 +2465,16 @@ o.startRecording = (observe, events, timeouts) => {
2268
2465
  if (aListSel != null) a.listSelector = aListSel;
2269
2466
  if (aIdx != null) a.index = aIdx;
2270
2467
  rec.assertions.push(a);
2468
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2469
+ console.log("[recording] +class assertion:", {
2470
+ actionIdx,
2471
+ lastAction: lastAction?.type + " " + lastAction?.target,
2472
+ selector: sel,
2473
+ className: m.target.className,
2474
+ index: aIdx,
2475
+ listSelector: aListSel
2476
+ });
2477
+ }
2271
2478
  }
2272
2479
  });
2273
2480
  });
@@ -2330,7 +2537,7 @@ o.startRecording = (observe, events, timeouts) => {
2330
2537
  const scrollY = ev === "scroll" ? window.scrollY : void 0;
2331
2538
  const value = ev === "input" || ev === "change" ? target?.value : void 0;
2332
2539
  const checked = ev === "change" && (target?.type === "checkbox" || target?.type === "radio") ? target?.checked : void 0;
2333
- const delay = stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
2540
+ const delay = ev === "click" || ev === "change" ? 0 : stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
2334
2541
  const pushAction = () => {
2335
2542
  const action = { type: ev, target: selector, time: Date.now() };
2336
2543
  if (targetType) action.targetType = targetType;
@@ -2373,6 +2580,7 @@ o.stopRecording = () => {
2373
2580
  initialData: { ...rec.initialData },
2374
2581
  stepDelays: { ...rec.stepDelays },
2375
2582
  assertions: [...rec.assertions || []],
2583
+ removedElements: [...rec.removedElements || []],
2376
2584
  observeRoot: rec.observeRoot || null
2377
2585
  };
2378
2586
  };
@@ -2388,34 +2596,218 @@ o.clearRecording = (id) => {
2388
2596
  }
2389
2597
  }
2390
2598
  };
2391
- o.exportTest = (recording) => {
2392
- const cases = recording.actions.map((a) => {
2599
+ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2600
+ const preFiltered = opts && opts.assertions;
2601
+ const assertions = preFiltered != null ? preFiltered : (recording.assertions || []).filter(
2602
+ (a) => actionIdx == null || a.actionIdx === actionIdx
2603
+ );
2604
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2605
+ console.log("[runRecordingAssertions] run:", {
2606
+ actionIdx,
2607
+ scope: actionIdx == null ? "teardown (all)" : "per-action",
2608
+ assertionsCount: assertions.length,
2609
+ assertions: assertions.map((a) => ({
2610
+ actionIdx: a.actionIdx,
2611
+ type: a.type,
2612
+ selector: a.selector,
2613
+ index: a.index,
2614
+ text: (a.text || "").slice(0, 40)
2615
+ }))
2616
+ });
2617
+ }
2618
+ const seen = /* @__PURE__ */ new Set();
2619
+ const deduped = assertions.filter((a) => {
2620
+ const key = `${a.selector}|${a.type}|${a.actionIdx}|${a.index ?? ""}`;
2621
+ if (seen.has(key)) return false;
2622
+ seen.add(key);
2623
+ return true;
2624
+ });
2625
+ const resolveRoot = () => {
2626
+ if (root != null) {
2627
+ return typeof root === "string" ? o.D.querySelector(root) || o.D.body : root;
2628
+ }
2629
+ const sel = recording.observeRoot;
2630
+ return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
2631
+ };
2632
+ const r = resolveRoot();
2633
+ const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
2634
+ const getText = (el) => el ? norm(el.textContent || "") : "";
2635
+ const removedElements = opts?.removedElements || [];
2636
+ const isRemoved = (a) => {
2637
+ if (!removedElements.length || actionIdx == null) return false;
2638
+ const expText = norm(a.text || "");
2639
+ for (const r2 of removedElements) {
2640
+ if (r2.actionIdx > actionIdx) continue;
2641
+ if (norm(r2.text || "") !== expText) continue;
2642
+ if (r2.selector !== a.selector) continue;
2643
+ if (a.listSelector != null && r2.listSelector !== a.listSelector) continue;
2644
+ if (a.index != null && r2.index !== a.index) continue;
2645
+ return true;
2646
+ }
2647
+ return false;
2648
+ };
2649
+ let passed = 0;
2650
+ const failures = [];
2651
+ for (const a of deduped) {
2652
+ if (isRemoved(a)) {
2653
+ passed += 1;
2654
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2655
+ console.log("[runRecordingAssertions] skip (explicit removed):", {
2656
+ actionIdx: a.actionIdx,
2657
+ selector: a.selector,
2658
+ text: (a.text || "").slice(0, 40)
2659
+ });
2660
+ }
2661
+ continue;
2662
+ }
2663
+ let el = null;
2664
+ let indexOutOfBounds = false;
2665
+ if (a.listSelector != null && a.index != null) {
2666
+ const items = r.querySelectorAll(a.listSelector);
2667
+ const expectedText = norm(a.text || "");
2668
+ const tryItem = (idx) => {
2669
+ const it = items[idx];
2670
+ if (!it) return null;
2671
+ const e = a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
2672
+ return e || (a.selector !== a.listSelector ? it : null);
2673
+ };
2674
+ let item = items[a.index];
2675
+ if (!item && a.index > 0) item = items[a.index - 1];
2676
+ if (item) {
2677
+ el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
2678
+ if (!el && a.selector !== a.listSelector) el = item;
2679
+ if (a.type === "visible" && expectedText && el) {
2680
+ const actualText = getText(el);
2681
+ const textMismatch = actualText.indexOf(expectedText) === -1 && expectedText.indexOf(actualText) === -1;
2682
+ if (textMismatch) {
2683
+ for (let j = 0; j < items.length; j++) {
2684
+ const candEl = tryItem(j);
2685
+ if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
2686
+ el = candEl;
2687
+ item = items[j];
2688
+ break;
2689
+ }
2690
+ }
2691
+ }
2692
+ }
2693
+ } else {
2694
+ indexOutOfBounds = true;
2695
+ }
2696
+ } else {
2697
+ const matches = r.querySelectorAll(a.selector);
2698
+ el = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
2699
+ }
2700
+ if (a.type === "visible") {
2701
+ const visible = el && el.nodeType === 1 && (el.offsetParent !== null || el.getBoundingClientRect && el.getBoundingClientRect().width > 0);
2702
+ const expectedText = norm(a.text || "");
2703
+ const actualText = getText(el);
2704
+ const fullActual = actualText;
2705
+ const textOk = !expectedText || actualText.indexOf(expectedText) !== -1 || fullActual.indexOf(expectedText) !== -1 || expectedText.length > 0 && expectedText.indexOf(actualText) !== -1;
2706
+ if (visible && textOk) {
2707
+ passed += 1;
2708
+ } else {
2709
+ const message = indexOutOfBounds ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector || a.selector).length} items, assertion expected index ${a.index})` : !el ? "element not found" : !visible ? "not visible" : !textOk ? "text mismatch" : "fail";
2710
+ failures.push({ selector: a.selector, message });
2711
+ if (typeof console !== "undefined" && console.warn) {
2712
+ console.warn("[runRecordingAssertions] visible failed:", {
2713
+ actionIdx: a.actionIdx,
2714
+ selector: a.selector,
2715
+ listSelector: a.listSelector,
2716
+ index: a.index,
2717
+ expectedText: a.text || "(any)",
2718
+ actualText: actualText.slice(0, 80),
2719
+ message
2720
+ });
2721
+ }
2722
+ }
2723
+ } else if (a.type === "class") {
2724
+ const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
2725
+ const hasClass = el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
2726
+ if (hasClass) {
2727
+ passed += 1;
2728
+ } else {
2729
+ const msg = indexOutOfBounds ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})` : !el ? "element not found" : `expected class "${a.className}"`;
2730
+ failures.push({ selector: a.selector, message: msg });
2731
+ if (typeof console !== "undefined" && console.warn) {
2732
+ console.warn("[runRecordingAssertions] failed:", {
2733
+ type: a.type,
2734
+ selector: a.selector,
2735
+ actionIdx: a.actionIdx,
2736
+ listSelector: a.listSelector,
2737
+ index: a.index,
2738
+ itemsInRoot: a.listSelector ? r.querySelectorAll(a.listSelector).length : "-",
2739
+ message: msg
2740
+ });
2741
+ }
2742
+ }
2743
+ }
2744
+ }
2745
+ return { passed, total: deduped.length, failures };
2746
+ };
2747
+ o.exportTest = (recording, options = {}) => {
2748
+ const delay = options.delay !== void 0 ? options.delay : 16;
2749
+ const recordingData = {
2750
+ actions: recording.actions,
2751
+ assertions: recording.assertions || [],
2752
+ observeRoot: recording.observeRoot || null
2753
+ };
2754
+ const rootVar = recording.observeRoot ? `(o.D.querySelector('${recording.observeRoot.replace(/'/g, "\\'")}') || o.D.body)` : "o.D.body";
2755
+ const getEl = (a) => {
2756
+ if (a.listSelector != null && a.targetIndex != null) {
2757
+ const listSel = JSON.stringify(a.listSelector);
2758
+ const useItem = a.target === a.listSelector;
2759
+ const targetSel = useItem ? listSel : JSON.stringify(a.target);
2760
+ return ` const items = o.D.querySelectorAll(${listSel});
2761
+ const item = items[${a.targetIndex}];
2762
+ let el = null;
2763
+ if (item) { el = ${useItem ? "item" : `item.querySelector(${targetSel}) || item`}; }`;
2764
+ }
2765
+ return ` const el = o.D.querySelector(${JSON.stringify(a.target)});`;
2766
+ };
2767
+ const endSuffix = delay > 0 ? `
2768
+ await o.sleep(${delay});
2769
+ return true;
2770
+ ` : ` return true;
2771
+ `;
2772
+ const stepFn = delay > 0 ? "async () =>" : "() =>";
2773
+ const steps = [];
2774
+ for (let i = 0; i < recording.actions.length; i++) {
2775
+ const a = recording.actions[i];
2393
2776
  let body;
2394
2777
  if (a.type === "scroll") {
2395
- body = ` window.scrollTo(0, ${a.scrollY || 0}); return true;
2396
- `;
2778
+ body = ` window.scrollTo(0, ${a.scrollY || 0});${endSuffix}`;
2397
2779
  } else if (a.type === "input" || a.type === "change") {
2398
2780
  body = (a.value !== void 0 ? ` el.value = ${JSON.stringify(a.value)};
2399
2781
  ` : "") + (a.checked !== void 0 ? ` el.checked = ${a.checked};
2400
- ` : "") + ` el.dispatchEvent(new Event('${a.type}', {bubbles:true})); return true;
2401
- `;
2782
+ ` : "") + ` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
2402
2783
  } else {
2403
2784
  const useNativeClick = a.type === "click";
2404
- body = useNativeClick ? ` el.click(); return true;
2405
- ` : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true})); return true;
2406
- `;
2785
+ body = useNativeClick ? ` el.click();${endSuffix}` : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
2407
2786
  }
2408
- return ` ['${a.type} on ${a.target}', () => {
2409
- const el = document.querySelector('${a.target}');
2410
- if (!el) return 'element not found';
2411
- ` + body + ` }],`;
2412
- }).join("\n");
2413
- const mocksStr = Object.keys(recording.mocks).length ? JSON.stringify(recording.mocks, null, 2) : "{}";
2787
+ steps.push(
2788
+ ` ['${a.type} on ${a.target}', ${stepFn} {
2789
+ ` + getEl(a) + `
2790
+ if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';
2791
+ ` + body + ` }]`
2792
+ );
2793
+ const assertsForAction = (recording.assertions || []).filter((x) => x.actionIdx === i);
2794
+ if (assertsForAction.length > 0) {
2795
+ steps.push(
2796
+ ` ['assert after ${a.type}', () => {
2797
+ const r = o.runRecordingAssertions(recordingData, ${rootVar}, ${i});
2798
+ return r.passed === r.total ? true : r.failures.map(f => f.selector + ': ' + f.message).join('; ');
2799
+ }]`
2800
+ );
2801
+ }
2802
+ }
2803
+ const mocksStr = Object.keys(recording.mocks || {}).length ? JSON.stringify(recording.mocks, null, 2) : "{}";
2414
2804
  return `// Auto-generated by o.exportTest() \u2014 review and anonymize mocks before committing
2415
2805
  const recordingMocks = ${mocksStr};
2806
+ const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };
2416
2807
 
2417
2808
  o.addTest('Recorded test', [
2418
- ${cases}
2809
+ ${steps.join(",\n")}
2810
+ // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],
2419
2811
  ], () => {
2420
2812
  // teardown
2421
2813
  });
@@ -2507,60 +2899,151 @@ test(${JSON.stringify(testName)}, async ({ page }) => {
2507
2899
  `) + `});
2508
2900
  `;
2509
2901
  };
2510
- o.playRecording = (recording, mockOverrides = {}) => {
2902
+ o.playRecording = (recording, opts = {}) => {
2903
+ const isOptions = opts && typeof opts === "object" && (opts.runAssertions !== void 0 || opts.root !== void 0 || opts.manualChecks !== void 0 || opts.actionDelay !== void 0);
2904
+ const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
2905
+ const runAssertions = isOptions && opts.runAssertions;
2906
+ const rootOpt = isOptions ? opts.root : void 0;
2907
+ const manualChecks = isOptions && opts.manualChecks || [];
2908
+ const actionDelay = isOptions && opts.actionDelay !== void 0 ? opts.actionDelay : 16;
2511
2909
  const allMocks = Object.assign({}, recording.mocks, mockOverrides);
2512
2910
  const origFetch = window.fetch;
2513
- window.fetch = (url, opts = {}) => {
2514
- const method = (opts.method || "GET").toUpperCase();
2911
+ window.fetch = (url, opts2 = {}) => {
2912
+ const method = (opts2.method || "GET").toUpperCase();
2515
2913
  const key = method + ":" + url;
2516
2914
  if (allMocks[key]) {
2517
2915
  const mock = allMocks[key];
2518
2916
  const body = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
2519
2917
  return Promise.resolve(new Response(body, { status: mock.status || 200 }));
2520
2918
  }
2521
- return origFetch(url, opts);
2919
+ return origFetch(url, opts2);
2522
2920
  };
2523
- const testCases = recording.actions.map((action) => [
2524
- `${action.type} on ${action.target}`,
2525
- () => {
2526
- let el = null;
2527
- if (action.target) {
2528
- if (action.listSelector != null && action.targetIndex != null) {
2529
- const items = o.D.querySelectorAll(action.listSelector);
2530
- const item = items[action.targetIndex];
2531
- if (item) {
2532
- el = action.target !== action.listSelector ? item.querySelector(action.target) : item;
2533
- if (!el && action.target !== action.listSelector) el = item;
2921
+ const resolveRoot = () => {
2922
+ if (rootOpt != null) {
2923
+ return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
2924
+ }
2925
+ const sel = recording.observeRoot;
2926
+ return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
2927
+ };
2928
+ const rootEl = runAssertions ? resolveRoot() : null;
2929
+ const actionScope = rootOpt != null ? resolveRoot() : o.D;
2930
+ const actions = recording.actions;
2931
+ const assertions = recording.assertions || [];
2932
+ const assertionsByAction = {};
2933
+ for (const a of assertions) {
2934
+ const k = a.actionIdx;
2935
+ if (!assertionsByAction[k]) assertionsByAction[k] = [];
2936
+ assertionsByAction[k].push(a);
2937
+ }
2938
+ if (o.recordingAssertionDebug && runAssertions && typeof console !== "undefined" && console.log) {
2939
+ const summary = actions.map((act, i) => ({
2940
+ i,
2941
+ action: act.type + " " + (act.target || ""),
2942
+ assertions: (assertionsByAction[i] || []).length,
2943
+ assertionDetails: (assertionsByAction[i] || []).map((x) => ({
2944
+ type: x.type,
2945
+ index: x.index,
2946
+ text: (x.text || "").slice(0, 30)
2947
+ }))
2948
+ }));
2949
+ console.log("[playRecording] assertions by action:", summary);
2950
+ }
2951
+ const manualByAction = {};
2952
+ for (const mc of manualChecks) {
2953
+ const k = mc.afterAction;
2954
+ if (!manualByAction[k]) manualByAction[k] = [];
2955
+ manualByAction[k].push(mc);
2956
+ }
2957
+ const testCases = [];
2958
+ let assertionAccum = { passed: 0, total: 0, failures: [] };
2959
+ for (let i = 0; i < actions.length; i++) {
2960
+ const action = actions[i];
2961
+ testCases.push([
2962
+ `${action.type} on ${action.target}`,
2963
+ async () => {
2964
+ let el = null;
2965
+ const scope = actionScope;
2966
+ if (action.target) {
2967
+ if (action.listSelector != null && action.targetIndex != null) {
2968
+ const items = scope.querySelectorAll(action.listSelector);
2969
+ const item = items[action.targetIndex];
2970
+ if (item) {
2971
+ el = action.target !== action.listSelector ? item.querySelector(action.target) : item;
2972
+ if (!el && action.target !== action.listSelector) el = item;
2973
+ }
2974
+ } else {
2975
+ el = scope.querySelector(action.target);
2534
2976
  }
2535
- } else {
2536
- el = o.D.querySelector(action.target);
2537
2977
  }
2538
- }
2539
- if (!el && action.type !== "scroll") {
2540
- return `element not found: ${action.target}`;
2541
- }
2542
- if (action.type === "scroll") {
2543
- window.scrollTo(0, action.scrollY || 0);
2544
- } else if (action.type === "input" || action.type === "change") {
2545
- if (action.value !== void 0) el.value = action.value;
2546
- if (action.checked !== void 0) el.checked = action.checked;
2547
- el.dispatchEvent(new Event(action.type, { bubbles: true }));
2548
- } else {
2549
- if (action.type === "click") {
2550
- el.click();
2978
+ if (!el && action.type !== "scroll") {
2979
+ return `element not found: ${action.target}`;
2980
+ }
2981
+ if (action.type === "scroll") {
2982
+ window.scrollTo(0, action.scrollY || 0);
2983
+ } else if (action.type === "input" || action.type === "change") {
2984
+ if (action.value !== void 0) el.value = action.value;
2985
+ if (action.checked !== void 0) el.checked = action.checked;
2986
+ el.dispatchEvent(new Event(action.type, { bubbles: true }));
2551
2987
  } else {
2552
- el.dispatchEvent(
2553
- new MouseEvent(action.type, { bubbles: true, cancelable: true })
2554
- );
2988
+ if (action.type === "click") {
2989
+ el.click();
2990
+ } else {
2991
+ el.dispatchEvent(
2992
+ new MouseEvent(action.type, { bubbles: true, cancelable: true })
2993
+ );
2994
+ }
2555
2995
  }
2996
+ if (actionDelay > 0) await o.sleep(actionDelay);
2997
+ return true;
2556
2998
  }
2557
- return true;
2999
+ ]);
3000
+ const asserted = assertionsByAction[i];
3001
+ if (runAssertions && asserted && asserted.length > 0) {
3002
+ testCases.push([
3003
+ `assert after ${action.type}`,
3004
+ () => new Promise((resolve) => {
3005
+ const run = () => {
3006
+ const r = o.runRecordingAssertions(recording, rootEl, i, {
3007
+ assertions: asserted,
3008
+ removedElements: recording.removedElements
3009
+ });
3010
+ assertionAccum.passed += r.passed;
3011
+ assertionAccum.total += r.total;
3012
+ assertionAccum.failures.push(...r.failures);
3013
+ resolve(
3014
+ r.passed === r.total ? true : r.failures.map((f) => f.selector + ": " + f.message).join("; ")
3015
+ );
3016
+ };
3017
+ requestAnimationFrame(() => requestAnimationFrame(run));
3018
+ })
3019
+ ]);
2558
3020
  }
2559
- ]);
2560
- const testId = o.test("Recorded playback", ...testCases, () => {
3021
+ for (const mc of manualByAction[i] || []) {
3022
+ testCases.push([
3023
+ `Manual: ${mc.label}`,
3024
+ () => typeof o.testConfirm === "function" ? o.testConfirm(mc.label, mc.items || []) : { ok: true }
3025
+ ]);
3026
+ }
3027
+ }
3028
+ for (const mc of manualByAction["end"] || []) {
3029
+ testCases.push([
3030
+ `Manual: ${mc.label}`,
3031
+ () => typeof o.testConfirm === "function" ? o.testConfirm(mc.label, mc.items || []) : { ok: true }
3032
+ ]);
3033
+ }
3034
+ const onComplete = isOptions && opts.onComplete;
3035
+ const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId2) => {
2561
3036
  window.fetch = origFetch;
3037
+ const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : void 0;
3038
+ if (assertionResult?.failures?.length > 0) {
3039
+ o.tRes[testId2] = false;
3040
+ const failLines = assertionResult.failures.map((f) => `${f.selector}: ${f.message}`).join("; ");
3041
+ const suffix = o.tStyled ? o.tPre + o.tXx + "Assertions failed: " + failLines + o.tDc : "\n\u2718 Assertions failed: " + failLines;
3042
+ o.tLog[testId2] = (o.tLog[testId2] || "") + suffix;
3043
+ }
3044
+ if (typeof onComplete === "function") onComplete(assertionResult);
2562
3045
  });
2563
- return testId;
3046
+ return runAssertions ? { testId } : testId;
2564
3047
  };
2565
3048
  o.testOverlay = () => {
2566
3049
  const btnId = "o-test-overlay-btn";
@@ -2589,54 +3072,14 @@ o.testOverlay = () => {
2589
3072
  a.click();
2590
3073
  });
2591
3074
  };
2592
- const overlayStyle = {
2593
- position: "fixed",
2594
- left: "50%",
2595
- bottom: "50px",
2596
- transform: "translateX(-50%)",
2597
- "z-index": "999999",
2598
- width: "fit-content",
2599
- "max-width": "min(90vw, 420px)",
2600
- "font-family": "system-ui,sans-serif",
2601
- cursor: "grab",
2602
- "user-select": "text"
2603
- };
2604
- const box = o.initState({
2605
- tag: "div",
2606
- id: btnId,
3075
+ const innerHTML = `<div style="display:flex;align-items:center;gap:12px;"><span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span><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><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">\xD7</button></div><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>`;
3076
+ const box = o.overlay({
3077
+ innerHTML,
3078
+ removeExisting: false,
2607
3079
  className: "o-test-overlay",
2608
- style: "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;",
2609
- html: `<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;"><div style="display:flex;align-items:center;gap:12px;"><span class="o-test-overlay-summary" style="flex:1;font-size:13px;">Tests: 0/0</span><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><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">\xD7</button></div></div><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>`
2610
- }).appendInside("body");
2611
- const applyOverlayStyle = () => {
2612
- box.css(overlayStyle);
2613
- };
2614
- let drag = null;
2615
- const onMove = (e) => {
2616
- if (!drag) return;
2617
- overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
2618
- overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
2619
- delete overlayStyle.bottom;
2620
- overlayStyle.transform = "none";
2621
- applyOverlayStyle();
2622
- };
2623
- const onUp = () => {
2624
- if (drag) {
2625
- overlayStyle.cursor = "grab";
2626
- applyOverlayStyle();
2627
- }
2628
- drag = null;
2629
- };
2630
- box.on("mousedown", (e) => {
2631
- if (e.target.closest(".o-test-overlay-close") || e.target.closest(".o-test-overlay-toggle") || e.target.closest("#" + panelId))
2632
- return;
2633
- const r = box.el.getBoundingClientRect();
2634
- drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
2635
- overlayStyle.cursor = "grabbing";
2636
- applyOverlayStyle();
3080
+ id: btnId,
3081
+ excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId
2637
3082
  });
2638
- o.D.addEventListener("mousemove", onMove);
2639
- o.D.addEventListener("mouseup", onUp);
2640
3083
  const refreshSummary = () => {
2641
3084
  const summary = o(".o-test-overlay-summary");
2642
3085
  if (summary.els.length)
@@ -2650,9 +3093,7 @@ o.testOverlay = () => {
2650
3093
  if (!isOpen) updatePanel();
2651
3094
  });
2652
3095
  box.first(".o-test-overlay-close").on("click", () => {
2653
- o.D.removeEventListener("mousemove", onMove);
2654
- o.D.removeEventListener("mouseup", onUp);
2655
- box.remove();
3096
+ box._overlayCleanup();
2656
3097
  });
2657
3098
  o.testOverlay.showPanel = () => {
2658
3099
  const panel = o("#" + panelId);
@@ -2674,43 +3115,18 @@ o.testOverlay = () => {
2674
3115
  return id;
2675
3116
  };
2676
3117
  };
2677
- o.testConfirm = (label, items = [], opts = {}) => new Promise((resolve) => {
2678
- o(".o-tc-overlay").remove();
2679
- const btnLabel = opts.confirm || "Continue";
2680
- const hasCheckboxes = items.length > 0;
2681
- const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
2682
- const itemIds = items.map((_, idx) => "o-tc-cb-" + idx);
2683
- const checkboxStyle = `.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;}.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;}`;
2684
- const itemsHtml = hasCheckboxes ? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;">` + items.map(
2685
- (i, idx) => `<li style="margin-bottom:4px;"><label for="${itemIds[idx]}" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;"><input type="checkbox" id="${itemIds[idx]}" class="o-tc-item-cb"> <span>${i}</span></label></li>`
2686
- ).join("") + "</ul>" : "";
2687
- const box = o.initState({
2688
- tag: "div",
2689
- className: "o-tc-overlay",
2690
- style: "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;",
2691
- html: `<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;"><div style="display:flex;align-items:center;gap:12px;"><span class="o-tc-label" style="flex:1;">${label}: Paused</span><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></div>` + itemsHtml + `</div>`
2692
- }).appendInside("body");
2693
- const okBtnStyles = {
2694
- padding: "6px 14px",
2695
- background: hasCheckboxes ? "#dc2626" : "#2563eb",
2696
- color: "#fff",
2697
- border: "none",
2698
- "border-radius": "6px",
2699
- "font-weight": "600",
2700
- cursor: "pointer",
2701
- "font-size": "13px",
2702
- "flex-shrink": "0"
2703
- };
2704
- if (hasCheckboxes) {
2705
- const okBtn = box.first(".o-tc-ok");
2706
- const cbs = o(".o-tc-overlay .o-tc-item-cb");
2707
- const updateBtn = () => {
2708
- const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
2709
- okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
2710
- };
2711
- cbs.on("change", updateBtn);
2712
- }
2713
- let drag = null;
3118
+ o.overlay = (opts = {}) => {
3119
+ const {
3120
+ innerHTML,
3121
+ onClose,
3122
+ timeout,
3123
+ excludeDragSelector,
3124
+ removeExisting = true,
3125
+ className = "o-overlay-common",
3126
+ id
3127
+ } = opts;
3128
+ if (removeExisting) o("." + className).remove();
3129
+ else if (id && o("#" + id).el) return o("#" + id);
2714
3130
  const overlayStyle = {
2715
3131
  position: "fixed",
2716
3132
  left: "50%",
@@ -2718,54 +3134,118 @@ o.testConfirm = (label, items = [], opts = {}) => new Promise((resolve) => {
2718
3134
  transform: "translateX(-50%)",
2719
3135
  "z-index": "999999",
2720
3136
  width: "fit-content",
2721
- "max-width": "min(90vw, 400px)",
3137
+ "max-width": "min(90vw, 420px)",
2722
3138
  "font-family": "system-ui,sans-serif",
2723
- cursor: "grab",
2724
3139
  "user-select": "text"
2725
3140
  };
2726
- const applyOverlayStyle = () => {
2727
- box.css(overlayStyle);
2728
- };
3141
+ const countdownId = "o-overlay-countdown";
3142
+ const barHtml = `<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;">` + innerHTML + (timeout ? `<div id="${countdownId}" style="margin-top:6px;font-size:11px;color:#94a3b8;"></div>` : "") + "</div>";
3143
+ const box = o.initState({
3144
+ tag: "div",
3145
+ className,
3146
+ id: id || void 0,
3147
+ style: "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;",
3148
+ html: barHtml
3149
+ }).appendInside("body");
3150
+ const applyStyle = () => box.css(overlayStyle);
3151
+ let drag = null;
2729
3152
  const onMove = (e) => {
2730
3153
  if (!drag) return;
2731
3154
  overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
2732
3155
  overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
2733
3156
  delete overlayStyle.bottom;
2734
3157
  overlayStyle.transform = "none";
2735
- applyOverlayStyle();
3158
+ applyStyle();
2736
3159
  };
2737
3160
  const onUp = () => {
2738
3161
  if (drag) {
2739
- overlayStyle.cursor = "grab";
2740
- applyOverlayStyle();
3162
+ delete overlayStyle.cursor;
3163
+ applyStyle();
2741
3164
  }
2742
3165
  drag = null;
2743
3166
  };
2744
3167
  box.on("mousedown", (e) => {
2745
- if (e.target.closest(".o-tc-ok")) return;
3168
+ if (excludeDragSelector && e.target.closest(excludeDragSelector)) return;
2746
3169
  const r = box.el.getBoundingClientRect();
2747
3170
  drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
2748
3171
  overlayStyle.cursor = "grabbing";
2749
- applyOverlayStyle();
3172
+ applyStyle();
2750
3173
  });
2751
3174
  o.D.addEventListener("mousemove", onMove);
2752
3175
  o.D.addEventListener("mouseup", onUp);
2753
- box.first(".o-tc-ok").on("click", () => {
3176
+ let timerId;
3177
+ const cleanup = () => {
2754
3178
  o.D.removeEventListener("mousemove", onMove);
2755
3179
  o.D.removeEventListener("mouseup", onUp);
3180
+ if (timerId) clearInterval(timerId);
3181
+ box.remove();
3182
+ };
3183
+ if (timeout && timeout > 0) {
3184
+ let remaining = Math.ceil(timeout / 1e3);
3185
+ const cd = o("#" + countdownId);
3186
+ if (cd.el) cd.el.textContent = remaining ? `Continue in ${remaining}s` : "";
3187
+ timerId = setInterval(() => {
3188
+ remaining -= 1;
3189
+ if (cd.el) cd.el.textContent = remaining > 0 ? `Continue in ${remaining}s` : "";
3190
+ if (remaining <= 0) {
3191
+ clearInterval(timerId);
3192
+ timerId = null;
3193
+ cleanup();
3194
+ if (typeof onClose === "function") onClose({ ok: false, errors: ["timeout"] });
3195
+ }
3196
+ }, 1e3);
3197
+ }
3198
+ box._overlayCleanup = cleanup;
3199
+ box._overlayOnClose = onClose;
3200
+ return box;
3201
+ };
3202
+ o.testConfirm = (label, items = [], opts = {}) => new Promise((resolve) => {
3203
+ const btnLabel = opts.confirm || "Continue";
3204
+ const hasCheckboxes = items.length > 0;
3205
+ const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
3206
+ const itemIds = items.map((_, idx) => "o-tc-cb-" + idx);
3207
+ const checkboxStyle = `.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;}.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;}`;
3208
+ const itemsHtml = hasCheckboxes ? `<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;">` + items.map(
3209
+ (i, idx) => `<li style="margin-bottom:4px;"><label for="${itemIds[idx]}" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;"><input type="checkbox" id="${itemIds[idx]}" class="o-tc-item-cb"> <span>${i}</span></label></li>`
3210
+ ).join("") + "</ul>" : "";
3211
+ const innerHTML = `<div style="display:flex;align-items:center;gap:12px;"><span class="o-tc-label" style="flex:1;cursor:grab;">${label}: Paused</span><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></div>` + itemsHtml;
3212
+ const box = o.overlay({
3213
+ innerHTML,
3214
+ timeout: opts.timeout,
3215
+ excludeDragSelector: ".o-tc-ok",
3216
+ onClose: (r) => resolve(r || { ok: true })
3217
+ });
3218
+ const okBtnStyles = {
3219
+ padding: "6px 14px",
3220
+ background: hasCheckboxes ? "#dc2626" : "#2563eb",
3221
+ color: "#fff",
3222
+ border: "none",
3223
+ "border-radius": "6px",
3224
+ "font-weight": "600",
3225
+ cursor: "pointer",
3226
+ "font-size": "13px",
3227
+ "flex-shrink": "0"
3228
+ };
3229
+ if (hasCheckboxes) {
3230
+ const okBtn = box.first(".o-tc-ok");
3231
+ const cbs = o(".o-overlay-common .o-tc-item-cb");
3232
+ const updateBtn = () => {
3233
+ const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
3234
+ okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
3235
+ };
3236
+ cbs.on("change", updateBtn);
3237
+ }
3238
+ box.first(".o-tc-ok").on("click", () => {
2756
3239
  let unchecked = [];
2757
3240
  if (hasCheckboxes) {
2758
- const cbsList = o(".o-tc-overlay .o-tc-item-cb");
2759
- cbsList.els.forEach((el, idx) => {
2760
- if (!el.checked && items[idx] !== void 0) unchecked.push(items[idx]);
2761
- });
2762
- }
2763
- box.remove();
2764
- if (unchecked.length === 0) {
2765
- resolve({ ok: true });
2766
- } else {
2767
- resolve({ ok: false, errors: unchecked });
3241
+ const cbsList = o(".o-overlay-common .o-tc-item-cb");
3242
+ if (cbsList.els.length)
3243
+ cbsList.els.forEach((el, idx) => {
3244
+ if (!el.checked && items[idx] !== void 0) unchecked.push(items[idx]);
3245
+ });
2768
3246
  }
3247
+ box._overlayCleanup();
3248
+ resolve(unchecked.length === 0 ? { ok: true } : { ok: false, errors: unchecked });
2769
3249
  });
2770
3250
  });
2771
3251