objs-core 2.3.0 → 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
@@ -3243,6 +3287,7 @@ o.recorder = {
3243
3287
  initialData: {},
3244
3288
  assertions: [],
3245
3289
  observeRoot: null,
3290
+ strictCapture: null,
3246
3291
  _originalFetch: null,
3247
3292
  _listeners: [],
3248
3293
  _observer: null,
@@ -3252,7 +3297,7 @@ o.recordingAssertionDebug = false;
3252
3297
 
3253
3298
  /**
3254
3299
  * Start recording user interactions
3255
- * @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)
3256
3301
  * @param {string[]} [events] - Events to record (default: click, mouseover, scroll, input, change)
3257
3302
  * @param {{[event: string]: number}} [timeouts] - Debounce delays per event type in ms
3258
3303
  */
@@ -3260,6 +3305,41 @@ o.startRecording = (observe, events, timeouts) => {
3260
3305
  if (o.recorder.active) {
3261
3306
  return;
3262
3307
  }
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
+ }
3263
3343
  const defaultEvents = [
3264
3344
  "click",
3265
3345
  "mouseover",
@@ -3282,8 +3362,8 @@ o.startRecording = (observe, events, timeouts) => {
3282
3362
  focus: 50,
3283
3363
  blur: 50,
3284
3364
  };
3285
- const listenEvents = events || defaultEvents;
3286
- const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
3365
+ const listenEvents = eventsOpt || defaultEvents;
3366
+ const stepDelays = Object.assign({}, defaultStepDelays, timeoutsOpt || {});
3287
3367
  const captureDebounce = {
3288
3368
  scroll: 30,
3289
3369
  mouseover: 50,
@@ -3297,8 +3377,9 @@ o.startRecording = (observe, events, timeouts) => {
3297
3377
  rec.mocks = {};
3298
3378
  rec.stepDelays = stepDelays;
3299
3379
  rec.initialData = { url: window.location.href, timestamp: Date.now() };
3380
+ rec.strictCapture = strictCapture;
3300
3381
 
3301
- rec.observeRoot = observe || null;
3382
+ rec.observeRoot = observeSel || null;
3302
3383
  rec.assertions = [];
3303
3384
  rec.removedElements = [];
3304
3385
 
@@ -3456,7 +3537,7 @@ o.startRecording = (observe, events, timeouts) => {
3456
3537
  };
3457
3538
 
3458
3539
  // Scoped MutationObserver: captures DOM mutations tied to the last recorded action
3459
- const observeTarget = (observe && o.D.querySelector(observe)) || o.D.body;
3540
+ const observeTarget = (observeSel && o.D.querySelector(observeSel)) || o.D.body;
3460
3541
  rec._observer = new MutationObserver((mutations) => {
3461
3542
  const actionIdx = rec.actions.length - 1;
3462
3543
  if (actionIdx < 0) return;
@@ -3477,22 +3558,28 @@ o.startRecording = (observe, events, timeouts) => {
3477
3558
  if (sel && observeTarget) {
3478
3559
  const matches = observeTarget.querySelectorAll(sel);
3479
3560
  if (matches.length > 1) {
3480
- let n = node;
3481
- while (n && n !== observeTarget && n.nodeType === 1) {
3482
- const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
3483
- if (qaAttr) {
3484
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3485
- const itemMatches = observeTarget.querySelectorAll(itemSel);
3486
- if (itemMatches.length > 1) {
3487
- const idx = [...itemMatches].indexOf(n);
3488
- if (idx !== -1) {
3489
- listSelector = itemSel;
3490
- index = idx;
3491
- 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
+ }
3492
3579
  }
3493
3580
  }
3581
+ n = n.parentElement;
3494
3582
  }
3495
- n = n.parentElement;
3496
3583
  }
3497
3584
  }
3498
3585
  }
@@ -3639,7 +3726,7 @@ o.startRecording = (observe, events, timeouts) => {
3639
3726
  const handler = (e) => {
3640
3727
  const target = e.target;
3641
3728
  if (
3642
- observe &&
3729
+ observeSel &&
3643
3730
  observeTarget &&
3644
3731
  target?.nodeType === 1 &&
3645
3732
  !observeTarget.contains(target)
@@ -3669,22 +3756,28 @@ o.startRecording = (observe, events, timeouts) => {
3669
3756
  if (selector && observeTarget) {
3670
3757
  const matches = observeTarget.querySelectorAll(selector);
3671
3758
  if (matches.length > 1) {
3672
- let node = target;
3673
- while (node && node !== observeTarget && node.nodeType === 1) {
3674
- const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
3675
- if (qaAttr) {
3676
- const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
3677
- const itemMatches = observeTarget.querySelectorAll(itemSel);
3678
- if (itemMatches.length > 1) {
3679
- const idx = [...itemMatches].indexOf(node);
3680
- if (idx !== -1) {
3681
- listSelector = itemSel;
3682
- targetIndex = idx;
3683
- 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
+ }
3684
3777
  }
3685
3778
  }
3779
+ node = node.parentElement;
3686
3780
  }
3687
- node = node.parentElement;
3688
3781
  }
3689
3782
  }
3690
3783
  }
@@ -3782,7 +3875,7 @@ o.stopRecording = () => {
3782
3875
  rec._observer.disconnect();
3783
3876
  rec._observer = null;
3784
3877
  }
3785
- return {
3878
+ const out = {
3786
3879
  actions: [...rec.actions],
3787
3880
  mocks: { ...rec.mocks },
3788
3881
  initialData: { ...rec.initialData },
@@ -3792,6 +3885,10 @@ o.stopRecording = () => {
3792
3885
  observeRoot: rec.observeRoot || null,
3793
3886
  websocketEvents: [...(rec.websocketEvents || [])],
3794
3887
  };
3888
+ if (rec.strictCapture) {
3889
+ out.strictCapture = { ...rec.strictCapture };
3890
+ }
3891
+ return out;
3795
3892
  };
3796
3893
 
3797
3894
  /**
@@ -3815,10 +3912,14 @@ o.clearRecording = (id) => {
3815
3912
  * Run recording assertions in the current DOM.
3816
3913
  * @param {{actions: Array, assertions: Array, observeRoot?: string}} recording
3817
3914
  * @param {Element|string} [root] - Root element or selector; defaults to recording.observeRoot or document.body
3818
- * @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)
3819
3917
  * @returns {{ passed: number, total: number, failures: Array<{selector: string, message: string}> }}
3820
3918
  */
3821
3919
  o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3920
+ const strictAssertions = !!(opts && opts.strictAssertions);
3921
+ const strictRemoved =
3922
+ opts && opts.strictRemoved !== undefined ? !!opts.strictRemoved : strictAssertions;
3822
3923
  const preFiltered = opts && opts.assertions;
3823
3924
  const assertions =
3824
3925
  preFiltered != null
@@ -3856,6 +3957,8 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3856
3957
  };
3857
3958
  const r = resolveRoot();
3858
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, "; "));
3859
3962
  const getText = (el) => (el ? norm(el.textContent || "") : "");
3860
3963
  const removedElements = opts?.removedElements || [];
3861
3964
  const isRemoved = (a) => {
@@ -3875,20 +3978,77 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3875
3978
  const failures = [];
3876
3979
  for (const a of deduped) {
3877
3980
  if (isRemoved(a)) {
3878
- passed += 1;
3879
- if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
3880
- console.log("[runRecordingAssertions] skip (explicit removed):", {
3881
- 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({
3882
4038
  selector: a.selector,
3883
- text: (a.text || "").slice(0, 40),
4039
+ message: "expected absent (recorded removed) but element still present",
3884
4040
  });
4041
+ continue;
3885
4042
  }
4043
+ passed += 1;
3886
4044
  continue;
3887
4045
  }
3888
4046
  let el = null;
3889
4047
  let indexOutOfBounds = false;
4048
+ let listItemsLength = -1;
3890
4049
  if (a.listSelector != null && a.index != null) {
3891
4050
  const items = r.querySelectorAll(a.listSelector);
4051
+ listItemsLength = items.length;
3892
4052
  const expectedText = norm(a.text || "");
3893
4053
  const tryItem = (idx) => {
3894
4054
  const it = items[idx];
@@ -3897,28 +4057,38 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3897
4057
  a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
3898
4058
  return (e || (a.selector !== a.listSelector ? it : null));
3899
4059
  };
3900
- let item = items[a.index];
3901
- if (!item && a.index > 0) item = items[a.index - 1];
3902
- if (item) {
3903
- el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
3904
- if (!el && a.selector !== a.listSelector) el = item;
3905
- if (a.type === "visible" && expectedText && el) {
3906
- const actualText = getText(el);
3907
- const textMismatch =
3908
- actualText.indexOf(expectedText) === -1 &&
3909
- expectedText.indexOf(actualText) === -1;
3910
- if (textMismatch) {
3911
- for (let j = 0; j < items.length; j++) {
3912
- const candEl = tryItem(j);
3913
- if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
3914
- el = candEl;
3915
- item = items[j];
3916
- 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
+ }
3917
4086
  }
3918
4087
  }
3919
4088
  }
3920
4089
  }
3921
- } else {
4090
+ }
4091
+ if (!item) {
3922
4092
  indexOutOfBounds = true;
3923
4093
  }
3924
4094
  } else {
@@ -3933,17 +4103,20 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3933
4103
  (el.getBoundingClientRect && el.getBoundingClientRect().width > 0));
3934
4104
  const expectedText = norm(a.text || "");
3935
4105
  const actualText = getText(el);
3936
- const fullActual = actualText;
3937
- const textOk =
3938
- !expectedText ||
3939
- actualText.indexOf(expectedText) !== -1 ||
3940
- fullActual.indexOf(expectedText) !== -1 ||
3941
- (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);
3942
4111
  if (visible && textOk) {
3943
4112
  passed += 1;
3944
4113
  } else {
4114
+ const listCount =
4115
+ listItemsLength >= 0
4116
+ ? listItemsLength
4117
+ : r.querySelectorAll(a.listSelector || a.selector).length;
3945
4118
  const message = indexOutOfBounds
3946
- ? `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})`
3947
4120
  : !el
3948
4121
  ? "element not found"
3949
4122
  : !visible
@@ -3968,14 +4141,20 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3968
4141
  const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
3969
4142
  const hasClass =
3970
4143
  el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
3971
- if (hasClass) {
4144
+ const classOrderOk =
4145
+ !strictAssertions ||
4146
+ !a.className ||
4147
+ norm((el?.className || "").trim()) === norm((a.className || "").trim());
4148
+ if (hasClass && classOrderOk) {
3972
4149
  passed += 1;
3973
4150
  } else {
3974
4151
  const msg = indexOutOfBounds
3975
4152
  ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})`
3976
4153
  : !el
3977
4154
  ? "element not found"
3978
- : `expected class "${a.className}"`;
4155
+ : hasClass && !classOrderOk
4156
+ ? `expected exact className "${a.className}" (strict)`
4157
+ : `expected class "${a.className}"`;
3979
4158
  failures.push({ selector: a.selector, message: msg });
3980
4159
  if (typeof console !== "undefined" && console.warn) {
3981
4160
  console.warn("[runRecordingAssertions] failed:", {
@@ -3992,7 +4171,12 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
3992
4171
  } else if (a.type === "style") {
3993
4172
  const expected = (a.style || "").trim();
3994
4173
  const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
3995
- const ok = el && (!expected || actual.indexOf(expected) !== -1 || expected === actual);
4174
+ const ok =
4175
+ el &&
4176
+ (!expected ||
4177
+ (strictAssertions
4178
+ ? styleNorm(actual) === styleNorm(expected)
4179
+ : actual.indexOf(expected) !== -1 || expected === actual));
3996
4180
  if (ok) {
3997
4181
  passed += 1;
3998
4182
  } else {
@@ -4039,14 +4223,15 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
4039
4223
  };
4040
4224
 
4041
4225
  /**
4042
- * Export a recording as a ready-to-commit o.addTest() code string.
4226
+ * Export a recording as a ready-to-commit test code string.
4043
4227
  * Includes assertions interleaved with actions (Playwright parity).
4044
4228
  * @param {{actions: Array, assertions: Array, mocks: Object, initialData: Object, observeRoot?: string}} recording
4045
- * @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()`.
4046
4230
  * @returns {string}
4047
4231
  */
4048
4232
  o.exportTest = (recording, options = {}) => {
4049
4233
  const delay = options.delay !== undefined ? options.delay : 16;
4234
+ const extensionExport = options.extensionExport === true;
4050
4235
  const recordingData = {
4051
4236
  actions: recording.actions,
4052
4237
  assertions: recording.assertions || [],
@@ -4119,11 +4304,24 @@ o.exportTest = (recording, options = {}) => {
4119
4304
  ? JSON.stringify(recording.mocks, null, 2)
4120
4305
  : "{}";
4121
4306
 
4122
- return (
4307
+ const header =
4123
4308
  `// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
4124
4309
  `const recordingMocks = ${mocksStr};\n` +
4125
- `const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };\n\n` +
4126
- `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` +
4127
4325
  ` // teardown\n});\n`
4128
4326
  );
4129
4327
  };
@@ -4351,8 +4549,8 @@ o.exportPlaywrightTest = (recording, options = {}) => {
4351
4549
  // Available in all builds so assessors can replay and see results (testOverlay) on staging.
4352
4550
  /**
4353
4551
  * Play back a recording as an automated test sequence
4354
- * @param {{actions: Array, mocks: Object, assertions?: Array, observeRoot?: string}} recording
4355
- * @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? }
4356
4554
  * @returns {number|{testId: number, assertionResult?: Object}}
4357
4555
  */
4358
4556
  o.playRecording = (recording, opts = {}) => {
@@ -4362,54 +4560,270 @@ o.playRecording = (recording, opts = {}) => {
4362
4560
  (opts.runAssertions !== undefined ||
4363
4561
  opts.root !== undefined ||
4364
4562
  opts.manualChecks !== undefined ||
4365
- 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);
4366
4573
  const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
4367
4574
  const runAssertions = isOptions && opts.runAssertions;
4368
4575
  const rootOpt = isOptions ? opts.root : undefined;
4369
4576
  const manualChecks = (isOptions && opts.manualChecks) || [];
4370
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
+ }
4371
4583
 
4372
- const allMocks = Object.assign({}, recording.mocks, mockOverrides);
4373
- const origFetch = window.fetch;
4374
- window.fetch = (url, opts = {}) => {
4375
- const method = (opts.method || "GET").toUpperCase();
4376
- const key = method + ":" + url;
4377
- if (allMocks[key]) {
4378
- const mock = allMocks[key];
4379
- const body =
4380
- typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
4381
- return Promise.resolve(new Response(body, { status: mock.status || 200 }));
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
+ }
4382
4615
  }
4383
- return origFetch(url, opts);
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);
4384
4626
  };
4627
+ const normWsData = (s) => String(s || "").trim().replace(/\s+/g, " ");
4385
4628
 
4629
+ const allMocks = Object.assign({}, recording.mocks, mockOverrides);
4630
+ const origFetch = window.fetch;
4386
4631
  const origXHROpen = XMLHttpRequest.prototype.open;
4387
4632
  const origXHRSend = XMLHttpRequest.prototype.send;
4388
- XMLHttpRequest.prototype.open = function (method, url) {
4389
- this._oMethod = (method || "GET").toUpperCase();
4390
- this._oUrl = url;
4391
- return origXHROpen.apply(this, arguments);
4392
- };
4393
- XMLHttpRequest.prototype.send = function (body) {
4394
- const xhr = this;
4395
- const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
4396
- const mock = allMocks[key];
4397
- if (mock) {
4398
- const respBody =
4399
- typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
4400
- setTimeout(() => {
4401
- xhr.readyState = 4;
4402
- xhr.status = mock.status || 200;
4403
- xhr.statusText = "OK";
4404
- xhr.responseText = respBody;
4405
- xhr.response = respBody;
4406
- xhr.dispatchEvent(new Event("readystatechange"));
4407
- xhr.dispatchEvent(new Event("load"));
4408
- }, 0);
4409
- return;
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 || "");
4662
+ const mock = allMocks[key];
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
+ }
4410
4812
  }
4411
- return origXHRSend.apply(this, arguments);
4412
- };
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
+ }
4413
4827
 
4414
4828
  const resolveRoot = () => {
4415
4829
  if (rootOpt != null) {
@@ -4525,6 +4939,8 @@ o.playRecording = (recording, opts = {}) => {
4525
4939
  const r = o.runRecordingAssertions(recording, rootEl, i, {
4526
4940
  assertions: asserted,
4527
4941
  removedElements: recording.removedElements,
4942
+ strictAssertions,
4943
+ strictRemoved,
4528
4944
  });
4529
4945
  assertionAccum.passed += r.passed;
4530
4946
  assertionAccum.total += r.total;
@@ -4564,6 +4980,7 @@ o.playRecording = (recording, opts = {}) => {
4564
4980
  window.fetch = origFetch;
4565
4981
  XMLHttpRequest.prototype.open = origXHROpen;
4566
4982
  XMLHttpRequest.prototype.send = origXHRSend;
4983
+ if (origWebSocket) window.WebSocket = origWebSocket;
4567
4984
  const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : undefined;
4568
4985
  if (assertionResult?.failures?.length > 0) {
4569
4986
  o.tRes[testId] = false;
@@ -4595,41 +5012,79 @@ o.testOverlay = () => {
4595
5012
  return;
4596
5013
  }
4597
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
+
4598
5026
  const updatePanel = () => {
4599
- const panel = o("#" + panelId);
4600
- if (!panel.el) return;
4601
- const total = o.tRes.length;
4602
- const passed = o.tRes.filter(Boolean).length;
4603
- 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 = "";
4604
5030
  o.tLog.forEach((log, i) => {
4605
5031
  const ok = o.tRes[i];
4606
- 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>`;
4607
- });
4608
- html += `<button id="o-test-export" style="margin-top:6px;padding:4px 8px;font-size:11px;cursor:pointer">Export results</button>`;
4609
- panel.html(html);
4610
- o("#o-test-export").on("click", () => {
4611
- const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
4612
- const blob = new Blob([data], { type: "application/json" });
4613
- const a = o.D.createElement("a");
4614
- a.href = URL.createObjectURL(blob);
4615
- a.download = "objs-test-results.json";
4616
- 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>`;
4617
5033
  });
5034
+ scroll.html(html);
4618
5035
  };
4619
5036
 
4620
5037
  const innerHTML =
4621
- `<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;">` +
4622
5040
  `<span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span>` +
4623
- `<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>` +
4624
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>` +
4625
5043
  `</div>` +
4626
- `<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>`;
4627
5050
  const box = o.overlay({
4628
5051
  innerHTML,
4629
5052
  removeExisting: false,
4630
5053
  className: "o-test-overlay",
4631
5054
  id: btnId,
4632
- 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
+ }
4633
5088
  });
4634
5089
 
4635
5090
  const refreshSummary = () => {
@@ -4642,8 +5097,12 @@ o.testOverlay = () => {
4642
5097
  const panel = o("#" + panelId);
4643
5098
  if (!panel.el) return;
4644
5099
  const isOpen = panel.el.style.display !== "none";
4645
- panel.css({ display: isOpen ? "none" : "block" });
4646
- if (!isOpen) updatePanel();
5100
+ if (isOpen) {
5101
+ panel.el.style.display = "none";
5102
+ } else {
5103
+ panel.el.style.display = "flex";
5104
+ updatePanel();
5105
+ }
4647
5106
  });
4648
5107
 
4649
5108
  box.first(".o-test-overlay-close").on("click", () => {
@@ -4653,7 +5112,7 @@ o.testOverlay = () => {
4653
5112
  o.testOverlay.showPanel = () => {
4654
5113
  const panel = o("#" + panelId);
4655
5114
  if (!panel.el) return;
4656
- panel.css({ display: "block" });
5115
+ panel.el.style.display = "flex";
4657
5116
  updatePanel();
4658
5117
  refreshSummary();
4659
5118
  };