objs-core 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +561 -618
- package/objs-extension/README.md +32 -0
- package/objs-extension/background.js +110 -0
- package/objs-extension/bridge.js +193 -0
- package/objs-extension/icons/icon128.png +0 -0
- package/objs-extension/lib/objs-inject.js +5308 -0
- package/objs-extension/manifest.json +18 -0
- package/objs-extension/sidepanel.css +455 -0
- package/objs-extension/sidepanel.html +56 -0
- package/objs-extension/sidepanel.js +908 -0
- package/objs.built.js +475 -120
- package/objs.built.min.js +57 -48
- package/objs.d.ts +584 -525
- package/objs.js +593 -134
- package/package.json +71 -70
package/objs.built.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Objs-core library
|
|
3
|
-
* @version 2.
|
|
3
|
+
* @version 2.4.0
|
|
4
4
|
* @author Roman Torshin
|
|
5
5
|
* @license Apache-2.0
|
|
6
6
|
*/
|
|
@@ -712,6 +712,44 @@ const o = (query) => {
|
|
|
712
712
|
});
|
|
713
713
|
result.style(val || null);
|
|
714
714
|
}, "css");
|
|
715
|
+
result.cssMerge = returner((styles = {}) => {
|
|
716
|
+
if (styles === null) {
|
|
717
|
+
result.style(null);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
typeVerify([[styles, objectType]]);
|
|
721
|
+
const normKey = (k) => k.indexOf("-") !== -1 ? k : o.camelToKebab(k);
|
|
722
|
+
const parseStyleAttr = (s) => {
|
|
723
|
+
const out = {};
|
|
724
|
+
if (!s || typeof s !== stringType) return out;
|
|
725
|
+
const parts = s.split(";");
|
|
726
|
+
for (let p = 0; p < parts.length; p++) {
|
|
727
|
+
const part = parts[p];
|
|
728
|
+
const idx = part.indexOf(":");
|
|
729
|
+
if (idx === -1) continue;
|
|
730
|
+
const key = part.slice(0, idx).trim();
|
|
731
|
+
const val = part.slice(idx + 1).trim();
|
|
732
|
+
if (key) out[key] = val;
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
};
|
|
736
|
+
iterator(() => {
|
|
737
|
+
const el = result.els[i];
|
|
738
|
+
const merged = parseStyleAttr(el.getAttribute("style"));
|
|
739
|
+
cycleObj(styles, (style) => {
|
|
740
|
+
const k = normKey(style);
|
|
741
|
+
const v = styles[style];
|
|
742
|
+
if (v === null || v === u) delete merged[k];
|
|
743
|
+
else merged[k] = String(v).replace('"', "'");
|
|
744
|
+
});
|
|
745
|
+
let serialized = "";
|
|
746
|
+
cycleObj(merged, (k) => {
|
|
747
|
+
serialized += k + ":" + merged[k] + ";";
|
|
748
|
+
});
|
|
749
|
+
if (serialized) el.setAttribute("style", serialized);
|
|
750
|
+
else el.removeAttribute("style");
|
|
751
|
+
});
|
|
752
|
+
}, "cssMerge");
|
|
715
753
|
result.setClass = returner((cl) => {
|
|
716
754
|
typeVerify([[cl, stringType]]);
|
|
717
755
|
iterator(() => {
|
|
@@ -2248,6 +2286,7 @@ o.recorder = {
|
|
|
2248
2286
|
initialData: {},
|
|
2249
2287
|
assertions: [],
|
|
2250
2288
|
observeRoot: null,
|
|
2289
|
+
strictCapture: null,
|
|
2251
2290
|
_originalFetch: null,
|
|
2252
2291
|
_listeners: [],
|
|
2253
2292
|
_observer: null
|
|
@@ -2257,6 +2296,28 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2257
2296
|
if (o.recorder.active) {
|
|
2258
2297
|
return;
|
|
2259
2298
|
}
|
|
2299
|
+
let observeSel;
|
|
2300
|
+
let eventsOpt;
|
|
2301
|
+
let timeoutsOpt;
|
|
2302
|
+
let strictCapture = null;
|
|
2303
|
+
const isStartBag = observe != null && typeof observe === "object" && !Array.isArray(observe) && (o.C(observe, "observe") || o.C(observe, "events") || o.C(observe, "timeouts") || o.C(observe, "strictCaptureAssertions") || o.C(observe, "strictCaptureNetwork") || o.C(observe, "strictCaptureWebSocket"));
|
|
2304
|
+
if (isStartBag) {
|
|
2305
|
+
const bag = observe;
|
|
2306
|
+
observeSel = bag.observe != null ? String(bag.observe) : void 0;
|
|
2307
|
+
eventsOpt = bag.events;
|
|
2308
|
+
timeoutsOpt = bag.timeouts;
|
|
2309
|
+
if (o.C(bag, "strictCaptureAssertions") || o.C(bag, "strictCaptureNetwork") || o.C(bag, "strictCaptureWebSocket")) {
|
|
2310
|
+
strictCapture = {
|
|
2311
|
+
assertions: !!bag.strictCaptureAssertions,
|
|
2312
|
+
network: !!bag.strictCaptureNetwork,
|
|
2313
|
+
websocket: !!bag.strictCaptureWebSocket
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
} else {
|
|
2317
|
+
observeSel = typeof observe === "string" ? observe : void 0;
|
|
2318
|
+
eventsOpt = events;
|
|
2319
|
+
timeoutsOpt = timeouts;
|
|
2320
|
+
}
|
|
2260
2321
|
const defaultEvents = [
|
|
2261
2322
|
"click",
|
|
2262
2323
|
"mouseover",
|
|
@@ -2279,8 +2340,8 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2279
2340
|
focus: 50,
|
|
2280
2341
|
blur: 50
|
|
2281
2342
|
};
|
|
2282
|
-
const listenEvents =
|
|
2283
|
-
const stepDelays = Object.assign({}, defaultStepDelays,
|
|
2343
|
+
const listenEvents = eventsOpt || defaultEvents;
|
|
2344
|
+
const stepDelays = Object.assign({}, defaultStepDelays, timeoutsOpt || {});
|
|
2284
2345
|
const captureDebounce = {
|
|
2285
2346
|
scroll: 30,
|
|
2286
2347
|
mouseover: 50,
|
|
@@ -2294,7 +2355,8 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2294
2355
|
rec.mocks = {};
|
|
2295
2356
|
rec.stepDelays = stepDelays;
|
|
2296
2357
|
rec.initialData = { url: window.location.href, timestamp: Date.now() };
|
|
2297
|
-
rec.
|
|
2358
|
+
rec.strictCapture = strictCapture;
|
|
2359
|
+
rec.observeRoot = observeSel || null;
|
|
2298
2360
|
rec.assertions = [];
|
|
2299
2361
|
rec.removedElements = [];
|
|
2300
2362
|
o.inits.forEach((inst, idx) => {
|
|
@@ -2434,7 +2496,7 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2434
2496
|
}
|
|
2435
2497
|
return sel;
|
|
2436
2498
|
};
|
|
2437
|
-
const observeTarget =
|
|
2499
|
+
const observeTarget = observeSel && o.D.querySelector(observeSel) || o.D.body;
|
|
2438
2500
|
rec._observer = new MutationObserver((mutations) => {
|
|
2439
2501
|
const actionIdx = rec.actions.length - 1;
|
|
2440
2502
|
if (actionIdx < 0) return;
|
|
@@ -2455,22 +2517,28 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2455
2517
|
if (sel && observeTarget) {
|
|
2456
2518
|
const matches = observeTarget.querySelectorAll(sel);
|
|
2457
2519
|
if (matches.length > 1) {
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2520
|
+
const idxAmong = [...matches].indexOf(node);
|
|
2521
|
+
if (idxAmong !== -1) {
|
|
2522
|
+
listSelector = sel;
|
|
2523
|
+
index = idxAmong;
|
|
2524
|
+
} else {
|
|
2525
|
+
let n = node;
|
|
2526
|
+
while (n && n !== observeTarget && n.nodeType === 1) {
|
|
2527
|
+
const qaAttr = o.autotag && n.dataset && n.dataset[o.autotag];
|
|
2528
|
+
if (qaAttr) {
|
|
2529
|
+
const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
|
|
2530
|
+
const itemMatches = observeTarget.querySelectorAll(itemSel);
|
|
2531
|
+
if (itemMatches.length > 1) {
|
|
2532
|
+
const idx = [...itemMatches].indexOf(n);
|
|
2533
|
+
if (idx !== -1) {
|
|
2534
|
+
listSelector = itemSel;
|
|
2535
|
+
index = idx;
|
|
2536
|
+
break;
|
|
2537
|
+
}
|
|
2470
2538
|
}
|
|
2471
2539
|
}
|
|
2540
|
+
n = n.parentElement;
|
|
2472
2541
|
}
|
|
2473
|
-
n = n.parentElement;
|
|
2474
2542
|
}
|
|
2475
2543
|
}
|
|
2476
2544
|
}
|
|
@@ -2608,7 +2676,7 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2608
2676
|
listenEvents.forEach((ev) => {
|
|
2609
2677
|
const handler = (e) => {
|
|
2610
2678
|
const target = e.target;
|
|
2611
|
-
if (
|
|
2679
|
+
if (observeSel && observeTarget && target?.nodeType === 1 && !observeTarget.contains(target)) {
|
|
2612
2680
|
return;
|
|
2613
2681
|
}
|
|
2614
2682
|
let selector = "";
|
|
@@ -2627,22 +2695,28 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2627
2695
|
if (selector && observeTarget) {
|
|
2628
2696
|
const matches = observeTarget.querySelectorAll(selector);
|
|
2629
2697
|
if (matches.length > 1) {
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2698
|
+
const idxAmongMatches = [...matches].indexOf(target);
|
|
2699
|
+
if (idxAmongMatches !== -1) {
|
|
2700
|
+
listSelector = selector;
|
|
2701
|
+
targetIndex = idxAmongMatches;
|
|
2702
|
+
} else {
|
|
2703
|
+
let node = target;
|
|
2704
|
+
while (node && node !== observeTarget && node.nodeType === 1) {
|
|
2705
|
+
const qaAttr = o.autotag && node.dataset && node.dataset[o.autotag];
|
|
2706
|
+
if (qaAttr) {
|
|
2707
|
+
const itemSel = `[data-${o.autotag}="${qaAttr}"]`;
|
|
2708
|
+
const itemMatches = observeTarget.querySelectorAll(itemSel);
|
|
2709
|
+
if (itemMatches.length > 1) {
|
|
2710
|
+
const idx = [...itemMatches].indexOf(node);
|
|
2711
|
+
if (idx !== -1) {
|
|
2712
|
+
listSelector = itemSel;
|
|
2713
|
+
targetIndex = idx;
|
|
2714
|
+
break;
|
|
2715
|
+
}
|
|
2642
2716
|
}
|
|
2643
2717
|
}
|
|
2718
|
+
node = node.parentElement;
|
|
2644
2719
|
}
|
|
2645
|
-
node = node.parentElement;
|
|
2646
2720
|
}
|
|
2647
2721
|
}
|
|
2648
2722
|
}
|
|
@@ -2714,7 +2788,7 @@ o.stopRecording = () => {
|
|
|
2714
2788
|
rec._observer.disconnect();
|
|
2715
2789
|
rec._observer = null;
|
|
2716
2790
|
}
|
|
2717
|
-
|
|
2791
|
+
const out = {
|
|
2718
2792
|
actions: [...rec.actions],
|
|
2719
2793
|
mocks: { ...rec.mocks },
|
|
2720
2794
|
initialData: { ...rec.initialData },
|
|
@@ -2724,6 +2798,10 @@ o.stopRecording = () => {
|
|
|
2724
2798
|
observeRoot: rec.observeRoot || null,
|
|
2725
2799
|
websocketEvents: [...rec.websocketEvents || []]
|
|
2726
2800
|
};
|
|
2801
|
+
if (rec.strictCapture) {
|
|
2802
|
+
out.strictCapture = { ...rec.strictCapture };
|
|
2803
|
+
}
|
|
2804
|
+
return out;
|
|
2727
2805
|
};
|
|
2728
2806
|
o.clearRecording = (id) => {
|
|
2729
2807
|
if (id !== void 0) {
|
|
@@ -2738,6 +2816,8 @@ o.clearRecording = (id) => {
|
|
|
2738
2816
|
}
|
|
2739
2817
|
};
|
|
2740
2818
|
o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
2819
|
+
const strictAssertions = !!(opts && opts.strictAssertions);
|
|
2820
|
+
const strictRemoved = opts && opts.strictRemoved !== void 0 ? !!opts.strictRemoved : strictAssertions;
|
|
2741
2821
|
const preFiltered = opts && opts.assertions;
|
|
2742
2822
|
const assertions = preFiltered != null ? preFiltered : (recording.assertions || []).filter(
|
|
2743
2823
|
(a) => actionIdx == null || a.actionIdx === actionIdx
|
|
@@ -2772,6 +2852,7 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2772
2852
|
};
|
|
2773
2853
|
const r = resolveRoot();
|
|
2774
2854
|
const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
|
|
2855
|
+
const styleNorm = (s) => norm(String(s || "").replace(/\s*:\s*/g, ": ").replace(/\s*;\s*/g, "; "));
|
|
2775
2856
|
const getText = (el) => el ? norm(el.textContent || "") : "";
|
|
2776
2857
|
const removedElements = opts?.removedElements || [];
|
|
2777
2858
|
const isRemoved = (a) => {
|
|
@@ -2791,20 +2872,67 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2791
2872
|
const failures = [];
|
|
2792
2873
|
for (const a of deduped) {
|
|
2793
2874
|
if (isRemoved(a)) {
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2875
|
+
if (!strictRemoved) {
|
|
2876
|
+
passed += 1;
|
|
2877
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
2878
|
+
console.log("[runRecordingAssertions] skip (explicit removed):", {
|
|
2879
|
+
actionIdx: a.actionIdx,
|
|
2880
|
+
selector: a.selector,
|
|
2881
|
+
text: (a.text || "").slice(0, 40)
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
let ghost = null;
|
|
2887
|
+
const expText = norm(a.text || "");
|
|
2888
|
+
if (a.listSelector != null && a.index != null) {
|
|
2889
|
+
const items = r.querySelectorAll(a.listSelector);
|
|
2890
|
+
let item = items[a.index];
|
|
2891
|
+
if (!item && a.index > 0) item = items[a.index - 1];
|
|
2892
|
+
if (item) {
|
|
2893
|
+
ghost = a.selector !== a.listSelector ? item.querySelector(a.selector) || item : item;
|
|
2894
|
+
}
|
|
2895
|
+
if (!ghost && expText && a.type === "visible") {
|
|
2896
|
+
for (let j = 0; j < items.length; j++) {
|
|
2897
|
+
const it = items[j];
|
|
2898
|
+
const cand = a.selector !== a.listSelector ? it.querySelector(a.selector) || it : it;
|
|
2899
|
+
if (cand && getText(cand).indexOf(expText) !== -1) {
|
|
2900
|
+
ghost = cand;
|
|
2901
|
+
break;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
} else {
|
|
2906
|
+
const matches = r.querySelectorAll(a.selector);
|
|
2907
|
+
ghost = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
|
|
2908
|
+
}
|
|
2909
|
+
if (ghost && a.type === "visible") {
|
|
2910
|
+
const vis = ghost.nodeType === 1 && (ghost.offsetParent !== null || ghost.getBoundingClientRect && ghost.getBoundingClientRect().width > 0);
|
|
2911
|
+
const gtext = getText(ghost);
|
|
2912
|
+
const still = vis && (!expText || gtext.indexOf(expText) !== -1 || expText.indexOf(gtext) !== -1);
|
|
2913
|
+
if (still) {
|
|
2914
|
+
failures.push({
|
|
2915
|
+
selector: a.selector,
|
|
2916
|
+
message: "expected absent (recorded removed) but matching content still visible"
|
|
2917
|
+
});
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
} else if (ghost && a.type !== "visible") {
|
|
2921
|
+
failures.push({
|
|
2798
2922
|
selector: a.selector,
|
|
2799
|
-
|
|
2923
|
+
message: "expected absent (recorded removed) but element still present"
|
|
2800
2924
|
});
|
|
2925
|
+
continue;
|
|
2801
2926
|
}
|
|
2927
|
+
passed += 1;
|
|
2802
2928
|
continue;
|
|
2803
2929
|
}
|
|
2804
2930
|
let el = null;
|
|
2805
2931
|
let indexOutOfBounds = false;
|
|
2932
|
+
let listItemsLength = -1;
|
|
2806
2933
|
if (a.listSelector != null && a.index != null) {
|
|
2807
2934
|
const items = r.querySelectorAll(a.listSelector);
|
|
2935
|
+
listItemsLength = items.length;
|
|
2808
2936
|
const expectedText = norm(a.text || "");
|
|
2809
2937
|
const tryItem = (idx) => {
|
|
2810
2938
|
const it = items[idx];
|
|
@@ -2812,26 +2940,36 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2812
2940
|
const e = a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
|
|
2813
2941
|
return e || (a.selector !== a.listSelector ? it : null);
|
|
2814
2942
|
};
|
|
2815
|
-
let item
|
|
2816
|
-
if (
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2943
|
+
let item;
|
|
2944
|
+
if (strictAssertions) {
|
|
2945
|
+
item = items[a.index];
|
|
2946
|
+
if (item) {
|
|
2947
|
+
el = tryItem(a.index);
|
|
2948
|
+
if (!el && a.selector !== a.listSelector) el = item;
|
|
2949
|
+
}
|
|
2950
|
+
} else {
|
|
2951
|
+
item = items[a.index];
|
|
2952
|
+
if (!item && a.index > 0) item = items[a.index - 1];
|
|
2953
|
+
if (item) {
|
|
2954
|
+
el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
|
|
2955
|
+
if (!el && a.selector !== a.listSelector) el = item;
|
|
2956
|
+
if (a.type === "visible" && expectedText && el) {
|
|
2957
|
+
const actualText = getText(el);
|
|
2958
|
+
const textMismatch = actualText.indexOf(expectedText) === -1 && expectedText.indexOf(actualText) === -1;
|
|
2959
|
+
if (textMismatch) {
|
|
2960
|
+
for (let j = 0; j < items.length; j++) {
|
|
2961
|
+
const candEl = tryItem(j);
|
|
2962
|
+
if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
|
|
2963
|
+
el = candEl;
|
|
2964
|
+
item = items[j];
|
|
2965
|
+
break;
|
|
2966
|
+
}
|
|
2830
2967
|
}
|
|
2831
2968
|
}
|
|
2832
2969
|
}
|
|
2833
2970
|
}
|
|
2834
|
-
}
|
|
2971
|
+
}
|
|
2972
|
+
if (!item) {
|
|
2835
2973
|
indexOutOfBounds = true;
|
|
2836
2974
|
}
|
|
2837
2975
|
} else {
|
|
@@ -2842,12 +2980,12 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2842
2980
|
const visible = el && el.nodeType === 1 && (el.offsetParent !== null || el.getBoundingClientRect && el.getBoundingClientRect().width > 0);
|
|
2843
2981
|
const expectedText = norm(a.text || "");
|
|
2844
2982
|
const actualText = getText(el);
|
|
2845
|
-
const
|
|
2846
|
-
const textOk = !expectedText || actualText.indexOf(expectedText) !== -1 || fullActual.indexOf(expectedText) !== -1 || expectedText.length > 0 && expectedText.indexOf(actualText) !== -1;
|
|
2983
|
+
const textOk = strictAssertions ? !expectedText || actualText === expectedText : !expectedText || actualText.indexOf(expectedText) !== -1 || expectedText.length > 0 && expectedText.indexOf(actualText) !== -1;
|
|
2847
2984
|
if (visible && textOk) {
|
|
2848
2985
|
passed += 1;
|
|
2849
2986
|
} else {
|
|
2850
|
-
const
|
|
2987
|
+
const listCount = listItemsLength >= 0 ? listItemsLength : r.querySelectorAll(a.listSelector || a.selector).length;
|
|
2988
|
+
const message = indexOutOfBounds ? `index out of bounds (list has ${listCount} items, assertion expected index ${a.index})` : !el ? "element not found" : !visible ? "not visible" : !textOk ? "text mismatch" : "fail";
|
|
2851
2989
|
failures.push({ selector: a.selector, message });
|
|
2852
2990
|
if (typeof console !== "undefined" && console.warn) {
|
|
2853
2991
|
console.warn("[runRecordingAssertions] visible failed:", {
|
|
@@ -2864,10 +3002,11 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2864
3002
|
} else if (a.type === "class") {
|
|
2865
3003
|
const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
|
|
2866
3004
|
const hasClass = el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
|
|
2867
|
-
|
|
3005
|
+
const classOrderOk = !strictAssertions || !a.className || norm((el?.className || "").trim()) === norm((a.className || "").trim());
|
|
3006
|
+
if (hasClass && classOrderOk) {
|
|
2868
3007
|
passed += 1;
|
|
2869
3008
|
} else {
|
|
2870
|
-
const msg = indexOutOfBounds ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})` : !el ? "element not found" : `expected class "${a.className}"`;
|
|
3009
|
+
const msg = indexOutOfBounds ? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})` : !el ? "element not found" : hasClass && !classOrderOk ? `expected exact className "${a.className}" (strict)` : `expected class "${a.className}"`;
|
|
2871
3010
|
failures.push({ selector: a.selector, message: msg });
|
|
2872
3011
|
if (typeof console !== "undefined" && console.warn) {
|
|
2873
3012
|
console.warn("[runRecordingAssertions] failed:", {
|
|
@@ -2884,7 +3023,7 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2884
3023
|
} else if (a.type === "style") {
|
|
2885
3024
|
const expected = (a.style || "").trim();
|
|
2886
3025
|
const actual = (el?.style?.cssText || el?.getAttribute?.("style") || "").trim();
|
|
2887
|
-
const ok = el && (!expected || actual.indexOf(expected) !== -1 || expected === actual);
|
|
3026
|
+
const ok = el && (!expected || (strictAssertions ? styleNorm(actual) === styleNorm(expected) : actual.indexOf(expected) !== -1 || expected === actual));
|
|
2888
3027
|
if (ok) {
|
|
2889
3028
|
passed += 1;
|
|
2890
3029
|
} else {
|
|
@@ -2931,6 +3070,7 @@ o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
|
2931
3070
|
};
|
|
2932
3071
|
o.exportTest = (recording, options = {}) => {
|
|
2933
3072
|
const delay = options.delay !== void 0 ? options.delay : 16;
|
|
3073
|
+
const extensionExport = options.extensionExport === true;
|
|
2934
3074
|
const recordingData = {
|
|
2935
3075
|
actions: recording.actions,
|
|
2936
3076
|
assertions: recording.assertions || [],
|
|
@@ -2995,13 +3135,24 @@ o.exportTest = (recording, options = {}) => {
|
|
|
2995
3135
|
}
|
|
2996
3136
|
}
|
|
2997
3137
|
const mocksStr = Object.keys(recording.mocks || {}).length ? JSON.stringify(recording.mocks, null, 2) : "{}";
|
|
2998
|
-
|
|
3138
|
+
const header = `// Auto-generated by o.exportTest() \u2014 review and anonymize mocks before committing
|
|
2999
3139
|
const recordingMocks = ${mocksStr};
|
|
3000
3140
|
const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };
|
|
3001
3141
|
|
|
3002
|
-
|
|
3142
|
+
`;
|
|
3143
|
+
const manualLine = ` // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],`;
|
|
3144
|
+
if (extensionExport) {
|
|
3145
|
+
return header + `const __objsExtensionTestRun = o.test('Recorded test',
|
|
3146
|
+
${steps.join(",\n")},
|
|
3147
|
+
${manualLine}
|
|
3148
|
+
{ sync: true }, () => {
|
|
3149
|
+
// teardown
|
|
3150
|
+
});
|
|
3151
|
+
`;
|
|
3152
|
+
}
|
|
3153
|
+
return header + `o.addTest('Recorded test', [
|
|
3003
3154
|
${steps.join(",\n")}
|
|
3004
|
-
|
|
3155
|
+
${manualLine}
|
|
3005
3156
|
], () => {
|
|
3006
3157
|
// teardown
|
|
3007
3158
|
});
|
|
@@ -3166,50 +3317,227 @@ test(${JSON.stringify(testName)}, async ({ page }) => {
|
|
|
3166
3317
|
`;
|
|
3167
3318
|
};
|
|
3168
3319
|
o.playRecording = (recording, opts = {}) => {
|
|
3169
|
-
const isOptions = opts && typeof opts === "object" && (opts.runAssertions !== void 0 || opts.root !== void 0 || opts.manualChecks !== void 0 || opts.actionDelay !== void 0);
|
|
3320
|
+
const isOptions = opts && typeof opts === "object" && (opts.runAssertions !== void 0 || opts.root !== void 0 || opts.manualChecks !== void 0 || opts.actionDelay !== void 0 || opts.skipWebSocketMock !== void 0 || opts.skipNetworkMocks !== void 0 || opts.recordingAssertionDebug !== void 0 || opts.strictPlay !== void 0 || opts.strictAssertions !== void 0 || opts.strictNetwork !== void 0 || opts.strictWebSocket !== void 0 || opts.strictRemoved !== void 0 || opts.onComplete !== void 0);
|
|
3170
3321
|
const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
|
|
3171
3322
|
const runAssertions = isOptions && opts.runAssertions;
|
|
3172
3323
|
const rootOpt = isOptions ? opts.root : void 0;
|
|
3173
3324
|
const manualChecks = isOptions && opts.manualChecks || [];
|
|
3174
3325
|
const actionDelay = isOptions && opts.actionDelay !== void 0 ? opts.actionDelay : 16;
|
|
3175
|
-
const
|
|
3176
|
-
const
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3326
|
+
const skipWebSocketMock = isOptions && opts.skipWebSocketMock;
|
|
3327
|
+
const skipNetworkMocks = isOptions && opts.skipNetworkMocks;
|
|
3328
|
+
if (isOptions && opts.recordingAssertionDebug !== void 0) {
|
|
3329
|
+
o.recordingAssertionDebug = !!opts.recordingAssertionDebug;
|
|
3330
|
+
}
|
|
3331
|
+
const sc = recording.strictCapture || {};
|
|
3332
|
+
const strictPlay = isOptions && opts.strictPlay === true;
|
|
3333
|
+
const strictAssertions = isOptions && opts.strictAssertions !== void 0 ? !!opts.strictAssertions : strictPlay ? true : !!sc.assertions;
|
|
3334
|
+
const strictNetwork = isOptions && opts.strictNetwork !== void 0 ? !!opts.strictNetwork : strictPlay ? true : !!sc.network;
|
|
3335
|
+
const strictWebSocket = isOptions && opts.strictWebSocket !== void 0 ? !!opts.strictWebSocket : strictPlay ? true : !!sc.websocket;
|
|
3336
|
+
const strictRemoved = isOptions && opts.strictRemoved !== void 0 ? !!opts.strictRemoved : strictAssertions;
|
|
3337
|
+
const parseBodyLikeRecorder = (body) => {
|
|
3338
|
+
if (body == null || body === "") return void 0;
|
|
3339
|
+
if (typeof body === "string") {
|
|
3340
|
+
try {
|
|
3341
|
+
return JSON.parse(body);
|
|
3342
|
+
} catch (_e) {
|
|
3343
|
+
return body;
|
|
3344
|
+
}
|
|
3184
3345
|
}
|
|
3185
|
-
return
|
|
3346
|
+
return body;
|
|
3347
|
+
};
|
|
3348
|
+
const mockRequestMatchesLive = (recordedReq, liveBody) => {
|
|
3349
|
+
const live = parseBodyLikeRecorder(liveBody);
|
|
3350
|
+
if (recordedReq === live) return true;
|
|
3351
|
+
if (recordedReq == null && live == null) return true;
|
|
3352
|
+
if (recordedReq == null || live == null) return false;
|
|
3353
|
+
if (typeof recordedReq === "object" && typeof live === "object")
|
|
3354
|
+
return JSON.stringify(recordedReq) === JSON.stringify(live);
|
|
3355
|
+
return String(recordedReq) === String(live);
|
|
3186
3356
|
};
|
|
3357
|
+
const normWsData = (s) => String(s || "").trim().replace(/\s+/g, " ");
|
|
3358
|
+
const allMocks = Object.assign({}, recording.mocks, mockOverrides);
|
|
3359
|
+
const origFetch = window.fetch;
|
|
3187
3360
|
const origXHROpen = XMLHttpRequest.prototype.open;
|
|
3188
3361
|
const origXHRSend = XMLHttpRequest.prototype.send;
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3362
|
+
if (!skipNetworkMocks) {
|
|
3363
|
+
window.fetch = (url, fetchOpts = {}) => {
|
|
3364
|
+
const method = (fetchOpts.method || "GET").toUpperCase();
|
|
3365
|
+
const key = method + ":" + url;
|
|
3366
|
+
if (allMocks[key]) {
|
|
3367
|
+
const mock = allMocks[key];
|
|
3368
|
+
if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, fetchOpts.body)) {
|
|
3369
|
+
return Promise.reject(
|
|
3370
|
+
new Error(
|
|
3371
|
+
"[Objs playRecording] strictNetwork: request body does not match recording for " + key
|
|
3372
|
+
)
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
const body = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
|
|
3376
|
+
return Promise.resolve(new Response(body, { status: mock.status || 200 }));
|
|
3377
|
+
}
|
|
3378
|
+
return origFetch(url, fetchOpts);
|
|
3379
|
+
};
|
|
3380
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
3381
|
+
this._oMethod = (method || "GET").toUpperCase();
|
|
3382
|
+
this._oUrl = url;
|
|
3383
|
+
return origXHROpen.apply(this, arguments);
|
|
3384
|
+
};
|
|
3385
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
3386
|
+
const xhr = this;
|
|
3387
|
+
const key = (xhr._oMethod || "GET") + ":" + (xhr._oUrl || "");
|
|
3388
|
+
const mock = allMocks[key];
|
|
3389
|
+
if (mock) {
|
|
3390
|
+
if (strictNetwork && o.C(mock, "request") && !mockRequestMatchesLive(mock.request, body)) {
|
|
3391
|
+
setTimeout(() => {
|
|
3392
|
+
xhr.readyState = 4;
|
|
3393
|
+
xhr.status = 0;
|
|
3394
|
+
xhr.statusText = "Objs strictNetwork mismatch";
|
|
3395
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
3396
|
+
xhr.dispatchEvent(new Event("error"));
|
|
3397
|
+
}, 0);
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
const respBody = typeof mock.response === "string" ? mock.response : JSON.stringify(mock.response);
|
|
3401
|
+
setTimeout(() => {
|
|
3402
|
+
xhr.readyState = 4;
|
|
3403
|
+
xhr.status = mock.status || 200;
|
|
3404
|
+
xhr.statusText = "OK";
|
|
3405
|
+
xhr.responseText = respBody;
|
|
3406
|
+
xhr.response = respBody;
|
|
3407
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
3408
|
+
xhr.dispatchEvent(new Event("load"));
|
|
3409
|
+
}, 0);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
return origXHRSend.apply(this, arguments);
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
let origWebSocket = null;
|
|
3416
|
+
const wsEvents = recording.websocketEvents || [];
|
|
3417
|
+
const useWsMock = !skipWebSocketMock && wsEvents.length > 0 && wsEvents.some((e) => e.messages && e.messages.length > 0);
|
|
3418
|
+
if (useWsMock && typeof window.WebSocket === "function") {
|
|
3419
|
+
origWebSocket = window.WebSocket;
|
|
3420
|
+
let wsConsumeIdx = 0;
|
|
3421
|
+
const normalizeWsUrl = (u) => {
|
|
3422
|
+
const s = typeof u === "string" ? u : String(u);
|
|
3423
|
+
try {
|
|
3424
|
+
return new URL(s, window.location.href).href;
|
|
3425
|
+
} catch (_e) {
|
|
3426
|
+
return s;
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
const takeNextRecorded = (urlStr) => {
|
|
3430
|
+
const norm = normalizeWsUrl(urlStr);
|
|
3431
|
+
for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
|
|
3432
|
+
if (normalizeWsUrl(wsEvents[i].url) === norm) {
|
|
3433
|
+
wsConsumeIdx = i + 1;
|
|
3434
|
+
return wsEvents[i];
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
for (let i = wsConsumeIdx; i < wsEvents.length; i++) {
|
|
3438
|
+
if (String(wsEvents[i].url) === String(urlStr)) {
|
|
3439
|
+
wsConsumeIdx = i + 1;
|
|
3440
|
+
return wsEvents[i];
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return null;
|
|
3444
|
+
};
|
|
3445
|
+
const C = origWebSocket;
|
|
3446
|
+
class O_MockWebSocket extends EventTarget {
|
|
3447
|
+
constructor(url, protocols, recorded) {
|
|
3448
|
+
super();
|
|
3449
|
+
const urlStr = typeof url === "string" ? url : String(url);
|
|
3450
|
+
this.url = urlStr;
|
|
3451
|
+
this.readyState = C.CONNECTING;
|
|
3452
|
+
const p = protocols;
|
|
3453
|
+
this.protocol = Array.isArray(p) ? p[0] || "" : p ? String(p) : "";
|
|
3454
|
+
this.extensions = "";
|
|
3455
|
+
this.binaryType = "blob";
|
|
3456
|
+
this._messages = (recorded.messages || []).slice();
|
|
3457
|
+
this._pos = 0;
|
|
3458
|
+
const self = this;
|
|
3459
|
+
setTimeout(() => {
|
|
3460
|
+
if (self.readyState === C.CLOSED) return;
|
|
3461
|
+
self.readyState = C.OPEN;
|
|
3462
|
+
self._dispatchOpen();
|
|
3463
|
+
self._drainInbound();
|
|
3464
|
+
}, 0);
|
|
3465
|
+
}
|
|
3466
|
+
_dispatchOpen() {
|
|
3467
|
+
const ev = new Event("open");
|
|
3468
|
+
this.dispatchEvent(ev);
|
|
3469
|
+
if (typeof this.onopen === "function") this.onopen(ev);
|
|
3470
|
+
}
|
|
3471
|
+
_dispatchMessage(data) {
|
|
3472
|
+
const ev = new MessageEvent("message", { data });
|
|
3473
|
+
this.dispatchEvent(ev);
|
|
3474
|
+
if (typeof this.onmessage === "function") this.onmessage(ev);
|
|
3475
|
+
}
|
|
3476
|
+
_drainInbound() {
|
|
3477
|
+
while (this._pos < this._messages.length && this._messages[this._pos].dir === "in") {
|
|
3478
|
+
const m = this._messages[this._pos++];
|
|
3479
|
+
this._dispatchMessage(m.data);
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
send(data) {
|
|
3483
|
+
if (this.readyState !== C.OPEN) {
|
|
3484
|
+
const err = typeof DOMException !== "undefined" ? new DOMException("Still in CONNECTING state.", "InvalidStateError") : new Error("InvalidStateError");
|
|
3485
|
+
throw err;
|
|
3486
|
+
}
|
|
3487
|
+
if (this._pos >= this._messages.length) {
|
|
3488
|
+
if (strictWebSocket) {
|
|
3489
|
+
throw new Error(
|
|
3490
|
+
"[Objs playRecording] strictWebSocket: unexpected send() after recorded frames exhausted"
|
|
3491
|
+
);
|
|
3492
|
+
}
|
|
3493
|
+
this._drainInbound();
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
const next = this._messages[this._pos];
|
|
3497
|
+
if (next.dir === "out") {
|
|
3498
|
+
if (strictWebSocket) {
|
|
3499
|
+
const got = typeof data === "string" ? data : String(data);
|
|
3500
|
+
const exp = String(next.data != null ? next.data : "");
|
|
3501
|
+
if (normWsData(got) !== normWsData(exp)) {
|
|
3502
|
+
throw new Error(
|
|
3503
|
+
"[Objs playRecording] strictWebSocket: outbound frame mismatch"
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
this._pos++;
|
|
3508
|
+
}
|
|
3509
|
+
this._drainInbound();
|
|
3510
|
+
}
|
|
3511
|
+
close(code, reason) {
|
|
3512
|
+
if (this.readyState === C.CLOSING || this.readyState === C.CLOSED) return;
|
|
3513
|
+
this.readyState = C.CLOSING;
|
|
3514
|
+
const self = this;
|
|
3515
|
+
setTimeout(() => {
|
|
3516
|
+
self.readyState = C.CLOSED;
|
|
3517
|
+
const ev = typeof CloseEvent !== "undefined" ? new CloseEvent("close", {
|
|
3518
|
+
code: code !== void 0 ? code : 1e3,
|
|
3519
|
+
reason: reason !== void 0 ? String(reason) : "",
|
|
3520
|
+
wasClean: true
|
|
3521
|
+
}) : new Event("close");
|
|
3522
|
+
self.dispatchEvent(ev);
|
|
3523
|
+
if (typeof self.onclose === "function") self.onclose(ev);
|
|
3524
|
+
}, 0);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
const MockWebSocketCtor = function MockWebSocketCtor2(url, protocols) {
|
|
3528
|
+
const urlStr = typeof url === "string" ? url : String(url);
|
|
3529
|
+
const rec = takeNextRecorded(urlStr);
|
|
3530
|
+
if (!rec || !rec.messages || rec.messages.length === 0) {
|
|
3531
|
+
return new origWebSocket(url, protocols);
|
|
3532
|
+
}
|
|
3533
|
+
return new O_MockWebSocket(url, protocols, rec);
|
|
3534
|
+
};
|
|
3535
|
+
MockWebSocketCtor.CONNECTING = C.CONNECTING;
|
|
3536
|
+
MockWebSocketCtor.OPEN = C.OPEN;
|
|
3537
|
+
MockWebSocketCtor.CLOSING = C.CLOSING;
|
|
3538
|
+
MockWebSocketCtor.CLOSED = C.CLOSED;
|
|
3539
|
+
window.WebSocket = MockWebSocketCtor;
|
|
3540
|
+
}
|
|
3213
3541
|
const resolveRoot = () => {
|
|
3214
3542
|
if (rootOpt != null) {
|
|
3215
3543
|
return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
|
|
@@ -3314,7 +3642,9 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
3314
3642
|
const run = () => {
|
|
3315
3643
|
const r = o.runRecordingAssertions(recording, rootEl, i, {
|
|
3316
3644
|
assertions: asserted,
|
|
3317
|
-
removedElements: recording.removedElements
|
|
3645
|
+
removedElements: recording.removedElements,
|
|
3646
|
+
strictAssertions,
|
|
3647
|
+
strictRemoved
|
|
3318
3648
|
});
|
|
3319
3649
|
assertionAccum.passed += r.passed;
|
|
3320
3650
|
assertionAccum.total += r.total;
|
|
@@ -3345,6 +3675,7 @@ o.playRecording = (recording, opts = {}) => {
|
|
|
3345
3675
|
window.fetch = origFetch;
|
|
3346
3676
|
XMLHttpRequest.prototype.open = origXHROpen;
|
|
3347
3677
|
XMLHttpRequest.prototype.send = origXHRSend;
|
|
3678
|
+
if (origWebSocket) window.WebSocket = origWebSocket;
|
|
3348
3679
|
const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : void 0;
|
|
3349
3680
|
if (assertionResult?.failures?.length > 0) {
|
|
3350
3681
|
o.tRes[testId2] = false;
|
|
@@ -3362,34 +3693,54 @@ o.testOverlay = () => {
|
|
|
3362
3693
|
if (o("#" + btnId).el) {
|
|
3363
3694
|
return;
|
|
3364
3695
|
}
|
|
3696
|
+
const scrollId = "o-test-overlay-scroll";
|
|
3697
|
+
const exportBtnId = "o-test-export-objs";
|
|
3698
|
+
const copyBtnId = "o-test-copy-txt";
|
|
3699
|
+
const btnBarStyle = "padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;";
|
|
3700
|
+
const buildListPlainText = () => o.tLog.map((log, i) => (log != null && log !== "" ? String(log) : "Test #" + i) + (o.tRes[i] ? " \u2713" : " \u2717")).join("\n\n");
|
|
3365
3701
|
const updatePanel = () => {
|
|
3366
|
-
const
|
|
3367
|
-
if (!
|
|
3368
|
-
|
|
3369
|
-
const passed = o.tRes.filter(Boolean).length;
|
|
3370
|
-
let html = `<b>Tests: ${passed}/${total}</b><hr style="margin:4px 0">`;
|
|
3702
|
+
const scroll = o("#" + scrollId);
|
|
3703
|
+
if (!scroll.el) return;
|
|
3704
|
+
let html = "";
|
|
3371
3705
|
o.tLog.forEach((log, i) => {
|
|
3372
3706
|
const ok = o.tRes[i];
|
|
3373
|
-
html += `<div style="margin:2px 0;padding:2px 4px;border-radius:3px;background:${ok ? "#
|
|
3374
|
-
});
|
|
3375
|
-
html += `<button id="o-test-export" style="margin-top:6px;padding:4px 8px;font-size:11px;cursor:pointer">Export results</button>`;
|
|
3376
|
-
panel.html(html);
|
|
3377
|
-
o("#o-test-export").on("click", () => {
|
|
3378
|
-
const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
|
|
3379
|
-
const blob = new Blob([data], { type: "application/json" });
|
|
3380
|
-
const a = o.D.createElement("a");
|
|
3381
|
-
a.href = URL.createObjectURL(blob);
|
|
3382
|
-
a.download = "objs-test-results.json";
|
|
3383
|
-
a.click();
|
|
3707
|
+
html += `<div style="margin:2px 0;padding:2px 4px;border-radius:3px;background:${ok ? "#14532d" : "#450a0a"};color:${ok ? "#86efac" : "#fca5a5"};font-size:11px;white-space:pre-wrap">${log || "Test #" + i}</div>`;
|
|
3384
3708
|
});
|
|
3709
|
+
scroll.html(html);
|
|
3385
3710
|
};
|
|
3386
|
-
const innerHTML = `<div style="display:flex;align-items:center;gap:12px;"><span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span><button type="button" class="o-test-overlay-toggle" style="
|
|
3711
|
+
const innerHTML = `<div class="o-test-overlay-root" style="display:flex;flex-direction:column;gap:4px;max-height:min(88vh,560px);overflow:hidden;"><div style="display:flex;align-items:center;gap:12px;flex-shrink:0;"><span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span><button type="button" class="o-test-overlay-toggle" style="${btnBarStyle}">List</button><button type="button" class="o-test-overlay-close" style="padding:4px 8px;background:transparent;color:#94a3b8;border:none;border-radius:4px;cursor:pointer;font-size:16px;line-height:1;" title="Close">\xD7</button></div><div id="${panelId}" style="display:none;flex-direction:column;margin-top:4px;max-height:min(52vh,420px);background:#0a0f1e;border:1px solid #1e293b;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.35);overflow:hidden;"><div id="${scrollId}" style="box-sizing:border-box;height:min(48vh,380px);overflow-y:scroll;padding:8px;font-size:11px;user-select:text;cursor:text;"></div><div id="o-test-overlay-footer" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px;border-top:1px solid #1e293b;background:#0f172a;flex-shrink:0;"><button type="button" id="${exportBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Export (objs)</button><button type="button" id="${copyBtnId}" class="o-test-overlay-export-btn" style="${btnBarStyle}">Copy (txt)</button></div></div></div>`;
|
|
3387
3712
|
const box = o.overlay({
|
|
3388
3713
|
innerHTML,
|
|
3389
3714
|
removeExisting: false,
|
|
3390
3715
|
className: "o-test-overlay",
|
|
3391
3716
|
id: btnId,
|
|
3392
|
-
excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId
|
|
3717
|
+
excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId + ", #" + scrollId + ", #o-test-overlay-footer, .o-test-overlay-export-btn"
|
|
3718
|
+
});
|
|
3719
|
+
o("#" + exportBtnId).on("click", () => {
|
|
3720
|
+
const data = JSON.stringify({ results: o.tRes, logs: o.tLog }, null, 2);
|
|
3721
|
+
const blob = new Blob([data], { type: "application/json" });
|
|
3722
|
+
const a = o.D.createElement("a");
|
|
3723
|
+
a.href = URL.createObjectURL(blob);
|
|
3724
|
+
a.download = "objs-test-results.json";
|
|
3725
|
+
a.click();
|
|
3726
|
+
});
|
|
3727
|
+
o("#" + copyBtnId).on("click", () => {
|
|
3728
|
+
const text = buildListPlainText();
|
|
3729
|
+
const write = () => {
|
|
3730
|
+
const ta = o.D.createElement("textarea");
|
|
3731
|
+
ta.value = text;
|
|
3732
|
+
ta.setAttribute("readonly", "");
|
|
3733
|
+
ta.style.cssText = "position:fixed;left:-9999px;top:0";
|
|
3734
|
+
o.D.body.appendChild(ta);
|
|
3735
|
+
ta.select();
|
|
3736
|
+
o.D.execCommand("copy");
|
|
3737
|
+
ta.remove();
|
|
3738
|
+
};
|
|
3739
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
|
3740
|
+
navigator.clipboard.writeText(text).catch(write);
|
|
3741
|
+
} else {
|
|
3742
|
+
write();
|
|
3743
|
+
}
|
|
3393
3744
|
});
|
|
3394
3745
|
const refreshSummary = () => {
|
|
3395
3746
|
const summary = o(".o-test-overlay-summary");
|
|
@@ -3400,8 +3751,12 @@ o.testOverlay = () => {
|
|
|
3400
3751
|
const panel = o("#" + panelId);
|
|
3401
3752
|
if (!panel.el) return;
|
|
3402
3753
|
const isOpen = panel.el.style.display !== "none";
|
|
3403
|
-
|
|
3404
|
-
|
|
3754
|
+
if (isOpen) {
|
|
3755
|
+
panel.el.style.display = "none";
|
|
3756
|
+
} else {
|
|
3757
|
+
panel.el.style.display = "flex";
|
|
3758
|
+
updatePanel();
|
|
3759
|
+
}
|
|
3405
3760
|
});
|
|
3406
3761
|
box.first(".o-test-overlay-close").on("click", () => {
|
|
3407
3762
|
box._overlayCleanup();
|
|
@@ -3409,7 +3764,7 @@ o.testOverlay = () => {
|
|
|
3409
3764
|
o.testOverlay.showPanel = () => {
|
|
3410
3765
|
const panel = o("#" + panelId);
|
|
3411
3766
|
if (!panel.el) return;
|
|
3412
|
-
panel.
|
|
3767
|
+
panel.el.style.display = "flex";
|
|
3413
3768
|
updatePanel();
|
|
3414
3769
|
refreshSummary();
|
|
3415
3770
|
};
|