objs-core 2.2.0 → 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 CHANGED
@@ -25,8 +25,20 @@
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
- - **`<div>${objInstance}</div>`**ObjsInstance 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).
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: &#96;&lt;div&gt;${child}&lt;/div&gt;&#96; 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.
31
43
  - **o.test(title, ..., { confirmOnFailure, confirmOnFailureTimeout })** — If a step fails, show overlay "Continue?" / "Stop" instead of aborting. Use `confirmOnFailure: true` and optionally `confirmOnFailureTimeout` (ms).
32
44
  - **o.test(title, ..., { sync: true })** — Run steps synchronously (one after another) instead of async; useful for playRecording.
@@ -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 = waits ? "\u251C " : "\u2558 ";
1882
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
1883
- log(row, done + waits !== num);
1884
- if (!waits) {
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 + waits !== num ? "red" : "green") + ';"><b>DONE ' + done + "/" + num + (waits ? ", waiting: " + waits : "") + "</b>" + o.tDc + o.tDc;
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 (!waits && typeof o.tFns[testN2] === "function") {
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 = ["click", "mouseover", "scroll", "input", "change"];
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 = { scroll: 30, mouseover: 50 };
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 === "class"
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 a = {
2460
- actionIdx,
2461
- type: "class",
2462
- selector: sel,
2463
- className: m.target.className
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] +class assertion:", {
2581
+ console.log("[recording] +attr assertion:", {
2470
2582
  actionIdx,
2471
2583
  lastAction: lastAction?.type + " " + lastAction?.target,
2472
2584
  selector: sel,
2473
- className: m.target.className,
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 delay = ev === "click" || ev === "change" ? 0 : stepDelays[ev] !== void 0 ? stepDelays[ev] : captureDebounce[ev] ?? 0;
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
- const urlPath = mock.url.startsWith("/") ? mock.url : "/" + mock.url;
2828
- const body = JSON.stringify(mock.response);
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(${body}) });
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
- { click: 100, mouseover: 50, scroll: 30, input: 50, change: 50 },
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
- return ` // class on ${a.selector} changed to: "${a.className}"`;
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" : "") + (!hasAutoAssertions ? ` // TODO: Add assertions before committing, e.g.:
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;