objs-core 2.2.1 → 2.3.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 +17 -0
- package/objs.built.js +344 -33
- package/objs.built.min.js +56 -40
- package/objs.d.ts +19 -1
- package/objs.js +388 -30
- package/package.json +1 -1
package/objs.js
CHANGED
|
@@ -2754,25 +2754,44 @@ o.test = (title = "", ...tests) => {
|
|
|
2754
2754
|
|
|
2755
2755
|
const finalize = () => {
|
|
2756
2756
|
if (o.tFinalized[testN]) return;
|
|
2757
|
+
// When waits > 0, defer finalization to o.testUpdate (when async step resolves)
|
|
2758
|
+
if (waits > 0) {
|
|
2759
|
+
row = "├ ";
|
|
2760
|
+
row += "DONE " + done + "/" + num + ", waiting: " + waits;
|
|
2761
|
+
log(row, true);
|
|
2762
|
+
if (o.tStyled) {
|
|
2763
|
+
o.tLog[testN] +=
|
|
2764
|
+
o.tPre +
|
|
2765
|
+
'<div style="color:orange;"><b>DONE ' +
|
|
2766
|
+
done +
|
|
2767
|
+
"/" +
|
|
2768
|
+
num +
|
|
2769
|
+
", waiting: " +
|
|
2770
|
+
waits +
|
|
2771
|
+
"</b>" +
|
|
2772
|
+
o.tDc +
|
|
2773
|
+
o.tDc;
|
|
2774
|
+
} else {
|
|
2775
|
+
o.tLog[testN] += row + "\n";
|
|
2776
|
+
}
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2757
2779
|
o.tFinalized[testN] = true;
|
|
2758
2780
|
const anyFailed = o.tStatus[testN].some((s) => s === false);
|
|
2759
2781
|
o.tRes[testN] = !anyFailed && done === num;
|
|
2760
|
-
row =
|
|
2761
|
-
row += "DONE " + done + "/" + num
|
|
2762
|
-
log(row, done
|
|
2763
|
-
|
|
2764
|
-
log();
|
|
2765
|
-
}
|
|
2782
|
+
row = "╘ ";
|
|
2783
|
+
row += "DONE " + done + "/" + num;
|
|
2784
|
+
log(row, done !== num);
|
|
2785
|
+
log();
|
|
2766
2786
|
if (o.tStyled) {
|
|
2767
2787
|
o.tLog[testN] +=
|
|
2768
2788
|
o.tPre +
|
|
2769
2789
|
'<div style="color:' +
|
|
2770
|
-
(done
|
|
2790
|
+
(done !== num ? "red" : "green") +
|
|
2771
2791
|
';"><b>DONE ' +
|
|
2772
2792
|
done +
|
|
2773
2793
|
"/" +
|
|
2774
2794
|
num +
|
|
2775
|
-
(waits ? ", waiting: " + waits : "") +
|
|
2776
2795
|
"</b>" +
|
|
2777
2796
|
o.tDc +
|
|
2778
2797
|
o.tDc;
|
|
@@ -2784,7 +2803,7 @@ o.test = (title = "", ...tests) => {
|
|
|
2784
2803
|
sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
|
|
2785
2804
|
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
2786
2805
|
}
|
|
2787
|
-
if (
|
|
2806
|
+
if (typeof o.tFns[testN] === "function") {
|
|
2788
2807
|
o.tFns[testN](testN);
|
|
2789
2808
|
}
|
|
2790
2809
|
};
|
|
@@ -3241,17 +3260,37 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3241
3260
|
if (o.recorder.active) {
|
|
3242
3261
|
return;
|
|
3243
3262
|
}
|
|
3244
|
-
const defaultEvents = [
|
|
3263
|
+
const defaultEvents = [
|
|
3264
|
+
"click",
|
|
3265
|
+
"mouseover",
|
|
3266
|
+
"scroll",
|
|
3267
|
+
"input",
|
|
3268
|
+
"change",
|
|
3269
|
+
"submit",
|
|
3270
|
+
"keydown",
|
|
3271
|
+
"focus",
|
|
3272
|
+
"blur",
|
|
3273
|
+
];
|
|
3245
3274
|
const defaultStepDelays = {
|
|
3246
3275
|
click: 100,
|
|
3247
3276
|
mouseover: 50,
|
|
3248
3277
|
scroll: 30,
|
|
3249
3278
|
input: 50,
|
|
3250
3279
|
change: 50,
|
|
3280
|
+
submit: 100,
|
|
3281
|
+
keydown: 50,
|
|
3282
|
+
focus: 50,
|
|
3283
|
+
blur: 50,
|
|
3251
3284
|
};
|
|
3252
3285
|
const listenEvents = events || defaultEvents;
|
|
3253
3286
|
const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
|
|
3254
|
-
const captureDebounce = {
|
|
3287
|
+
const captureDebounce = {
|
|
3288
|
+
scroll: 30,
|
|
3289
|
+
mouseover: 50,
|
|
3290
|
+
keydown: 50,
|
|
3291
|
+
focus: 50,
|
|
3292
|
+
blur: 50,
|
|
3293
|
+
};
|
|
3255
3294
|
const rec = o.recorder;
|
|
3256
3295
|
rec.active = true;
|
|
3257
3296
|
rec.actions = [];
|
|
@@ -3299,6 +3338,71 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3299
3338
|
return response;
|
|
3300
3339
|
};
|
|
3301
3340
|
|
|
3341
|
+
// intercept XMLHttpRequest
|
|
3342
|
+
rec._originalXHROpen = XMLHttpRequest.prototype.open;
|
|
3343
|
+
rec._originalXHRSend = XMLHttpRequest.prototype.send;
|
|
3344
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
3345
|
+
this._oMethod = (method || "GET").toUpperCase();
|
|
3346
|
+
this._oUrl = url;
|
|
3347
|
+
return rec._originalXHROpen.apply(this, arguments);
|
|
3348
|
+
};
|
|
3349
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
3350
|
+
const capture = () => {
|
|
3351
|
+
if (this.readyState !== 4) return;
|
|
3352
|
+
let reqBody;
|
|
3353
|
+
try {
|
|
3354
|
+
reqBody = body ? JSON.parse(body) : undefined;
|
|
3355
|
+
} catch (_e) {
|
|
3356
|
+
reqBody = body;
|
|
3357
|
+
}
|
|
3358
|
+
let respBody;
|
|
3359
|
+
try {
|
|
3360
|
+
const text = this.responseText;
|
|
3361
|
+
respBody = text ? JSON.parse(text) : null;
|
|
3362
|
+
} catch (_e) {
|
|
3363
|
+
respBody = this.responseText ?? null;
|
|
3364
|
+
}
|
|
3365
|
+
const key = (this._oMethod || "GET") + ":" + (this._oUrl || "");
|
|
3366
|
+
rec.mocks[key] = {
|
|
3367
|
+
url: this._oUrl,
|
|
3368
|
+
method: this._oMethod,
|
|
3369
|
+
request: reqBody,
|
|
3370
|
+
response: respBody,
|
|
3371
|
+
status: this.status,
|
|
3372
|
+
};
|
|
3373
|
+
};
|
|
3374
|
+
this.addEventListener("readystatechange", capture);
|
|
3375
|
+
return rec._originalXHRSend.apply(this, arguments);
|
|
3376
|
+
};
|
|
3377
|
+
|
|
3378
|
+
// intercept WebSocket
|
|
3379
|
+
rec.websocketEvents = [];
|
|
3380
|
+
rec._originalWebSocket = window.WebSocket;
|
|
3381
|
+
window.WebSocket = function (url, protocols) {
|
|
3382
|
+
const ws = new rec._originalWebSocket(url, protocols);
|
|
3383
|
+
const id = rec.websocketEvents.length;
|
|
3384
|
+
rec.websocketEvents.push({
|
|
3385
|
+
url: typeof url === "string" ? url : String(url),
|
|
3386
|
+
protocol: Array.isArray(protocols) ? protocols[0] : protocols,
|
|
3387
|
+
open: true,
|
|
3388
|
+
messages: [],
|
|
3389
|
+
});
|
|
3390
|
+
ws.addEventListener("message", (e) => {
|
|
3391
|
+
const data = typeof e.data === "string" ? e.data : String(e.data);
|
|
3392
|
+
rec.websocketEvents[id].messages.push({ dir: "in", data });
|
|
3393
|
+
});
|
|
3394
|
+
ws.addEventListener("close", () => {
|
|
3395
|
+
rec.websocketEvents[id].open = false;
|
|
3396
|
+
});
|
|
3397
|
+
const origSend = ws.send.bind(ws);
|
|
3398
|
+
ws.send = function (data) {
|
|
3399
|
+
const d = typeof data === "string" ? data : String(data);
|
|
3400
|
+
rec.websocketEvents[id].messages.push({ dir: "out", data: d });
|
|
3401
|
+
return origSend(data);
|
|
3402
|
+
};
|
|
3403
|
+
return ws;
|
|
3404
|
+
};
|
|
3405
|
+
|
|
3302
3406
|
// Internal Objs attributes must not be used for selectors (they change across restores).
|
|
3303
3407
|
const unstableDataAttrs = { "data-o-init": 1, "data-o-init-i": 1, "data-o-state": 1 };
|
|
3304
3408
|
const qualify = (sel, fromNode) => {
|
|
@@ -3461,30 +3565,53 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3461
3565
|
});
|
|
3462
3566
|
}
|
|
3463
3567
|
if (m.type === "attributes") {
|
|
3568
|
+
const attr = m.attributeName;
|
|
3569
|
+
if (!attr) return;
|
|
3464
3570
|
const sel = buildSelector(m.target);
|
|
3465
3571
|
if (!sel) return;
|
|
3572
|
+
const attrToType = {
|
|
3573
|
+
class: "class",
|
|
3574
|
+
style: "style",
|
|
3575
|
+
hidden: "hidden",
|
|
3576
|
+
disabled: "disabled",
|
|
3577
|
+
"aria-expanded": "aria-expanded",
|
|
3578
|
+
"aria-checked": "aria-checked",
|
|
3579
|
+
};
|
|
3580
|
+
const type = attrToType[attr];
|
|
3581
|
+
if (!type) return;
|
|
3466
3582
|
if (
|
|
3467
3583
|
rec.assertions.some(
|
|
3468
|
-
(a) => a.actionIdx === actionIdx && a.selector === sel && a.type ===
|
|
3584
|
+
(a) => a.actionIdx === actionIdx && a.selector === sel && a.type === type,
|
|
3469
3585
|
)
|
|
3470
3586
|
)
|
|
3471
3587
|
return;
|
|
3472
3588
|
const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, m.target);
|
|
3473
|
-
const
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3589
|
+
const el = m.target;
|
|
3590
|
+
let value;
|
|
3591
|
+
if (type === "class") value = el.className;
|
|
3592
|
+
else if (type === "style") value = el.style?.cssText || el.getAttribute("style") || "";
|
|
3593
|
+
else if (type === "hidden") value = el.hidden;
|
|
3594
|
+
else if (type === "disabled") value = el.disabled === true;
|
|
3595
|
+
else if (type === "aria-expanded")
|
|
3596
|
+
value = el.getAttribute("aria-expanded");
|
|
3597
|
+
else if (type === "aria-checked") value = el.getAttribute("aria-checked");
|
|
3598
|
+
const a = { actionIdx, type, selector: sel };
|
|
3599
|
+
if (type === "class") a.className = value;
|
|
3600
|
+
else if (type === "style") a.style = value;
|
|
3601
|
+
else if (type === "hidden") a.hidden = value;
|
|
3602
|
+
else if (type === "disabled") a.disabled = value;
|
|
3603
|
+
else if (type === "aria-expanded") a.ariaExpanded = value;
|
|
3604
|
+
else if (type === "aria-checked") a.ariaChecked = value;
|
|
3479
3605
|
if (aListSel != null) a.listSelector = aListSel;
|
|
3480
3606
|
if (aIdx != null) a.index = aIdx;
|
|
3481
3607
|
rec.assertions.push(a);
|
|
3482
3608
|
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3483
|
-
console.log("[recording] +
|
|
3609
|
+
console.log("[recording] +attr assertion:", {
|
|
3484
3610
|
actionIdx,
|
|
3485
3611
|
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
3486
3612
|
selector: sel,
|
|
3487
|
-
|
|
3613
|
+
type,
|
|
3614
|
+
value,
|
|
3488
3615
|
index: aIdx,
|
|
3489
3616
|
listSelector: aListSel,
|
|
3490
3617
|
});
|
|
@@ -3572,21 +3699,44 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3572
3699
|
ev === "change" && (target?.type === "checkbox" || target?.type === "radio")
|
|
3573
3700
|
? target?.checked
|
|
3574
3701
|
: undefined;
|
|
3702
|
+
// For keydown, capture key/code for replay
|
|
3703
|
+
const key = ev === "keydown" ? target?.key : undefined;
|
|
3704
|
+
const code = ev === "keydown" ? target?.code : undefined;
|
|
3575
3705
|
|
|
3576
|
-
// Push click/change immediately so MutationObserver sees correct actionIdx
|
|
3706
|
+
// Push click/change/submit immediately so MutationObserver sees correct actionIdx
|
|
3577
3707
|
// (mutations fire sync after target handler; debounce would attach assertions to wrong action)
|
|
3578
3708
|
const delay =
|
|
3579
|
-
ev === "click" || ev === "change"
|
|
3709
|
+
ev === "click" || ev === "change" || ev === "submit"
|
|
3580
3710
|
? 0
|
|
3581
3711
|
: stepDelays[ev] !== undefined
|
|
3582
3712
|
? stepDelays[ev]
|
|
3583
3713
|
: captureDebounce[ev] ?? 0;
|
|
3584
3714
|
const pushAction = () => {
|
|
3715
|
+
// Don't record blur/focus on elements removed by the previous action (e.g. click delete → blur on removed node)
|
|
3716
|
+
if ((ev === "blur" || ev === "focus") && selector) {
|
|
3717
|
+
const lastIdx = rec.actions.length - 1;
|
|
3718
|
+
const lastAction = lastIdx >= 0 ? rec.actions[lastIdx] : null;
|
|
3719
|
+
if (lastAction) {
|
|
3720
|
+
const sameTarget =
|
|
3721
|
+
lastAction.target === selector &&
|
|
3722
|
+
(lastAction.listSelector == null) === (listSelector == null) &&
|
|
3723
|
+
(lastAction.targetIndex == null) === (targetIndex == null) &&
|
|
3724
|
+
(lastAction.targetIndex == null || lastAction.targetIndex === targetIndex);
|
|
3725
|
+
if (sameTarget) return;
|
|
3726
|
+
for (const r of rec.removedElements) {
|
|
3727
|
+
if (r.actionIdx !== lastIdx) continue;
|
|
3728
|
+
if (r.selector === selector || selector.startsWith(r.selector + " ") || selector.startsWith(r.selector + ">"))
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3585
3733
|
const action = { type: ev, target: selector, time: Date.now() };
|
|
3586
3734
|
if (targetType) action.targetType = targetType;
|
|
3587
3735
|
if (scrollY !== undefined) action.scrollY = scrollY;
|
|
3588
3736
|
if (value !== undefined) action.value = value;
|
|
3589
3737
|
if (checked !== undefined) action.checked = checked;
|
|
3738
|
+
if (key !== undefined) action.key = key;
|
|
3739
|
+
if (code !== undefined) action.code = code;
|
|
3590
3740
|
if (listSelector != null) action.listSelector = listSelector;
|
|
3591
3741
|
if (targetIndex != null) action.targetIndex = targetIndex;
|
|
3592
3742
|
rec.actions.push(action);
|
|
@@ -3614,6 +3764,16 @@ o.stopRecording = () => {
|
|
|
3614
3764
|
window.fetch = rec._originalFetch;
|
|
3615
3765
|
rec._originalFetch = null;
|
|
3616
3766
|
}
|
|
3767
|
+
if (rec._originalXHROpen) {
|
|
3768
|
+
XMLHttpRequest.prototype.open = rec._originalXHROpen;
|
|
3769
|
+
XMLHttpRequest.prototype.send = rec._originalXHRSend;
|
|
3770
|
+
rec._originalXHROpen = null;
|
|
3771
|
+
rec._originalXHRSend = null;
|
|
3772
|
+
}
|
|
3773
|
+
if (rec._originalWebSocket) {
|
|
3774
|
+
window.WebSocket = rec._originalWebSocket;
|
|
3775
|
+
rec._originalWebSocket = null;
|
|
3776
|
+
}
|
|
3617
3777
|
rec._listeners.forEach(({ ev, handler }) => {
|
|
3618
3778
|
o.D.removeEventListener(ev, handler, true);
|
|
3619
3779
|
});
|
|
@@ -3630,6 +3790,7 @@ o.stopRecording = () => {
|
|
|
3630
3790
|
assertions: [...(rec.assertions || [])],
|
|
3631
3791
|
removedElements: [...(rec.removedElements || [])],
|
|
3632
3792
|
observeRoot: rec.observeRoot || null,
|
|
3793
|
+
websocketEvents: [...(rec.websocketEvents || [])],
|
|
3633
3794
|
};
|
|
3634
3795
|
};
|
|
3635
3796
|
|
|
@@ -3828,6 +3989,50 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
3828
3989
|
});
|
|
3829
3990
|
}
|
|
3830
3991
|
}
|
|
3992
|
+
} else if (a.type === "style") {
|
|
3993
|
+
const expected = (a.style || "").trim();
|
|
3994
|
+
const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
|
|
3995
|
+
const ok = el && (!expected || actual.indexOf(expected) !== -1 || expected === actual);
|
|
3996
|
+
if (ok) {
|
|
3997
|
+
passed += 1;
|
|
3998
|
+
} else {
|
|
3999
|
+
const msg = !el ? "element not found" : `expected style "${expected.slice(0, 60)}..."`;
|
|
4000
|
+
failures.push({ selector: a.selector, message: msg });
|
|
4001
|
+
}
|
|
4002
|
+
} else if (a.type === "hidden") {
|
|
4003
|
+
const ok = el && el.hidden === a.hidden;
|
|
4004
|
+
if (ok) {
|
|
4005
|
+
passed += 1;
|
|
4006
|
+
} else {
|
|
4007
|
+
const msg = !el ? "element not found" : `expected hidden=${a.hidden}`;
|
|
4008
|
+
failures.push({ selector: a.selector, message: msg });
|
|
4009
|
+
}
|
|
4010
|
+
} else if (a.type === "disabled") {
|
|
4011
|
+
const ok = el && el.disabled === a.disabled;
|
|
4012
|
+
if (ok) {
|
|
4013
|
+
passed += 1;
|
|
4014
|
+
} else {
|
|
4015
|
+
const msg = !el ? "element not found" : `expected disabled=${a.disabled}`;
|
|
4016
|
+
failures.push({ selector: a.selector, message: msg });
|
|
4017
|
+
}
|
|
4018
|
+
} else if (a.type === "aria-expanded") {
|
|
4019
|
+
const actual = el?.getAttribute?.("aria-expanded");
|
|
4020
|
+
const ok = el && (a.ariaExpanded == null || String(actual) === String(a.ariaExpanded));
|
|
4021
|
+
if (ok) {
|
|
4022
|
+
passed += 1;
|
|
4023
|
+
} else {
|
|
4024
|
+
const msg = !el ? "element not found" : `expected aria-expanded="${a.ariaExpanded}"`;
|
|
4025
|
+
failures.push({ selector: a.selector, message: msg });
|
|
4026
|
+
}
|
|
4027
|
+
} else if (a.type === "aria-checked") {
|
|
4028
|
+
const actual = el?.getAttribute?.("aria-checked");
|
|
4029
|
+
const ok = el && (a.ariaChecked == null || String(actual) === String(a.ariaChecked));
|
|
4030
|
+
if (ok) {
|
|
4031
|
+
passed += 1;
|
|
4032
|
+
} else {
|
|
4033
|
+
const msg = !el ? "element not found" : `expected aria-checked="${a.ariaChecked}"`;
|
|
4034
|
+
failures.push({ selector: a.selector, message: msg });
|
|
4035
|
+
}
|
|
3831
4036
|
}
|
|
3832
4037
|
}
|
|
3833
4038
|
return { passed, total: deduped.length, failures };
|
|
@@ -3877,16 +4082,26 @@ o.exportTest = (recording, options = {}) => {
|
|
|
3877
4082
|
(a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
|
|
3878
4083
|
(a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
|
|
3879
4084
|
` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
|
|
4085
|
+
} else if (a.type === "submit") {
|
|
4086
|
+
body = ` (el.requestSubmit && el.requestSubmit()) || el.submit();${endSuffix}`;
|
|
4087
|
+
} else if (a.type === "keydown") {
|
|
4088
|
+
body =
|
|
4089
|
+
` el.dispatchEvent(new KeyboardEvent('keydown', {key:${JSON.stringify(a.key || "")}, code:${JSON.stringify(a.code || "")}, bubbles:true, cancelable:true}));${endSuffix}`;
|
|
4090
|
+
} else if (a.type === "focus") {
|
|
4091
|
+
body = ` el.focus();${endSuffix}`;
|
|
4092
|
+
} else if (a.type === "blur") {
|
|
4093
|
+
body = ` el.blur();${endSuffix}`;
|
|
3880
4094
|
} else {
|
|
3881
4095
|
const useNativeClick = a.type === "click";
|
|
3882
4096
|
body = useNativeClick
|
|
3883
4097
|
? ` el.click();${endSuffix}`
|
|
3884
4098
|
: ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
|
|
3885
4099
|
}
|
|
4100
|
+
const skipIfMissing = a.type === "blur" || a.type === "focus";
|
|
3886
4101
|
steps.push(
|
|
3887
4102
|
` ['${a.type} on ${a.target}', ${stepFn} {\n` +
|
|
3888
4103
|
getEl(a) +
|
|
3889
|
-
`\n if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}'
|
|
4104
|
+
`\n if (!el && '${a.type}' !== 'scroll') { if (${skipIfMissing}) return true; return 'element not found: ${a.target.replace(/'/g, "\\'")}'; }\n` +
|
|
3890
4105
|
body +
|
|
3891
4106
|
` }]`,
|
|
3892
4107
|
);
|
|
@@ -3933,19 +4148,43 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
3933
4148
|
|
|
3934
4149
|
const routes = Object.values(recording.mocks)
|
|
3935
4150
|
.map((mock) => {
|
|
3936
|
-
|
|
3937
|
-
|
|
4151
|
+
let urlPath = mock.url;
|
|
4152
|
+
try {
|
|
4153
|
+
urlPath = new URL(mock.url).pathname || urlPath;
|
|
4154
|
+
} catch (_e) {}
|
|
4155
|
+
if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
|
|
4156
|
+
const respBody = JSON.stringify(mock.response);
|
|
4157
|
+
const reqBody = JSON.stringify(mock.request);
|
|
4158
|
+
const method = (mock.method || "GET").toUpperCase();
|
|
4159
|
+
let verify = ` if (route.request().method() !== ${JSON.stringify(method)}) { await route.continue(); return; }\n`;
|
|
4160
|
+
if (mock.request != null && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
4161
|
+
verify +=
|
|
4162
|
+
` const postData = route.request().postData();\n` +
|
|
4163
|
+
` const body = (() => { try { return JSON.parse(postData || '{}'); } catch { return {}; } })();\n` +
|
|
4164
|
+
` expect(body).toEqual(${reqBody});\n`;
|
|
4165
|
+
}
|
|
3938
4166
|
return (
|
|
3939
4167
|
` await page.route('**${urlPath}', async route => {\n` +
|
|
4168
|
+
verify +
|
|
3940
4169
|
` await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',\n` +
|
|
3941
|
-
` body: JSON.stringify(${
|
|
4170
|
+
` body: JSON.stringify(${respBody}) });\n` +
|
|
3942
4171
|
` });`
|
|
3943
4172
|
);
|
|
3944
4173
|
})
|
|
3945
4174
|
.join("\n");
|
|
3946
4175
|
|
|
3947
4176
|
const sd = Object.assign(
|
|
3948
|
-
{
|
|
4177
|
+
{
|
|
4178
|
+
click: 100,
|
|
4179
|
+
mouseover: 50,
|
|
4180
|
+
scroll: 30,
|
|
4181
|
+
input: 50,
|
|
4182
|
+
change: 50,
|
|
4183
|
+
submit: 100,
|
|
4184
|
+
keydown: 50,
|
|
4185
|
+
focus: 50,
|
|
4186
|
+
blur: 50,
|
|
4187
|
+
},
|
|
3949
4188
|
recording.stepDelays || {},
|
|
3950
4189
|
);
|
|
3951
4190
|
const steps = recording.actions
|
|
@@ -3977,6 +4216,20 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
3977
4216
|
} else {
|
|
3978
4217
|
step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
|
|
3979
4218
|
}
|
|
4219
|
+
} else if (action.type === "submit") {
|
|
4220
|
+
step = ` await ${loc}.evaluate((el) => el.requestSubmit?.() || el.submit());`;
|
|
4221
|
+
} else if (action.type === "keydown") {
|
|
4222
|
+
const key = action.key || "";
|
|
4223
|
+
step =
|
|
4224
|
+
key === "Enter"
|
|
4225
|
+
? ` await ${loc}.press("Enter");`
|
|
4226
|
+
: key
|
|
4227
|
+
? ` await ${loc}.press(${JSON.stringify(key)});`
|
|
4228
|
+
: ` await ${loc}.press(${JSON.stringify(action.code || "")});`;
|
|
4229
|
+
} else if (action.type === "focus") {
|
|
4230
|
+
step = ` if (await ${loc}.count() > 0) await ${loc}.focus();`;
|
|
4231
|
+
} else if (action.type === "blur") {
|
|
4232
|
+
step = ` if (await ${loc}.count() > 0) await ${loc}.blur();`;
|
|
3980
4233
|
} else {
|
|
3981
4234
|
step = ` await ${loc}.click();`;
|
|
3982
4235
|
}
|
|
@@ -4004,7 +4257,39 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
4004
4257
|
return s;
|
|
4005
4258
|
}
|
|
4006
4259
|
if (a.type === "class") {
|
|
4007
|
-
|
|
4260
|
+
const classes = (a.className || "").trim().split(/\s+/).filter(Boolean);
|
|
4261
|
+
if (classes.length > 0)
|
|
4262
|
+
return classes
|
|
4263
|
+
.map((c) => ` await expect(${aLoc}).toHaveClass(${JSON.stringify(c)});`)
|
|
4264
|
+
.join("\n");
|
|
4265
|
+
return ` // class on ${a.selector} (no specific classes asserted)`;
|
|
4266
|
+
}
|
|
4267
|
+
if (a.type === "style") {
|
|
4268
|
+
const style = (a.style || "").trim();
|
|
4269
|
+
if (style) {
|
|
4270
|
+
// Try to emit toHaveCSS for common props; fallback to attribute
|
|
4271
|
+
const m = style.match(/(\w+)\s*:\s*([^;]+)/);
|
|
4272
|
+
if (m)
|
|
4273
|
+
return ` await expect(${aLoc}).toHaveCSS(${JSON.stringify(m[1])}, ${JSON.stringify(m[2].trim())});`;
|
|
4274
|
+
return ` await expect(${aLoc}).toHaveAttribute("style", ${JSON.stringify(style)});`;
|
|
4275
|
+
}
|
|
4276
|
+
return "";
|
|
4277
|
+
}
|
|
4278
|
+
if (a.type === "hidden") {
|
|
4279
|
+
return a.hidden
|
|
4280
|
+
? ` await expect(${aLoc}).toBeHidden();`
|
|
4281
|
+
: ` await expect(${aLoc}).toBeVisible();`;
|
|
4282
|
+
}
|
|
4283
|
+
if (a.type === "disabled") {
|
|
4284
|
+
return a.disabled
|
|
4285
|
+
? ` await expect(${aLoc}).toBeDisabled();`
|
|
4286
|
+
: ` await expect(${aLoc}).toBeEnabled();`;
|
|
4287
|
+
}
|
|
4288
|
+
if (a.type === "aria-expanded" && a.ariaExpanded != null) {
|
|
4289
|
+
return ` await expect(${aLoc}).toHaveAttribute("aria-expanded", ${JSON.stringify(String(a.ariaExpanded))});`;
|
|
4290
|
+
}
|
|
4291
|
+
if (a.type === "aria-checked" && a.ariaChecked != null) {
|
|
4292
|
+
return ` await expect(${aLoc}).toHaveAttribute("aria-checked", ${JSON.stringify(String(a.ariaChecked))});`;
|
|
4008
4293
|
}
|
|
4009
4294
|
return "";
|
|
4010
4295
|
})
|
|
@@ -4016,6 +4301,27 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
4016
4301
|
.join("\n");
|
|
4017
4302
|
|
|
4018
4303
|
const hasAutoAssertions = (recording.assertions || []).length > 0;
|
|
4304
|
+
const wsEvents = recording.websocketEvents || [];
|
|
4305
|
+
const hasWsEvents = wsEvents.length > 0 && wsEvents.some((c) => c.messages?.length > 0);
|
|
4306
|
+
const wsSetup =
|
|
4307
|
+
hasWsEvents
|
|
4308
|
+
? ` const wsCollected = [];\n` +
|
|
4309
|
+
` page.on('websocket', ws => {\n` +
|
|
4310
|
+
` ws.on('framereceived', ev => wsCollected.push({ dir: 'in', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));\n` +
|
|
4311
|
+
` ws.on('framesent', ev => wsCollected.push({ dir: 'out', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));\n` +
|
|
4312
|
+
` });\n\n`
|
|
4313
|
+
: "";
|
|
4314
|
+
const wsAssertions =
|
|
4315
|
+
hasWsEvents
|
|
4316
|
+
? wsEvents
|
|
4317
|
+
.flatMap((conn) => (conn.messages || []).map((msg) => ({ dir: msg.dir, data: msg.data })))
|
|
4318
|
+
.map(
|
|
4319
|
+
(msg) =>
|
|
4320
|
+
` expect(wsCollected).toContainEqual({ dir: ${JSON.stringify(msg.dir)}, payload: ${JSON.stringify(msg.data)} });`,
|
|
4321
|
+
)
|
|
4322
|
+
.join("\n") + "\n\n"
|
|
4323
|
+
: "";
|
|
4324
|
+
|
|
4019
4325
|
return (
|
|
4020
4326
|
`// Auto-generated by o.exportPlaywrightTest() — review and anonymize mocks before committing\n` +
|
|
4021
4327
|
`// Prerequisites: npm install @playwright/test && npx playwright install chromium\n` +
|
|
@@ -4025,15 +4331,19 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
4025
4331
|
(routes
|
|
4026
4332
|
? ` // Network mocks — edit/anonymize before committing\n` + routes + "\n\n"
|
|
4027
4333
|
: "") +
|
|
4334
|
+
wsSetup +
|
|
4028
4335
|
` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }\n` +
|
|
4029
4336
|
` await page.goto(${JSON.stringify(baseUrl)});\n\n` +
|
|
4030
4337
|
(steps ? steps + "\n\n" : "") +
|
|
4031
|
-
(
|
|
4338
|
+
(wsAssertions ? ` // WebSocket verifications\n` + wsAssertions : "") +
|
|
4339
|
+
(!hasAutoAssertions && !hasWsEvents
|
|
4032
4340
|
? ` // TODO: Add assertions before committing, e.g.:\n` +
|
|
4033
4341
|
` // await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();\n` +
|
|
4034
4342
|
` // await expect(page).toHaveURL(/\\/confirmation/);\n` +
|
|
4035
4343
|
` // await expect(page.locator('[data-qa="error-banner"]')).not.toBeVisible();\n`
|
|
4036
|
-
:
|
|
4344
|
+
: hasAutoAssertions || hasWsEvents
|
|
4345
|
+
? ` // Auto-generated assertions above — review for correctness before committing\n`
|
|
4346
|
+
: "") +
|
|
4037
4347
|
`});\n`
|
|
4038
4348
|
);
|
|
4039
4349
|
};
|
|
@@ -4073,6 +4383,34 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
4073
4383
|
return origFetch(url, opts);
|
|
4074
4384
|
};
|
|
4075
4385
|
|
|
4386
|
+
const origXHROpen = XMLHttpRequest.prototype.open;
|
|
4387
|
+
const origXHRSend = XMLHttpRequest.prototype.send;
|
|
4388
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
4389
|
+
this._oMethod = (method || "GET").toUpperCase();
|
|
4390
|
+
this._oUrl = url;
|
|
4391
|
+
return origXHROpen.apply(this, arguments);
|
|
4392
|
+
};
|
|
4393
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
4394
|
+
const xhr = this;
|
|
4395
|
+
const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
|
|
4396
|
+
const mock = allMocks[key];
|
|
4397
|
+
if (mock) {
|
|
4398
|
+
const respBody =
|
|
4399
|
+
typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
|
|
4400
|
+
setTimeout(() => {
|
|
4401
|
+
xhr.readyState = 4;
|
|
4402
|
+
xhr.status = mock.status || 200;
|
|
4403
|
+
xhr.statusText = "OK";
|
|
4404
|
+
xhr.responseText = respBody;
|
|
4405
|
+
xhr.response = respBody;
|
|
4406
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
4407
|
+
xhr.dispatchEvent(new Event("load"));
|
|
4408
|
+
}, 0);
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
return origXHRSend.apply(this, arguments);
|
|
4412
|
+
};
|
|
4413
|
+
|
|
4076
4414
|
const resolveRoot = () => {
|
|
4077
4415
|
if (rootOpt != null) {
|
|
4078
4416
|
return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
|
|
@@ -4137,7 +4475,9 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
4137
4475
|
el = scope.querySelector(action.target);
|
|
4138
4476
|
}
|
|
4139
4477
|
}
|
|
4478
|
+
// blur/focus on removed elements: skip (fallback for older recordings)
|
|
4140
4479
|
if (!el && action.type !== "scroll") {
|
|
4480
|
+
if (action.type === "blur" || action.type === "focus") return true;
|
|
4141
4481
|
return `element not found: ${action.target}`;
|
|
4142
4482
|
}
|
|
4143
4483
|
if (action.type === "scroll") {
|
|
@@ -4146,6 +4486,22 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
4146
4486
|
if (action.value !== undefined) el.value = action.value;
|
|
4147
4487
|
if (action.checked !== undefined) el.checked = action.checked;
|
|
4148
4488
|
el.dispatchEvent(new Event(action.type, { bubbles: true }));
|
|
4489
|
+
} else if (action.type === "submit") {
|
|
4490
|
+
if (typeof el.requestSubmit === "function") el.requestSubmit();
|
|
4491
|
+
else el.submit();
|
|
4492
|
+
} else if (action.type === "keydown") {
|
|
4493
|
+
el.dispatchEvent(
|
|
4494
|
+
new KeyboardEvent("keydown", {
|
|
4495
|
+
key: action.key || "",
|
|
4496
|
+
code: action.code || "",
|
|
4497
|
+
bubbles: true,
|
|
4498
|
+
cancelable: true,
|
|
4499
|
+
}),
|
|
4500
|
+
);
|
|
4501
|
+
} else if (action.type === "focus") {
|
|
4502
|
+
el.focus();
|
|
4503
|
+
} else if (action.type === "blur") {
|
|
4504
|
+
el.blur();
|
|
4149
4505
|
} else {
|
|
4150
4506
|
if (action.type === "click") {
|
|
4151
4507
|
el.click();
|
|
@@ -4206,6 +4562,8 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
4206
4562
|
const onComplete = isOptions && opts.onComplete;
|
|
4207
4563
|
const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId) => {
|
|
4208
4564
|
window.fetch = origFetch;
|
|
4565
|
+
XMLHttpRequest.prototype.open = origXHROpen;
|
|
4566
|
+
XMLHttpRequest.prototype.send = origXHRSend;
|
|
4209
4567
|
const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : undefined;
|
|
4210
4568
|
if (assertionResult?.failures?.length > 0) {
|
|
4211
4569
|
o.tRes[testId] = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "objs-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lightweight (~6 kB) library for fast, AI-friendly front-end development: samples and state control, built-in store (createStore), routing, caching, and recording → Playwright tests. No build step; split design into samples and give them data and actions. Works standalone or alongside React; SSR and hydrate from server-rendered DOM. v2.0: exportPlaywrightTest(), refs, TypeScript definitions, recording in all builds.",
|
|
6
6
|
"homepage": "https://en.fous.name/objs/",
|