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.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
  */
@@ -1029,6 +1029,50 @@ const __DEV__ = true;
1029
1029
  result.style(val || null);
1030
1030
  }, "css");
1031
1031
 
1032
+ /**
1033
+ * Merge into existing inline styles. Pass null for a property to remove it; pass null for the whole argument to clear the style attribute (same as css(null)).
1034
+ * Keys may be camelCase or kebab-case; stored names follow kebab-case in the serialized attribute.
1035
+ * @param {Object|null} styles - Partial CSS properties, or null to remove style entirely
1036
+ */
1037
+ result.cssMerge = returner((styles = {}) => {
1038
+ if (styles === null) {
1039
+ result.style(null);
1040
+ return;
1041
+ }
1042
+ typeVerify([[styles, objectType]]);
1043
+ const normKey = (k) => (k.indexOf("-") !== -1 ? k : o.camelToKebab(k));
1044
+ const parseStyleAttr = (s) => {
1045
+ const out = {};
1046
+ if (!s || typeof s !== stringType) return out;
1047
+ const parts = s.split(";");
1048
+ for (let p = 0; p < parts.length; p++) {
1049
+ const part = parts[p];
1050
+ const idx = part.indexOf(":");
1051
+ if (idx === -1) continue;
1052
+ const key = part.slice(0, idx).trim();
1053
+ const val = part.slice(idx + 1).trim();
1054
+ if (key) out[key] = val;
1055
+ }
1056
+ return out;
1057
+ };
1058
+ iterator(() => {
1059
+ const el = result.els[i];
1060
+ const merged = parseStyleAttr(el.getAttribute("style"));
1061
+ cycleObj(styles, (style) => {
1062
+ const k = normKey(style);
1063
+ const v = styles[style];
1064
+ if (v === null || v === u) delete merged[k];
1065
+ else merged[k] = String(v).replace('"', "'");
1066
+ });
1067
+ let serialized = "";
1068
+ cycleObj(merged, (k) => {
1069
+ serialized += k + ":" + merged[k] + ";";
1070
+ });
1071
+ if (serialized) el.setAttribute("style", serialized);
1072
+ else el.removeAttribute("style");
1073
+ });
1074
+ }, "cssMerge");
1075
+
1032
1076
  /**
1033
1077
  * Set class attribute
1034
1078
  * @param {string} cl - Class name
@@ -2754,25 +2798,44 @@ o.test = (title = "", ...tests) => {
2754
2798
 
2755
2799
  const finalize = () => {
2756
2800
  if (o.tFinalized[testN]) return;
2801
+ // When waits > 0, defer finalization to o.testUpdate (when async step resolves)
2802
+ if (waits > 0) {
2803
+ row = "├ ";
2804
+ row += "DONE " + done + "/" + num + ", waiting: " + waits;
2805
+ log(row, true);
2806
+ if (o.tStyled) {
2807
+ o.tLog[testN] +=
2808
+ o.tPre +
2809
+ '<div style="color:orange;"><b>DONE ' +
2810
+ done +
2811
+ "/" +
2812
+ num +
2813
+ ", waiting: " +
2814
+ waits +
2815
+ "</b>" +
2816
+ o.tDc +
2817
+ o.tDc;
2818
+ } else {
2819
+ o.tLog[testN] += row + "\n";
2820
+ }
2821
+ return;
2822
+ }
2757
2823
  o.tFinalized[testN] = true;
2758
2824
  const anyFailed = o.tStatus[testN].some((s) => s === false);
2759
2825
  o.tRes[testN] = !anyFailed && done === num;
2760
- row = waits ? "├ " : "╘ ";
2761
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2762
- log(row, done + waits !== num);
2763
- if (!waits) {
2764
- log();
2765
- }
2826
+ row = "╘ ";
2827
+ row += "DONE " + done + "/" + num;
2828
+ log(row, done !== num);
2829
+ log();
2766
2830
  if (o.tStyled) {
2767
2831
  o.tLog[testN] +=
2768
2832
  o.tPre +
2769
2833
  '<div style="color:' +
2770
- (done + waits !== num ? "red" : "green") +
2834
+ (done !== num ? "red" : "green") +
2771
2835
  ';"><b>DONE ' +
2772
2836
  done +
2773
2837
  "/" +
2774
2838
  num +
2775
- (waits ? ", waiting: " + waits : "") +
2776
2839
  "</b>" +
2777
2840
  o.tDc +
2778
2841
  o.tDc;
@@ -2784,7 +2847,7 @@ o.test = (title = "", ...tests) => {
2784
2847
  sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
2785
2848
  sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
2786
2849
  }
2787
- if (!waits && typeof o.tFns[testN] === "function") {
2850
+ if (typeof o.tFns[testN] === "function") {
2788
2851
  o.tFns[testN](testN);
2789
2852
  }
2790
2853
  };
@@ -3224,6 +3287,7 @@ o.recorder = {
3224
3287
  initialData: {},
3225
3288
  assertions: [],
3226
3289
  observeRoot: null,
3290
+ strictCapture: null,
3227
3291
  _originalFetch: null,
3228
3292
  _listeners: [],
3229
3293
  _observer: null,
@@ -3233,7 +3297,7 @@ o.recordingAssertionDebug = false;
3233
3297
 
3234
3298
  /**
3235
3299
  * Start recording user interactions
3236
- * @param {string} [observe] - CSS selector to scope the MutationObserver (reduces assertion noise)
3300
+ * @param {string|{observe?: string, events?: string[], timeouts?: Record<string, number>, strictCaptureAssertions?: boolean, strictCaptureNetwork?: boolean, strictCaptureWebSocket?: boolean}} [observe] - CSS selector, or an options object with observe/events/timeouts and optional strictCapture* flags (stored on the recording for replay defaults)
3237
3301
  * @param {string[]} [events] - Events to record (default: click, mouseover, scroll, input, change)
3238
3302
  * @param {{[event: string]: number}} [timeouts] - Debounce delays per event type in ms
3239
3303
  */
