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/README.md
CHANGED
|
@@ -25,6 +25,18 @@
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
+
### Update v2.3: New features
|
|
29
|
+
- **Recording: extended events** — Default events now include `submit`, `keydown`, `focus`, `blur` in addition to click, input, change, scroll, mouseover.
|
|
30
|
+
- **Recording: extended attributes** — MutationObserver records `style`, `hidden`, `disabled`, `aria-expanded`, `aria-checked` (not just class). Assertions and Playwright export support all types.
|
|
31
|
+
- **Playwright export: real expect()** — All assertion types emit `expect()` calls: `toHaveClass`, `toHaveCSS`, `toBeHidden`, `toBeDisabled`, `toHaveAttribute` for aria-*.
|
|
32
|
+
- **Network: XHR interception** — Captures `XMLHttpRequest` alongside `fetch`; GET/POST/PUT with request and response body stored in mocks.
|
|
33
|
+
- **Playwright export: route verification** — Generated route handlers verify request method and body (POST/PUT) before fulfilling.
|
|
34
|
+
- **WebSocket monitoring** — Records WebSocket connections and messages (in/out); Playwright export includes `framereceived`/`framesent` assertions.
|
|
35
|
+
- **Recording: blur/focus on removed elements** — Blur and focus events on elements removed by the previous action (e.g. click delete) are not recorded.
|
|
36
|
+
- **Test overlay: async steps** — `onComplete` callback now runs when manual check (Promise) resolves; overlay counter and panel display correctly.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
28
40
|
### Update v2.2: New features
|
|
29
41
|
- **<div>${objInstance}</div>** — Objs instance has `toString()` and `Symbol.toPrimitive`; use in template literals without `.html()` call. The HTML is inserted and auto-hydrated when the parent sets `innerHTML` (e.g. html: `<div>${child}</div>` in render()).
|
|
30
42
|
- **o.playRecording(recording, opts)** — Extended options: `runAssertions`, `root`, `actionDelay`, `manualChecks`, `onComplete`. Assertions verification and manual checks are natively supported. [Recording example](https://foggysq.github.io/objs/examples/recording/index.html) updated.
|
|
@@ -102,6 +114,11 @@
|
|
|
102
114
|
<script src="objs.js" type="text/javascript"></script>
|
|
103
115
|
```
|
|
104
116
|
|
|
117
|
+
**Browser (smaller)** — minified `objs.built.min.js` for production. Use `type="module"`:
|
|
118
|
+
```html
|
|
119
|
+
<script src="objs.min.js" type="module"></script>
|
|
120
|
+
```
|
|
121
|
+
|
|
105
122
|
**npm / bundler** — correct file chosen automatically via `package.json` exports:
|
|
106
123
|
```js
|
|
107
124
|
import o from 'objs-core'; // resolves to objs.built.js
|
package/objs.built.js
CHANGED
|
@@ -1875,17 +1875,26 @@ o.test = (title = "", ...tests) => {
|
|
|
1875
1875
|
});
|
|
1876
1876
|
const finalize = () => {
|
|
1877
1877
|
if (o.tFinalized[testN2]) return;
|
|
1878
|
+
if (waits > 0) {
|
|
1879
|
+
row = "\u251C ";
|
|
1880
|
+
row += "DONE " + done + "/" + num + ", waiting: " + waits;
|
|
1881
|
+
log(row, true);
|
|
1882
|
+
if (o.tStyled) {
|
|
1883
|
+
o.tLog[testN2] += o.tPre + '<div style="color:orange;"><b>DONE ' + done + "/" + num + ", waiting: " + waits + "</b>" + o.tDc + o.tDc;
|
|
1884
|
+
} else {
|
|
1885
|
+
o.tLog[testN2] += row + "\n";
|
|
1886
|
+
}
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1878
1889
|
o.tFinalized[testN2] = true;
|
|
1879
1890
|
const anyFailed = o.tStatus[testN2].some((s) => s === false);
|
|
1880
1891
|
o.tRes[testN2] = !anyFailed && done === num;
|
|
1881
|
-
row =
|
|
1882
|
-
row += "DONE " + done + "/" + num
|
|
1883
|
-
log(row, done
|
|
1884
|
-
|
|
1885
|
-
log();
|
|
1886
|
-
}
|
|
1892
|
+
row = "\u2558 ";
|
|
1893
|
+
row += "DONE " + done + "/" + num;
|
|
1894
|
+
log(row, done !== num);
|
|
1895
|
+
log();
|
|
1887
1896
|
if (o.tStyled) {
|
|
1888
|
-
o.tLog[testN2] += o.tPre + '<div style="color:' + (done
|
|
1897
|
+
o.tLog[testN2] += o.tPre + '<div style="color:' + (done !== num ? "red" : "green") + ';"><b>DONE ' + done + "/" + num + "</b>" + o.tDc + o.tDc;
|
|
1889
1898
|
} else {
|
|
1890
1899
|
o.tLog[testN2] += row + "\n";
|
|
1891
1900
|
}
|
|
@@ -1894,7 +1903,7 @@ o.test = (title = "", ...tests) => {
|
|
|
1894
1903
|
sessionStorage.setItem(`oTest-Res-${testN2}`, o.tRes[testN2]);
|
|
1895
1904
|
sessionStorage.setItem(`oTest-Status-${testN2}`, JSON.stringify(o.tStatus[testN2]));
|
|
1896
1905
|
}
|
|
1897
|
-
if (
|
|
1906
|
+
if (typeof o.tFns[testN2] === "function") {
|
|
1898
1907
|
o.tFns[testN2](testN2);
|
|
1899
1908
|
}
|
|
1900
1909
|
};
|
|
@@ -2248,17 +2257,37 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2248
2257
|
if (o.recorder.active) {
|
|
2249
2258
|
return;
|
|
2250
2259
|
}
|
|
2251
|
-
const defaultEvents = [
|
|
2260
|
+
const defaultEvents = [
|
|
2261
|
+
"click",
|
|
2262
|
+
"mouseover",
|
|
2263
|
+
"scroll",
|
|
2264
|
+
"input",
|
|
2265
|
+
"change",
|
|
2266
|
+
"submit",
|
|
2267
|
+
"keydown",
|
|
2268
|
+
"focus",
|
|
2269
|
+
"blur"
|
|
2270
|
+
];
|
|
2252
2271
|
const defaultStepDelays = {
|
|
2253
2272
|
click: 100,
|
|
2254
2273
|
mouseover: 50,
|
|
2255
2274
|
scroll: 30,
|
|
2256
2275
|
input: 50,
|
|
2257
|
-
change: 50
|
|
2276
|
+
change: 50,
|
|
2277
|
+
submit: 100,
|
|
2278
|
+
keydown: 50,
|
|
2279
|
+
focus: 50,
|
|
2280
|
+
blur: 50
|
|
2258
2281
|
};
|
|
2259
2282
|
const listenEvents = events || defaultEvents;
|
|
2260
2283
|
const stepDelays = Object.assign({}, defaultStepDelays, timeouts || {});
|
|
2261
|
-
const captureDebounce = {
|
|
2284
|
+
const captureDebounce = {
|
|
2285
|
+
scroll: 30,
|
|
2286
|
+
mouseover: 50,
|
|
2287
|
+
keydown: 50,
|
|
2288
|
+
focus: 50,
|
|
2289
|
+
blur: 50
|
|
2290
|
+
};
|
|
2262
2291
|
const rec = o.recorder;
|
|
2263
2292
|
rec.active = true;
|
|
2264
2293
|
rec.actions = [];
|
|
@@ -2300,6 +2329,67 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2300
2329
|
};
|
|
2301
2330
|
return response;
|
|
2302
2331
|
};
|
|
2332
|
+
rec._originalXHROpen = XMLHttpRequest.prototype.open;
|
|
2333
|
+
rec._originalXHRSend = XMLHttpRequest.prototype.send;
|
|
2334
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
2335
|
+
this._oMethod = (method || "GET").toUpperCase();
|
|
2336
|
+
this._oUrl = url;
|
|
2337
|
+
return rec._originalXHROpen.apply(this, arguments);
|
|
2338
|
+
};
|
|
2339
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
2340
|
+
const capture = () => {
|
|
2341
|
+
if (this.readyState !== 4) return;
|
|
2342
|
+
let reqBody;
|
|
2343
|
+
try {
|
|
2344
|
+
reqBody = body ? JSON.parse(body) : void 0;
|
|
2345
|
+
} catch (_e) {
|
|
2346
|
+
reqBody = body;
|
|
2347
|
+
}
|
|
2348
|
+
let respBody;
|
|
2349
|
+
try {
|
|
2350
|
+
const text = this.responseText;
|
|
2351
|
+
respBody = text ? JSON.parse(text) : null;
|
|
2352
|
+
} catch (_e) {
|
|
2353
|
+
respBody = this.responseText ?? null;
|
|
2354
|
+
}
|
|
2355
|
+
const key = (this._oMethod || "GET") + ":" + (this._oUrl || "");
|
|
2356
|
+
rec.mocks[key] = {
|
|
2357
|
+
url: this._oUrl,
|
|
2358
|
+
method: this._oMethod,
|
|
2359
|
+
request: reqBody,
|
|
2360
|
+
response: respBody,
|
|
2361
|
+
status: this.status
|
|
2362
|
+
};
|
|
2363
|
+
};
|
|
2364
|
+
this.addEventListener("readystatechange", capture);
|
|
2365
|
+
return rec._originalXHRSend.apply(this, arguments);
|
|
2366
|
+
};
|
|
2367
|
+
rec.websocketEvents = [];
|
|
2368
|
+
rec._originalWebSocket = window.WebSocket;
|
|
2369
|
+
window.WebSocket = function(url, protocols) {
|
|
2370
|
+
const ws = new rec._originalWebSocket(url, protocols);
|
|
2371
|
+
const id = rec.websocketEvents.length;
|
|
2372
|
+
rec.websocketEvents.push({
|
|
2373
|
+
url: typeof url === "string" ? url : String(url),
|
|
2374
|
+
protocol: Array.isArray(protocols) ? protocols[0] : protocols,
|
|
2375
|
+
open: true,
|
|
2376
|
+
messages: []
|
|
2377
|
+
});
|
|
2378
|
+
ws.addEventListener("message", (e) => {
|
|
2379
|
+
const data = typeof e.data === "string" ? e.data : String(e.data);
|
|
2380
|
+
rec.websocketEvents[id].messages.push({ dir: "in", data });
|
|
2381
|
+
});
|
|
2382
|
+
ws.addEventListener("close", () => {
|
|
2383
|
+
rec.websocketEvents[id].open = false;
|
|
2384
|
+
});
|
|
2385
|
+
const origSend = ws.send.bind(ws);
|
|
2386
|
+
ws.send = function(data) {
|
|
2387
|
+
const d = typeof data === "string" ? data : String(data);
|
|
2388
|
+
rec.websocketEvents[id].messages.push({ dir: "out", data: d });
|
|
2389
|
+
return origSend(data);
|
|
2390
|
+
};
|
|
2391
|
+
return ws;
|
|
2392
|
+
};
|
|
2303
2393
|
const unstableDataAttrs = { "data-o-init": 1, "data-o-init-i": 1, "data-o-state": 1 };
|
|
2304
2394
|
const qualify = (sel, fromNode) => {
|
|
2305
2395
|
if (o.D.querySelectorAll(sel).length <= 1) return sel;
|
|
@@ -2449,28 +2539,51 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2449
2539
|
});
|
|
2450
2540
|
}
|
|
2451
2541
|
if (m.type === "attributes") {
|
|
2542
|
+
const attr = m.attributeName;
|
|
2543
|
+
if (!attr) return;
|
|
2452
2544
|
const sel = buildSelector(m.target);
|
|
2453
2545
|
if (!sel) return;
|
|
2546
|
+
const attrToType = {
|
|
2547
|
+
class: "class",
|
|
2548
|
+
style: "style",
|
|
2549
|
+
hidden: "hidden",
|
|
2550
|
+
disabled: "disabled",
|
|
2551
|
+
"aria-expanded": "aria-expanded",
|
|
2552
|
+
"aria-checked": "aria-checked"
|
|
2553
|
+
};
|
|
2554
|
+
const type = attrToType[attr];
|
|
2555
|
+
if (!type) return;
|
|
2454
2556
|
if (rec.assertions.some(
|
|
2455
|
-
(a2) => a2.actionIdx === actionIdx && a2.selector === sel && a2.type ===
|
|
2557
|
+
(a2) => a2.actionIdx === actionIdx && a2.selector === sel && a2.type === type
|
|
2456
2558
|
))
|
|
2457
2559
|
return;
|
|
2458
2560
|
const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, m.target);
|
|
2459
|
-
const
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2561
|
+
const el = m.target;
|
|
2562
|
+
let value;
|
|
2563
|
+
if (type === "class") value = el.className;
|
|
2564
|
+
else if (type === "style") value = el.style?.cssText || el.getAttribute("style") || "";
|
|
2565
|
+
else if (type === "hidden") value = el.hidden;
|
|
2566
|
+
else if (type === "disabled") value = el.disabled === true;
|
|
2567
|
+
else if (type === "aria-expanded")
|
|
2568
|
+
value = el.getAttribute("aria-expanded");
|
|
2569
|
+
else if (type === "aria-checked") value = el.getAttribute("aria-checked");
|
|
2570
|
+
const a = { actionIdx, type, selector: sel };
|
|
2571
|
+
if (type === "class") a.className = value;
|
|
2572
|
+
else if (type === "style") a.style = value;
|
|
2573
|
+
else if (type === "hidden") a.hidden = value;
|
|
2574
|
+
else if (type === "disabled") a.disabled = value;
|
|
2575
|
+
else if (type === "aria-expanded") a.ariaExpanded = value;
|
|
2576
|
+
else if (type === "aria-checked") a.ariaChecked = value;
|
|
2465
2577
|
if (aListSel != null) a.listSelector = aListSel;
|
|
2466
2578
|
if (aIdx != null) a.index = aIdx;
|
|
2467
2579
|
rec.assertions.push(a);
|
|
2468
2580
|
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
2469
|
-
console.log("[recording] +
|
|
2581
|
+
console.log("[recording] +attr assertion:", {
|
|
2470
2582
|
actionIdx,
|
|
2471
2583
|
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
2472
2584
|
selector: sel,
|
|
2473
|
-
|
|
2585
|
+
type,
|
|
2586
|
+
value,
|
|
2474
2587
|
index: aIdx,
|
|
2475
2588
|
listSelector: aListSel
|
|
2476
2589
|
});
|
|
@@ -2537,13 +2650,30 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2537
2650
|
const scrollY = ev === "scroll" ? window.scrollY : void 0;
|
|
2538
2651
|
const value = ev === "input" || ev === "change" ? target?.value : void 0;
|
|
2539
2652
|
const checked = ev === "change" && (target?.type === "checkbox" || target?.type === "radio") ? target?.checked : void 0;
|
|
2540
|
-
const
|
|
2653
|
+
const key = ev === "keydown" ? target?.key : void 0;
|
|
2654
|
+
const code = ev === "keydown" ? target?.code : void 0;
|
|
2655
|
+
const delay = ev === "click" || ev === "change" || ev === "submit" ? 0 : stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
|
|
2541
2656
|
const pushAction = () => {
|
|
2657
|
+
if ((ev === "blur" || ev === "focus") && selector) {
|
|
2658
|
+
const lastIdx = rec.actions.length - 1;
|
|
2659
|
+
const lastAction = lastIdx >= 0 ? rec.actions[lastIdx] : null;
|
|
2660
|
+
if (lastAction) {
|
|
2661
|
+
const sameTarget = lastAction.target === selector && lastAction.listSelector == null === (listSelector == null) && lastAction.targetIndex == null === (targetIndex == null) && (lastAction.targetIndex == null || lastAction.targetIndex === targetIndex);
|
|
2662
|
+
if (sameTarget) return;
|
|
2663
|
+
for (const r of rec.removedElements) {
|
|
2664
|
+
if (r.actionIdx !== lastIdx) continue;
|
|
2665
|
+
if (r.selector === selector || selector.startsWith(r.selector + " ") || selector.startsWith(r.selector + ">"))
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2542
2670
|
const action = { type: ev, target: selector, time: Date.now() };
|
|
2543
2671
|
if (targetType) action.targetType = targetType;
|
|
2544
2672
|
if (scrollY !== void 0) action.scrollY = scrollY;
|
|
2545
2673
|
if (value !== void 0) action.value = value;
|
|
2546
2674
|
if (checked !== void 0) action.checked = checked;
|
|
2675
|
+
if (key !== void 0) action.key = key;
|
|
2676
|
+
if (code !== void 0) action.code = code;
|
|
2547
2677
|
if (listSelector != null) action.listSelector = listSelector;
|
|
2548
2678
|
if (targetIndex != null) action.targetIndex = targetIndex;
|
|
2549
2679
|
rec.actions.push(action);
|
|
@@ -2566,6 +2696,16 @@ o.stopRecording = () => {
|
|
|
2566
2696
|
window.fetch = rec._originalFetch;
|
|
2567
2697
|
rec._originalFetch = null;
|
|
2568
2698
|
}
|
|
2699
|
+
if (rec._originalXHROpen) {
|
|
2700
|
+
XMLHttpRequest.prototype.open = rec._originalXHROpen;
|
|
2701
|
+
XMLHttpRequest.prototype.send = rec._originalXHRSend;
|
|
2702
|
+
rec._originalXHROpen = null;
|
|
2703
|
+
rec._originalXHRSend = null;
|
|
2704
|
+
}
|
|
2705
|
+
if (rec._originalWebSocket) {
|
|
2706
|
+
window.WebSocket = rec._originalWebSocket;
|
|
2707
|
+
rec._originalWebSocket = null;
|
|
2708
|
+
}
|
|
2569
2709
|
rec._listeners.forEach(({ ev, handler }) => {
|
|
2570
2710
|
o.D.removeEventListener(ev, handler, true);
|
|
2571
2711
|
});
|
|
@@ -2581,7 +2721,8 @@ o.stopRecording = () => {
|
|
|
2581
2721
|
stepDelays: { ...rec.stepDelays },
|
|
2582
2722
|
assertions: [...rec.assertions || []],
|
|
2583
2723
|
removedElements: [...rec.removedElements || []],
|
|
2584
|
-
observeRoot: rec.observeRoot || null
|
|
2724
|
+
observeRoot: rec.observeRoot || null,
|
|
2725
|
+
websocketEvents: [...rec.websocketEvents || []]
|
|
2585
2726
|
};
|
|
2586
2727
|
};
|
|
2587
2728
|
o.clearRecording = (id) => {
|
|
@@ -2740,6 +2881,50 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2740
2881
|
});
|
|
2741
2882
|
}
|
|
2742
2883
|
}
|
|
2884
|
+
} else if (a.type === "style") {
|
|
2885
|
+
const expected = (a.style || "").trim();
|
|
2886
|
+
const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
|
|
2887
|
+
const ok = el && (!expected || actual.indexOf(expected) !== -1 || expected === actual);
|
|
2888
|
+
if (ok) {
|
|
2889
|
+
passed += 1;
|
|
2890
|
+
} else {
|
|
2891
|
+
const msg = !el ? "element not found" : `expected style "${expected.slice(0, 60)}..."`;
|
|
2892
|
+
failures.push({ selector: a.selector, message: msg });
|
|
2893
|
+
}
|
|
2894
|
+
} else if (a.type === "hidden") {
|
|
2895
|
+
const ok = el && el.hidden === a.hidden;
|
|
2896
|
+
if (ok) {
|
|
2897
|
+
passed += 1;
|
|
2898
|
+
} else {
|
|
2899
|
+
const msg = !el ? "element not found" : `expected hidden=${a.hidden}`;
|
|
2900
|
+
failures.push({ selector: a.selector, message: msg });
|
|
2901
|
+
}
|
|
2902
|
+
} else if (a.type === "disabled") {
|
|
2903
|
+
const ok = el && el.disabled === a.disabled;
|
|
2904
|
+
if (ok) {
|
|
2905
|
+
passed += 1;
|
|
2906
|
+
} else {
|
|
2907
|
+
const msg = !el ? "element not found" : `expected disabled=${a.disabled}`;
|
|
2908
|
+
failures.push({ selector: a.selector, message: msg });
|
|
2909
|
+
}
|
|
2910
|
+
} else if (a.type === "aria-expanded") {
|
|
2911
|
+
const actual = el?.getAttribute?.("aria-expanded");
|
|
2912
|
+
const ok = el && (a.ariaExpanded == null || String(actual) === String(a.ariaExpanded));
|
|
2913
|
+
if (ok) {
|
|
2914
|
+
passed += 1;
|
|
2915
|
+
} else {
|
|
2916
|
+
const msg = !el ? "element not found" : `expected aria-expanded="${a.ariaExpanded}"`;
|
|
2917
|
+
failures.push({ selector: a.selector, message: msg });
|
|
2918
|
+
}
|
|
2919
|
+
} else if (a.type === "aria-checked") {
|
|
2920
|
+
const actual = el?.getAttribute?.("aria-checked");
|
|
2921
|
+
const ok = el && (a.ariaChecked == null || String(actual) === String(a.ariaChecked));
|
|
2922
|
+
if (ok) {
|
|
2923
|
+
passed += 1;
|
|
2924
|
+
} else {
|
|
2925
|
+
const msg = !el ? "element not found" : `expected aria-checked="${a.ariaChecked}"`;
|
|
2926
|
+
failures.push({ selector: a.selector, message: msg });
|
|
2927
|
+
}
|
|
2743
2928
|
}
|
|
2744
2929
|
}
|
|
2745
2930
|
return { passed, total: deduped.length, failures };
|
|
@@ -2780,14 +2965,23 @@ o.exportTest = (recording, options = {}) => {
|
|
|
2780
2965
|
body = (a.value !== void 0 ? ` el.value = ${JSON.stringify(a.value)};
|
|
2781
2966
|
` : "") + (a.checked !== void 0 ? ` el.checked = ${a.checked};
|
|
2782
2967
|
` : "") + ` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
|
|
2968
|
+
} else if (a.type === "submit") {
|
|
2969
|
+
body = ` (el.requestSubmit && el.requestSubmit()) || el.submit();${endSuffix}`;
|
|
2970
|
+
} else if (a.type === "keydown") {
|
|
2971
|
+
body = ` el.dispatchEvent(new KeyboardEvent('keydown', {key:${JSON.stringify(a.key || "")}, code:${JSON.stringify(a.code || "")}, bubbles:true, cancelable:true}));${endSuffix}`;
|
|
2972
|
+
} else if (a.type === "focus") {
|
|
2973
|
+
body = ` el.focus();${endSuffix}`;
|
|
2974
|
+
} else if (a.type === "blur") {
|
|
2975
|
+
body = ` el.blur();${endSuffix}`;
|
|
2783
2976
|
} else {
|
|
2784
2977
|
const useNativeClick = a.type === "click";
|
|
2785
2978
|
body = useNativeClick ? ` el.click();${endSuffix}` : ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
|
|
2786
2979
|
}
|
|
2980
|
+
const skipIfMissing = a.type === "blur" || a.type === "focus";
|
|
2787
2981
|
steps.push(
|
|
2788
2982
|
` ['${a.type} on ${a.target}', ${stepFn} {
|
|
2789
2983
|
` + getEl(a) + `
|
|
2790
|
-
if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';
|
|
2984
|
+
if (!el && '${a.type}' !== 'scroll') { if (${skipIfMissing}) return true; return 'element not found: ${a.target.replace(/'/g, "\\'")}'; }
|
|
2791
2985
|
` + body + ` }]`
|
|
2792
2986
|
);
|
|
2793
2987
|
const assertsForAction = (recording.assertions || []).filter((x) => x.actionIdx === i);
|
|
@@ -2824,15 +3018,40 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
2824
3018
|
}
|
|
2825
3019
|
const baseUrl = options.baseUrl || path;
|
|
2826
3020
|
const routes = Object.values(recording.mocks).map((mock) => {
|
|
2827
|
-
|
|
2828
|
-
|
|
3021
|
+
let urlPath = mock.url;
|
|
3022
|
+
try {
|
|
3023
|
+
urlPath = new URL(mock.url).pathname || urlPath;
|
|
3024
|
+
} catch (_e) {
|
|
3025
|
+
}
|
|
3026
|
+
if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
|
|
3027
|
+
const respBody = JSON.stringify(mock.response);
|
|
3028
|
+
const reqBody = JSON.stringify(mock.request);
|
|
3029
|
+
const method = (mock.method || "GET").toUpperCase();
|
|
3030
|
+
let verify = ` if (route.request().method() !== ${JSON.stringify(method)}) { await route.continue(); return; }
|
|
3031
|
+
`;
|
|
3032
|
+
if (mock.request != null && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
3033
|
+
verify += ` const postData = route.request().postData();
|
|
3034
|
+
const body = (() => { try { return JSON.parse(postData || '{}'); } catch { return {}; } })();
|
|
3035
|
+
expect(body).toEqual(${reqBody});
|
|
3036
|
+
`;
|
|
3037
|
+
}
|
|
2829
3038
|
return ` await page.route('**${urlPath}', async route => {
|
|
2830
|
-
await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',
|
|
2831
|
-
body: JSON.stringify(${
|
|
3039
|
+
` + verify + ` await route.fulfill({ status: ${mock.status || 200}, contentType: 'application/json',
|
|
3040
|
+
body: JSON.stringify(${respBody}) });
|
|
2832
3041
|
});`;
|
|
2833
3042
|
}).join("\n");
|
|
2834
3043
|
const sd = Object.assign(
|
|
2835
|
-
{
|
|
3044
|
+
{
|
|
3045
|
+
click: 100,
|
|
3046
|
+
mouseover: 50,
|
|
3047
|
+
scroll: 30,
|
|
3048
|
+
input: 50,
|
|
3049
|
+
change: 50,
|
|
3050
|
+
submit: 100,
|
|
3051
|
+
keydown: 50,
|
|
3052
|
+
focus: 50,
|
|
3053
|
+
blur: 50
|
|
3054
|
+
},
|
|
2836
3055
|
recording.stepDelays || {}
|
|
2837
3056
|
);
|
|
2838
3057
|
const steps = recording.actions.map((action, i) => {
|
|
@@ -2857,6 +3076,15 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
2857
3076
|
} else {
|
|
2858
3077
|
step = ` await ${loc}.fill(${JSON.stringify(action.value || "")});`;
|
|
2859
3078
|
}
|
|
3079
|
+
} else if (action.type === "submit") {
|
|
3080
|
+
step = ` await ${loc}.evaluate((el) => el.requestSubmit?.() || el.submit());`;
|
|
3081
|
+
} else if (action.type === "keydown") {
|
|
3082
|
+
const key = action.key || "";
|
|
3083
|
+
step = key === "Enter" ? ` await ${loc}.press("Enter");` : key ? ` await ${loc}.press(${JSON.stringify(key)});` : ` await ${loc}.press(${JSON.stringify(action.code || "")});`;
|
|
3084
|
+
} else if (action.type === "focus") {
|
|
3085
|
+
step = ` if (await ${loc}.count() > 0) await ${loc}.focus();`;
|
|
3086
|
+
} else if (action.type === "blur") {
|
|
3087
|
+
step = ` if (await ${loc}.count() > 0) await ${loc}.blur();`;
|
|
2860
3088
|
} else {
|
|
2861
3089
|
step = ` await ${loc}.click();`;
|
|
2862
3090
|
}
|
|
@@ -2874,13 +3102,50 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
2874
3102
|
return s;
|
|
2875
3103
|
}
|
|
2876
3104
|
if (a.type === "class") {
|
|
2877
|
-
|
|
3105
|
+
const classes = (a.className || "").trim().split(/\s+/).filter(Boolean);
|
|
3106
|
+
if (classes.length > 0)
|
|
3107
|
+
return classes.map((c) => ` await expect(${aLoc}).toHaveClass(${JSON.stringify(c)});`).join("\n");
|
|
3108
|
+
return ` // class on ${a.selector} (no specific classes asserted)`;
|
|
3109
|
+
}
|
|
3110
|
+
if (a.type === "style") {
|
|
3111
|
+
const style = (a.style || "").trim();
|
|
3112
|
+
if (style) {
|
|
3113
|
+
const m = style.match(/(\w+)\s*:\s*([^;]+)/);
|
|
3114
|
+
if (m)
|
|
3115
|
+
return ` await expect(${aLoc}).toHaveCSS(${JSON.stringify(m[1])}, ${JSON.stringify(m[2].trim())});`;
|
|
3116
|
+
return ` await expect(${aLoc}).toHaveAttribute("style", ${JSON.stringify(style)});`;
|
|
3117
|
+
}
|
|
3118
|
+
return "";
|
|
3119
|
+
}
|
|
3120
|
+
if (a.type === "hidden") {
|
|
3121
|
+
return a.hidden ? ` await expect(${aLoc}).toBeHidden();` : ` await expect(${aLoc}).toBeVisible();`;
|
|
3122
|
+
}
|
|
3123
|
+
if (a.type === "disabled") {
|
|
3124
|
+
return a.disabled ? ` await expect(${aLoc}).toBeDisabled();` : ` await expect(${aLoc}).toBeEnabled();`;
|
|
3125
|
+
}
|
|
3126
|
+
if (a.type === "aria-expanded" && a.ariaExpanded != null) {
|
|
3127
|
+
return ` await expect(${aLoc}).toHaveAttribute("aria-expanded", ${JSON.stringify(String(a.ariaExpanded))});`;
|
|
3128
|
+
}
|
|
3129
|
+
if (a.type === "aria-checked" && a.ariaChecked != null) {
|
|
3130
|
+
return ` await expect(${aLoc}).toHaveAttribute("aria-checked", ${JSON.stringify(String(a.ariaChecked))});`;
|
|
2878
3131
|
}
|
|
2879
3132
|
return "";
|
|
2880
3133
|
}).filter(Boolean).join("\n");
|
|
2881
3134
|
return step + "\n" + wait + (asserts ? "\n" + asserts : "");
|
|
2882
3135
|
}).join("\n");
|
|
2883
3136
|
const hasAutoAssertions = (recording.assertions || []).length > 0;
|
|
3137
|
+
const wsEvents = recording.websocketEvents || [];
|
|
3138
|
+
const hasWsEvents = wsEvents.length > 0 && wsEvents.some((c) => c.messages?.length > 0);
|
|
3139
|
+
const wsSetup = hasWsEvents ? ` const wsCollected = [];
|
|
3140
|
+
page.on('websocket', ws => {
|
|
3141
|
+
ws.on('framereceived', ev => wsCollected.push({ dir: 'in', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));
|
|
3142
|
+
ws.on('framesent', ev => wsCollected.push({ dir: 'out', payload: typeof ev.payload === 'string' ? ev.payload : String(ev.payload) }));
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
` : "";
|
|
3146
|
+
const wsAssertions = hasWsEvents ? wsEvents.flatMap((conn) => (conn.messages || []).map((msg) => ({ dir: msg.dir, data: msg.data }))).map(
|
|
3147
|
+
(msg) => ` expect(wsCollected).toContainEqual({ dir: ${JSON.stringify(msg.dir)}, payload: ${JSON.stringify(msg.data)} });`
|
|
3148
|
+
).join("\n") + "\n\n" : "";
|
|
2884
3149
|
return `// Auto-generated by o.exportPlaywrightTest() \u2014 review and anonymize mocks before committing
|
|
2885
3150
|
// Prerequisites: npm install @playwright/test && npx playwright install chromium
|
|
2886
3151
|
// Run: npx playwright test recorded.spec.ts
|
|
@@ -2888,15 +3153,16 @@ import { test, expect } from '@playwright/test';
|
|
|
2888
3153
|
|
|
2889
3154
|
test(${JSON.stringify(testName)}, async ({ page }) => {
|
|
2890
3155
|
` + (routes ? ` // Network mocks \u2014 edit/anonymize before committing
|
|
2891
|
-
` + routes + "\n\n" : "") + ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
|
|
3156
|
+
` + routes + "\n\n" : "") + wsSetup + ` // Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
|
|
2892
3157
|
await page.goto(${JSON.stringify(baseUrl)});
|
|
2893
3158
|
|
|
2894
|
-
` + (steps ? steps + "\n\n" : "") + (
|
|
3159
|
+
` + (steps ? steps + "\n\n" : "") + (wsAssertions ? ` // WebSocket verifications
|
|
3160
|
+
` + wsAssertions : "") + (!hasAutoAssertions && !hasWsEvents ? ` // TODO: Add assertions before committing, e.g.:
|
|
2895
3161
|
// await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();
|
|
2896
3162
|
// await expect(page).toHaveURL(/\\/confirmation/);
|
|
2897
3163
|
// await expect(page.locator('[data-qa="error-banner"]')).not.toBeVisible();
|
|
2898
|
-
` : ` // Auto-generated assertions above \u2014 review for correctness before committing
|
|
2899
|
-
`) + `});
|
|
3164
|
+
` : hasAutoAssertions || hasWsEvents ? ` // Auto-generated assertions above \u2014 review for correctness before committing
|
|
3165
|
+
` : "") + `});
|
|
2900
3166
|
`;
|
|
2901
3167
|
};
|
|
2902
3168
|
o.playRecording = (recording, opts = {}) => {
|
|
@@ -2918,6 +3184,32 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
2918
3184
|
}
|
|
2919
3185
|
return origFetch(url, opts2);
|
|
2920
3186
|
};
|
|
3187
|
+
const origXHROpen = XMLHttpRequest.prototype.open;
|
|
3188
|
+
const origXHRSend = XMLHttpRequest.prototype.send;
|
|
3189
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
3190
|
+
this._oMethod = (method || "GET").toUpperCase();
|
|
3191
|
+
this._oUrl = url;
|
|
3192
|
+
return origXHROpen.apply(this, arguments);
|
|
3193
|
+
};
|
|
3194
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
3195
|
+
const xhr = this;
|
|
3196
|
+
const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
|
|
3197
|
+
const mock = allMocks[key];
|
|
3198
|
+
if (mock) {
|
|
3199
|
+
const respBody = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
|
|
3200
|
+
setTimeout(() => {
|
|
3201
|
+
xhr.readyState = 4;
|
|
3202
|
+
xhr.status = mock.status || 200;
|
|
3203
|
+
xhr.statusText = "OK";
|
|
3204
|
+
xhr.responseText = respBody;
|
|
3205
|
+
xhr.response = respBody;
|
|
3206
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
3207
|
+
xhr.dispatchEvent(new Event("load"));
|
|
3208
|
+
}, 0);
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
return origXHRSend.apply(this, arguments);
|
|
3212
|
+
};
|
|
2921
3213
|
const resolveRoot = () => {
|
|
2922
3214
|
if (rootOpt != null) {
|
|
2923
3215
|
return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
|
|
@@ -2976,6 +3268,7 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
2976
3268
|
}
|
|
2977
3269
|
}
|
|
2978
3270
|
if (!el && action.type !== "scroll") {
|
|
3271
|
+
if (action.type === "blur" || action.type === "focus") return true;
|
|
2979
3272
|
return `element not found: ${action.target}`;
|
|
2980
3273
|
}
|
|
2981
3274
|
if (action.type === "scroll") {
|
|
@@ -2984,6 +3277,22 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
2984
3277
|
if (action.value !== void 0) el.value = action.value;
|
|
2985
3278
|
if (action.checked !== void 0) el.checked = action.checked;
|
|
2986
3279
|
el.dispatchEvent(new Event(action.type, { bubbles: true }));
|
|
3280
|
+
} else if (action.type === "submit") {
|
|
3281
|
+
if (typeof el.requestSubmit === "function") el.requestSubmit();
|
|
3282
|
+
else el.submit();
|
|
3283
|
+
} else if (action.type === "keydown") {
|
|
3284
|
+
el.dispatchEvent(
|
|
3285
|
+
new KeyboardEvent("keydown", {
|
|
3286
|
+
key: action.key || "",
|
|
3287
|
+
code: action.code || "",
|
|
3288
|
+
bubbles: true,
|
|
3289
|
+
cancelable: true
|
|
3290
|
+
})
|
|
3291
|
+
);
|
|
3292
|
+
} else if (action.type === "focus") {
|
|
3293
|
+
el.focus();
|
|
3294
|
+
} else if (action.type === "blur") {
|
|
3295
|
+
el.blur();
|
|
2987
3296
|
} else {
|
|
2988
3297
|
if (action.type === "click") {
|
|
2989
3298
|
el.click();
|
|
@@ -3034,6 +3343,8 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
3034
3343
|
const onComplete = isOptions && opts.onComplete;
|
|
3035
3344
|
const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId2) => {
|
|
3036
3345
|
window.fetch = origFetch;
|
|
3346
|
+
XMLHttpRequest.prototype.open = origXHROpen;
|
|
3347
|
+
XMLHttpRequest.prototype.send = origXHRSend;
|
|
3037
3348
|
const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : void 0;
|
|
3038
3349
|
if (assertionResult?.failures?.length > 0) {
|
|
3039
3350
|
o.tRes[testId2] = false;
|