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/README.md +561 -618
- package/objs-extension/README.md +32 -0
- package/objs-extension/background.js +110 -0
- package/objs-extension/bridge.js +193 -0
- package/objs-extension/icons/icon128.png +0 -0
- package/objs-extension/lib/objs-inject.js +5308 -0
- package/objs-extension/manifest.json +18 -0
- package/objs-extension/sidepanel.css +455 -0
- package/objs-extension/sidepanel.html +56 -0
- package/objs-extension/sidepanel.js +908 -0
- package/objs.built.js +475 -120
- package/objs.built.min.js +57 -48
- package/objs.d.ts +584 -525
- package/objs.js +593 -134
- package/package.json +71 -70
package/objs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Objs-core library
|
|
3
|
-
* @version 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
|
|
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 =
|
|
3286
|
-
const stepDelays = Object.assign({}, defaultStepDelays,
|
|
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 =
|
|
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 = (
|
|
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
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
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
|
-
|
|
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
|
|
3901
|
-
if (
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
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
|
-
}
|
|
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
|
|
3937
|
-
|
|
3938
|
-
!expectedText ||
|
|
3939
|
-
|
|
3940
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
:
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4373
|
-
const
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
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
|
|
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
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
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
|
-
|
|
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
|
|
4600
|
-
if (!
|
|
4601
|
-
|
|
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 ? "#
|
|
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;
|
|
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="
|
|
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;
|
|
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:
|
|
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
|
-
|
|
4646
|
-
|
|
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.
|
|
5115
|
+
panel.el.style.display = "flex";
|
|
4657
5116
|
updatePanel();
|
|
4658
5117
|
refreshSummary();
|
|
4659
5118
|
};
|