@@ -3241,25 +3305,81 @@ o.startRecording = (observe, events, timeouts) => {
3241
3305
  if (o.recorder.active) {
3242
3306
  return;
3243
3307
  }
3244
- const defaultEvents = ["click", "mouseover", "scroll", "input", "change"];
3308
+ let observeSel;
3309
+ let eventsOpt;
3310
+ let timeoutsOpt;
3311
+ let strictCapture = null;
3312
+ const isStartBag =
3313
+ observe != null &&
3314
+ typeof observe === "object" &&
3315
+ !Array.isArray(observe) &&
3316
+ (o.C(observe, "observe") ||
3317
+ o.C(observe, "events") ||
3318
+ o.C(observe, "timeouts") ||
3319
+ o.C(observe, "strictCaptureAssertions") ||
3320
+ o.C(observe, "strictCaptureNetwork") ||
3321
+ o.C(observe, "strictCaptureWebSocket"));
3322
+ if (isStartBag) {
3323
+ const bag = observe;
3324
+ observeSel = bag.observe != null ? String(bag.observe) : undefined;
3325
+ eventsOpt = bag.events;
3326
+ timeoutsOpt = bag.timeouts;
3327
+ if (
3328
+ o.C(bag, "strictCaptureAssertions") ||
3329
+ o.C(bag, "strictCaptureNetwork") ||
3330
+ o.C(bag, "strictCaptureWebSocket")
3331
+ ) {
3332
+ strictCapture = {
3333
+ assertions: !!bag.strictCaptureAssertions,
3334
+ network: !!bag.strictCaptureNetwork,
3335
+ websocket: !!bag.strictCaptureWebSocket,
3336
+ };
3337
+ }
3338
+ } else {
3339
+ observeSel = typeof observe === "string" ? observe : undefined;
3340
+ eventsOpt = events;
3341
+ timeoutsOpt = timeouts;
3342
+ }
3343
+ const defaultEvents = [
3344
+ "click",
3345
+ "mouseover",
3346
+ "scroll",
3347
+ "input",
3348
+ "change",
3349
+ "submit",
3350
+ "keydown",
3351
+ "focus",
3352
+ "blur",
3353
+ ];
3245
3354
  const defaultStepDelays = {
3246
3355
  click: 100,
3247
3356
  mouseover: 50,
3248
3357
  scroll: 30,
3249
3358
  input: 50,
3250
3359
  change: 50,
3360
+ submit: 100,
3361
+ keydown: 50,
3362
+ focus: 50,
3363
+ blur: 50,
3364
+ };
3365
+ const listenEvents = eventsOpt || defaultEvents;
3366
+ const stepDelays = Object.assign({}, defaultStepDelays, timeoutsOpt || {});
3367
+ const captureDebounce = {
3368
+ scroll: 30,
3369
+ mouseover: 50,
3370
+ keydown: 50,
3371
+ focus: 50,
3372
+ blur: 50,
3251
3373
  };
3252
- const listenEvents = events || defaultEvents;
3253
- const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
3254
- const captureDebounce = { scroll: 30, mouseover: 50 };
3255
3374
  const rec = o.recorder;
3256
3375
  rec.active = true;
3257
3376
  rec.actions = [];
3258
3377
  rec.mocks = {};
3259
3378
  rec.stepDelays = stepDelays;
3260
3379
  rec.initialData = { url: window.location.href, timestamp: Date.now() };
3380
+ rec.strictCapture = strictCapture;
3261
3381
 
3262
- rec.observeRoot = observe || null;
3382
+ rec.observeRoot = observeSel || null;
3263
3383
  rec.assertions = [];
3264
3384
  rec.removedElements = [];
3265
3385
 
@@ -3299,6 +3419,71 @@ o.startRecording = (observe, events, timeouts) => {
3299
3419
  return response;
3300
3420
  };
3301
3421
 
3422
+ // intercept XMLHttpRequest
3423
+ rec._originalXHROpen = XMLHttpRequest.prototype.open;
3424
+ rec._originalXHRSend = XMLHttpRequest.prototype.send;
3425
+ XMLHttpRequest.prototype.open = function (method, url) {
3426
+ this._oMethod = (method || "GET").toUpperCase();
3427
+ this._oUrl = url;
3428
+ return rec._originalXHROpen.apply(this, arguments);
3429
+ };
3430
+ XMLHttpRequest.prototype.send = function (body) {
3431
+ const capture = () => {
3432
+ if (this.readyState !== 4) return;
3433
+ let reqBody;
3434
+ try {
3435
+ reqBody = body ? JSON.parse(body) : undefined;
3436
+ } catch (_e) {
3437
+ reqBody = body;
3438
+ }
3439
+ let respBody;
3440
+ try {
3441
+ const text = this.responseText;
3442
+ respBody = text ? JSON.parse(text) : null;
3443
+ } catch (_e) {
3444
+ respBody = this.responseText ?? null;
3445
+ }
3446
+ const key = (this._oMethod || "GET") + ":" + (this._oUrl || "");
3447
+ rec.mocks[key] = {
3448
+ url: this._oUrl,
3449
+ method: this._oMethod,
3450
+ request: reqBody,
3451
+ response: respBody,
3452
+ status: this.status,
3453
+ };
3454
+ };
3455
+ this.addEventListener("readystatechange", capture);
3456
+ return rec._originalXHRSend.apply(this, arguments);
3457
+ };
3458
+
3459
+ // intercept WebSocket
3460
+ rec.websocketEvents = [];
3461
+ rec._originalWebSocket = window.WebSocket;
3462
+ window.WebSocket = function (url, protocols) {
3463
+ const ws = new rec._originalWebSocket(url, protocols);
3464
+ const id = rec.websocketEvents.length;
3465
+ rec.websocketEvents.push({
3466
+ url: typeof url === "string" ? url : String(url),
3467
+ protocol: Array.isArray(protocols) ? protocols[0] : protocols,
3468
+ open: true,
3469
+ messages: [],
3470
+ });
3471
+ ws.addEventListener("message", (e) => {
3472
+ const data = typeof e.data === "string" ? e.data : String(e.data);
3473
+ rec.websocketEvents[id].messages.push({ dir: "in", data });
3474
+ });
3475
+ ws.addEventListener("close", () => {
3476
+ rec.websocketEvents[id].open = false;
3477
+ });
3478
+ const origSend = ws.send.bind(ws);
3479
+ ws.send = function (data) {
3480
+ const d = typeof data === "string" ? data : String(data);
3481
+ rec.websocketEvents[id].messages.push({ dir: "out", data: d });
3482
+ return origSend(data);
3483
+ };
3484
+ return ws;
3485
+ };
3486
+
3302
3487
  // Internal Objs attributes must not be used for selectors (they change across restores).
3303
3488
  const unstableDataAttrs = { "data-o-init": 1, "data-o-init-i": 1, "data-o-state": 1 };
3304
3489
  const qualify = (sel, fromNode) => {
@@ -3352,7 +3537,7 @@ o.startRecording = (observe, events, timeouts) => {
3352
3537
  };
3353
3538
 
3354
3539
  // Scoped MutationObserver: captures DOM mutations tied to the last recorded action
3355
- const observeTarget = (observe && o.D.querySelector(observe)) || o.D.body;
3540
+ const observeTarget = (observeSel && o.D.querySelector(observeSel)) || o.D.body;
3356
3541
  rec._observer = new MutationObserver((mutations) => {
3357
3542
  const actionIdx = rec.actions.length - 1;
3358
3543
  if (actionIdx < 0) return;
@@ -3373,22 +3558,28 @@ o.startRecording = (observe, events, timeouts) => {
3373
3558
  if (sel && observeTarget) {
3374
3559
  const matches = observeTarget.querySelectorAll(sel);
3375
3560
  if (matches.length > 1) {
3376
- let n = node;
3377
- while (n && n !== observeTarget && n.nodeType === 1) {
3378
- const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
3379
- if (qaAttr) {
3380
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3381
- const itemMatches = observeTarget.querySelectorAll(itemSel);
3382
- if (itemMatches.length > 1) {
3383
- const idx = [...itemMatches].indexOf(n);
3384
- if (idx !== -1) {
3385
- listSelector = itemSel;
3386
- index = idx;
3387
- break;
3561
+ const idxAmong = [...matches].indexOf(node);
3562
+ if (idxAmong !== -1) {
3563
+ listSelector = sel;
3564
+ index = idxAmong;
3565
+ } else {
3566
+ let n = node;
3567
+ while (n && n !== observeTarget && n.nodeType === 1) {
3568
+ const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
3569
+ if (qaAttr) {
3570
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3571
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
3572
+ if (itemMatches.length > 1) {
3573
+ const idx = [...itemMatches].indexOf(n);
3574
+ if (idx !== -1) {
3575
+ listSelector = itemSel;
3576
+ index = idx;
3577
+ break;
3578
+ }
3388
3579
  }
3389
3580
  }
3581
+ n = n.parentElement;
3390
3582
  }
3391
- n = n.parentElement;
3392
3583
  }
3393
3584
  }
3394
3585
  }
@@ -3461,30 +3652,53 @@ o.startRecording = (observe, events, timeouts) => {
3461
3652
  });
3462
3653
  }
3463
3654
  if (m.type === "attributes") {
3655
+ const attr = m.attributeName;
3656
+ if (!attr) return;
3464
3657
  const sel = buildSelector(m.target);
3465
3658
  if (!sel) return;
3659
+ const attrToType = {
3660
+ class: "class",
3661
+ style: "style",
3662
+ hidden: "hidden",
3663
+ disabled: "disabled",
3664
+ "aria-expanded": "aria-expanded",
3665
+ "aria-checked": "aria-checked",
3666
+ };
3667
+ const type = attrToType[attr];
3668
+ if (!type) return;
3466
3669
  if (
3467
3670
  rec.assertions.some(
3468
- (a) => a.actionIdx === actionIdx && a.selector === sel && a.type === "class",
3671
+ (a) => a.actionIdx === actionIdx && a.selector === sel && a.type === type,
3469
3672
  )
3470
3673
  )
3471
3674
  return;
3472
3675
  const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, m.target);
3473
- const a = {
3474
- actionIdx,
3475
- type: "class",
3476
- selector: sel,
3477
- className: m.target.className,
3478
- };
3676
+ const el = m.target;
3677
+ let value;
3678
+ if (type === "class") value = el.className;
3679
+ else if (type === "style") value = el.style?.cssText || el.getAttribute("style") || "";
3680
+ else if (type === "hidden") value = el.hidden;
3681
+ else if (type === "disabled") value = el.disabled === true;
3682
+ else if (type === "aria-expanded")
3683
+ value = el.getAttribute("aria-expanded");
3684
+ else if (type === "aria-checked") value = el.getAttribute("aria-checked");
3685
+ const a = { actionIdx, type, selector: sel };
3686
+ if (type === "class") a.className = value;
3687
+ else if (type === "style") a.style = value;
3688
+ else if (type === "hidden") a.hidden = value;
3689
+ else if (type === "disabled") a.disabled = value;
3690
+ else if (type === "aria-expanded") a.ariaExpanded = value;
3691
+ else if (type === "aria-checked") a.ariaChecked = value;
3479
3692
  if (aListSel != null) a.listSelector = aListSel;
3480
3693
  if (aIdx != null) a.index = aIdx;
3481
3694
  rec.assertions.push(a);
3482
3695
  if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3483
- console.log("[recording] +class assertion:", {
3696
+ console.log("[recording] +attr assertion:", {
3484
3697
  actionIdx,
3485
3698
  lastAction: lastAction?.type + " " + lastAction?.target,
3486
3699
  selector: sel,
3487
- className: m.target.className,
3700
+ type,
3701
+ value,
3488
3702
  index: aIdx,
3489
3703
  listSelector: aListSel,
3490
3704
  });
@@ -3512,7 +3726,7 @@ o.startRecording = (observe, events, timeouts) => {
3512
3726
  const handler = (e) => {
3513
3727
  const target = e.target;
3514
3728
  if (
3515
- observe &&
3729
+ observeSel &&
3516
3730
  observeTarget &&
3517
3731
  target?.nodeType === 1 &&
3518
3732
  !observeTarget.contains(target)
@@ -3542,22 +3756,28 @@ o.startRecording = (observe, events, timeouts) => {
3542
3756
  if (selector && observeTarget) {
3543
3757
  const matches = observeTarget.querySelectorAll(selector);
3544
3758
  if (matches.length > 1) {
3545
- let node = target;
3546
- while (node && node !== observeTarget && node.nodeType === 1) {
3547
- const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
3548
- if (qaAttr) {
3549
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3550
- const itemMatches = observeTarget.querySelectorAll(itemSel);
3551
- if (itemMatches.length > 1) {
3552
- const idx = [...itemMatches].indexOf(node);
3553
- if (idx !== -1) {
3554
- listSelector = itemSel;
3555
- targetIndex = idx;
3556
- break;
3759
+ const idxAmongMatches = [...matches].indexOf(target);
3760
+ if (idxAmongMatches !== -1) {
3761
+ listSelector = selector;
3762
+ targetIndex = idxAmongMatches;
3763
+ } else {
3764
+ let node = target;
3765
+ while (node && node !== observeTarget && node.nodeType === 1) {
3766
+ const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
3767
+ if (qaAttr) {
3768
+ const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3769
+ const itemMatches = observeTarget.querySelectorAll(itemSel);
3770
+ if (itemMatches.length > 1) {
3771
+ const idx = [...itemMatches].indexOf(node);
3772
+ if (idx !== -1) {
3773
+ listSelector = itemSel;
3774
+ targetIndex = idx;
3775
+ break;
3776
+ }
3557
3777
  }
3558
3778
  }
3779
+ node = node.parentElement;
3559
3780
  }
3560
- node = node.parentElement;
3561
3781
  }
3562
3782
  }
3563
3783
  }
@@ -3572,21 +3792,44 @@ o.startRecording = (observe, events, timeouts) => {
3572
3792
  ev === "change" && (target?.type === "checkbox" || target?.type === "radio")
3573
3793
  ? target?.checked
3574
3794
  : undefined;
3795
+ // For keydown, capture key/code for replay
3796
+ const key = ev === "keydown" ? target?.key : undefined;
3797
+ const code = ev === "keydown" ? target?.code : undefined;
3575
3798
 
3576
- // Push click/change immediately so MutationObserver sees correct actionIdx
3799
+ // Push click/change/submit immediately so MutationObserver sees correct actionIdx
3577
3800
  // (mutations fire sync after target handler; debounce would attach assertions to wrong action)
3578
3801
  const delay =
3579
- ev === "click" || ev === "change"
3802
+ ev === "click" || ev === "change" || ev === "submit"
3580
3803
  ? 0
3581
3804
  : stepDelays[ev] !== undefined
3582
3805
  ? stepDelays[ev]
3583
3806
  : captureDebounce[ev] ?? 0;
3584
3807
  const pushAction = () => {
3808
+ // Don't record blur/focus on elements removed by the previous action (e.g. click delete → blur on removed node)
3809
+ if ((ev === "blur" || ev === "focus") && selector) {
3810
+ const lastIdx = rec.actions.length - 1;
3811
+ const lastAction = lastIdx >= 0 ? rec.actions[lastIdx] : null;
3812
+ if (lastAction) {
3813
+ const sameTarget =
3814
+ lastAction.target === selector &&
3815
+ (lastAction.listSelector == null) === (listSelector == null) &&
3816
+ (lastAction.targetIndex == null) === (targetIndex == null) &&
3817
+ (lastAction.targetIndex == null || lastAction.targetIndex === targetIndex);
3818
+ if (sameTarget) return;
3819
+ for (const r of rec.removedElements) {
3820
+ if (r.actionIdx !== lastIdx) continue;
3821
+ if (r.selector === selector || selector.startsWith(r.selector + " ") || selector.startsWith(r.selector + ">"))
3822
+ return;
3823
+ }
3824
+ }
3825
+ }
3585
3826
  const action = { type: ev, target: selector, time: Date.now() };
3586
3827
  if (targetType) action.targetType = targetType;
3587
3828
  if (scrollY !== undefined) action.scrollY = scrollY;
3588
3829
  if (value !== undefined) action.value = value;
3589
3830
  if (checked !== undefined) action.checked = checked;
3831
+ if (key !== undefined) action.key = key;
3832
+ if (code !== undefined) action.code = code;
3590
3833
  if (listSelector != null) action.listSelector = listSelector;
3591
3834
  if (targetIndex != null) action.targetIndex = targetIndex;
3592
3835
  rec.actions.push(action);
@@ -3614,6 +3857,16 @@ o.stopRecording = () => {
3614
3857
  window.fetch = rec._originalFetch;
3615
3858
  rec._originalFetch = null;
3616
3859
  }
3860
+ if (rec._originalXHROpen) {
3861
+ XMLHttpRequest.prototype.open = rec._originalXHROpen;
3862
+ XMLHttpRequest.prototype.send = rec._originalXHRSend;
3863
+ rec._originalXHROpen = null;
3864
+ rec._originalXHRSend = null;
3865
+ }
3866
+ if (rec._originalWebSocket) {
3867
+ window.WebSocket = rec._originalWebSocket;
3868
+ rec._originalWebSocket = null;
3869
+ }
3617
3870
  rec._listeners.forEach(({ ev, handler }) => {
3618
3871
  o.D.removeEventListener(ev, handler, true);
3619
3872
  });
@@ -3622,7 +3875,7 @@ o.stopRecording = () => {
3622
3875
  rec._observer.disconnect();
3623
3876
  rec._observer = null;
3624
3877
  }
3625
- return {
3878
+ const out = {
3626
3879
  actions: [...rec.actions],
3627
3880
  mocks: { ...rec.mocks },
3628
3881
  initialData: { ...rec.initialData },
@@ -3630,7 +3883,12 @@ o.stopRecording = () => {
3630
3883
  assertions: [...(rec.assertions || [])],
3631
3884
  removedElements: [...(rec.removedElements || [])],
3632
3885
  observeRoot: rec.observeRoot || null,
3886
+ websocketEvents: [...(rec.websocketEvents || [])],
3633
3887
  };
3888
+ if (rec.strictCapture) {
3889
+ out.strictCapture = { ...rec.strictCapture };
3890
+ }
3891
+ return out;
3634
3892
  };
3635
3893
 
3636
3894
  /**
@@ -3654,10 +3912,14 @@ o.clearRecording = (id) => {
3654
3912
  * Run recording assertions in the current DOM.
3655
3913
  * @param {{actions: Array, assertions: Array, observeRoot?: string}} recording
3656
3914
  * @param {Element|string} [root] - Root element or selector; defaults to recording.observeRoot or document.body
3657
- * @param {number} [actionIdx] - When provided, run only assertions for this action index
3915
+ * @param {number} [actionIdx] - When set, only assertions for this action run and **removedElements** matching uses this index; when omitted, **isRemoved** is never true (no removed-element skip / strictRemoved), so full runs should pass **actionIdx** per step like **o.playRecording** does.
3916
+ * @param {{assertions?: Array, removedElements?: Array, strictAssertions?: boolean, strictRemoved?: boolean}} [opts] - optional filtered assertions; strictAssertions tightens list index, visible text, style, className; strictRemoved verifies removed nodes are absent (default: same as strictAssertions when omitted)
3658
3917
  * @returns {{ passed: number, total: number, failures: Array<{selector: string, message: string}> }}
3659
3918
  */
3660
3919
  o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3920
+ const strictAssertions = !!(opts && opts.strictAssertions);
3921
+ const strictRemoved =
3922
+ opts && opts.strictRemoved !== undefined ? !!opts.strictRemoved : strictAssertions;
3661
3923
  const preFiltered = opts && opts.assertions;
3662
3924
  const assertions =
3663
3925
  preFiltered != null
@@ -3695,6 +3957,8 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3695
3957
  };
3696
3958
  const r = resolveRoot();
3697
3959
  const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
3960
+ const styleNorm = (s) =>
3961
+ norm(String(s || "").replace(/\s*:\s*/g, ": ").replace(/\s*;\s*/g, "; "));
3698
3962
  const getText = (el) => (el ? norm(el.textContent || "") : "");
3699
3963
  const removedElements = opts?.removedElements || [];
3700
3964
  const isRemoved = (a) => {
@@ -3714,20 +3978,77 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3714
3978
  const failures = [];
3715
3979
  for (const a of deduped) {
3716
3980
  if (isRemoved(a)) {
3717
- passed += 1;
3718
- if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3719
- console.log("[runRecordingAssertions] skip (explicit removed):", {
3720
- actionIdx: a.actionIdx,
3981
+ if (!strictRemoved) {
3982
+ passed += 1;
3983
+ if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3984
+ console.log("[runRecordingAssertions] skip (explicit removed):", {
3985
+ actionIdx: a.actionIdx,
3986
+ selector: a.selector,
3987
+ text: (a.text || "").slice(0, 40),
3988
+ });
3989
+ }
3990
+ continue;
3991
+ }
3992
+ let ghost = null;
3993
+ const expText = norm(a.text || "");
3994
+ if (a.listSelector != null && a.index != null) {
3995
+ const items = r.querySelectorAll(a.listSelector);
3996
+ let item = items[a.index];
3997
+ if (!item && a.index > 0) item = items[a.index - 1];
3998
+ if (item) {
3999
+ ghost =
4000
+ a.selector !== a.listSelector
4001
+ ? item.querySelector(a.selector) || item
4002
+ : item;
4003
+ }
4004
+ if (!ghost && expText && a.type === "visible") {
4005
+ for (let j = 0; j < items.length; j++) {
4006
+ const it = items[j];
4007
+ const cand =
4008
+ a.selector !== a.listSelector
4009
+ ? it.querySelector(a.selector) || it
4010
+ : it;
4011
+ if (cand && getText(cand).indexOf(expText) !== -1) {
4012
+ ghost = cand;
4013
+ break;
4014
+ }
4015
+ }
4016
+ }
4017
+ } else {
4018
+ const matches = r.querySelectorAll(a.selector);
4019
+ ghost = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
4020
+ }
4021
+ if (ghost && a.type === "visible") {
4022
+ const vis =
4023
+ ghost.nodeType === 1 &&
4024
+ (ghost.offsetParent !== null ||
4025
+ (ghost.getBoundingClientRect && ghost.getBoundingClientRect().width > 0));
4026
+ const gtext = getText(ghost);
4027
+ const still =
4028
+ vis && (!expText || gtext.indexOf(expText) !== -1 || expText.indexOf(gtext) !== -1);
4029
+ if (still) {
4030
+ failures.push({
4031
+ selector: a.selector,
4032
+ message: "expected absent (recorded removed) but matching content still visible",
4033
+ });
4034
+ continue;
4035
+ }
4036
+ } else if (ghost && a.type !== "visible") {
4037
+ failures.push({
3721
4038
  selector: a.selector,
3722
- text: (a.text || "").slice(0, 40),
4039
+ message: "expected absent (recorded removed) but element still present",
3723
4040
  });
4041
+ continue;
3724
4042
  }
4043
+ passed += 1;
3725
4044
  continue;
3726
4045
  }
3727
4046
  let el = null;
3728
4047
  let indexOutOfBounds = false;
4048
+ let listItemsLength = -1;
3729
4049
  if (a.listSelector != null && a.index != null) {
3730
4050
  const items = r.querySelectorAll(a.listSelector);
4051
+ listItemsLength = items.length;
3731
4052
  const expectedText = norm(a.text || "");
3732
4053
  const tryItem = (idx) => {
3733
4054
  const it = items[idx];
@@ -3736,28 +4057,38 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3736
4057
  a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
3737
4058
  return (e || (a.selector !== a.listSelector ? it : null));
3738
4059
  };
3739
- let item = items[a.index];
3740
- if (!item && a.index > 0) item = items[a.index - 1];
3741
- if (item) {
3742
- el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
3743
- if (!el && a.selector !== a.listSelector) el = item;
3744
- if (a.type === "visible" && expectedText && el) {
3745
- const actualText = getText(el);
3746
- const textMismatch =
3747
- actualText.indexOf(expectedText) === -1 &&
3748
- expectedText.indexOf(actualText) === -1;
3749
- if (textMismatch) {
3750
- for (let j = 0; j < items.length; j++) {
3751
- const candEl = tryItem(j);
3752
- if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
3753
- el = candEl;
3754
- item = items[j];
3755
- break;
4060
+ let item;
4061
+ if (strictAssertions) {
4062
+ item = items[a.index];
4063
+ if (item) {
4064
+ el = tryItem(a.index);
4065
+ if (!el && a.selector !== a.listSelector) el = item;
4066
+ }
4067
+ } else {
4068
+ item = items[a.index];
4069
+ if (!item && a.index > 0) item = items[a.index - 1];
4070
+ if (item) {
4071
+ el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
4072
+ if (!el && a.selector !== a.listSelector) el = item;
4073
+ if (a.type === "visible" && expectedText && el) {
4074
+ const actualText = getText(el);
4075
+ const textMismatch =
4076
+ actualText.indexOf(expectedText) === -1 &&
4077
+ expectedText.indexOf(actualText) === -1;
4078
+ if (textMismatch) {
4079
+ for (let j = 0; j < items.length; j++) {
4080
+ const candEl = tryItem(j);
4081
+ if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
4082
+ el = candEl;
4083
+ item = items[j];
4084
+ break;
4085
+ }
3756
4086
  }
3757
4087
  }
3758
4088
  }
3759
4089
  }
3760
- } else {
4090
+ }
4091
+ if (!item) {
3761
4092
  indexOutOfBounds = true;
3762
4093
  }
3763
4094
  } else {
@@ -3772,17 +4103,20 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3772
4103
  (el.getBoundingClientRect && el.getBoundingClientRect().width > 0));
3773
4104
  const expectedText = norm(a.text || "");
3774
4105
  const actualText = getText(el);
3775
- const fullActual = actualText;
3776
- const textOk =
3777
- !expectedText ||
3778
- actualText.indexOf(expectedText) !== -1 ||
3779
- fullActual.indexOf(expectedText) !== -1 ||
3780
- (expectedText.length > 0 && expectedText.indexOf(actualText) !== -1);
4106
+ const textOk = strictAssertions
4107
+ ? !expectedText || actualText === expectedText
4108
+ : !expectedText ||
4109
+ actualText.indexOf(expectedText) !== -1 ||
4110
+ (expectedText.length > 0 && expectedText.indexOf(actualText) !== -1);
3781
4111
  if (visible && textOk) {
3782
4112
  passed += 1;
3783
4113
  } else {
4114
+ const listCount =
4115
+ listItemsLength >= 0
4116
+ ? listItemsLength
4117
+ : r.querySelectorAll(a.listSelector || a.selector).length;
3784
4118
  const message = indexOutOfBounds
3785
- ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector || a.selector).length} items, assertion expected index ${a.index})`
4119
+ ? `index out of bounds (list has ${listCount} items, assertion expected index ${a.index})`
3786
4120
  : !el
3787
4121
  ? "element not found"
3788
4122
  : !visible
@@ -3807,14 +4141,20 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3807
4141
  const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
3808
4142
  const hasClass =
3809
4143
  el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
3810
- if (hasClass) {
4144
+ const classOrderOk =
4145
+ !strictAssertions ||
4146
+ !a.className ||
4147
+ norm((el?.className || "").trim()) === norm((a.className || "").trim());
4148
+ if (hasClass && classOrderOk) {
3811
4149
  passed += 1;
3812
4150
  } else {
3813
4151
  const msg = indexOutOfBounds
3814
4152
  ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})`
3815
4153
  : !el
3816
4154
  ? "element not found"
3817
- : `expected class "${a.className}"`;
4155
+ : hasClass && !classOrderOk
4156
+ ? `expected exact className "${a.className}" (strict)`
4157
+ : `expected class "${a.className}"`;
3818
4158
  failures.push({ selector: a.selector, message: msg });
3819
4159
  if (typeof console !== "undefined" && console.warn) {
3820
4160
  console.warn("[runRecordingAssertions] failed:", {
@@ -3828,20 +4168,70 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3828
4168
  });
3829
4169
  }
3830
4170
  }
4171
+ } else if (a.type === "style") {
4172
+ const expected = (a.style || "").trim();
4173
+ const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
4174
+ const ok =
4175
+ el &&
4176
+ (!expected ||
4177
+ (strictAssertions
4178
+ ? styleNorm(actual) === styleNorm(expected)
4179
+ : actual.indexOf(expected) !== -1 || expected === actual));
4180
+ if (ok) {
4181
+ passed += 1;
4182
+ } else {
4183
+ const msg = !el ? "element not found" : `expected style "${expected.slice(0, 60)}..."`;
4184
+ failures.push({ selector: a.selector, message: msg });
4185
+ }
4186
+ } else if (a.type === "hidden") {
4187
+ const ok = el && el.hidden === a.hidden;
4188
+ if (ok) {
4189
+ passed += 1;
4190
+ } else {
4191
+ const msg = !el ? "element not found" : `expected hidden=${a.hidden}`;
4192
+ failures.push({ selector: a.selector, message: msg });
4193
+ }
4194
+ } else if (a.type === "disabled") {
4195
+ const ok = el && el.disabled === a.disabled;
4196
+ if (ok) {
4197
+ passed += 1;
4198
+ } else {
4199
+ const msg = !el ? "element not found" : `expected disabled=${a.disabled}`;
4200
+ failures.push({ selector: a.selector, message: msg });
4201
+ }
4202
+ } else if (a.type === "aria-expanded") {
4203
+ const actual = el?.getAttribute?.("aria-expanded");
4204
+ const ok = el && (a.ariaExpanded == null || String(actual) === String(a.ariaExpanded));
4205
+ if (ok) {
4206
+ passed += 1;
4207
+ } else {
4208
+ const msg = !el ? "element not found" : `expected aria-expanded="${a.ariaExpanded}"`;
4209
+ failures.push({ selector: a.selector, message: msg });
4210
+ }
4211
+ } else if (a.type === "aria-checked") {
4212
+ const actual = el?.getAttribute?.("aria-checked");
4213
+ const ok = el && (a.ariaChecked == null || String(actual) === String(a.ariaChecked));
4214
+ if (ok) {
4215
+ passed += 1;
4216
+ } else {
4217
+ const msg = !el ? "element not found" : `expected aria-checked="${a.ariaChecked}"`;
4218
+ failures.push({ selector: a.selector, message: msg });
4219
+ }
3831
4220
  }
3832
4221
  }
3833
4222
  return { passed, total: deduped.length, failures };
3834
4223
  };
3835
4224
 
3836
4225
  /**
3837
- * Export a recording as a ready-to-commit o.addTest() code string.
4226
+ * Export a recording as a ready-to-commit test code string.
3838
4227
  * Includes assertions interleaved with actions (Playwright parity).
3839
4228
  * @param {{actions: Array, assertions: Array, mocks: Object, initialData: Object, observeRoot?: string}} recording
3840
- * @param {{delay?: number}} [options] - delay in ms at end of each action (default 16 for recorded actions)
4229
+ * @param {{delay?: number, extensionExport?: boolean}} [options] - delay in ms at end of each action (default 16). Set **extensionExport: true** for the Chrome extension (variadic `o.test` + `{ sync: true }` + `__objsExtensionTestRun`); default is **`o.addTest`** for normal use with `handle.run()`.
3841
4230
  * @returns {string}
3842
4231
  */
3843
4232
  o.exportTest = (recording, options = {}) => {
3844
4233
  const delay = options.delay !== undefined ? options.delay : 16;
4234
+ const extensionExport = options.extensionExport === true;
3845
4235
  const recordingData = {
3846
4236
  actions: recording.actions,
3847
4237
  assertions: recording.assertions || [],
@@ -3877,16 +4267,26 @@ o.exportTest = (recording, options = {}) => {
3877
4267
  (a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
3878
4268
  (a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
3879
4269
  ` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
4270
+ } else if (a.type === "submit") {
4271
+ body = ` (el.requestSubmit && el.requestSubmit()) || el.submit();${endSuffix}`;
4272
+ } else if (a.type === "keydown") {
4273
+ body =
4274
+ ` el.dispatchEvent(new KeyboardEvent('keydown', {key:${JSON.stringify(a.key || "")}, code:${JSON.stringify(a.code || "")}, bubbles:true, cancelable:true}));${endSuffix}`;
4275
+ } else if (a.type === "focus") {
4276
+ body = ` el.focus();${endSuffix}`;
4277
+ } else if (a.type === "blur") {
4278
+ body = ` el.blur();${endSuffix}`;
3880
4279
  } else {
3881
4280
  const useNativeClick = a.type === "click";
3882
4281
  body = useNativeClick
3883
4282
  ? ` el.click();${endSuffix}`
3884
4283
  : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
3885
4284
  }
4285
+ const skipIfMissing = a.type === "blur" || a.type === "focus";
3886
4286
  steps.push(
3887
4287
  ` ['${a.type} on ${a.target}', ${stepFn} {\n` +
3888
4288
  getEl(a) +
3889
- `\n if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';\n` +
4289
+ `\n if (!el && '${a.type}' !== 'scroll') { if (${skipIfMissing}) return true; return 'element not found: ${a.target.replace(/'/g, "\\'")}'; }\n` +
3890
4290
  body +
3891
4291
  ` }]`,
3892
4292
  );
@@ -3904,11 +4304,24 @@ o.exportTest = (recording, options = {}) => {
3904
4304
  ? JSON.stringify(recording.mocks, null, 2)
3905
4305
  : "{}";
3906
4306
 
3907
- return (
4307
+ const header =
3908
4308
  `// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
3909
4309
  `const recordingMocks = ${mocksStr};\n` +
3910
- `const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };\n\n` +
3911
- `o.addTest('Recorded test', [\n${steps.join(",\n")}\n // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],\n], () => {\n` +
4310
+ `const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };\n\n`;
4311
+ const manualLine =
4312
+ ` // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],`;
4313
+
4314
+ if (extensionExport) {
4315
+ return (
4316
+ header +
4317
+ `const __objsExtensionTestRun = o.test('Recorded test',\n${steps.join(",\n")},\n${manualLine}\n{ sync: true }, () => {\n` +
4318
+ ` // teardown\n});\n`
4319
+ );
4320
+ }
4321
+
4322
+ return (
4323
+ header +
4324
+ `o.addTest('Recorded test', [\n${steps.join(",\n")}\n${manualLine}\n], () => {\n` +
3912
4325
  ` // teardown\n});\n`
3913
4326
  );
3914
4327
  };
@@ -3933,19 +4346,43 @@ o.exportPlaywrightTest = (recording, options = {}) => {
3933
4346
 
3934
4347
  const routes = Object.values(recording.mocks)
3935
4348
  .map((mock) => {
3936
- const urlPath = mock.url.startsWith("/") ? mock.url : "/" + mock.url;
3937
- const body = JSON.stringify(mock.response);
4349
+ let urlPath = mock.url;
4350
+ try {
4351
+ urlPath = new URL(mock.url).pathname || urlPath;
4352
+ } catch (_e) {}
4353
+ if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
4354
+ const respBody = JSON.stringify(mock.response);
4355
+ const reqBody = JSON.stringify(mock.request);
4356
+ const method = (mock.method || "GET").toUpperCase();
4357
+ let verify = ` if (route.request().method() !== ${JSON.stringify(method)}) { await route.continue(); return; }\n`;
4358
+ if (mock.request != null && (method === "POST" || method === "PUT" || method === "PATCH")) {
4359
+ verify +=
4360
+ ` const postData = route.request().postData();\n` +
4361
+ ` const body = (() => { try { return JSON.parse(postData || '{}'); } catch { return {}; } })();\n` +
4362
+ ` expect(body).toEqual(${reqBody});\n`;
4363
+ }
3938
4364
  return (
3939
4365
  ` await page.route('**${urlPath}', async route => {\n` +
4366
+ verify +
3940
4367
  ` await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',\n` +
3941
- ` body: JSON.stringify(${body}) });\n` +
4368
+ ` body: JSON.stringify(${respBody}) });\n` +
3942
4369
  ` });`
3943
4370
  );
3944
4371
  })
3945
4372
  .join("\n");
3946
4373
 
3947
4374
  const sd = Object.assign(
3948
- { click: 100, mouseover: 50, scroll: 30, input: 50, change: 50 },
4375
+ {
4376
+ click: 100,
4377
+ mouseover: 50,
4378
+ scroll: 30,
4379
+ input: 50,
4380
+ change: 50,
4381
+ submit: 100,
4382
+ keydown: 50,
4383
+ focus: 50,
4384
+ blur: 50,
4385
+ },
3949
4386
  recording.stepDelays || {},
3950
4387
  );
3951
4388
  const steps = recording.actions
@@ -3977,6 +4414,20 @@ o.exportPlaywrightTest = (recording, options = {}) => {
3977
4414
  } else {
3978
4415
  step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
3979
4416
  }
4417
+ } else if (action.type === "submit") {
4418
+ step = ` await ${loc}.evaluate((el) => el.requestSubmit?.() || el.submit());`;
4419
+ } else if (action.type === "keydown") {
4420
+ const key = action.key || "";
4421
+ step =
4422
+ key === "Enter"
4423
+ ? ` await ${loc}.press("Enter");`
4424
+ : key
4425
+ ? ` await ${loc}.press(${JSON.stringify(key)});`
4426
+ : ` await ${loc}.press(${JSON.stringify(action.code || "")});`;
4427
+ } else if (action.type === "focus") {
4428
+ step = ` if (await ${loc}.count() > 0) await ${loc}.focus();`;
4429
+ } else if (action.type === "blur") {
4430
+ step = ` if (await ${loc}.count() > 0) await ${loc}.blur();`;
3980
4431
  } else {
3981
4432
  step = ` await ${loc}.click();`;
3982
4433
  }
@@ -4004,7 +4455,39 @@ o.exportPlaywrightTest = (recording, options = {}) => {
4004
4455
  return s;
4005
4456
  }
4006
4457
  if (a.type === "class") {
4007
- return ` // class on ${a.selector} changed to: "${a.className}"`;
4458
+ const classes = (a.className || "").trim().split(/\s+/).filter(Boolean);
4459
+ if (classes.length > 0)
4460
+ return classes
4461
+ .map((c) => ` await expect(${aLoc}).toHaveClass(${JSON.stringify(c)});`)
4462
+ .join("\n");
4463
+ return ` // class on ${a.selector} (no specific classes asserted)`;
4464
+ }
4465
+ if (a.type === "style") {
4466
+ const style = (a.style || "").trim();
4467
+ if (style) {
4468
+ // Try to emit toHaveCSS for common props; fallback to attribute
4469
+ const m = style.match(/(\w+)\s*:\s*([^;]+)/);
4470
+ if (m)
4471
+ return ` await expect(${aLoc}).toHaveCSS(${JSON.stringify(m[1])}, ${JSON.stringify(m[2].trim())});`;
4472
+ return ` await expect(${aLoc}).toHaveAttribute("style", ${JSON.stringify(style)});`;
4473
+ }
4474
+ return "";
4475
+ }
4476
+ if (a.type === "hidden") {
4477
+ return a.hidden
4478
+ ? ` await expect(${aLoc}).toBeHidden();`
4479
+ : ` await expect(${aLoc}).toBeVisible();`;
4480
+ }
4481
+ if (a.type === "disabled") {
4482
+ return a.disabled
4483
+ ? ` await expect(${aLoc}).toBeDisabled();`
4484
+ : ` await expect(${aLoc}).toBeEnabled();`;
4485
+ }
4486
+ if (a.type === "aria-expanded" && a.ariaExpanded != null) {
4487
+ return ` await expect(${aLoc}).toHaveAttribute("aria-expanded", ${JSON.stringify(String(a.ariaExpanded))});`;
4488
+ }
4489
+ if (a.type === "aria-checked" && a.ariaChecked != null) {
4490
+ return ` await expect(${aLoc}).toHaveAttribute("aria-checked", ${JSON.stringify(String(a.ariaChecked))});`;
4008
4491
  }
4009
4492
  return "";
4010
4493
  })
@@ -4016,6 +4499,27 @@ o.exportPlaywrightTest = (recording, options = {}) => {
4016
4499
  .join("\n");
4017
4500
 
4018
4501
  const hasAutoAssertions = (recording.assertions || []).length > 0;
4502
+ const wsEvents = recording.websocketEvents || [];
4503
+ const hasWsEvents = wsEvents.length > 0 && wsEvents.some((c) => c.messages?.length > 0);
4504
+ const wsSetup =
4505
+ hasWsEvents
4506
+ ? ` const wsCollected = [];\n` +
4507
+ ` page.on('websocket', ws => {\n` +
4508
+ ` ws.on('framereceived', ev => wsCollected.push({ dir: 'in', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));\n` +
4509
+ ` ws.on('framesent', ev => wsCollected.push({ dir: 'out', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));\n` +
4510
+ ` });\n\n`
4511
+ : "";
4512
+ const wsAssertions =
4513
+ hasWsEvents
4514
+ ? wsEvents
4515
+ .flatMap((conn) => (conn.messages || []).map((msg) => ({ dir: msg.dir, data: msg.data })))
4516
+ .map(
4517
+ (msg) =>
4518
+ ` expect(wsCollected).toContainEqual({ dir: ${JSON.stringify(msg.dir)}, payload: ${JSON.stringify(msg.data)} });`,
4519
+ )
4520
+ .join("\n") + "\n\n"
4521
+ : "";
4522
+
4019
4523
  return (
4020
4524
  `// Auto-generated by o.exportPlaywrightTest() — review and anonymize mocks before committing\n` +
4021
4525
  `// Prerequisites: npm install @playwright/test && npx playwright install chromium\n` +
@@ -4025,15 +4529,19 @@ o.exportPlaywrightTest = (recording, options = {}) => {
4025
4529
  (routes
4026
4530
  ? ` // Network mocks — edit/anonymize before committing\n` + routes + "\n\n"
4027
4531
  : "") +
4532
+ wsSetup +
4028
4533
  ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }\n` +
4029
4534
  ` await page.goto(${JSON.stringify(baseUrl)});\n\n` +
4030
4535
  (steps ? steps + "\n\n" : "") +
4031
- (!hasAutoAssertions
4536
+ (wsAssertions ? ` // WebSocket verifications\n` + wsAssertions : "") +
4537
+ (!hasAutoAssertions && !hasWsEvents
4032
4538
  ? ` // TODO: Add assertions before committing, e.g.:\n` +
4033
4539
  ` // await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();\n` +
4034
4540
  ` // await expect(page).toHaveURL(/\\/confirmation/);\n` +
4035
4541
  ` // await expect(page.locator('[data-qa="error-banner"]')).not.toBeVisible();\n`
4036
- : ` // Auto-generated assertions above — review for correctness before committing\n`) +
4542
+ : hasAutoAssertions || hasWsEvents
4543
+ ? ` // Auto-generated assertions above — review for correctness before committing\n`
4544
+ : "") +
4037
4545
  `});\n`
4038
4546
  );
4039
4547
  };
@@ -4041,8 +4549,8 @@ o.exportPlaywrightTest = (recording, options = {}) => {
4041
4549
  // Available in all builds so assessors can replay and see results (testOverlay) on staging.
4042
4550
  /**
4043
4551
  * Play back a recording as an automated test sequence
4044
- * @param {{actions: Array, mocks: Object, assertions?: Array, observeRoot?: string}} recording
4045
- * @param {Object} [opts] - mockOverrides or { runAssertions?, root?, manualChecks?, mockOverrides? }
4552
+ * @param {{actions: Array, mocks: Object, assertions?: Array, observeRoot?: string, websocketEvents?: Array}} recording
4553
+ * @param {Object} [opts] - mockOverrides or { runAssertions?, root?, manualChecks?, mockOverrides?, skipWebSocketMock?, skipNetworkMocks?, recordingAssertionDebug?, strictPlay?, strictAssertions?, strictNetwork?, strictWebSocket?, strictRemoved? }
4046
4554
  * @returns {number|{testId: number, assertionResult?: Object}}
4047
4555
  */
4048
4556
  o.playRecording = (recording, opts = {}) => {
@@ -4052,26 +4560,270 @@ o.playRecording = (recording, opts = {}) => {
4052
4560
  (opts.runAssertions !== undefined ||
4053
4561
  opts.root !== undefined ||
4054
4562
  opts.manualChecks !== undefined ||
4055
- opts.actionDelay !== undefined);
4563
+ opts.actionDelay !== undefined ||
4564
+ opts.skipWebSocketMock !== undefined ||
4565
+ opts.skipNetworkMocks !== undefined ||
4566
+ opts.recordingAssertionDebug !== undefined ||
4567
+ opts.strictPlay !== undefined ||
4568
+ opts.strictAssertions !== undefined ||
4569
+ opts.strictNetwork !== undefined ||
4570
+ opts.strictWebSocket !== undefined ||
4571
+ opts.strictRemoved !== undefined ||
4572
+ opts.onComplete !== undefined);
4056
4573
  const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
4057
4574
  const runAssertions = isOptions && opts.runAssertions;
4058
4575
  const rootOpt = isOptions ? opts.root : undefined;
4059
4576
  const manualChecks = (isOptions && opts.manualChecks) || [];
4060
4577
  const actionDelay = isOptions && opts.actionDelay !== undefined ? opts.actionDelay : 16;
4578
+ const skipWebSocketMock = isOptions && opts.skipWebSocketMock;
4579
+ const skipNetworkMocks = isOptions && opts.skipNetworkMocks;
4580
+ if (isOptions && opts.recordingAssertionDebug !== undefined) {
4581
+ o.recordingAssertionDebug = !!opts.recordingAssertionDebug;
4582
+ }
4583
+
4584
+ const sc = recording.strictCapture || {};
4585
+ const strictPlay = isOptions && opts.strictPlay === true;
4586
+ const strictAssertions =
4587
+ isOptions && opts.strictAssertions !== undefined
4588
+ ? !!opts.strictAssertions
4589
+ : strictPlay
4590
+ ? true
4591
+ : !!sc.assertions;
4592
+ const strictNetwork =
4593
+ isOptions && opts.strictNetwork !== undefined
4594
+ ? !!opts.strictNetwork
4595
+ : strictPlay
4596
+ ? true
4597
+ : !!sc.network;
4598
+ const strictWebSocket =
4599
+ isOptions && opts.strictWebSocket !== undefined
4600
+ ? !!opts.strictWebSocket
4601
+ : strictPlay
4602
+ ? true
4603
+ : !!sc.websocket;
4604
+ const strictRemoved =
4605
+ isOptions && opts.strictRemoved !== undefined ? !!opts.strictRemoved : strictAssertions;
4606
+
4607
+ const parseBodyLikeRecorder = (body) => {
4608
+ if (body == null || body === "") return undefined;
4609
+ if (typeof body === "string") {
4610
+ try {
4611
+ return JSON.parse(body);
4612
+ } catch (_e) {
4613
+ return body;
4614
+ }
4615
+ }
4616
+ return body;
4617
+ };
4618
+ const mockRequestMatchesLive = (recordedReq, liveBody) => {
4619
+ const live = parseBodyLikeRecorder(liveBody);
4620
+ if (recordedReq === live) return true;
4621
+ if (recordedReq == null && live == null) return true;
4622
+ if (recordedReq == null || live == null) return false;
4623
+ if (typeof recordedReq === "object" && typeof live === "object")
4624
+ return JSON.stringify(recordedReq) === JSON.stringify(live);
4625
+ return String(recordedReq) === String(live);
4626
+ };
4627
+ const normWsData = (s) => String(s || "").trim().replace(/\s+/g, " ");
4061
4628
 
4062
4629
  const allMocks = Object.assign({}, recording.mocks, mockOverrides);
4063
4630
  const origFetch = window.fetch;
4064
- window.fetch = (url, opts = {}) => {
4065
- const method = (opts.method || "GET").toUpperCase();
4066
- const key = method + ":" + url;
4067
- if (allMocks[key]) {
4631
+ const origXHROpen = XMLHttpRequest.prototype.open;
4632
+ const origXHRSend = XMLHttpRequest.prototype.send;
4633
+ if (!skipNetworkMocks) {
4634
+ window.fetch = (url, fetchOpts = {}) => {
4635
+ const method = (fetchOpts.method || "GET").toUpperCase();
4636
+ const key = method + ":" + url;
4637
+ if (allMocks[key]) {
4638
+ const mock = allMocks[key];
4639
+ if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, fetchOpts.body)) {
4640
+ return Promise.reject(
4641
+ new Error(
4642
+ "[Objs playRecording] strictNetwork: request body does not match recording for " +
4643
+ key,
4644
+ ),
4645
+ );
4646
+ }
4647
+ const body =
4648
+ typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
4649
+ return Promise.resolve(new Response(body, { status: mock.status || 200 }));
4650
+ }
4651
+ return origFetch(url, fetchOpts);
4652
+ };
4653
+
4654
+ XMLHttpRequest.prototype.open = function (method, url) {
4655
+ this._oMethod = (method || "GET").toUpperCase();
4656
+ this._oUrl = url;
4657
+ return origXHROpen.apply(this, arguments);
4658
+ };
4659
+ XMLHttpRequest.prototype.send = function (body) {
4660
+ const xhr = this;
4661
+ const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
4068
4662
  const mock = allMocks[key];
4069
- const body =
4070
- typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
4071
- return Promise.resolve(new Response(body, { status: mock.status || 200 }));
4663
+ if (mock) {
4664
+ if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, body)) {
4665
+ setTimeout(() => {
4666
+ xhr.readyState = 4;
4667
+ xhr.status = 0;
4668
+ xhr.statusText = "Objs strictNetwork mismatch";
4669
+ xhr.dispatchEvent(new Event("readystatechange"));
4670
+ xhr.dispatchEvent(new Event("error"));
4671
+ }, 0);
4672
+ return;
4673
+ }
4674
+ const respBody =
4675
+ typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
4676
+ setTimeout(() => {
4677
+ xhr.readyState = 4;
4678
+ xhr.status = mock.status || 200;
4679
+ xhr.statusText = "OK";
4680
+ xhr.responseText = respBody;
4681
+ xhr.response = respBody;
4682
+ xhr.dispatchEvent(new Event("readystatechange"));
4683
+ xhr.dispatchEvent(new Event("load"));
4684
+ }, 0);
4685
+ return;
4686
+ }
4687
+ return origXHRSend.apply(this, arguments);
4688
+ };
4689
+ }
4690
+
4691
+ /** @type {typeof WebSocket | null} */
4692
+ let origWebSocket = null;
4693
+ const wsEvents = recording.websocketEvents || [];
4694
+ const useWsMock =
4695
+ !skipWebSocketMock &&
4696
+ wsEvents.length > 0 &&
4697
+ wsEvents.some((e) => e.messages && e.messages.length > 0);
4698
+ if (useWsMock && typeof window.WebSocket === "function") {
4699
+ origWebSocket = window.WebSocket;
4700
+ let wsConsumeIdx = 0;
4701
+ const normalizeWsUrl = (u) => {
4702
+ const s = typeof u === "string" ? u : String(u);
4703
+ try {
4704
+ return new URL(s, window.location.href).href;
4705
+ } catch (_e) {
4706
+ return s;
4707
+ }
4708
+ };
4709
+ const takeNextRecorded = (urlStr) => {
4710
+ const norm = normalizeWsUrl(urlStr);
4711
+ for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
4712
+ if (normalizeWsUrl(wsEvents[i].url) === norm) {
4713
+ wsConsumeIdx = i + 1;
4714
+ return wsEvents[i];
4715
+ }
4716
+ }
4717
+ for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
4718
+ if (String(wsEvents[i].url) === String(urlStr)) {
4719
+ wsConsumeIdx = i + 1;
4720
+ return wsEvents[i];
4721
+ }
4722
+ }
4723
+ return null;
4724
+ };
4725
+ const C = origWebSocket;
4726
+ class O_MockWebSocket extends EventTarget {
4727
+ constructor(url, protocols, recorded) {
4728
+ super();
4729
+ const urlStr = typeof url === "string" ? url : String(url);
4730
+ this.url = urlStr;
4731
+ this.readyState = C.CONNECTING;
4732
+ const p = protocols;
4733
+ this.protocol = Array.isArray(p) ? p[0] || "" : p ? String(p) : "";
4734
+ this.extensions = "";
4735
+ this.binaryType = "blob";
4736
+ this._messages = (recorded.messages || []).slice();
4737
+ this._pos = 0;
4738
+ const self = this;
4739
+ setTimeout(() => {
4740
+ if (self.readyState === C.CLOSED) return;
4741
+ self.readyState = C.OPEN;
4742
+ self._dispatchOpen();
4743
+ self._drainInbound();
4744
+ }, 0);
4745
+ }
4746
+ _dispatchOpen() {
4747
+ const ev = new Event("open");
4748
+ this.dispatchEvent(ev);
4749
+ if (typeof this.onopen === "function") this.onopen(ev);
4750
+ }
4751
+ _dispatchMessage(data) {
4752
+ const ev = new MessageEvent("message", { data });
4753
+ this.dispatchEvent(ev);
4754
+ if (typeof this.onmessage === "function") this.onmessage(ev);
4755
+ }
4756
+ _drainInbound() {
4757
+ while (this._pos < this._messages.length && this._messages[this._pos].dir === "in") {
4758
+ const m = this._messages[this._pos++];
4759
+ this._dispatchMessage(m.data);
4760
+ }
4761
+ }
4762
+ send(data) {
4763
+ if (this.readyState !== C.OPEN) {
4764
+ const err =
4765
+ typeof DOMException !== "undefined"
4766
+ ? new DOMException("Still in CONNECTING state.", "InvalidStateError")
4767
+ : new Error("InvalidStateError");
4768
+ throw err;
4769
+ }
4770
+ if (this._pos >= this._messages.length) {
4771
+ if (strictWebSocket) {
4772
+ throw new Error(
4773
+ "[Objs playRecording] strictWebSocket: unexpected send() after recorded frames exhausted",
4774
+ );
4775
+ }
4776
+ this._drainInbound();
4777
+ return;
4778
+ }
4779
+ const next = this._messages[this._pos];
4780
+ if (next.dir === "out") {
4781
+ if (strictWebSocket) {
4782
+ const got = typeof data === "string" ? data : String(data);
4783
+ const exp = String(next.data != null ? next.data : "");
4784
+ if (normWsData(got) !== normWsData(exp)) {
4785
+ throw new Error(
4786
+ "[Objs playRecording] strictWebSocket: outbound frame mismatch",
4787
+ );
4788
+ }
4789
+ }
4790
+ this._pos++;
4791
+ }
4792
+ this._drainInbound();
4793
+ }
4794
+ close(code, reason) {
4795
+ if (this.readyState === C.CLOSING || this.readyState === C.CLOSED) return;
4796
+ this.readyState = C.CLOSING;
4797
+ const self = this;
4798
+ setTimeout(() => {
4799
+ self.readyState = C.CLOSED;
4800
+ const ev =
4801
+ typeof CloseEvent !== "undefined"
4802
+ ? new CloseEvent("close", {
4803
+ code: code !== undefined ? code : 1000,
4804
+ reason: reason !== undefined ? String(reason) : "",
4805
+ wasClean: true,
4806
+ })
4807
+ : new Event("close");
4808
+ self.dispatchEvent(ev);
4809
+ if (typeof self.onclose === "function") self.onclose(ev);
4810
+ }, 0);
4811
+ }
4072
4812
  }
4073
- return origFetch(url, opts);
4074
- };
4813
+ const MockWebSocketCtor = function MockWebSocketCtor(url, protocols) {
4814
+ const urlStr = typeof url === "string" ? url : String(url);
4815
+ const rec = takeNextRecorded(urlStr);
4816
+ if (!rec || !rec.messages || rec.messages.length === 0) {
4817
+ return new origWebSocket(url, protocols);
4818
+ }
4819
+ return new O_MockWebSocket(url, protocols, rec);
4820
+ };
4821
+ MockWebSocketCtor.CONNECTING = C.CONNECTING;
4822
+ MockWebSocketCtor.OPEN = C.OPEN;
4823
+ MockWebSocketCtor.CLOSING = C.CLOSING;
4824
+ MockWebSocketCtor.CLOSED = C.CLOSED;
4825
+ window.WebSocket = MockWebSocketCtor;
4826
+ }
4075
4827
 
4076
4828
  const resolveRoot = () => {
4077
4829
  if (rootOpt != null) {
@@ -4137,7 +4889,9 @@ o.playRecording = (recording, opts = {}) => {
4137
4889
  el = scope.querySelector(action.target);
4138
4890
  }
4139
4891
  }
4892
+ // blur/focus on removed elements: skip (fallback for older recordings)
4140
4893
  if (!el && action.type !== "scroll") {
4894
+ if (action.type === "blur" || action.type === "focus") return true;
4141
4895
  return `element not found: ${action.target}`;
4142
4896
  }
4143
4897
  if (action.type === "scroll") {
@@ -4146,6 +4900,22 @@ o.playRecording = (recording, opts = {}) => {
4146
4900
  if (action.value !== undefined) el.value = action.value;
4147
4901
  if (action.checked !== undefined) el.checked = action.checked;
4148
4902
  el.dispatchEvent(new Event(action.type, { bubbles: true }));
4903
+ } else if (action.type === "submit") {
4904
+ if (typeof el.requestSubmit === "function") el.requestSubmit();
4905
+ else el.submit();
4906
+ } else if (action.type === "keydown") {
4907
+ el.dispatchEvent(
4908
+ new KeyboardEvent("keydown", {
4909
+ key: action.key || "",
4910
+ code: action.code || "",
4911
+ bubbles: true,
4912
+ cancelable: true,
4913
+ }),
4914
+ );
4915
+ } else if (action.type === "focus") {
4916
+ el.focus();
4917
+ } else if (action.type === "blur") {
4918
+ el.blur();
4149
4919
  } else {
4150
4920
  if (action.type === "click") {
4151
4921
  el.click();
@@ -4169,6 +4939,8 @@ o.playRecording = (recording, opts = {}) => {
4169
4939
  const r = o.runRecordingAssertions(recording, rootEl, i, {
4170
4940
  assertions: asserted,
4171
4941
  removedElements: recording.removedElements,
4942
+ strictAssertions,
4943
+ strictRemoved,
4172
4944
  });
4173
4945
  assertionAccum.passed += r.passed;
4174
4946
  assertionAccum.total += r.total;
@@ -4206,6 +4978,9 @@ o.playRecording = (recording, opts = {}) => {
4206
4978
  const onComplete = isOptions && opts.onComplete;
4207
4979
  const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId) => {
4208
4980
  window.fetch = origFetch;
4981
+ XMLHttpRequest.prototype.open = origXHROpen;
4982
+ XMLHttpRequest.prototype.send = origXHRSend;
4983
+ if (origWebSocket) window.WebSocket = origWebSocket;
4209
4984
  const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : undefined;
4210
4985
  if (assertionResult?.failures?.length > 0) {
4211
4986
  o.tRes[testId] = false;
@@ -4237,41 +5012,79 @@ o.testOverlay = () => {
4237
5012
  return;
4238
5013
  }
4239
5014
 
5015
+ const scrollId = "o-test-overlay-scroll";
5016
+ const exportBtnId = "o-test-export-objs";
5017
+ const copyBtnId = "o-test-copy-txt";
5018
+ const btnBarStyle =
5019
+ "padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;";
5020
+
5021
+ const buildListPlainText = () =>
5022
+ o.tLog
5023
+ .map((log, i) => (log != null && log !== "" ? String(log) : "Test #" + i) + (o.tRes[i] ? " ✓" : " ✗"))
5024
+ .join("\n\n");
5025
+
4240
5026
  const updatePanel = () => {
4241
- const panel = o("#" + panelId);
4242
- if (!panel.el) return;
4243
- const total = o.tRes.length;
4244
- const passed = o.tRes.filter(Boolean).length;
4245
- let html = `<b>Tests: ${passed}/${total}</b><hr style="margin:4px 0">`;
5027
+ const scroll = o("#" + scrollId);
5028
+ if (!scroll.el) return;
5029
+ let html = "";
4246
5030
  o.tLog.forEach((log, i) => {
4247
5031
  const ok = o.tRes[i];
4248
- 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>`;
4249
- });
4250
- html += `<button id="o-test-export" style="margin-top:6px;padding:4px 8px;font-size:11px;cursor:pointer">Export results</button>`;
4251
- panel.html(html);
4252
- o("#o-test-export").on("click", () => {
4253
- const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
4254
- const blob = new Blob([data], { type: "application/json" });
4255
- const a = o.D.createElement("a");
4256
- a.href = URL.createObjectURL(blob);
4257
- a.download = "objs-test-results.json";
4258
- a.click();
5032
+ 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>`;
4259
5033
  });
5034
+ scroll.html(html);
4260
5035
  };
4261
5036
 
4262
5037
  const innerHTML =
4263
- `<div style="display:flex;align-items:center;gap:12px;">` +
5038
+ `<div class="o-test-overlay-root" style="display:flex;flex-direction:column;gap:4px;max-height:min(88vh,560px);overflow:hidden;">` +
5039
+ `<div style="display:flex;align-items:center;gap:12px;flex-shrink:0;">` +
4264
5040
  `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span>` +
4265
- `<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
5041
+ `<button type="button" class="o-test-overlay-toggle" style="${btnBarStyle}">List</button>` +
4266
5042
  `<button type="button" class="o-test-overlay-close" style="padding:4px 8px;background:transparent;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;font-size:16px;line-height:1;" title="Close">×</button>` +
4267
5043
  `</div>` +
4268
- `<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:240px;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`;
5044
+ `<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;">` +
5045
+ `<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>` +
5046
+ `<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;">` +
5047
+ `<button type="button" id="${exportBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Export (objs)</button>` +
5048
+ `<button type="button" id="${copyBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Copy (txt)</button>` +
5049
+ `</div></div></div>`;
4269
5050
  const box = o.overlay({
4270
5051
  innerHTML,
4271
5052
  removeExisting: false,
4272
5053
  className: "o-test-overlay",
4273
5054
  id: btnId,
4274
- excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId,
5055
+ excludeDragSelector:
5056
+ ".o-test-overlay-close, .o-test-overlay-toggle, #" +
5057
+ panelId +
5058
+ ", #" +
5059
+ scrollId +
5060
+ ", #o-test-overlay-footer, .o-test-overlay-export-btn",
5061
+ });
5062
+
5063
+ o("#" + exportBtnId).on("click", () => {
5064
+ const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
5065
+ const blob = new Blob([data], { type: "application/json" });
5066
+ const a = o.D.createElement("a");
5067
+ a.href = URL.createObjectURL(blob);
5068
+ a.download = "objs-test-results.json";
5069
+ a.click();
5070
+ });
5071
+ o("#" + copyBtnId).on("click", () => {
5072
+ const text = buildListPlainText();
5073
+ const write = () => {
5074
+ const ta = o.D.createElement("textarea");
5075
+ ta.value = text;
5076
+ ta.setAttribute("readonly", "");
5077
+ ta.style.cssText = "position:fixed;left:-9999px;top:0";
5078
+ o.D.body.appendChild(ta);
5079
+ ta.select();
5080
+ o.D.execCommand("copy");
5081
+ ta.remove();
5082
+ };
5083
+ if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
5084
+ navigator.clipboard.writeText(text).catch(write);
5085
+ } else {
5086
+ write();
5087
+ }
4275
5088
  });
4276
5089
 
4277
5090
  const refreshSummary = () => {
@@ -4284,8 +5097,12 @@ o.testOverlay = () => {
4284
5097
  const panel = o("#" + panelId);
4285
5098
  if (!panel.el) return;
4286
5099
  const isOpen = panel.el.style.display !== "none";
4287
- panel.css({ display: isOpen ? "none" : "block" });
4288
- if (!isOpen) updatePanel();
5100
+ if (isOpen) {
5101
+ panel.el.style.display = "none";
5102
+ } else {
5103
+ panel.el.style.display = "flex";
5104
+ updatePanel();
5105
+ }
4289
5106
  });
4290
5107
 
4291
5108
  box.first(".o-test-overlay-close").on("click", () => {
@@ -4295,7 +5112,7 @@ o.testOverlay = () => {
4295
5112
  o.testOverlay.showPanel = () => {
4296
5113
  const panel = o("#" + panelId);
4297
5114
  if (!panel.el) return;
4298
- panel.css({ display: "block" });
5115
+ panel.el.style.display = "flex";
4299
5116
  updatePanel();
4300
5117
  refreshSummary();
4301
5118
  };