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/README.md +561 -601
- 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 +793 -127
- package/objs.built.min.js +69 -44
- package/objs.d.ts +584 -507
- package/objs.js +954 -137
- 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
|
|
@@ -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 =
|
|
2761
|
-
row += "DONE " + done + "/" + num
|
|
2762
|
-
log(row, done
|
|
2763
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 = (
|
|
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
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
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 ===
|
|
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
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
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] +
|
|
3696
|
+
console.log("[recording] +attr assertion:", {
|
|
3484
3697
|
actionIdx,
|
|
3485
3698
|
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
3486
3699
|
selector: sel,
|
|
3487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
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
|
-
|
|
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
|
|
3740
|
-
if (
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
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
|
-
}
|
|
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
|
|
3776
|
-
|
|
3777
|
-
!expectedText ||
|
|
3778
|
-
|
|
3779
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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, "\\'")}'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3937
|
-
|
|
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(${
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
:
|
|
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
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
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
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
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
|
-
|
|
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
|
|
4242
|
-
if (!
|
|
4243
|
-
|
|
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 ? "#
|
|
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;
|
|
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="
|
|
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;
|
|
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:
|
|
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
|
-
|
|
4288
|
-
|
|
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.
|
|
5115
|
+
panel.el.style.display = "flex";
|
|
4299
5116
|
updatePanel();
|
|
4300
5117
|
refreshSummary();
|
|
4301
5118
|
};
|