objs-core 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/objs.built.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Objs-core library
3
- * @version 2.2
3
+ * @version 2.4.0
4
4
  * @author Roman Torshin
5
5
  * @license Apache-2.0
6
6
  */
@@ -712,6 +712,44 @@ const o = (query) => {
712
712
  });
713
713
  result.style(val || null);
714
714
  }, "css");
715
+ result.cssMerge = returner((styles = {}) => {
716
+ if (styles === null) {
717
+ result.style(null);
718
+ return;
719
+ }
720
+ typeVerify([[styles, objectType]]);
721
+ const normKey = (k) => k.indexOf("-") !== -1 ? k : o.camelToKebab(k);
722
+ const parseStyleAttr = (s) => {
723
+ const out = {};
724
+ if (!s || typeof s !== stringType) return out;
725
+ const parts = s.split(";");
726
+ for (let p = 0; p < parts.length; p++) {
727
+ const part = parts[p];
728
+ const idx = part.indexOf(":");
729
+ if (idx === -1) continue;
730
+ const key = part.slice(0, idx).trim();
731
+ const val = part.slice(idx + 1).trim();
732
+ if (key) out[key] = val;
733
+ }
734
+ return out;
735
+ };
736
+ iterator(() => {
737
+ const el = result.els[i];
738
+ const merged = parseStyleAttr(el.getAttribute("style"));
739
+ cycleObj(styles, (style) => {
740
+ const k = normKey(style);
741
+ const v = styles[style];
742
+ if (v === null || v === u) delete merged[k];
743
+ else merged[k] = String(v).replace('"', "'");
744
+ });
745
+ let serialized = "";
746
+ cycleObj(merged, (k) => {
747
+ serialized += k + ":" + merged[k] + ";";
748
+ });
749
+ if (serialized) el.setAttribute("style", serialized);
750
+ else el.removeAttribute("style");
751
+ });
752
+ }, "cssMerge");
715
753
  result.setClass = returner((cl) => {
716
754
  typeVerify([[cl, stringType]]);
717
755
  iterator(() => {
@@ -1875,17 +1913,26 @@ o.test = (title = "", ...tests) => {
1875
1913
  });
1876
1914
  const finalize = () => {
1877
1915
  if (o.tFinalized[testN2]) return;
1916
+ if (waits > 0) {
1917
+ row = "\u251C ";
1918
+ row += "DONE " + done + "/" + num + ", waiting: " + waits;
1919
+ log(row, true);
1920
+ if (o.tStyled) {
1921
+ o.tLog[testN2] += o.tPre + '<div style="color:orange;"><b>DONE ' + done + "/" + num + ", waiting: " + waits + "</b>" + o.tDc + o.tDc;
1922
+ } else {
1923
+ o.tLog[testN2] += row + "\n";
1924
+ }
1925
+ return;
1926
+ }
1878
1927
  o.tFinalized[testN2] = true;
1879
1928
  const anyFailed = o.tStatus[testN2].some((s) => s === false);
1880
1929
  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
- }
1930
+ row = "\u2558 ";
1931
+ row += "DONE " + done + "/" + num;
1932
+ log(row, done !== num);
1933
+ log();
1887
1934
  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;
1935
+ o.tLog[testN2] += o.tPre + '<div style="color:' + (done !== num ? "red" : "green") + ';"><b>DONE ' + done + "/" + num + "</b>" + o.tDc + o.tDc;
1889
1936
  } else {
1890
1937
  o.tLog[testN2] += row + "\n";
1891
1938
  }
@@ -1894,7 +1941,7 @@ o.test = (title = "", ...tests) => {
1894
1941
  sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
1895
1942
  sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
1896
1943
  }
1897
- if (!waits && typeof o.tFns[testN2] === "function") {
1944
+ if (typeof o.tFns[testN2] === "function") {
1898
1945
  o.tFns[testN2](testN2);
1899
1946
  }
1900
1947
  };
@@ -2239,6 +2286,7 @@ o.recorder = {
2239
2286
  initialData: {},
2240
2287
  assertions: [],
2241
2288
  observeRoot: null,
2289
+ strictCapture: null,
2242
2290
  _originalFetch: null,
2243
2291
  _listeners: [],
2244
2292
  _observer: null
@@ -2248,24 +2296,67 @@ o.startRecording = (observe, events, timeouts) => {
2248
2296
  if (o.recorder.active) {
2249
2297
  return;
2250
2298
  }
2251
- const defaultEvents = ["click", "mouseover", "scroll", "input", "change"];
2299
+ let observeSel;
2300
+ let eventsOpt;
2301
+ let timeoutsOpt;
2302
+ let strictCapture = null;
2303
+ const isStartBag = observe != null && typeof observe === "object" && !Array.isArray(observe) && (o.C(observe, "observe") || o.C(observe, "events") || o.C(observe, "timeouts") || o.C(observe, "strictCaptureAssertions") || o.C(observe, "strictCaptureNetwork") || o.C(observe, "strictCaptureWebSocket"));
2304
+ if (isStartBag) {
2305
+ const bag = observe;
2306
+ observeSel = bag.observe != null ? String(bag.observe) : void 0;
2307
+ eventsOpt = bag.events;
2308
+ timeoutsOpt = bag.timeouts;
2309
+ if (o.C(bag, "strictCaptureAssertions") || o.C(bag, "strictCaptureNetwork") || o.C(bag, "strictCaptureWebSocket")) {
2310
+ strictCapture = {
2311
+ assertions: !!bag.strictCaptureAssertions,
2312
+ network: !!bag.strictCaptureNetwork,
2313
+ websocket: !!bag.strictCaptureWebSocket
2314
+ };
2315
+ }
2316
+ } else {
2317
+ observeSel = typeof observe === "string" ? observe : void 0;
2318
+ eventsOpt = events;
2319
+ timeoutsOpt = timeouts;
2320
+ }
2321
+ const defaultEvents = [
2322
+ "click",
2323
+ "mouseover",
2324
+ "scroll",
2325
+ "input",
2326
+ "change",
2327
+ "submit",
2328
+ "keydown",
2329
+ "focus",
2330
+ "blur"
2331
+ ];
2252
2332
  const defaultStepDelays = {
2253
2333
  click: 100,
2254
2334
  mouseover: 50,
2255
2335
  scroll: 30,
2256
2336
  input: 50,
2257
- change: 50
2337
+ change: 50,
2338
+ submit: 100,
2339
+ keydown: 50,
2340
+ focus: 50,
2341
+ blur: 50
2342
+ };
2343
+ const listenEvents = eventsOpt || defaultEvents;
2344
+ const stepDelays = Object.assign({}, defaultStepDelays, timeoutsOpt || {});
2345
+ const captureDebounce = {
2346
+ scroll: 30,
2347
+ mouseover: 50,
2348
+ keydown: 50,
2349
+ focus: 50,
2350
+ blur: 50
2258
2351
  };
2259
- const listenEvents = events || defaultEvents;
2260
- const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
2261
- const captureDebounce = { scroll: 30, mouseover: 50 };
2262
2352
  const rec = o.recorder;
2263
2353
  rec.active = true;
2264
2354
  rec.actions = [];
2265
2355
  rec.mocks = {};
2266
2356
  rec.stepDelays = stepDelays;
2267
2357
  rec.initialData = { url: window.location.href, timestamp: Date.now() };
2268
- rec.observeRoot = observe || null;
2358
+ rec.strictCapture = strictCapture;
2359
+ rec.observeRoot = observeSel || null;
2269
2360
  rec.assertions = [];
2270
2361
  rec.removedElements = [];
2271
2362
  o.inits.forEach((inst, idx) => {
@@ -2300,6 +2391,67 @@ o.startRecording = (observe, events, timeouts) => {
2300
2391
  };
2301
2392
  return response;
2302
2393
  };
2394
+ rec._originalXHROpen = XMLHttpRequest.prototype.open;
2395
+ rec._originalXHRSend = XMLHttpRequest.prototype.send;
2396
+ XMLHttpRequest.prototype.open = function(method, url) {
2397
+ this._oMethod = (method || "GET").toUpperCase();
2398
+ this._oUrl = url;
2399
+ return rec._originalXHROpen.apply(this, arguments);
2400
+ };
2401
+ XMLHttpRequest.prototype.send = function(body) {
2402
+ const capture = () => {
2403
+ if (this.readyState !== 4) return;
2404
+ let reqBody;
2405
+ try {
2406
+ reqBody = body ? JSON.parse(body) : void 0;
2407
+ } catch (_e) {
2408
+ reqBody = body;
2409
+ }
2410
+ let respBody;
2411
+ try {
2412
+ const text = this.responseText;
2413
+ respBody = text ? JSON.parse(text) : null;
2414
+ } catch (_e) {
2415
+ respBody = this.responseText ?? null;
2416
+ }
2417
+ const key = (this._oMethod || "GET") + ":" + (this._oUrl || "");
2418
+ rec.mocks[key] = {
2419
+ url: this._oUrl,
2420
+ method: this._oMethod,
2421
+ request: reqBody,
2422
+ response: respBody,
2423
+ status: this.status
2424
+ };
2425
+ };
2426
+ this.addEventListener("readystatechange", capture);
2427
+ return rec._originalXHRSend.apply(this, arguments);
2428
+ };
2429
+ rec.websocketEvents = [];
2430
+ rec._originalWebSocket = window.WebSocket;
2431
+ window.WebSocket = function(url, protocols) {
2432
+ const ws = new rec._originalWebSocket(url, protocols);
2433
+ const id = rec.websocketEvents.length;
2434
+ rec.websocketEvents.push({
2435
+ url: typeof url === "string" ? url : String(url),
2436
+ protocol: Array.isArray(protocols) ? protocols[0] : protocols,
2437
+ open: true,
2438
+ messages: []
2439
+ });
2440
+ ws.addEventListener("message", (e) => {
2441
+ const data = typeof e.data === "string" ? e.data : String(e.data);
2442
+ rec.websocketEvents[id].messages.push({ dir: "in", data });
2443
+ });
2444
+ ws.addEventListener("close", () => {
2445
+ rec.websocketEvents[id].open = false;
2446
+ });
2447
+ const origSend = ws.send.bind(ws);
2448
+ ws.send = function(data) {
2449
+ const d = typeof data === "string" ? data : String(data);
2450
+ rec.websocketEvents[id].messages.push({ dir: "out", data: d });
2451
+ return origSend(data);
2452
+ };
2453
+ return ws;
2454
+ };
2303
2455
  const unstableDataAttrs = { "data-o-init": 1, "data-o-init-i": 1, "data-o-state": 1 };
2304
2456
  const qualify = (sel, fromNode) => {
2305
2457
  if (o.D.querySelectorAll(sel).length <= 1) return sel;
@@ -2344,7 +2496,7 @@ o.startRecording = (observe, events, timeouts) => {
2344
2496
  }
2345
2497
  return sel;
2346
2498
  };
2347
- const observeTarget = observe && o.D.querySelector(observe) || o.D.body;
2499
+ const observeTarget = observeSel && o.D.querySelector(observeSel) || o.D.body;
2348
2500
  rec._observer = new MutationObserver((mutations) => {
2349
2501
  const actionIdx = rec.actions.length - 1;
2350
2502
  if (actionIdx < 0) return;
@@ -2365,22 +2517,28 @@ o.startRecording = (observe, events, timeouts) => {
2365
2517
  if (sel && observeTarget) {
2366
2518
  const matches = observeTarget.querySelectorAll(sel);
2367
2519
  if (matches.length > 1) {
2368
- let n = node;
2369
- while (n && n !== observeTarget && n.nodeType === 1) {
2370
- const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
2371
- if (qaAttr) {
2372
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
2373
- const itemMatches = observeTarget.querySelectorAll(itemSel);
2374
- if (itemMatches.length > 1) {
2375
- const idx = [...itemMatches].indexOf(n);
2376
- if (idx !== -1) {
2377
- listSelector = itemSel;
2378
- index = idx;
2379
- break;
2520
+ const idxAmong = [...matches].indexOf(node);
2521
+ if (idxAmong !== -1) {
2522
+ listSelector = sel;
2523
+ index = idxAmong;
2524
+ } else {
2525
+ let n = node;
2526
+ while (n && n !== observeTarget && n.nodeType === 1) {
2527
+ const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
2528
+ if (qaAttr) {
2529
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
2530
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
2531
+ if (itemMatches.length > 1) {
2532
+ const idx = [...itemMatches].indexOf(n);
2533
+ if (idx !== -1) {
2534
+ listSelector = itemSel;
2535
+ index = idx;
2536
+ break;
2537
+ }
2380
2538
  }
2381
2539
  }
2540
+ n = n.parentElement;
2382
2541
  }
2383
- n = n.parentElement;
2384
2542
  }
2385
2543
  }
2386
2544
  }
@@ -2449,28 +2607,51 @@ o.startRecording = (observe, events, timeouts) => {
2449
2607
  });
2450
2608
  }
2451
2609
  if (m.type === "attributes") {
2610
+ const attr = m.attributeName;
2611
+ if (!attr) return;
2452
2612
  const sel = buildSelector(m.target);
2453
2613
  if (!sel) return;
2614
+ const attrToType = {
2615
+ class: "class",
2616
+ style: "style",
2617
+ hidden: "hidden",
2618
+ disabled: "disabled",
2619
+ "aria-expanded": "aria-expanded",
2620
+ "aria-checked": "aria-checked"
2621
+ };
2622
+ const type = attrToType[attr];
2623
+ if (!type) return;
2454
2624
  if (rec.assertions.some(
2455
- (a2) => a2.actionIdx === actionIdx && a2.selector === sel && a2.type === "class"
2625
+ (a2) => a2.actionIdx === actionIdx && a2.selector === sel && a2.type === type
2456
2626
  ))
2457
2627
  return;
2458
2628
  const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, m.target);
2459
- const a = {
2460
- actionIdx,
2461
- type: "class",
2462
- selector: sel,
2463
- className: m.target.className
2464
- };
2629
+ const el = m.target;
2630
+ let value;
2631
+ if (type === "class") value = el.className;
2632
+ else if (type === "style") value = el.style?.cssText || el.getAttribute("style") || "";
2633
+ else if (type === "hidden") value = el.hidden;
2634
+ else if (type === "disabled") value = el.disabled === true;
2635
+ else if (type === "aria-expanded")
2636
+ value = el.getAttribute("aria-expanded");
2637
+ else if (type === "aria-checked") value = el.getAttribute("aria-checked");
2638
+ const a = { actionIdx, type, selector: sel };
2639
+ if (type === "class") a.className = value;
2640
+ else if (type === "style") a.style = value;
2641
+ else if (type === "hidden") a.hidden = value;
2642
+ else if (type === "disabled") a.disabled = value;
2643
+ else if (type === "aria-expanded") a.ariaExpanded = value;
2644
+ else if (type === "aria-checked") a.ariaChecked = value;
2465
2645
  if (aListSel != null) a.listSelector = aListSel;
2466
2646
  if (aIdx != null) a.index = aIdx;
2467
2647
  rec.assertions.push(a);
2468
2648
  if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2469
- console.log("[recording] +class assertion:", {
2649
+ console.log("[recording] +attr assertion:", {
2470
2650
  actionIdx,
2471
2651
  lastAction: lastAction?.type + " " + lastAction?.target,
2472
2652
  selector: sel,
2473
- className: m.target.className,
2653
+ type,
2654
+ value,
2474
2655
  index: aIdx,
2475
2656
  listSelector: aListSel
2476
2657
  });
@@ -2495,7 +2676,7 @@ o.startRecording = (observe, events, timeouts) => {
2495
2676
  listenEvents.forEach((ev) => {
2496
2677
  const handler = (e) => {
2497
2678
  const target = e.target;
2498
- if (observe && observeTarget && target?.nodeType === 1 && !observeTarget.contains(target)) {
2679
+ if (observeSel && observeTarget && target?.nodeType === 1 && !observeTarget.contains(target)) {
2499
2680
  return;
2500
2681
  }
2501
2682
  let selector = "";
@@ -2514,22 +2695,28 @@ o.startRecording = (observe, events, timeouts) => {
2514
2695
  if (selector && observeTarget) {
2515
2696
  const matches = observeTarget.querySelectorAll(selector);
2516
2697
  if (matches.length > 1) {
2517
- let node = target;
2518
- while (node && node !== observeTarget && node.nodeType === 1) {
2519
- const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
2520
- if (qaAttr) {
2521
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
2522
- const itemMatches = observeTarget.querySelectorAll(itemSel);
2523
- if (itemMatches.length > 1) {
2524
- const idx = [...itemMatches].indexOf(node);
2525
- if (idx !== -1) {
2526
- listSelector = itemSel;
2527
- targetIndex = idx;
2528
- break;
2698
+ const idxAmongMatches = [...matches].indexOf(target);
2699
+ if (idxAmongMatches !== -1) {
2700
+ listSelector = selector;
2701
+ targetIndex = idxAmongMatches;
2702
+ } else {
2703
+ let node = target;
2704
+ while (node && node !== observeTarget && node.nodeType === 1) {
2705
+ const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
2706
+ if (qaAttr) {
2707
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
2708
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
2709
+ if (itemMatches.length > 1) {
2710
+ const idx = [...itemMatches].indexOf(node);
2711
+ if (idx !== -1) {
2712
+ listSelector = itemSel;
2713
+ targetIndex = idx;
2714
+ break;
2715
+ }
2529
2716
  }
2530
2717
  }
2718
+ node = node.parentElement;
2531
2719
  }
2532
- node = node.parentElement;
2533
2720
  }
2534
2721
  }
2535
2722
  }
@@ -2537,13 +2724,30 @@ o.startRecording = (observe, events, timeouts) => {
2537
2724
  const scrollY = ev === "scroll" ? window.scrollY : void 0;
2538
2725
  const value = ev === "input" || ev === "change" ? target?.value : void 0;
2539
2726
  const checked = ev === "change" && (target?.type === "checkbox" || target?.type === "radio") ? target?.checked : void 0;
2540
- const delay = ev === "click" || ev === "change" ? 0 : stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
2727
+ const key = ev === "keydown" ? target?.key : void 0;
2728
+ const code = ev === "keydown" ? target?.code : void 0;
2729
+ const delay = ev === "click" || ev === "change" || ev === "submit" ? 0 : stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
2541
2730
  const pushAction = () => {
2731
+ if ((ev === "blur" || ev === "focus") && selector) {
2732
+ const lastIdx = rec.actions.length - 1;
2733
+ const lastAction = lastIdx >= 0 ? rec.actions[lastIdx] : null;
2734
+ if (lastAction) {
2735
+ const sameTarget = lastAction.target === selector && lastAction.listSelector == null === (listSelector == null) && lastAction.targetIndex == null === (targetIndex == null) && (lastAction.targetIndex == null || lastAction.targetIndex === targetIndex);
2736
+ if (sameTarget) return;
2737
+ for (const r of rec.removedElements) {
2738
+ if (r.actionIdx !== lastIdx) continue;
2739
+ if (r.selector === selector || selector.startsWith(r.selector + " ") || selector.startsWith(r.selector + ">"))
2740
+ return;
2741
+ }
2742
+ }
2743
+ }
2542
2744
  const action = { type: ev, target: selector, time: Date.now() };
2543
2745
  if (targetType) action.targetType = targetType;
2544
2746
  if (scrollY !== void 0) action.scrollY = scrollY;
2545
2747
  if (value !== void 0) action.value = value;
2546
2748
  if (checked !== void 0) action.checked = checked;
2749
+ if (key !== void 0) action.key = key;
2750
+ if (code !== void 0) action.code = code;
2547
2751
  if (listSelector != null) action.listSelector = listSelector;
2548
2752
  if (targetIndex != null) action.targetIndex = targetIndex;
2549
2753
  rec.actions.push(action);
@@ -2566,6 +2770,16 @@ o.stopRecording = () => {
2566
2770
  window.fetch = rec._originalFetch;
2567
2771
  rec._originalFetch = null;
2568
2772
  }
2773
+ if (rec._originalXHROpen) {
2774
+ XMLHttpRequest.prototype.open = rec._originalXHROpen;
2775
+ XMLHttpRequest.prototype.send = rec._originalXHRSend;
2776
+ rec._originalXHROpen = null;
2777
+ rec._originalXHRSend = null;
2778
+ }
2779
+ if (rec._originalWebSocket) {
2780
+ window.WebSocket = rec._originalWebSocket;
2781
+ rec._originalWebSocket = null;
2782
+ }
2569
2783
  rec._listeners.forEach(({ ev, handler }) => {
2570
2784
  o.D.removeEventListener(ev, handler, true);
2571
2785
  });
@@ -2574,15 +2788,20 @@ o.stopRecording = () => {
2574
2788
  rec._observer.disconnect();
2575
2789
  rec._observer = null;
2576
2790
  }
2577
- return {
2791
+ const out = {
2578
2792
  actions: [...rec.actions],
2579
2793
  mocks: { ...rec.mocks },
2580
2794
  initialData: { ...rec.initialData },
2581
2795
  stepDelays: { ...rec.stepDelays },
2582
2796
  assertions: [...rec.assertions || []],
2583
2797
  removedElements: [...rec.removedElements || []],
2584
- observeRoot: rec.observeRoot || null
2798
+ observeRoot: rec.observeRoot || null,
2799
+ websocketEvents: [...rec.websocketEvents || []]
2585
2800
  };
2801
+ if (rec.strictCapture) {
2802
+ out.strictCapture = { ...rec.strictCapture };
2803
+ }
2804
+ return out;
2586
2805
  };
2587
2806
  o.clearRecording = (id) => {
2588
2807
  if (id !== void 0) {
@@ -2597,6 +2816,8 @@ o.clearRecording = (id) => {
2597
2816
  }
2598
2817
  };
2599
2818
  o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2819
+ const strictAssertions = !!(opts && opts.strictAssertions);
2820
+ const strictRemoved = opts && opts.strictRemoved !== void 0 ? !!opts.strictRemoved : strictAssertions;
2600
2821
  const preFiltered = opts && opts.assertions;
2601
2822
  const assertions = preFiltered != null ? preFiltered : (recording.assertions || []).filter(
2602
2823
  (a) => actionIdx == null || a.actionIdx === actionIdx
@@ -2631,6 +2852,7 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2631
2852
  };
2632
2853
  const r = resolveRoot();
2633
2854
  const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
2855
+ const styleNorm = (s) => norm(String(s || "").replace(/\s*:\s*/g, ": ").replace(/\s*;\s*/g, "; "));
2634
2856
  const getText = (el) => el ? norm(el.textContent || "") : "";
2635
2857
  const removedElements = opts?.removedElements || [];
2636
2858
  const isRemoved = (a) => {
@@ -2650,20 +2872,67 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2650
2872
  const failures = [];
2651
2873
  for (const a of deduped) {
2652
2874
  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,
2875
+ if (!strictRemoved) {
2876
+ passed += 1;
2877
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
2878
+ console.log("[runRecordingAssertions] skip (explicit removed):", {
2879
+ actionIdx: a.actionIdx,
2880
+ selector: a.selector,
2881
+ text: (a.text || "").slice(0, 40)
2882
+ });
2883
+ }
2884
+ continue;
2885
+ }
2886
+ let ghost = null;
2887
+ const expText = norm(a.text || "");
2888
+ if (a.listSelector != null && a.index != null) {
2889
+ const items = r.querySelectorAll(a.listSelector);
2890
+ let item = items[a.index];
2891
+ if (!item && a.index > 0) item = items[a.index - 1];
2892
+ if (item) {
2893
+ ghost = a.selector !== a.listSelector ? item.querySelector(a.selector) || item : item;
2894
+ }
2895
+ if (!ghost && expText && a.type === "visible") {
2896
+ for (let j = 0; j < items.length; j++) {
2897
+ const it = items[j];
2898
+ const cand = a.selector !== a.listSelector ? it.querySelector(a.selector) || it : it;
2899
+ if (cand && getText(cand).indexOf(expText) !== -1) {
2900
+ ghost = cand;
2901
+ break;
2902
+ }
2903
+ }
2904
+ }
2905
+ } else {
2906
+ const matches = r.querySelectorAll(a.selector);
2907
+ ghost = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
2908
+ }
2909
+ if (ghost && a.type === "visible") {
2910
+ const vis = ghost.nodeType === 1 && (ghost.offsetParent !== null || ghost.getBoundingClientRect && ghost.getBoundingClientRect().width > 0);
2911
+ const gtext = getText(ghost);
2912
+ const still = vis && (!expText || gtext.indexOf(expText) !== -1 || expText.indexOf(gtext) !== -1);
2913
+ if (still) {
2914
+ failures.push({
2915
+ selector: a.selector,
2916
+ message: "expected absent (recorded removed) but matching content still visible"
2917
+ });
2918
+ continue;
2919
+ }
2920
+ } else if (ghost && a.type !== "visible") {
2921
+ failures.push({
2657
2922
  selector: a.selector,
2658
- text: (a.text || "").slice(0, 40)
2923
+ message: "expected absent (recorded removed) but element still present"
2659
2924
  });
2925
+ continue;
2660
2926
  }
2927
+ passed += 1;
2661
2928
  continue;
2662
2929
  }
2663
2930
  let el = null;
2664
2931
  let indexOutOfBounds = false;
2932
+ let listItemsLength = -1;
2665
2933
  if (a.listSelector != null && a.index != null) {
2666
2934
  const items = r.querySelectorAll(a.listSelector);
2935
+ listItemsLength = items.length;
2667
2936
  const expectedText = norm(a.text || "");
2668
2937
  const tryItem = (idx) => {
2669
2938
  const it = items[idx];
@@ -2671,26 +2940,36 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2671
2940
  const e = a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
2672
2941
  return e || (a.selector !== a.listSelector ? it : null);
2673
2942
  };
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;
2943
+ let item;
2944
+ if (strictAssertions) {
2945
+ item = items[a.index];
2946
+ if (item) {
2947
+ el = tryItem(a.index);
2948
+ if (!el && a.selector !== a.listSelector) el = item;
2949
+ }
2950
+ } else {
2951
+ item = items[a.index];
2952
+ if (!item && a.index > 0) item = items[a.index - 1];
2953
+ if (item) {
2954
+ el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
2955
+ if (!el && a.selector !== a.listSelector) el = item;
2956
+ if (a.type === "visible" && expectedText && el) {
2957
+ const actualText = getText(el);
2958
+ const textMismatch = actualText.indexOf(expectedText) === -1 && expectedText.indexOf(actualText) === -1;
2959
+ if (textMismatch) {
2960
+ for (let j = 0; j < items.length; j++) {
2961
+ const candEl = tryItem(j);
2962
+ if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
2963
+ el = candEl;
2964
+ item = items[j];
2965
+ break;
2966
+ }
2689
2967
  }
2690
2968
  }
2691
2969
  }
2692
2970
  }
2693
- } else {
2971
+ }
2972
+ if (!item) {
2694
2973
  indexOutOfBounds = true;
2695
2974
  }
2696
2975
  } else {
@@ -2701,12 +2980,12 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2701
2980
  const visible = el && el.nodeType === 1 && (el.offsetParent !== null || el.getBoundingClientRect && el.getBoundingClientRect().width > 0);
2702
2981
  const expectedText = norm(a.text || "");
2703
2982
  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;
2983
+ const textOk = strictAssertions ? !expectedText || actualText === expectedText : !expectedText || actualText.indexOf(expectedText) !== -1 || expectedText.length > 0 && expectedText.indexOf(actualText) !== -1;
2706
2984
  if (visible && textOk) {
2707
2985
  passed += 1;
2708
2986
  } 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";
2987
+ const listCount = listItemsLength >= 0 ? listItemsLength : r.querySelectorAll(a.listSelector || a.selector).length;
2988
+ const message = indexOutOfBounds ? `index out of bounds (list has ${listCount} items, assertion expected index ${a.index})` : !el ? "element not found" : !visible ? "not visible" : !textOk ? "text mismatch" : "fail";
2710
2989
  failures.push({ selector: a.selector, message });
2711
2990
  if (typeof console !== "undefined" && console.warn) {
2712
2991
  console.warn("[runRecordingAssertions] visible failed:", {
@@ -2723,10 +3002,11 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2723
3002
  } else if (a.type === "class") {
2724
3003
  const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
2725
3004
  const hasClass = el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
2726
- if (hasClass) {
3005
+ const classOrderOk = !strictAssertions || !a.className || norm((el?.className || "").trim()) === norm((a.className || "").trim());
3006
+ if (hasClass && classOrderOk) {
2727
3007
  passed += 1;
2728
3008
  } 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}"`;
3009
+ const msg = indexOutOfBounds ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})` : !el ? "element not found" : hasClass && !classOrderOk ? `expected exact className "${a.className}" (strict)` : `expected class "${a.className}"`;
2730
3010
  failures.push({ selector: a.selector, message: msg });
2731
3011
  if (typeof console !== "undefined" && console.warn) {
2732
3012
  console.warn("[runRecordingAssertions] failed:", {
@@ -2740,12 +3020,57 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
2740
3020
  });
2741
3021
  }
2742
3022
  }
3023
+ } else if (a.type === "style") {
3024
+ const expected = (a.style || "").trim();
3025
+ const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
3026
+ const ok = el && (!expected || (strictAssertions ? styleNorm(actual) === styleNorm(expected) : actual.indexOf(expected) !== -1 || expected === actual));
3027
+ if (ok) {
3028
+ passed += 1;
3029
+ } else {
3030
+ const msg = !el ? "element not found" : `expected style "${expected.slice(0, 60)}..."`;
3031
+ failures.push({ selector: a.selector, message: msg });
3032
+ }
3033
+ } else if (a.type === "hidden") {
3034
+ const ok = el && el.hidden === a.hidden;
3035
+ if (ok) {
3036
+ passed += 1;
3037
+ } else {
3038
+ const msg = !el ? "element not found" : `expected hidden=${a.hidden}`;
3039
+ failures.push({ selector: a.selector, message: msg });
3040
+ }
3041
+ } else if (a.type === "disabled") {
3042
+ const ok = el && el.disabled === a.disabled;
3043
+ if (ok) {
3044
+ passed += 1;
3045
+ } else {
3046
+ const msg = !el ? "element not found" : `expected disabled=${a.disabled}`;
3047
+ failures.push({ selector: a.selector, message: msg });
3048
+ }
3049
+ } else if (a.type === "aria-expanded") {
3050
+ const actual = el?.getAttribute?.("aria-expanded");
3051
+ const ok = el && (a.ariaExpanded == null || String(actual) === String(a.ariaExpanded));
3052
+ if (ok) {
3053
+ passed += 1;
3054
+ } else {
3055
+ const msg = !el ? "element not found" : `expected aria-expanded="${a.ariaExpanded}"`;
3056
+ failures.push({ selector: a.selector, message: msg });
3057
+ }
3058
+ } else if (a.type === "aria-checked") {
3059
+ const actual = el?.getAttribute?.("aria-checked");
3060
+ const ok = el && (a.ariaChecked == null || String(actual) === String(a.ariaChecked));
3061
+ if (ok) {
3062
+ passed += 1;
3063
+ } else {
3064
+ const msg = !el ? "element not found" : `expected aria-checked="${a.ariaChecked}"`;
3065
+ failures.push({ selector: a.selector, message: msg });
3066
+ }
2743
3067
  }
2744
3068
  }
2745
3069
  return { passed, total: deduped.length, failures };
2746
3070
  };
2747
3071
  o.exportTest = (recording, options = {}) => {
2748
3072
  const delay = options.delay !== void 0 ? options.delay : 16;
3073
+ const extensionExport = options.extensionExport === true;
2749
3074
  const recordingData = {
2750
3075
  actions: recording.actions,
2751
3076
  assertions: recording.assertions || [],
@@ -2780,14 +3105,23 @@ o.exportTest = (recording, options = {}) => {
2780
3105
  body = (a.value !== void 0 ? ` el.value = ${JSON.stringify(a.value)};
2781
3106
  ` : "") + (a.checked !== void 0 ? ` el.checked = ${a.checked};
2782
3107
  ` : "") + ` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
3108
+ } else if (a.type === "submit") {
3109
+ body = ` (el.requestSubmit && el.requestSubmit()) || el.submit();${endSuffix}`;
3110
+ } else if (a.type === "keydown") {
3111
+ body = ` el.dispatchEvent(new KeyboardEvent('keydown', {key:${JSON.stringify(a.key || "")}, code:${JSON.stringify(a.code || "")}, bubbles:true, cancelable:true}));${endSuffix}`;
3112
+ } else if (a.type === "focus") {
3113
+ body = ` el.focus();${endSuffix}`;
3114
+ } else if (a.type === "blur") {
3115
+ body = ` el.blur();${endSuffix}`;
2783
3116
  } else {
2784
3117
  const useNativeClick = a.type === "click";
2785
3118
  body = useNativeClick ? ` el.click();${endSuffix}` : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
2786
3119
  }
3120
+ const skipIfMissing = a.type === "blur" || a.type === "focus";
2787
3121
  steps.push(
2788
3122
  ` ['${a.type} on ${a.target}', ${stepFn} {
2789
3123
  ` + getEl(a) + `
2790
- if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';
3124
+ if (!el && '${a.type}' !== 'scroll') { if (${skipIfMissing}) return true; return 'element not found: ${a.target.replace(/'/g, "\\'")}'; }
2791
3125
  ` + body + ` }]`
2792
3126
  );
2793
3127
  const assertsForAction = (recording.assertions || []).filter((x) => x.actionIdx === i);
@@ -2801,13 +3135,24 @@ o.exportTest = (recording, options = {}) => {
2801
3135
  }
2802
3136
  }
2803
3137
  const mocksStr = Object.keys(recording.mocks || {}).length ? JSON.stringify(recording.mocks, null, 2) : "{}";
2804
- return `// Auto-generated by o.exportTest() \u2014 review and anonymize mocks before committing
3138
+ const header = `// Auto-generated by o.exportTest() \u2014 review and anonymize mocks before committing
2805
3139
  const recordingMocks = ${mocksStr};
2806
3140
  const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };
2807
3141
 
2808
- o.addTest('Recorded test', [
3142
+ `;
3143
+ const manualLine = ` // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],`;
3144
+ if (extensionExport) {
3145
+ return header + `const __objsExtensionTestRun = o.test('Recorded test',
3146
+ ${steps.join(",\n")},
3147
+ ${manualLine}
3148
+ { sync: true }, () => {
3149
+ // teardown
3150
+ });
3151
+ `;
3152
+ }
3153
+ return header + `o.addTest('Recorded test', [
2809
3154
  ${steps.join(",\n")}
2810
- // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],
3155
+ ${manualLine}
2811
3156
  ], () => {
2812
3157
  // teardown
2813
3158
  });
@@ -2824,15 +3169,40 @@ o.exportPlaywrightTest = (recording, options = {}) => {
2824
3169
  }
2825
3170
  const baseUrl = options.baseUrl || path;
2826
3171
  const routes = Object.values(recording.mocks).map((mock) => {
2827
- const urlPath = mock.url.startsWith("/") ? mock.url : "/" + mock.url;
2828
- const body = JSON.stringify(mock.response);
3172
+ let urlPath = mock.url;
3173
+ try {
3174
+ urlPath = new URL(mock.url).pathname || urlPath;
3175
+ } catch (_e) {
3176
+ }
3177
+ if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
3178
+ const respBody = JSON.stringify(mock.response);
3179
+ const reqBody = JSON.stringify(mock.request);
3180
+ const method = (mock.method || "GET").toUpperCase();
3181
+ let verify = ` if (route.request().method() !== ${JSON.stringify(method)}) { await route.continue(); return; }
3182
+ `;
3183
+ if (mock.request != null && (method === "POST" || method === "PUT" || method === "PATCH")) {
3184
+ verify += ` const postData = route.request().postData();
3185
+ const body = (() => { try { return JSON.parse(postData || '{}'); } catch { return {}; } })();
3186
+ expect(body).toEqual(${reqBody});
3187
+ `;
3188
+ }
2829
3189
  return ` await page.route('**${urlPath}', async route => {
2830
- await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',
2831
- body: JSON.stringify(${body}) });
3190
+ ` + verify + ` await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',
3191
+ body: JSON.stringify(${respBody}) });
2832
3192
  });`;
2833
3193
  }).join("\n");
2834
3194
  const sd = Object.assign(
2835
- { click: 100, mouseover: 50, scroll: 30, input: 50, change: 50 },
3195
+ {
3196
+ click: 100,
3197
+ mouseover: 50,
3198
+ scroll: 30,
3199
+ input: 50,
3200
+ change: 50,
3201
+ submit: 100,
3202
+ keydown: 50,
3203
+ focus: 50,
3204
+ blur: 50
3205
+ },
2836
3206
  recording.stepDelays || {}
2837
3207
  );
2838
3208
  const steps = recording.actions.map((action, i) => {
@@ -2857,6 +3227,15 @@ o.exportPlaywrightTest = (recording, options = {}) => {
2857
3227
  } else {
2858
3228
  step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
2859
3229
  }
3230
+ } else if (action.type === "submit") {
3231
+ step = ` await ${loc}.evaluate((el) => el.requestSubmit?.() || el.submit());`;
3232
+ } else if (action.type === "keydown") {
3233
+ const key = action.key || "";
3234
+ step = key === "Enter" ? ` await ${loc}.press("Enter");` : key ? ` await ${loc}.press(${JSON.stringify(key)});` : ` await ${loc}.press(${JSON.stringify(action.code || "")});`;
3235
+ } else if (action.type === "focus") {
3236
+ step = ` if (await ${loc}.count() > 0) await ${loc}.focus();`;
3237
+ } else if (action.type === "blur") {
3238
+ step = ` if (await ${loc}.count() > 0) await ${loc}.blur();`;
2860
3239
  } else {
2861
3240
  step = ` await ${loc}.click();`;
2862
3241
  }
@@ -2874,13 +3253,50 @@ o.exportPlaywrightTest = (recording, options = {}) => {
2874
3253
  return s;
2875
3254
  }
2876
3255
  if (a.type === "class") {
2877
- return ` // class on ${a.selector} changed to: "${a.className}"`;
3256
+ const classes = (a.className || "").trim().split(/\s+/).filter(Boolean);
3257
+ if (classes.length > 0)
3258
+ return classes.map((c) => ` await expect(${aLoc}).toHaveClass(${JSON.stringify(c)});`).join("\n");
3259
+ return ` // class on ${a.selector} (no specific classes asserted)`;
3260
+ }
3261
+ if (a.type === "style") {
3262
+ const style = (a.style || "").trim();
3263
+ if (style) {
3264
+ const m = style.match(/(\w+)\s*:\s*([^;]+)/);
3265
+ if (m)
3266
+ return ` await expect(${aLoc}).toHaveCSS(${JSON.stringify(m[1])}, ${JSON.stringify(m[2].trim())});`;
3267
+ return ` await expect(${aLoc}).toHaveAttribute("style", ${JSON.stringify(style)});`;
3268
+ }
3269
+ return "";
3270
+ }
3271
+ if (a.type === "hidden") {
3272
+ return a.hidden ? ` await expect(${aLoc}).toBeHidden();` : ` await expect(${aLoc}).toBeVisible();`;
3273
+ }
3274
+ if (a.type === "disabled") {
3275
+ return a.disabled ? ` await expect(${aLoc}).toBeDisabled();` : ` await expect(${aLoc}).toBeEnabled();`;
3276
+ }
3277
+ if (a.type === "aria-expanded" && a.ariaExpanded != null) {
3278
+ return ` await expect(${aLoc}).toHaveAttribute("aria-expanded", ${JSON.stringify(String(a.ariaExpanded))});`;
3279
+ }
3280
+ if (a.type === "aria-checked" && a.ariaChecked != null) {
3281
+ return ` await expect(${aLoc}).toHaveAttribute("aria-checked", ${JSON.stringify(String(a.ariaChecked))});`;
2878
3282
  }
2879
3283
  return "";
2880
3284
  }).filter(Boolean).join("\n");
2881
3285
  return step + "\n" + wait + (asserts ? "\n" + asserts : "");
2882
3286
  }).join("\n");
2883
3287
  const hasAutoAssertions = (recording.assertions || []).length > 0;
3288
+ const wsEvents = recording.websocketEvents || [];
3289
+ const hasWsEvents = wsEvents.length > 0 && wsEvents.some((c) => c.messages?.length > 0);
3290
+ const wsSetup = hasWsEvents ? ` const wsCollected = [];
3291
+ page.on('websocket', ws => {
3292
+ ws.on('framereceived', ev => wsCollected.push({ dir: 'in', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));
3293
+ ws.on('framesent', ev => wsCollected.push({ dir: 'out', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));
3294
+ });
3295
+
3296
+ ` : "";
3297
+ const wsAssertions = hasWsEvents ? wsEvents.flatMap((conn) => (conn.messages || []).map((msg) => ({ dir: msg.dir, data: msg.data }))).map(
3298
+ (msg) => ` expect(wsCollected).toContainEqual({ dir: ${JSON.stringify(msg.dir)}, payload: ${JSON.stringify(msg.data)} });`
3299
+ ).join("\n") + "\n\n" : "";
2884
3300
  return `// Auto-generated by o.exportPlaywrightTest() \u2014 review and anonymize mocks before committing
2885
3301
  // Prerequisites: npm install @playwright/test && npx playwright install chromium
2886
3302
  // Run: npx playwright test recorded.spec.ts
@@ -2888,36 +3304,240 @@ import { test, expect } from '@playwright/test';
2888
3304
 
2889
3305
  test(${JSON.stringify(testName)}, async ({ page }) => {
2890
3306
  ` + (routes ? ` // Network mocks \u2014 edit/anonymize before committing
2891
- ` + routes + "\n\n" : "") + ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
3307
+ ` + routes + "\n\n" : "") + wsSetup + ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
2892
3308
  await page.goto(${JSON.stringify(baseUrl)});
2893
3309
 
2894
- ` + (steps ? steps + "\n\n" : "") + (!hasAutoAssertions ? ` // TODO: Add assertions before committing, e.g.:
3310
+ ` + (steps ? steps + "\n\n" : "") + (wsAssertions ? ` // WebSocket verifications
3311
+ ` + wsAssertions : "") + (!hasAutoAssertions && !hasWsEvents ? ` // TODO: Add assertions before committing, e.g.:
2895
3312
  // await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();
2896
3313
  // await expect(page).toHaveURL(/\\/confirmation/);
2897
3314
  // await expect(page.locator('[data-qa="error-banner"]')).not.toBeVisible();
2898
- ` : ` // Auto-generated assertions above \u2014 review for correctness before committing
2899
- `) + `});
3315
+ ` : hasAutoAssertions || hasWsEvents ? ` // Auto-generated assertions above \u2014 review for correctness before committing
3316
+ ` : "") + `});
2900
3317
  `;
2901
3318
  };
2902
3319
  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);
3320
+ const isOptions = opts && typeof opts === "object" && (opts.runAssertions !== void 0 || opts.root !== void 0 || opts.manualChecks !== void 0 || opts.actionDelay !== void 0 || opts.skipWebSocketMock !== void 0 || opts.skipNetworkMocks !== void 0 || opts.recordingAssertionDebug !== void 0 || opts.strictPlay !== void 0 || opts.strictAssertions !== void 0 || opts.strictNetwork !== void 0 || opts.strictWebSocket !== void 0 || opts.strictRemoved !== void 0 || opts.onComplete !== void 0);
2904
3321
  const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
2905
3322
  const runAssertions = isOptions && opts.runAssertions;
2906
3323
  const rootOpt = isOptions ? opts.root : void 0;
2907
3324
  const manualChecks = isOptions && opts.manualChecks || [];
2908
3325
  const actionDelay = isOptions && opts.actionDelay !== void 0 ? opts.actionDelay : 16;
3326
+ const skipWebSocketMock = isOptions && opts.skipWebSocketMock;
3327
+ const skipNetworkMocks = isOptions && opts.skipNetworkMocks;
3328
+ if (isOptions && opts.recordingAssertionDebug !== void 0) {
3329
+ o.recordingAssertionDebug = !!opts.recordingAssertionDebug;
3330
+ }
3331
+ const sc = recording.strictCapture || {};
3332
+ const strictPlay = isOptions && opts.strictPlay === true;
3333
+ const strictAssertions = isOptions && opts.strictAssertions !== void 0 ? !!opts.strictAssertions : strictPlay ? true : !!sc.assertions;
3334
+ const strictNetwork = isOptions && opts.strictNetwork !== void 0 ? !!opts.strictNetwork : strictPlay ? true : !!sc.network;
3335
+ const strictWebSocket = isOptions && opts.strictWebSocket !== void 0 ? !!opts.strictWebSocket : strictPlay ? true : !!sc.websocket;
3336
+ const strictRemoved = isOptions && opts.strictRemoved !== void 0 ? !!opts.strictRemoved : strictAssertions;
3337
+ const parseBodyLikeRecorder = (body) => {
3338
+ if (body == null || body === "") return void 0;
3339
+ if (typeof body === "string") {
3340
+ try {
3341
+ return JSON.parse(body);
3342
+ } catch (_e) {
3343
+ return body;
3344
+ }
3345
+ }
3346
+ return body;
3347
+ };
3348
+ const mockRequestMatchesLive = (recordedReq, liveBody) => {
3349
+ const live = parseBodyLikeRecorder(liveBody);
3350
+ if (recordedReq === live) return true;
3351
+ if (recordedReq == null && live == null) return true;
3352
+ if (recordedReq == null || live == null) return false;
3353
+ if (typeof recordedReq === "object" && typeof live === "object")
3354
+ return JSON.stringify(recordedReq) === JSON.stringify(live);
3355
+ return String(recordedReq) === String(live);
3356
+ };
3357
+ const normWsData = (s) => String(s || "").trim().replace(/\s+/g, " ");
2909
3358
  const allMocks = Object.assign({}, recording.mocks, mockOverrides);
2910
3359
  const origFetch = window.fetch;
2911
- window.fetch = (url, opts2 = {}) => {
2912
- const method = (opts2.method || "GET").toUpperCase();
2913
- const key = method + ":" + url;
2914
- if (allMocks[key]) {
3360
+ const origXHROpen = XMLHttpRequest.prototype.open;
3361
+ const origXHRSend = XMLHttpRequest.prototype.send;
3362
+ if (!skipNetworkMocks) {
3363
+ window.fetch = (url, fetchOpts = {}) => {
3364
+ const method = (fetchOpts.method || "GET").toUpperCase();
3365
+ const key = method + ":" + url;
3366
+ if (allMocks[key]) {
3367
+ const mock = allMocks[key];
3368
+ if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, fetchOpts.body)) {
3369
+ return Promise.reject(
3370
+ new Error(
3371
+ "[Objs playRecording] strictNetwork: request body does not match recording for " + key
3372
+ )
3373
+ );
3374
+ }
3375
+ const body = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
3376
+ return Promise.resolve(new Response(body, { status: mock.status || 200 }));
3377
+ }
3378
+ return origFetch(url, fetchOpts);
3379
+ };
3380
+ XMLHttpRequest.prototype.open = function(method, url) {
3381
+ this._oMethod = (method || "GET").toUpperCase();
3382
+ this._oUrl = url;
3383
+ return origXHROpen.apply(this, arguments);
3384
+ };
3385
+ XMLHttpRequest.prototype.send = function(body) {
3386
+ const xhr = this;
3387
+ const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
2915
3388
  const mock = allMocks[key];
2916
- const body = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
2917
- return Promise.resolve(new Response(body, { status: mock.status || 200 }));
2918
- }
2919
- return origFetch(url, opts2);
2920
- };
3389
+ if (mock) {
3390
+ if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, body)) {
3391
+ setTimeout(() => {
3392
+ xhr.readyState = 4;
3393
+ xhr.status = 0;
3394
+ xhr.statusText = "Objs strictNetwork mismatch";
3395
+ xhr.dispatchEvent(new Event("readystatechange"));
3396
+ xhr.dispatchEvent(new Event("error"));
3397
+ }, 0);
3398
+ return;
3399
+ }
3400
+ const respBody = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
3401
+ setTimeout(() => {
3402
+ xhr.readyState = 4;
3403
+ xhr.status = mock.status || 200;
3404
+ xhr.statusText = "OK";
3405
+ xhr.responseText = respBody;
3406
+ xhr.response = respBody;
3407
+ xhr.dispatchEvent(new Event("readystatechange"));
3408
+ xhr.dispatchEvent(new Event("load"));
3409
+ }, 0);
3410
+ return;
3411
+ }
3412
+ return origXHRSend.apply(this, arguments);
3413
+ };
3414
+ }
3415
+ let origWebSocket = null;
3416
+ const wsEvents = recording.websocketEvents || [];
3417
+ const useWsMock = !skipWebSocketMock && wsEvents.length > 0 && wsEvents.some((e) => e.messages && e.messages.length > 0);
3418
+ if (useWsMock && typeof window.WebSocket === "function") {
3419
+ origWebSocket = window.WebSocket;
3420
+ let wsConsumeIdx = 0;
3421
+ const normalizeWsUrl = (u) => {
3422
+ const s = typeof u === "string" ? u : String(u);
3423
+ try {
3424
+ return new URL(s, window.location.href).href;
3425
+ } catch (_e) {
3426
+ return s;
3427
+ }
3428
+ };
3429
+ const takeNextRecorded = (urlStr) => {
3430
+ const norm = normalizeWsUrl(urlStr);
3431
+ for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
3432
+ if (normalizeWsUrl(wsEvents[i].url) === norm) {
3433
+ wsConsumeIdx = i + 1;
3434
+ return wsEvents[i];
3435
+ }
3436
+ }
3437
+ for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
3438
+ if (String(wsEvents[i].url) === String(urlStr)) {
3439
+ wsConsumeIdx = i + 1;
3440
+ return wsEvents[i];
3441
+ }
3442
+ }
3443
+ return null;
3444
+ };
3445
+ const C = origWebSocket;
3446
+ class O_MockWebSocket extends EventTarget {
3447
+ constructor(url, protocols, recorded) {
3448
+ super();
3449
+ const urlStr = typeof url === "string" ? url : String(url);
3450
+ this.url = urlStr;
3451
+ this.readyState = C.CONNECTING;
3452
+ const p = protocols;
3453
+ this.protocol = Array.isArray(p) ? p[0] || "" : p ? String(p) : "";
3454
+ this.extensions = "";
3455
+ this.binaryType = "blob";
3456
+ this._messages = (recorded.messages || []).slice();
3457
+ this._pos = 0;
3458
+ const self = this;
3459
+ setTimeout(() => {
3460
+ if (self.readyState === C.CLOSED) return;
3461
+ self.readyState = C.OPEN;
3462
+ self._dispatchOpen();
3463
+ self._drainInbound();
3464
+ }, 0);
3465
+ }
3466
+ _dispatchOpen() {
3467
+ const ev = new Event("open");
3468
+ this.dispatchEvent(ev);
3469
+ if (typeof this.onopen === "function") this.onopen(ev);
3470
+ }
3471
+ _dispatchMessage(data) {
3472
+ const ev = new MessageEvent("message", { data });
3473
+ this.dispatchEvent(ev);
3474
+ if (typeof this.onmessage === "function") this.onmessage(ev);
3475
+ }
3476
+ _drainInbound() {
3477
+ while (this._pos < this._messages.length && this._messages[this._pos].dir === "in") {
3478
+ const m = this._messages[this._pos++];
3479
+ this._dispatchMessage(m.data);
3480
+ }
3481
+ }
3482
+ send(data) {
3483
+ if (this.readyState !== C.OPEN) {
3484
+ const err = typeof DOMException !== "undefined" ? new DOMException("Still in CONNECTING state.", "InvalidStateError") : new Error("InvalidStateError");
3485
+ throw err;
3486
+ }
3487
+ if (this._pos >= this._messages.length) {
3488
+ if (strictWebSocket) {
3489
+ throw new Error(
3490
+ "[Objs playRecording] strictWebSocket: unexpected send() after recorded frames exhausted"
3491
+ );
3492
+ }
3493
+ this._drainInbound();
3494
+ return;
3495
+ }
3496
+ const next = this._messages[this._pos];
3497
+ if (next.dir === "out") {
3498
+ if (strictWebSocket) {
3499
+ const got = typeof data === "string" ? data : String(data);
3500
+ const exp = String(next.data != null ? next.data : "");
3501
+ if (normWsData(got) !== normWsData(exp)) {
3502
+ throw new Error(
3503
+ "[Objs playRecording] strictWebSocket: outbound frame mismatch"
3504
+ );
3505
+ }
3506
+ }
3507
+ this._pos++;
3508
+ }
3509
+ this._drainInbound();
3510
+ }
3511
+ close(code, reason) {
3512
+ if (this.readyState === C.CLOSING || this.readyState === C.CLOSED) return;
3513
+ this.readyState = C.CLOSING;
3514
+ const self = this;
3515
+ setTimeout(() => {
3516
+ self.readyState = C.CLOSED;
3517
+ const ev = typeof CloseEvent !== "undefined" ? new CloseEvent("close", {
3518
+ code: code !== void 0 ? code : 1e3,
3519
+ reason: reason !== void 0 ? String(reason) : "",
3520
+ wasClean: true
3521
+ }) : new Event("close");
3522
+ self.dispatchEvent(ev);
3523
+ if (typeof self.onclose === "function") self.onclose(ev);
3524
+ }, 0);
3525
+ }
3526
+ }
3527
+ const MockWebSocketCtor = function MockWebSocketCtor2(url, protocols) {
3528
+ const urlStr = typeof url === "string" ? url : String(url);
3529
+ const rec = takeNextRecorded(urlStr);
3530
+ if (!rec || !rec.messages || rec.messages.length === 0) {
3531
+ return new origWebSocket(url, protocols);
3532
+ }
3533
+ return new O_MockWebSocket(url, protocols, rec);
3534
+ };
3535
+ MockWebSocketCtor.CONNECTING = C.CONNECTING;
3536
+ MockWebSocketCtor.OPEN = C.OPEN;
3537
+ MockWebSocketCtor.CLOSING = C.CLOSING;
3538
+ MockWebSocketCtor.CLOSED = C.CLOSED;
3539
+ window.WebSocket = MockWebSocketCtor;
3540
+ }
2921
3541
  const resolveRoot = () => {
2922
3542
  if (rootOpt != null) {
2923
3543
  return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
@@ -2976,6 +3596,7 @@ o.playRecording = (recording, opts = {}) => {
2976
3596
  }
2977
3597
  }
2978
3598
  if (!el && action.type !== "scroll") {
3599
+ if (action.type === "blur" || action.type === "focus") return true;
2979
3600
  return `element not found: ${action.target}`;
2980
3601
  }
2981
3602
  if (action.type === "scroll") {
@@ -2984,6 +3605,22 @@ o.playRecording = (recording, opts = {}) => {
2984
3605
  if (action.value !== void 0) el.value = action.value;
2985
3606
  if (action.checked !== void 0) el.checked = action.checked;
2986
3607
  el.dispatchEvent(new Event(action.type, { bubbles: true }));
3608
+ } else if (action.type === "submit") {
3609
+ if (typeof el.requestSubmit === "function") el.requestSubmit();
3610
+ else el.submit();
3611
+ } else if (action.type === "keydown") {
3612
+ el.dispatchEvent(
3613
+ new KeyboardEvent("keydown", {
3614
+ key: action.key || "",
3615
+ code: action.code || "",
3616
+ bubbles: true,
3617
+ cancelable: true
3618
+ })
3619
+ );
3620
+ } else if (action.type === "focus") {
3621
+ el.focus();
3622
+ } else if (action.type === "blur") {
3623
+ el.blur();
2987
3624
  } else {
2988
3625
  if (action.type === "click") {
2989
3626
  el.click();
@@ -3005,7 +3642,9 @@ o.playRecording = (recording, opts = {}) => {
3005
3642
  const run = () => {
3006
3643
  const r = o.runRecordingAssertions(recording, rootEl, i, {
3007
3644
  assertions: asserted,
3008
- removedElements: recording.removedElements
3645
+ removedElements: recording.removedElements,
3646
+ strictAssertions,
3647
+ strictRemoved
3009
3648
  });
3010
3649
  assertionAccum.passed += r.passed;
3011
3650
  assertionAccum.total += r.total;
@@ -3034,6 +3673,9 @@ o.playRecording = (recording, opts = {}) => {
3034
3673
  const onComplete = isOptions && opts.onComplete;
3035
3674
  const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId2) => {
3036
3675
  window.fetch = origFetch;
3676
+ XMLHttpRequest.prototype.open = origXHROpen;
3677
+ XMLHttpRequest.prototype.send = origXHRSend;
3678
+ if (origWebSocket) window.WebSocket = origWebSocket;
3037
3679
  const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : void 0;
3038
3680
  if (assertionResult?.failures?.length > 0) {
3039
3681
  o.tRes[testId2] = false;
@@ -3051,34 +3693,54 @@ o.testOverlay = () => {
3051
3693
  if (o("#" + btnId).el) {
3052
3694
  return;
3053
3695
  }
3696
+ const scrollId = "o-test-overlay-scroll";
3697
+ const exportBtnId = "o-test-export-objs";
3698
+ const copyBtnId = "o-test-copy-txt";
3699
+ const btnBarStyle = "padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;";
3700
+ const buildListPlainText = () => o.tLog.map((log, i) => (log != null && log !== "" ? String(log) : "Test #" + i) + (o.tRes[i] ? " \u2713" : " \u2717")).join("\n\n");
3054
3701
  const updatePanel = () => {
3055
- const panel = o("#" + panelId);
3056
- if (!panel.el) return;
3057
- const total = o.tRes.length;
3058
- const passed = o.tRes.filter(Boolean).length;
3059
- let html = `<b>Tests: ${passed}/${total}</b><hr style="margin:4px 0">`;
3702
+ const scroll = o("#" + scrollId);
3703
+ if (!scroll.el) return;
3704
+ let html = "";
3060
3705
  o.tLog.forEach((log, i) => {
3061
3706
  const ok = o.tRes[i];
3062
- html += `<div style="margin:2px 0;padding:2px 4px;border-radius:3px;background:${ok ? "#d4edda" : "#f8d7da"};color:${ok ? "#155724" : "#721c24"};font-size:11px;white-space:pre-wrap">${log || "Test #" + i}</div>`;
3063
- });
3064
- html += `<button id="o-test-export" style="margin-top:6px;padding:4px 8px;font-size:11px;cursor:pointer">Export results</button>`;
3065
- panel.html(html);
3066
- o("#o-test-export").on("click", () => {
3067
- const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
3068
- const blob = new Blob([data], { type: "application/json" });
3069
- const a = o.D.createElement("a");
3070
- a.href = URL.createObjectURL(blob);
3071
- a.download = "objs-test-results.json";
3072
- a.click();
3707
+ html += `<div style="margin:2px 0;padding:2px 4px;border-radius:3px;background:${ok ? "#14532d" : "#450a0a"};color:${ok ? "#86efac" : "#fca5a5"};font-size:11px;white-space:pre-wrap">${log || "Test #" + i}</div>`;
3073
3708
  });
3709
+ scroll.html(html);
3074
3710
  };
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>`;
3711
+ const innerHTML = `<div class="o-test-overlay-root" style="display:flex;flex-direction:column;gap:4px;max-height:min(88vh,560px);overflow:hidden;"><div style="display:flex;align-items:center;gap:12px;flex-shrink:0;"><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="${btnBarStyle}">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;flex-direction:column;margin-top:4px;max-height:min(52vh,420px);background:#0a0f1e;border:1px solid #1e293b;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.35);overflow:hidden;"><div id="${scrollId}" style="box-sizing:border-box;height:min(48vh,380px);overflow-y:scroll;padding:8px;font-size:11px;user-select:text;cursor:text;"></div><div id="o-test-overlay-footer" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px;border-top:1px solid #1e293b;background:#0f172a;flex-shrink:0;"><button type="button" id="${exportBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Export (objs)</button><button type="button" id="${copyBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Copy (txt)</button></div></div></div>`;
3076
3712
  const box = o.overlay({
3077
3713
  innerHTML,
3078
3714
  removeExisting: false,
3079
3715
  className: "o-test-overlay",
3080
3716
  id: btnId,
3081
- excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId
3717
+ excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId + ", #" + scrollId + ", #o-test-overlay-footer, .o-test-overlay-export-btn"
3718
+ });
3719
+ o("#" + exportBtnId).on("click", () => {
3720
+ const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
3721
+ const blob = new Blob([data], { type: "application/json" });
3722
+ const a = o.D.createElement("a");
3723
+ a.href = URL.createObjectURL(blob);
3724
+ a.download = "objs-test-results.json";
3725
+ a.click();
3726
+ });
3727
+ o("#" + copyBtnId).on("click", () => {
3728
+ const text = buildListPlainText();
3729
+ const write = () => {
3730
+ const ta = o.D.createElement("textarea");
3731
+ ta.value = text;
3732
+ ta.setAttribute("readonly", "");
3733
+ ta.style.cssText = "position:fixed;left:-9999px;top:0";
3734
+ o.D.body.appendChild(ta);
3735
+ ta.select();
3736
+ o.D.execCommand("copy");
3737
+ ta.remove();
3738
+ };
3739
+ if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
3740
+ navigator.clipboard.writeText(text).catch(write);
3741
+ } else {
3742
+ write();
3743
+ }
3082
3744
  });
3083
3745
  const refreshSummary = () => {
3084
3746
  const summary = o(".o-test-overlay-summary");
@@ -3089,8 +3751,12 @@ o.testOverlay = () => {
3089
3751
  const panel = o("#" + panelId);
3090
3752
  if (!panel.el) return;
3091
3753
  const isOpen = panel.el.style.display !== "none";
3092
- panel.css({ display: isOpen ? "none" : "block" });
3093
- if (!isOpen) updatePanel();
3754
+ if (isOpen) {
3755
+ panel.el.style.display = "none";
3756
+ } else {
3757
+ panel.el.style.display = "flex";
3758
+ updatePanel();
3759
+ }
3094
3760
  });
3095
3761
  box.first(".o-test-overlay-close").on("click", () => {
3096
3762
  box._overlayCleanup();
@@ -3098,7 +3764,7 @@ o.testOverlay = () => {
3098
3764
  o.testOverlay.showPanel = () => {
3099
3765
  const panel = o("#" + panelId);
3100
3766
  if (!panel.el) return;
3101
- panel.css({ display: "block" });
3767
+ panel.el.style.display = "flex";
3102
3768
  updatePanel();
3103
3769
  refreshSummary();
3104
3770
  };