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/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 = waits ? "├ " : "╘ ";
2761
- row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
2762
- log(row, done + waits !== num);
2763
- if (!waits) {
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 + waits !== num ? "red" : "green") +
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 (!waits && typeof o.tFns[testN] === "function") {
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 = ["click", "mouseover", "scroll", "input", "change"];
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 = { scroll: 30, mouseover: 50 };
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 === "class",
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 a = {
3474
- actionIdx,
3475
- type: "class",
3476
- selector: sel,
3477
- className: m.target.className,
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] +class assertion:", {
3609
+ console.log("[recording] +attr assertion:", {
3484
3610
  actionIdx,
3485
3611
  lastAction: lastAction?.type + " " + lastAction?.target,
3486
3612
  selector: sel,
3487
- className: m.target.className,
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, "\\'")}';\n` +
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
- const urlPath = mock.url.startsWith("/") ? mock.url : "/" + mock.url;
3937
- const body = JSON.stringify(mock.response);
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(${body}) });\n` +
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
- { click: 100, mouseover: 50, scroll: 30, input: 50, change: 50 },
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
- return ` // class on ${a.selector} changed to: "${a.className}"`;
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
- (!hasAutoAssertions
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
- : ` // Auto-generated assertions above — review for correctness before committing\n`) +
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.2.1",
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/",