objs-core 2.0.3 → 2.2.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/EXAMPLES.md +59 -58
- package/README.md +70 -27
- package/SKILL.md +53 -4
- package/objs.built.js +820 -223
- package/objs.built.min.js +47 -37
- package/objs.d.ts +58 -6
- package/objs.js +987 -305
- package/package.json +1 -1
package/objs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Objs-core library
|
|
3
|
-
* @version 2.
|
|
3
|
+
* @version 2.2
|
|
4
4
|
* @author Roman Torshin
|
|
5
5
|
* @license Apache-2.0
|
|
6
6
|
*/
|
|
@@ -14,7 +14,7 @@ const __DEV__ = true;
|
|
|
14
14
|
* @param {any} query - Selector, DOM element to use, an array of elements, inited ID or nothing for creating an element
|
|
15
15
|
* @returns {Object} Objs instance with DOM manipulation methods
|
|
16
16
|
*/
|
|
17
|
-
const o = (query) => {
|
|
17
|
+
const o = (query) => {
|
|
18
18
|
let result = {
|
|
19
19
|
els: [],
|
|
20
20
|
ie: {},
|
|
@@ -22,6 +22,7 @@ const o = (query) => {
|
|
|
22
22
|
parented: {},
|
|
23
23
|
store: {},
|
|
24
24
|
refs: {},
|
|
25
|
+
_refsByIndex: [],
|
|
25
26
|
states: [],
|
|
26
27
|
isDebug: false,
|
|
27
28
|
currentState: "",
|
|
@@ -133,10 +134,49 @@ const o = (query) => {
|
|
|
133
134
|
result.states = [];
|
|
134
135
|
result.ie = {};
|
|
135
136
|
}
|
|
137
|
+
if (Array.isArray(result._refsByIndex)) {
|
|
138
|
+
const currentLen = result._refsByIndex.length;
|
|
139
|
+
if (currentLen > ln) {
|
|
140
|
+
cycleObj(result._refsByIndex, (k) => {
|
|
141
|
+
const idx = +k;
|
|
142
|
+
if (idx >= ln) {
|
|
143
|
+
delete result._refsByIndex[idx];
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
result._refsByIndex.length = ln;
|
|
147
|
+
} else if (currentLen < ln) {
|
|
148
|
+
for (let idx = currentLen; idx < ln; idx++) {
|
|
149
|
+
result._refsByIndex[idx] = {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
136
153
|
};
|
|
137
154
|
// sets new objects to operate
|
|
138
155
|
result.reset = o;
|
|
139
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Auto-hydrate: after innerHTML is set, bind inited instances (e.g. those
|
|
159
|
+
* created and stored in the parent render) to the DOM nodes that came from
|
|
160
|
+
* the container's HTML, so the parent can control them via store/refs.
|
|
161
|
+
* Scopes by container so the elements from HTML are the Objs instances.
|
|
162
|
+
*/
|
|
163
|
+
const hydrateDataOInitIn = (containerEl) => {
|
|
164
|
+
if (ssr || !containerEl.querySelectorAll) return;
|
|
165
|
+
const nodes = containerEl.querySelectorAll("[data-o-init]");
|
|
166
|
+
const byId = {};
|
|
167
|
+
nodes.forEach((node) => {
|
|
168
|
+
const id = node.getAttribute("data-o-init");
|
|
169
|
+
if (id === null) return;
|
|
170
|
+
if (!byId[id]) byId[id] = [];
|
|
171
|
+
byId[id].push(node);
|
|
172
|
+
});
|
|
173
|
+
cycleObj(byId, (id) => {
|
|
174
|
+
const inst = o.inits[id];
|
|
175
|
+
if (!inst) return;
|
|
176
|
+
inst.getSSR(Number(id), byId[id]);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
140
180
|
/**
|
|
141
181
|
* Transform DOM elements based on state and props
|
|
142
182
|
* @param {Element} el - DOM element to transform
|
|
@@ -186,7 +226,7 @@ const o = (query) => {
|
|
|
186
226
|
) {
|
|
187
227
|
// insert html
|
|
188
228
|
["html", "innerHTML"].includes(s)
|
|
189
|
-
? (el.innerHTML = value)
|
|
229
|
+
? (el.innerHTML = value, !ssr && hydrateDataOInitIn(el))
|
|
190
230
|
: // className alias
|
|
191
231
|
s === "className"
|
|
192
232
|
? el.setAttribute("class", value)
|
|
@@ -326,23 +366,23 @@ const o = (query) => {
|
|
|
326
366
|
|
|
327
367
|
// creation elements for prop in props
|
|
328
368
|
const newEl = (n, prop = {}) => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const newElem = D.createElement("div");
|
|
333
|
-
newElem.innerHTML = type(data) === functionType ? data(prop) : data;
|
|
334
|
-
if (newElem.children.length > ONE || !newElem.firstElementChild) {
|
|
335
|
-
newElem.dataset.oInit = n;
|
|
336
|
-
return newElem;
|
|
337
|
-
} else {
|
|
338
|
-
newElem.firstElementChild.dataset.oInit = n;
|
|
339
|
-
return newElem.firstElementChild;
|
|
340
|
-
}
|
|
369
|
+
const resolved = type(data) === functionType ? data(prop) : data;
|
|
370
|
+
if (type(resolved) === objectType) {
|
|
371
|
+
return D.createElement(resolved.tag || resolved.tagName || "div");
|
|
341
372
|
}
|
|
373
|
+
const newElem = D.createElement("div");
|
|
374
|
+
newElem.innerHTML = resolved;
|
|
375
|
+
if (newElem.children.length > ONE || !newElem.firstElementChild) {
|
|
376
|
+
newElem.dataset.oInit = n;
|
|
377
|
+
return newElem;
|
|
378
|
+
}
|
|
379
|
+
newElem.firstElementChild.dataset.oInit = n;
|
|
380
|
+
return newElem.firstElementChild;
|
|
342
381
|
};
|
|
343
382
|
|
|
344
383
|
// properties creation
|
|
345
384
|
const rawData = props; // raw argument before array-wrapping
|
|
385
|
+
if (!Array.isArray(props)) props = [props];
|
|
346
386
|
!props.length ? (props = [props]) : props;
|
|
347
387
|
|
|
348
388
|
// creating elements if no one was selected
|
|
@@ -383,19 +423,45 @@ const o = (query) => {
|
|
|
383
423
|
if (creation) {
|
|
384
424
|
buff["data-o-init"] = initN;
|
|
385
425
|
buff["data-o-init-i"] = i;
|
|
426
|
+
if (buff.events) {
|
|
427
|
+
result._hydrateEvents = result._hydrateEvents || [];
|
|
428
|
+
result._hydrateEvents[i] = buff.events;
|
|
429
|
+
}
|
|
386
430
|
}
|
|
387
431
|
transform(el, buff, props[j ? i : 0]);
|
|
388
432
|
}
|
|
389
433
|
});
|
|
390
434
|
if (creation) {
|
|
435
|
+
result._refsByIndex = [];
|
|
391
436
|
result.refs = {};
|
|
392
|
-
result.els.forEach((el) => {
|
|
437
|
+
result.els.forEach((el, idx) => {
|
|
393
438
|
if (!el.querySelectorAll) return;
|
|
439
|
+
const refsForEl = {};
|
|
394
440
|
el.querySelectorAll("[ref]").forEach((refEl) => {
|
|
395
|
-
|
|
396
|
-
refEl
|
|
441
|
+
const refName = refEl.getAttribute("ref");
|
|
442
|
+
const refInstance = o(refEl);
|
|
443
|
+
refsForEl[refName] = refInstance;
|
|
444
|
+
if (idx === 0) result.refs[refName] = refInstance;
|
|
397
445
|
});
|
|
446
|
+
result._refsByIndex[idx] = refsForEl;
|
|
398
447
|
});
|
|
448
|
+
if (!ssr && result._hydrateEvents) {
|
|
449
|
+
result._hydrateEvents.forEach((evts, idx) => {
|
|
450
|
+
if (!evts) return;
|
|
451
|
+
result.select(idx);
|
|
452
|
+
cycleObj(evts, (event) => {
|
|
453
|
+
const spec = evts[event];
|
|
454
|
+
if (type(spec) === objectType && spec.targetRef && type(spec.handler) === functionType) {
|
|
455
|
+
const refsForIdx = result._refsByIndex?.[idx] ?? result.refs;
|
|
456
|
+
const ref = refsForIdx?.[spec.targetRef];
|
|
457
|
+
if (ref) ref.on(event, spec.handler);
|
|
458
|
+
} else if (type(spec) === functionType) {
|
|
459
|
+
result.on(event, spec);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
result.all();
|
|
464
|
+
}
|
|
399
465
|
}
|
|
400
466
|
}
|
|
401
467
|
|
|
@@ -409,24 +475,47 @@ const o = (query) => {
|
|
|
409
475
|
});
|
|
410
476
|
});
|
|
411
477
|
const renderState = states.render || states;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
renderState.events &&
|
|
416
|
-
renderState.ssr
|
|
417
|
-
) {
|
|
478
|
+
const hasStateEvents = !ssr && type(renderState) === objectType && renderState.events;
|
|
479
|
+
const hasHydrateEvents = !ssr && result._hydrateEvents && result._hydrateEvents.length;
|
|
480
|
+
if (hasStateEvents || hasHydrateEvents) {
|
|
418
481
|
result.initSSRAfterGettingSSR = () => {
|
|
482
|
+
result._refsByIndex = [];
|
|
419
483
|
result.refs = {};
|
|
420
|
-
result.els.forEach((el) => {
|
|
484
|
+
result.els.forEach((el, idx) => {
|
|
421
485
|
if (!el.querySelectorAll) return;
|
|
486
|
+
const refsForEl = {};
|
|
422
487
|
el.querySelectorAll("[ref]").forEach((refEl) => {
|
|
423
|
-
|
|
488
|
+
const refName = refEl.getAttribute("ref");
|
|
489
|
+
const refInstance = o(refEl);
|
|
490
|
+
refsForEl[refName] = refInstance;
|
|
491
|
+
if (idx === 0) result.refs[refName] = refInstance;
|
|
424
492
|
refEl.removeAttribute("ref");
|
|
425
493
|
});
|
|
494
|
+
result._refsByIndex[idx] = refsForEl;
|
|
495
|
+
if (idx === 0) result.refs = refsForEl;
|
|
426
496
|
});
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
497
|
+
if (hasStateEvents) {
|
|
498
|
+
cycleObj(renderState.events, (event) => {
|
|
499
|
+
result.on(event, renderState.events[event]);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (result._hydrateEvents) {
|
|
503
|
+
result._hydrateEvents.forEach((evts, idx) => {
|
|
504
|
+
if (!evts) return;
|
|
505
|
+
result.select(idx);
|
|
506
|
+
cycleObj(evts, (event) => {
|
|
507
|
+
const spec = evts[event];
|
|
508
|
+
if (type(spec) === objectType && spec.targetRef && type(spec.handler) === functionType) {
|
|
509
|
+
const refsForIdx = result._refsByIndex?.[idx] ?? result.refs;
|
|
510
|
+
const ref = refsForIdx?.[spec.targetRef];
|
|
511
|
+
if (ref) ref.on(event, spec.handler);
|
|
512
|
+
} else if (type(spec) === functionType) {
|
|
513
|
+
result.on(event, spec);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
result.all();
|
|
518
|
+
}
|
|
430
519
|
};
|
|
431
520
|
}
|
|
432
521
|
}, "init");
|
|
@@ -447,10 +536,13 @@ const o = (query) => {
|
|
|
447
536
|
}, "connect");
|
|
448
537
|
|
|
449
538
|
/**
|
|
450
|
-
* Get SSR elements
|
|
539
|
+
* Get SSR elements: bind this instance to DOM nodes (by initId or from a list).
|
|
540
|
+
* When called from auto-hydration, fromEls are the nodes from the parent's HTML
|
|
541
|
+
* so the inited instance stored in the parent can control those elements.
|
|
451
542
|
* @param {number} initId - Initialization ID
|
|
543
|
+
* @param {Element[]} [fromEls] - Optional list of elements to bind to (e.g. from containerEl)
|
|
452
544
|
*/
|
|
453
|
-
result.getSSR = returner((initId) => {
|
|
545
|
+
result.getSSR = returner((initId, fromEls) => {
|
|
454
546
|
typeVerify([[initId, [numberType, undefinedType]]]);
|
|
455
547
|
const effectiveId = initId !== undefined ? initId : result.initID;
|
|
456
548
|
if (
|
|
@@ -459,17 +551,35 @@ const o = (query) => {
|
|
|
459
551
|
) {
|
|
460
552
|
return;
|
|
461
553
|
}
|
|
462
|
-
const ssrEls =
|
|
554
|
+
const ssrEls =
|
|
555
|
+
fromEls && fromEls.length
|
|
556
|
+
? fromEls
|
|
557
|
+
: o.D.querySelectorAll(`[data-o-init="${effectiveId}"]`);
|
|
463
558
|
|
|
464
|
-
if (ssrEls.length
|
|
559
|
+
if (ssrEls.length) {
|
|
465
560
|
result.els = Array.from(ssrEls);
|
|
466
|
-
|
|
467
|
-
|
|
561
|
+
if (initId !== undefined) {
|
|
562
|
+
result.initID = initId;
|
|
563
|
+
o.inits[initId] = result;
|
|
564
|
+
}
|
|
468
565
|
setResultVals(false);
|
|
469
|
-
|
|
470
566
|
if (type(result.initSSRAfterGettingSSR) === functionType) {
|
|
471
567
|
result.initSSRAfterGettingSSR();
|
|
472
|
-
|
|
568
|
+
} else if (fromEls && fromEls.length) {
|
|
569
|
+
result._refsByIndex = [];
|
|
570
|
+
result.refs = {};
|
|
571
|
+
result.els.forEach((el, idx) => {
|
|
572
|
+
if (!el.querySelectorAll) return;
|
|
573
|
+
const refsForEl = {};
|
|
574
|
+
el.querySelectorAll("[ref]").forEach((refEl) => {
|
|
575
|
+
const refName = refEl.getAttribute("ref");
|
|
576
|
+
refsForEl[refName] = o(refEl);
|
|
577
|
+
if (idx === 0) result.refs[refName] = refsForEl[refName];
|
|
578
|
+
refEl.removeAttribute("ref");
|
|
579
|
+
});
|
|
580
|
+
result._refsByIndex[idx] = refsForEl;
|
|
581
|
+
if (idx === 0) result.refs = refsForEl;
|
|
582
|
+
});
|
|
473
583
|
}
|
|
474
584
|
}
|
|
475
585
|
}, "getSSR");
|
|
@@ -648,18 +758,26 @@ const o = (query) => {
|
|
|
648
758
|
}, "sample");
|
|
649
759
|
|
|
650
760
|
/**
|
|
651
|
-
* Select element to control
|
|
652
|
-
* @param {number} i - Index of element to
|
|
761
|
+
* Select element to control. Accepts index (number) or event: select(e) selects the element in this instance that contains e.target (e.g. the row that had the event).
|
|
762
|
+
* @param {number|Event} i - Index of element, or event object (uses e.target to find containing element)
|
|
653
763
|
*/
|
|
654
764
|
result.select = returner((i) => {
|
|
655
|
-
|
|
656
|
-
if (
|
|
657
|
-
|
|
765
|
+
let idx = i;
|
|
766
|
+
if (idx != null && type(idx) === objectType && idx.target && result.els.length) {
|
|
767
|
+
idx = result.els.findIndex((el) => el === idx.target || el.contains(idx.target));
|
|
768
|
+
if (idx < 0) idx = 0;
|
|
658
769
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
770
|
+
typeVerify([[idx, [numberType, undefinedType]]]);
|
|
771
|
+
if (idx === u) {
|
|
772
|
+
idx = result.length - ONE;
|
|
773
|
+
}
|
|
774
|
+
start = idx;
|
|
775
|
+
finish = idx;
|
|
776
|
+
result.el = result.els[idx];
|
|
662
777
|
select = ONE;
|
|
778
|
+
if (Array.isArray(result._refsByIndex) && result._refsByIndex[idx]) {
|
|
779
|
+
result.refs = result._refsByIndex[idx];
|
|
780
|
+
}
|
|
663
781
|
}, "select");
|
|
664
782
|
|
|
665
783
|
/**
|
|
@@ -670,6 +788,9 @@ const o = (query) => {
|
|
|
670
788
|
finish = 0;
|
|
671
789
|
result.el = result.els[0];
|
|
672
790
|
select = 0;
|
|
791
|
+
if (Array.isArray(result._refsByIndex) && result._refsByIndex.length) {
|
|
792
|
+
result.refs = result._refsByIndex[0] || {};
|
|
793
|
+
}
|
|
673
794
|
}, "all");
|
|
674
795
|
|
|
675
796
|
/**
|
|
@@ -708,7 +829,10 @@ const o = (query) => {
|
|
|
708
829
|
j = finish;
|
|
709
830
|
}
|
|
710
831
|
|
|
711
|
-
result.els.splice(
|
|
832
|
+
result.els.splice(j, ONE);
|
|
833
|
+
if (Array.isArray(result._refsByIndex)) {
|
|
834
|
+
result._refsByIndex.splice(j, ONE);
|
|
835
|
+
}
|
|
712
836
|
setResultVals();
|
|
713
837
|
}, "skip");
|
|
714
838
|
|
|
@@ -1033,6 +1157,15 @@ const o = (query) => {
|
|
|
1033
1157
|
}
|
|
1034
1158
|
}, "html");
|
|
1035
1159
|
|
|
1160
|
+
result.toString = function () {
|
|
1161
|
+
return result.html();
|
|
1162
|
+
};
|
|
1163
|
+
result[Symbol.toPrimitive] = function (hint) {
|
|
1164
|
+
if (hint === "string" || hint === "default") return result.html();
|
|
1165
|
+
if (hint === "number") return result.els?.length ?? 0;
|
|
1166
|
+
return result.html();
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1036
1169
|
/**
|
|
1037
1170
|
* Get or set the value property of form elements (input, textarea, select).
|
|
1038
1171
|
* @param {string} [value] - Value to set. Omit to get.
|
|
@@ -2382,6 +2515,7 @@ if (__DEV__) {
|
|
|
2382
2515
|
}
|
|
2383
2516
|
|
|
2384
2517
|
/* tests function parameters */
|
|
2518
|
+
o.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2385
2519
|
o.tLog = []; // test sessions and results
|
|
2386
2520
|
o.tRes = []; // test results
|
|
2387
2521
|
o.tStatus = []; // test statuses
|
|
@@ -2390,6 +2524,8 @@ o.tShowOk = o.F; // show success tests or only errors
|
|
|
2390
2524
|
o.tStyled = o.F; // styled HTML results or plain style
|
|
2391
2525
|
o.tTime = 2000; // timeout for async tests
|
|
2392
2526
|
o.tests = []; // tests with storage
|
|
2527
|
+
o.tExpectedSteps = {}; // expected step count per test (for playRecording when o.tests not used)
|
|
2528
|
+
o.tFinalized = {}; // prevent duplicate finalization
|
|
2393
2529
|
o.tAutolog = o.F; // auto log to console
|
|
2394
2530
|
o.tBeforeEach = undefined; // called before each test case
|
|
2395
2531
|
o.tAfterEach = undefined; // called after each test case
|
|
@@ -2541,10 +2677,21 @@ o.test = (title = "", ...tests) => {
|
|
|
2541
2677
|
}
|
|
2542
2678
|
};
|
|
2543
2679
|
|
|
2680
|
+
// Extract callback and options
|
|
2681
|
+
let opts = {};
|
|
2544
2682
|
if (typeof tests[num - 1] === "function") {
|
|
2545
2683
|
o.tFns[testN] = tests[num - 1];
|
|
2546
2684
|
num--;
|
|
2547
2685
|
}
|
|
2686
|
+
if (
|
|
2687
|
+
num > 0 &&
|
|
2688
|
+
typeof tests[num - 1] === "object" &&
|
|
2689
|
+
!Array.isArray(tests[num - 1]) &&
|
|
2690
|
+
(tests[num - 1].sync !== undefined || tests[num - 1].confirmOnFailure !== undefined)
|
|
2691
|
+
) {
|
|
2692
|
+
opts = tests[num - 1];
|
|
2693
|
+
num--;
|
|
2694
|
+
}
|
|
2548
2695
|
|
|
2549
2696
|
// get tLog from sessionStorage
|
|
2550
2697
|
if (testSession) {
|
|
@@ -2578,6 +2725,172 @@ o.test = (title = "", ...tests) => {
|
|
|
2578
2725
|
o.tRes[testN] = o.F;
|
|
2579
2726
|
o.tStatus[testN] = [];
|
|
2580
2727
|
}
|
|
2728
|
+
o.tExpectedSteps[testN] = num;
|
|
2729
|
+
o.tFinalized[testN] = false;
|
|
2730
|
+
|
|
2731
|
+
const showConfirmOnFailureOverlay = (stepIdx, msg) =>
|
|
2732
|
+
new Promise((resolve) => {
|
|
2733
|
+
const box = o.overlay({
|
|
2734
|
+
innerHTML:
|
|
2735
|
+
`<div style="display:flex;flex-direction:column;gap:8px;">` +
|
|
2736
|
+
`<div style="cursor:grab;">Step ${stepIdx + 1} failed: ${msg || "error"}. Continue testing?</div>` +
|
|
2737
|
+
`<div style="display:flex;gap:8px;">` +
|
|
2738
|
+
`<button class="o-cf-continue" style="padding:6px 12px;background:#2563eb;color:#fff;border:none;border-radius:6px;cursor:pointer;">Continue</button>` +
|
|
2739
|
+
`<button class="o-cf-stop" style="padding:6px 12px;background:#dc2626;color:#fff;border:none;border-radius:6px;cursor:pointer;">Stop</button>` +
|
|
2740
|
+
`</div></div>`,
|
|
2741
|
+
timeout: opts.confirmOnFailureTimeout || undefined,
|
|
2742
|
+
onClose: (r) => resolve(r || { continue: false }),
|
|
2743
|
+
excludeDragSelector: ".o-cf-continue, .o-cf-stop",
|
|
2744
|
+
});
|
|
2745
|
+
box.first(".o-cf-continue").on("click", () => {
|
|
2746
|
+
box._overlayCleanup();
|
|
2747
|
+
resolve({ continue: true });
|
|
2748
|
+
});
|
|
2749
|
+
box.first(".o-cf-stop").on("click", () => {
|
|
2750
|
+
box._overlayCleanup();
|
|
2751
|
+
resolve({ continue: false });
|
|
2752
|
+
});
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
const finalize = () => {
|
|
2756
|
+
if (o.tFinalized[testN]) return;
|
|
2757
|
+
o.tFinalized[testN] = true;
|
|
2758
|
+
const anyFailed = o.tStatus[testN].some((s) => s === false);
|
|
2759
|
+
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
|
+
}
|
|
2766
|
+
if (o.tStyled) {
|
|
2767
|
+
o.tLog[testN] +=
|
|
2768
|
+
o.tPre +
|
|
2769
|
+
'<div style="color:' +
|
|
2770
|
+
(done + waits !== num ? "red" : "green") +
|
|
2771
|
+
';"><b>DONE ' +
|
|
2772
|
+
done +
|
|
2773
|
+
"/" +
|
|
2774
|
+
num +
|
|
2775
|
+
(waits ? ", waiting: " + waits : "") +
|
|
2776
|
+
"</b>" +
|
|
2777
|
+
o.tDc +
|
|
2778
|
+
o.tDc;
|
|
2779
|
+
} else {
|
|
2780
|
+
o.tLog[testN] += row + "\n";
|
|
2781
|
+
}
|
|
2782
|
+
if (testSession) {
|
|
2783
|
+
sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
|
|
2784
|
+
sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
|
|
2785
|
+
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
2786
|
+
}
|
|
2787
|
+
if (!waits && typeof o.tFns[testN] === "function") {
|
|
2788
|
+
o.tFns[testN](testN);
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
|
|
2792
|
+
if (opts.sync || opts.confirmOnFailure) {
|
|
2793
|
+
(async () => {
|
|
2794
|
+
for (let i = o.tStatus[testN].length; i < num; i++) {
|
|
2795
|
+
const testInfo = {
|
|
2796
|
+
n: testN,
|
|
2797
|
+
i,
|
|
2798
|
+
title: tests[i][0],
|
|
2799
|
+
tShowOk: o.tShowOk,
|
|
2800
|
+
tStyled: o.tStyled,
|
|
2801
|
+
};
|
|
2802
|
+
let res = tests[i][1];
|
|
2803
|
+
if (typeof res === "undefined") {
|
|
2804
|
+
if (o.tStyled) {
|
|
2805
|
+
o.tLog[testN] += "<div>" + testInfo.title + "</div>";
|
|
2806
|
+
} else {
|
|
2807
|
+
o.tLog[testN] += testInfo.title + "\n";
|
|
2808
|
+
}
|
|
2809
|
+
log("├ " + testInfo.title, false, true);
|
|
2810
|
+
o.tStatus[testN][i] = true;
|
|
2811
|
+
done++;
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
if (typeof o.tBeforeEach === "function") {
|
|
2815
|
+
o.tBeforeEach(testInfo);
|
|
2816
|
+
}
|
|
2817
|
+
if (typeof res === "function") {
|
|
2818
|
+
try {
|
|
2819
|
+
res = res(testInfo);
|
|
2820
|
+
} catch (error) {
|
|
2821
|
+
res = error.message;
|
|
2822
|
+
if (o.onError) {
|
|
2823
|
+
o.onError(error);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
if (typeof o.tAfterEach === "function") {
|
|
2828
|
+
o.tAfterEach(testInfo, res);
|
|
2829
|
+
}
|
|
2830
|
+
if (res && typeof res.then === "function") {
|
|
2831
|
+
try {
|
|
2832
|
+
const value = await res;
|
|
2833
|
+
const ok =
|
|
2834
|
+
value === true ||
|
|
2835
|
+
value == null ||
|
|
2836
|
+
(value && typeof value === "object" && value.ok === true);
|
|
2837
|
+
const msg =
|
|
2838
|
+
value && value.errors && value.errors.length
|
|
2839
|
+
? value.errors.join("; ")
|
|
2840
|
+
: typeof value === "string"
|
|
2841
|
+
? value
|
|
2842
|
+
: "";
|
|
2843
|
+
o.testUpdate(testInfo, ok, ok ? "" : msg ? ": " + msg : "");
|
|
2844
|
+
done++;
|
|
2845
|
+
if (!ok && opts.confirmOnFailure) {
|
|
2846
|
+
const choice = await showConfirmOnFailureOverlay(i, msg);
|
|
2847
|
+
if (!choice.continue) break;
|
|
2848
|
+
}
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
o.testUpdate(testInfo, false, err.message || "Promise rejected");
|
|
2851
|
+
if (opts.confirmOnFailure) {
|
|
2852
|
+
const choice = await showConfirmOnFailureOverlay(i, err.message || "Promise rejected");
|
|
2853
|
+
if (!choice.continue) break;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
continue;
|
|
2857
|
+
}
|
|
2858
|
+
if (typeof o.tStatus[testN][i] === "undefined") {
|
|
2859
|
+
o.tStatus[testN][i] = typeof res === "string" ? o.F : res;
|
|
2860
|
+
} else {
|
|
2861
|
+
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
if (res === true) {
|
|
2865
|
+
done++;
|
|
2866
|
+
if (o.tShowOk) {
|
|
2867
|
+
o.tLog[testN] += preOk + tests[i][0] + posOk;
|
|
2868
|
+
log("├ OK: " + tests[i][0]);
|
|
2869
|
+
}
|
|
2870
|
+
} else if (res !== o.U) {
|
|
2871
|
+
o.tLog[testN] += preXx + tests[i][0] + (res !== o.F ? ": " + res : "") + posXx;
|
|
2872
|
+
log("├ ✘ " + tests[i][0] + (res !== o.F ? ": " + res : ""), true);
|
|
2873
|
+
if (opts.confirmOnFailure) {
|
|
2874
|
+
const choice = await showConfirmOnFailureOverlay(i, typeof res === "string" ? res : "");
|
|
2875
|
+
if (!choice.continue) break;
|
|
2876
|
+
}
|
|
2877
|
+
} else {
|
|
2878
|
+
waits++;
|
|
2879
|
+
setTimeout(
|
|
2880
|
+
(info) => {
|
|
2881
|
+
info.title += " (timeout)";
|
|
2882
|
+
o.testUpdate(info);
|
|
2883
|
+
},
|
|
2884
|
+
o.tTime,
|
|
2885
|
+
testInfo,
|
|
2886
|
+
);
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
finalize();
|
|
2891
|
+
})();
|
|
2892
|
+
return testN;
|
|
2893
|
+
}
|
|
2581
2894
|
|
|
2582
2895
|
for (let i = o.tStatus[testN].length; i < num; i++) {
|
|
2583
2896
|
const testInfo = {
|
|
@@ -2677,42 +2990,7 @@ o.test = (title = "", ...tests) => {
|
|
|
2677
2990
|
}
|
|
2678
2991
|
}
|
|
2679
2992
|
|
|
2680
|
-
|
|
2681
|
-
row = waits ? "├ " : "╘ ";
|
|
2682
|
-
row += "DONE " + done + "/" + num + (waits ? ", waiting: " + waits : "");
|
|
2683
|
-
log(row, done + waits !== num);
|
|
2684
|
-
if (!waits) {
|
|
2685
|
-
log();
|
|
2686
|
-
}
|
|
2687
|
-
|
|
2688
|
-
if (o.tStyled) {
|
|
2689
|
-
o.tLog[testN] +=
|
|
2690
|
-
o.tPre +
|
|
2691
|
-
'<div style="color:' +
|
|
2692
|
-
(done + waits !== num ? "red" : "green") +
|
|
2693
|
-
';"><b>DONE ' +
|
|
2694
|
-
done +
|
|
2695
|
-
"/" +
|
|
2696
|
-
num +
|
|
2697
|
-
(waits ? ", waiting: " + waits : "") +
|
|
2698
|
-
"</b>" +
|
|
2699
|
-
o.tDc +
|
|
2700
|
-
o.tDc;
|
|
2701
|
-
} else {
|
|
2702
|
-
o.tLog[testN] += row + "\n";
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
// Save test results to sessionStorage
|
|
2706
|
-
if (testSession) {
|
|
2707
|
-
sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
|
|
2708
|
-
sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
|
|
2709
|
-
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
if (!waits && typeof o.tFns[testN] === "function") {
|
|
2713
|
-
o.tFns[testN](testN);
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2993
|
+
finalize();
|
|
2716
2994
|
return testN;
|
|
2717
2995
|
};
|
|
2718
2996
|
|
|
@@ -2778,16 +3056,23 @@ o.testUpdate = (info, res = o.F, suff = "") => {
|
|
|
2778
3056
|
n++;
|
|
2779
3057
|
}
|
|
2780
3058
|
|
|
2781
|
-
|
|
3059
|
+
const expectedSteps =
|
|
3060
|
+
o.tests[testN]?.tests?.length ?? o.tExpectedSteps[testN] ?? Number.MAX_SAFE_INTEGER;
|
|
3061
|
+
if (n < expectedSteps) {
|
|
3062
|
+
if (sessionStorage?.getItem("oTest-Run") === testN) {
|
|
3063
|
+
sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
|
|
3064
|
+
sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
|
|
3065
|
+
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
3066
|
+
}
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
if (o.tFinalized[testN]) return;
|
|
3071
|
+
o.tFinalized[testN] = true;
|
|
2782
3072
|
if (sessionStorage?.getItem("oTest-Run") === testN) {
|
|
2783
|
-
// save test results to sessionStorage
|
|
2784
3073
|
sessionStorage.setItem(`oTest-Log-${testN}`, o.tLog[testN]);
|
|
2785
3074
|
sessionStorage.setItem(`oTest-Res-${testN}`, o.tRes[testN]);
|
|
2786
3075
|
sessionStorage.setItem(`oTest-Status-${testN}`, JSON.stringify(o.tStatus[testN]));
|
|
2787
|
-
|
|
2788
|
-
if (n < o.tests[testN].tests.length) {
|
|
2789
|
-
return;
|
|
2790
|
-
}
|
|
2791
3076
|
}
|
|
2792
3077
|
|
|
2793
3078
|
o.tRes[testN] = !fails;
|
|
@@ -2943,6 +3228,8 @@ o.recorder = {
|
|
|
2943
3228
|
_listeners: [],
|
|
2944
3229
|
_observer: null,
|
|
2945
3230
|
};
|
|
3231
|
+
/** When true, log assertion flow (recording + playback) for debugging. */
|
|
3232
|
+
o.recordingAssertionDebug = false;
|
|
2946
3233
|
|
|
2947
3234
|
/**
|
|
2948
3235
|
* Start recording user interactions
|
|
@@ -2974,6 +3261,7 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
2974
3261
|
|
|
2975
3262
|
rec.observeRoot = observe || null;
|
|
2976
3263
|
rec.assertions = [];
|
|
3264
|
+
rec.removedElements = [];
|
|
2977
3265
|
|
|
2978
3266
|
// snapshot current o.inits data
|
|
2979
3267
|
o.inits.forEach((inst, idx) => {
|
|
@@ -3068,6 +3356,16 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3068
3356
|
rec._observer = new MutationObserver((mutations) => {
|
|
3069
3357
|
const actionIdx = rec.actions.length - 1;
|
|
3070
3358
|
if (actionIdx < 0) return;
|
|
3359
|
+
const lastAction = rec.actions[actionIdx];
|
|
3360
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3361
|
+
console.log("[recording] MutationObserver batch:", {
|
|
3362
|
+
actionIdx,
|
|
3363
|
+
lastAction: lastAction ? { type: lastAction.type, target: lastAction.target } : null,
|
|
3364
|
+
mutationTypes: mutations.map((x) => x.type),
|
|
3365
|
+
addedCount: mutations.reduce((n, x) => n + (x.addedNodes?.length || 0), 0),
|
|
3366
|
+
removedCount: mutations.reduce((n, x) => n + (x.removedNodes?.length || 0), 0),
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3071
3369
|
mutations.forEach((m) => {
|
|
3072
3370
|
const addAssertionIndex = (sel, node) => {
|
|
3073
3371
|
let listSelector;
|
|
@@ -3109,16 +3407,57 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3109
3407
|
)
|
|
3110
3408
|
)
|
|
3111
3409
|
return;
|
|
3112
|
-
// Prefer stable content (e.g. .task-text) so assertions survive reorder/restore
|
|
3113
|
-
const textEl = node.querySelector?.(".task-text") || node;
|
|
3114
3410
|
const text =
|
|
3115
|
-
(
|
|
3116
|
-
undefined;
|
|
3411
|
+
(node.textContent?.trim() || "").slice(0, 80) || undefined;
|
|
3117
3412
|
const { listSelector: aListSel, index: aIdx } = addAssertionIndex(sel, node);
|
|
3118
3413
|
const a = { actionIdx, type: "visible", selector: sel, text };
|
|
3119
3414
|
if (aListSel != null) a.listSelector = aListSel;
|
|
3120
3415
|
if (aIdx != null) a.index = aIdx;
|
|
3121
3416
|
rec.assertions.push(a);
|
|
3417
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3418
|
+
console.log("[recording] +visible assertion:", {
|
|
3419
|
+
actionIdx,
|
|
3420
|
+
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
3421
|
+
selector: sel,
|
|
3422
|
+
text: (text || "").slice(0, 40),
|
|
3423
|
+
index: aIdx,
|
|
3424
|
+
listSelector: aListSel,
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
});
|
|
3428
|
+
m.removedNodes.forEach((node) => {
|
|
3429
|
+
if (node.nodeType !== 1) return;
|
|
3430
|
+
const sel = buildSelector(node);
|
|
3431
|
+
if (!sel) return;
|
|
3432
|
+
const text = (node.textContent?.trim() || "").slice(0, 80) || undefined;
|
|
3433
|
+
const parent = m.target;
|
|
3434
|
+
let index;
|
|
3435
|
+
if (node.previousSibling) {
|
|
3436
|
+
index = Array.from(parent.children).indexOf(node.previousSibling) + 1;
|
|
3437
|
+
} else if (node.nextSibling) {
|
|
3438
|
+
index = Array.from(parent.children).indexOf(node.nextSibling);
|
|
3439
|
+
} else {
|
|
3440
|
+
index = 0;
|
|
3441
|
+
}
|
|
3442
|
+
let listSelector;
|
|
3443
|
+
if (o.autotag && node.dataset?.[o.autotag]) {
|
|
3444
|
+
const qaVal = node.dataset[o.autotag];
|
|
3445
|
+
listSelector = `[data-${o.autotag}="${qaVal}"]`;
|
|
3446
|
+
}
|
|
3447
|
+
const entry = { actionIdx, type: "removed", selector: sel, text };
|
|
3448
|
+
if (listSelector) entry.listSelector = listSelector;
|
|
3449
|
+
entry.index = index;
|
|
3450
|
+
rec.removedElements.push(entry);
|
|
3451
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3452
|
+
console.log("[recording] +removed element:", {
|
|
3453
|
+
actionIdx,
|
|
3454
|
+
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
3455
|
+
selector: sel,
|
|
3456
|
+
text: (text || "").slice(0, 40),
|
|
3457
|
+
index,
|
|
3458
|
+
listSelector,
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3122
3461
|
});
|
|
3123
3462
|
}
|
|
3124
3463
|
if (m.type === "attributes") {
|
|
@@ -3140,6 +3479,16 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3140
3479
|
if (aListSel != null) a.listSelector = aListSel;
|
|
3141
3480
|
if (aIdx != null) a.index = aIdx;
|
|
3142
3481
|
rec.assertions.push(a);
|
|
3482
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3483
|
+
console.log("[recording] +class assertion:", {
|
|
3484
|
+
actionIdx,
|
|
3485
|
+
lastAction: lastAction?.type + " " + lastAction?.target,
|
|
3486
|
+
selector: sel,
|
|
3487
|
+
className: m.target.className,
|
|
3488
|
+
index: aIdx,
|
|
3489
|
+
listSelector: aListSel,
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3143
3492
|
}
|
|
3144
3493
|
});
|
|
3145
3494
|
});
|
|
@@ -3224,8 +3573,14 @@ o.startRecording = (observe, events, timeouts) => {
|
|
|
3224
3573
|
? target?.checked
|
|
3225
3574
|
: undefined;
|
|
3226
3575
|
|
|
3576
|
+
// Push click/change immediately so MutationObserver sees correct actionIdx
|
|
3577
|
+
// (mutations fire sync after target handler; debounce would attach assertions to wrong action)
|
|
3227
3578
|
const delay =
|
|
3228
|
-
|
|
3579
|
+
ev === "click" || ev === "change"
|
|
3580
|
+
? 0
|
|
3581
|
+
: stepDelays[ev] !== undefined
|
|
3582
|
+
? stepDelays[ev]
|
|
3583
|
+
: captureDebounce[ev] ?? 0;
|
|
3229
3584
|
const pushAction = () => {
|
|
3230
3585
|
const action = { type: ev, target: selector, time: Date.now() };
|
|
3231
3586
|
if (targetType) action.targetType = targetType;
|
|
@@ -3273,6 +3628,7 @@ o.stopRecording = () => {
|
|
|
3273
3628
|
initialData: { ...rec.initialData },
|
|
3274
3629
|
stepDelays: { ...rec.stepDelays },
|
|
3275
3630
|
assertions: [...(rec.assertions || [])],
|
|
3631
|
+
removedElements: [...(rec.removedElements || [])],
|
|
3276
3632
|
observeRoot: rec.observeRoot || null,
|
|
3277
3633
|
};
|
|
3278
3634
|
};
|
|
@@ -3295,46 +3651,264 @@ o.clearRecording = (id) => {
|
|
|
3295
3651
|
};
|
|
3296
3652
|
|
|
3297
3653
|
/**
|
|
3298
|
-
*
|
|
3299
|
-
*
|
|
3300
|
-
* @param {
|
|
3301
|
-
* @
|
|
3654
|
+
* Run recording assertions in the current DOM.
|
|
3655
|
+
* @param {{actions: Array, assertions: Array, observeRoot?: string}} recording
|
|
3656
|
+
* @param {Element|string} [root] - Root element or selector; defaults to recording.observeRoot or document.body
|
|
3657
|
+
* @param {number} [actionIdx] - When provided, run only assertions for this action index
|
|
3658
|
+
* @returns {{ passed: number, total: number, failures: Array<{selector: string, message: string}> }}
|
|
3302
3659
|
*/
|
|
3303
|
-
o.
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3660
|
+
o.runRecordingAssertions = (recording, root, actionIdx, opts) => {
|
|
3661
|
+
const preFiltered = opts && opts.assertions;
|
|
3662
|
+
const assertions =
|
|
3663
|
+
preFiltered != null
|
|
3664
|
+
? preFiltered
|
|
3665
|
+
: (recording.assertions || []).filter(
|
|
3666
|
+
(a) => actionIdx == null || a.actionIdx === actionIdx,
|
|
3667
|
+
);
|
|
3668
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3669
|
+
console.log("[runRecordingAssertions] run:", {
|
|
3670
|
+
actionIdx,
|
|
3671
|
+
scope: actionIdx == null ? "teardown (all)" : "per-action",
|
|
3672
|
+
assertionsCount: assertions.length,
|
|
3673
|
+
assertions: assertions.map((a) => ({
|
|
3674
|
+
actionIdx: a.actionIdx,
|
|
3675
|
+
type: a.type,
|
|
3676
|
+
selector: a.selector,
|
|
3677
|
+
index: a.index,
|
|
3678
|
+
text: (a.text || "").slice(0, 40),
|
|
3679
|
+
})),
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3682
|
+
const seen = new Set();
|
|
3683
|
+
const deduped = assertions.filter((a) => {
|
|
3684
|
+
const key = `${a.selector}|${a.type}|${a.actionIdx}|${a.index ?? ""}`;
|
|
3685
|
+
if (seen.has(key)) return false;
|
|
3686
|
+
seen.add(key);
|
|
3687
|
+
return true;
|
|
3688
|
+
});
|
|
3689
|
+
const resolveRoot = () => {
|
|
3690
|
+
if (root != null) {
|
|
3691
|
+
return typeof root === "string" ? o.D.querySelector(root) || o.D.body : root;
|
|
3692
|
+
}
|
|
3693
|
+
const sel = recording.observeRoot;
|
|
3694
|
+
return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
|
|
3695
|
+
};
|
|
3696
|
+
const r = resolveRoot();
|
|
3697
|
+
const norm = (s) => (s || "").trim().replace(/\s+/g, " ");
|
|
3698
|
+
const getText = (el) => (el ? norm(el.textContent || "") : "");
|
|
3699
|
+
const removedElements = opts?.removedElements || [];
|
|
3700
|
+
const isRemoved = (a) => {
|
|
3701
|
+
if (!removedElements.length || actionIdx == null) return false;
|
|
3702
|
+
const expText = norm(a.text || "");
|
|
3703
|
+
for (const r of removedElements) {
|
|
3704
|
+
if (r.actionIdx > actionIdx) continue;
|
|
3705
|
+
if (norm(r.text || "") !== expText) continue;
|
|
3706
|
+
if (r.selector !== a.selector) continue;
|
|
3707
|
+
if (a.listSelector != null && r.listSelector !== a.listSelector) continue;
|
|
3708
|
+
if (a.index != null && r.index !== a.index) continue;
|
|
3709
|
+
return true;
|
|
3710
|
+
}
|
|
3711
|
+
return false;
|
|
3712
|
+
};
|
|
3713
|
+
let passed = 0;
|
|
3714
|
+
const failures = [];
|
|
3715
|
+
for (const a of deduped) {
|
|
3716
|
+
if (isRemoved(a)) {
|
|
3717
|
+
passed += 1;
|
|
3718
|
+
if (o.recordingAssertionDebug && typeof console !== "undefined" && console.log) {
|
|
3719
|
+
console.log("[runRecordingAssertions] skip (explicit removed):", {
|
|
3720
|
+
actionIdx: a.actionIdx,
|
|
3721
|
+
selector: a.selector,
|
|
3722
|
+
text: (a.text || "").slice(0, 40),
|
|
3723
|
+
});
|
|
3724
|
+
}
|
|
3725
|
+
continue;
|
|
3726
|
+
}
|
|
3727
|
+
let el = null;
|
|
3728
|
+
let indexOutOfBounds = false;
|
|
3729
|
+
if (a.listSelector != null && a.index != null) {
|
|
3730
|
+
const items = r.querySelectorAll(a.listSelector);
|
|
3731
|
+
const expectedText = norm(a.text || "");
|
|
3732
|
+
const tryItem = (idx) => {
|
|
3733
|
+
const it = items[idx];
|
|
3734
|
+
if (!it) return null;
|
|
3735
|
+
const e =
|
|
3736
|
+
a.selector !== a.listSelector ? it.querySelector(a.selector) : it;
|
|
3737
|
+
return (e || (a.selector !== a.listSelector ? it : null));
|
|
3738
|
+
};
|
|
3739
|
+
let item = items[a.index];
|
|
3740
|
+
if (!item && a.index > 0) item = items[a.index - 1];
|
|
3741
|
+
if (item) {
|
|
3742
|
+
el = tryItem(a.index) || (a.index > 0 ? tryItem(a.index - 1) : null);
|
|
3743
|
+
if (!el && a.selector !== a.listSelector) el = item;
|
|
3744
|
+
if (a.type === "visible" && expectedText && el) {
|
|
3745
|
+
const actualText = getText(el);
|
|
3746
|
+
const textMismatch =
|
|
3747
|
+
actualText.indexOf(expectedText) === -1 &&
|
|
3748
|
+
expectedText.indexOf(actualText) === -1;
|
|
3749
|
+
if (textMismatch) {
|
|
3750
|
+
for (let j = 0; j < items.length; j++) {
|
|
3751
|
+
const candEl = tryItem(j);
|
|
3752
|
+
if (candEl && getText(candEl).indexOf(expectedText) !== -1) {
|
|
3753
|
+
el = candEl;
|
|
3754
|
+
item = items[j];
|
|
3755
|
+
break;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3314
3760
|
} else {
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3761
|
+
indexOutOfBounds = true;
|
|
3762
|
+
}
|
|
3763
|
+
} else {
|
|
3764
|
+
const matches = r.querySelectorAll(a.selector);
|
|
3765
|
+
el = matches.length > 0 ? matches[0] : o.D.querySelector(a.selector);
|
|
3766
|
+
}
|
|
3767
|
+
if (a.type === "visible") {
|
|
3768
|
+
const visible =
|
|
3769
|
+
el &&
|
|
3770
|
+
el.nodeType === 1 &&
|
|
3771
|
+
(el.offsetParent !== null ||
|
|
3772
|
+
(el.getBoundingClientRect && el.getBoundingClientRect().width > 0));
|
|
3773
|
+
const expectedText = norm(a.text || "");
|
|
3774
|
+
const actualText = getText(el);
|
|
3775
|
+
const fullActual = actualText;
|
|
3776
|
+
const textOk =
|
|
3777
|
+
!expectedText ||
|
|
3778
|
+
actualText.indexOf(expectedText) !== -1 ||
|
|
3779
|
+
fullActual.indexOf(expectedText) !== -1 ||
|
|
3780
|
+
(expectedText.length > 0 && expectedText.indexOf(actualText) !== -1);
|
|
3781
|
+
if (visible && textOk) {
|
|
3782
|
+
passed += 1;
|
|
3783
|
+
} else {
|
|
3784
|
+
const message = indexOutOfBounds
|
|
3785
|
+
? `index out of bounds (list has ${r.querySelectorAll(a.listSelector || a.selector).length} items, assertion expected index ${a.index})`
|
|
3786
|
+
: !el
|
|
3787
|
+
? "element not found"
|
|
3788
|
+
: !visible
|
|
3789
|
+
? "not visible"
|
|
3790
|
+
: !textOk
|
|
3791
|
+
? "text mismatch"
|
|
3792
|
+
: "fail";
|
|
3793
|
+
failures.push({ selector: a.selector, message });
|
|
3794
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
3795
|
+
console.warn("[runRecordingAssertions] visible failed:", {
|
|
3796
|
+
actionIdx: a.actionIdx,
|
|
3797
|
+
selector: a.selector,
|
|
3798
|
+
listSelector: a.listSelector,
|
|
3799
|
+
index: a.index,
|
|
3800
|
+
expectedText: a.text || "(any)",
|
|
3801
|
+
actualText: actualText.slice(0, 80),
|
|
3802
|
+
message,
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
} else if (a.type === "class") {
|
|
3807
|
+
const tokens = (a.className || "").trim().split(/\s+/).filter(Boolean);
|
|
3808
|
+
const hasClass =
|
|
3809
|
+
el && (tokens.length === 0 || tokens.every((c) => el.classList?.contains(c)));
|
|
3810
|
+
if (hasClass) {
|
|
3811
|
+
passed += 1;
|
|
3812
|
+
} else {
|
|
3813
|
+
const msg = indexOutOfBounds
|
|
3814
|
+
? `index out of bounds (list has ${r.querySelectorAll(a.listSelector).length} items, expected index ${a.index})`
|
|
3815
|
+
: !el
|
|
3816
|
+
? "element not found"
|
|
3817
|
+
: `expected class "${a.className}"`;
|
|
3818
|
+
failures.push({ selector: a.selector, message: msg });
|
|
3819
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
3820
|
+
console.warn("[runRecordingAssertions] failed:", {
|
|
3821
|
+
type: a.type,
|
|
3822
|
+
selector: a.selector,
|
|
3823
|
+
actionIdx: a.actionIdx,
|
|
3824
|
+
listSelector: a.listSelector,
|
|
3825
|
+
index: a.index,
|
|
3826
|
+
itemsInRoot: a.listSelector ? r.querySelectorAll(a.listSelector).length : "-",
|
|
3827
|
+
message: msg,
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3319
3830
|
}
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
return { passed, total: deduped.length, failures };
|
|
3834
|
+
};
|
|
3835
|
+
|
|
3836
|
+
/**
|
|
3837
|
+
* Export a recording as a ready-to-commit o.addTest() code string.
|
|
3838
|
+
* Includes assertions interleaved with actions (Playwright parity).
|
|
3839
|
+
* @param {{actions: Array, assertions: Array, mocks: Object, initialData: Object, observeRoot?: string}} recording
|
|
3840
|
+
* @param {{delay?: number}} [options] - delay in ms at end of each action (default 16 for recorded actions)
|
|
3841
|
+
* @returns {string}
|
|
3842
|
+
*/
|
|
3843
|
+
o.exportTest = (recording, options = {}) => {
|
|
3844
|
+
const delay = options.delay !== undefined ? options.delay : 16;
|
|
3845
|
+
const recordingData = {
|
|
3846
|
+
actions: recording.actions,
|
|
3847
|
+
assertions: recording.assertions || [],
|
|
3848
|
+
observeRoot: recording.observeRoot || null,
|
|
3849
|
+
};
|
|
3850
|
+
const rootVar = recording.observeRoot
|
|
3851
|
+
? `(o.D.querySelector('${recording.observeRoot.replace(/'/g, "\\'")}') || o.D.body)`
|
|
3852
|
+
: "o.D.body";
|
|
3853
|
+
const getEl = (a) => {
|
|
3854
|
+
if (a.listSelector != null && a.targetIndex != null) {
|
|
3855
|
+
const listSel = JSON.stringify(a.listSelector);
|
|
3856
|
+
const useItem = a.target === a.listSelector;
|
|
3857
|
+
const targetSel = useItem ? listSel : JSON.stringify(a.target);
|
|
3320
3858
|
return (
|
|
3321
|
-
`
|
|
3322
|
-
` const
|
|
3323
|
-
`
|
|
3859
|
+
` const items = o.D.querySelectorAll(${listSel});\n` +
|
|
3860
|
+
` const item = items[${a.targetIndex}];\n` +
|
|
3861
|
+
` let el = null;\n` +
|
|
3862
|
+
` if (item) { el = ${useItem ? "item" : `item.querySelector(${targetSel}) || item`}; }`
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
return ` const el = o.D.querySelector(${JSON.stringify(a.target)});`;
|
|
3866
|
+
};
|
|
3867
|
+
const endSuffix = delay > 0 ? `\n await o.sleep(${delay});\n return true;\n` : ` return true;\n`;
|
|
3868
|
+
const stepFn = delay > 0 ? "async () =>" : "() =>";
|
|
3869
|
+
const steps = [];
|
|
3870
|
+
for (let i = 0; i < recording.actions.length; i++) {
|
|
3871
|
+
const a = recording.actions[i];
|
|
3872
|
+
let body;
|
|
3873
|
+
if (a.type === "scroll") {
|
|
3874
|
+
body = ` window.scrollTo(0, ${a.scrollY || 0});${endSuffix}`;
|
|
3875
|
+
} else if (a.type === "input" || a.type === "change") {
|
|
3876
|
+
body =
|
|
3877
|
+
(a.value !== undefined ? ` el.value = ${JSON.stringify(a.value)};\n` : "") +
|
|
3878
|
+
(a.checked !== undefined ? ` el.checked = ${a.checked};\n` : "") +
|
|
3879
|
+
` el.dispatchEvent(new Event('${a.type}', {bubbles:true}));${endSuffix}`;
|
|
3880
|
+
} else {
|
|
3881
|
+
const useNativeClick = a.type === "click";
|
|
3882
|
+
body = useNativeClick
|
|
3883
|
+
? ` el.click();${endSuffix}`
|
|
3884
|
+
: ` el.dispatchEvent(new MouseEvent('${a.type}', {bubbles:true,cancelable:true}));${endSuffix}`;
|
|
3885
|
+
}
|
|
3886
|
+
steps.push(
|
|
3887
|
+
` ['${a.type} on ${a.target}', ${stepFn} {\n` +
|
|
3888
|
+
getEl(a) +
|
|
3889
|
+
`\n if (!el && '${a.type}' !== 'scroll') return 'element not found: ${a.target.replace(/'/g, "\\'")}';\n` +
|
|
3324
3890
|
body +
|
|
3325
|
-
` }]
|
|
3891
|
+
` }]`,
|
|
3892
|
+
);
|
|
3893
|
+
const assertsForAction = (recording.assertions || []).filter((x) => x.actionIdx === i);
|
|
3894
|
+
if (assertsForAction.length > 0) {
|
|
3895
|
+
steps.push(
|
|
3896
|
+
` ['assert after ${a.type}', () => {\n` +
|
|
3897
|
+
` const r = o.runRecordingAssertions(recordingData, ${rootVar}, ${i});\n` +
|
|
3898
|
+
` return r.passed === r.total ? true : r.failures.map(f => f.selector + ': ' + f.message).join('; ');\n` +
|
|
3899
|
+
` }]`,
|
|
3326
3900
|
);
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
const mocksStr = Object.keys(recording.mocks).length
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
const mocksStr = Object.keys(recording.mocks || {}).length
|
|
3331
3904
|
? JSON.stringify(recording.mocks, null, 2)
|
|
3332
3905
|
: "{}";
|
|
3333
3906
|
|
|
3334
3907
|
return (
|
|
3335
3908
|
`// Auto-generated by o.exportTest() — review and anonymize mocks before committing\n` +
|
|
3336
|
-
`const recordingMocks = ${mocksStr};\n
|
|
3337
|
-
`
|
|
3909
|
+
`const recordingMocks = ${mocksStr};\n` +
|
|
3910
|
+
`const recordingData = { actions: ${JSON.stringify(recording.actions)}, assertions: ${JSON.stringify(recording.assertions || [])}, observeRoot: ${JSON.stringify(recording.observeRoot || null)} };\n\n` +
|
|
3911
|
+
`o.addTest('Recorded test', [\n${steps.join(",\n")}\n // Add manual checks: ['Manual: label', () => o.testConfirm('label', ['item1'])],\n], () => {\n` +
|
|
3338
3912
|
` // teardown\n});\n`
|
|
3339
3913
|
);
|
|
3340
3914
|
};
|
|
@@ -3467,13 +4041,25 @@ o.exportPlaywrightTest = (recording, options = {}) => {
|
|
|
3467
4041
|
// Available in all builds so assessors can replay and see results (testOverlay) on staging.
|
|
3468
4042
|
/**
|
|
3469
4043
|
* Play back a recording as an automated test sequence
|
|
3470
|
-
* @param {{actions: Array, mocks: Object}} recording
|
|
3471
|
-
* @param {Object} [
|
|
3472
|
-
* @returns {number}
|
|
4044
|
+
* @param {{actions: Array, mocks: Object, assertions?: Array, observeRoot?: string}} recording
|
|
4045
|
+
* @param {Object} [opts] - mockOverrides or { runAssertions?, root?, manualChecks?, mockOverrides? }
|
|
4046
|
+
* @returns {number|{testId: number, assertionResult?: Object}}
|
|
3473
4047
|
*/
|
|
3474
|
-
o.playRecording = (recording,
|
|
4048
|
+
o.playRecording = (recording, opts = {}) => {
|
|
4049
|
+
const isOptions =
|
|
4050
|
+
opts &&
|
|
4051
|
+
typeof opts === "object" &&
|
|
4052
|
+
(opts.runAssertions !== undefined ||
|
|
4053
|
+
opts.root !== undefined ||
|
|
4054
|
+
opts.manualChecks !== undefined ||
|
|
4055
|
+
opts.actionDelay !== undefined);
|
|
4056
|
+
const mockOverrides = isOptions ? opts.mockOverrides || {} : opts;
|
|
4057
|
+
const runAssertions = isOptions && opts.runAssertions;
|
|
4058
|
+
const rootOpt = isOptions ? opts.root : undefined;
|
|
4059
|
+
const manualChecks = (isOptions && opts.manualChecks) || [];
|
|
4060
|
+
const actionDelay = isOptions && opts.actionDelay !== undefined ? opts.actionDelay : 16;
|
|
4061
|
+
|
|
3475
4062
|
const allMocks = Object.assign({}, recording.mocks, mockOverrides);
|
|
3476
|
-
// install mock fetch
|
|
3477
4063
|
const origFetch = window.fetch;
|
|
3478
4064
|
window.fetch = (url, opts = {}) => {
|
|
3479
4065
|
const method = (opts.method || "GET").toUpperCase();
|
|
@@ -3487,51 +4073,154 @@ o.playRecording = (recording, mockOverrides = {}) => {
|
|
|
3487
4073
|
return origFetch(url, opts);
|
|
3488
4074
|
};
|
|
3489
4075
|
|
|
3490
|
-
const
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
4076
|
+
const resolveRoot = () => {
|
|
4077
|
+
if (rootOpt != null) {
|
|
4078
|
+
return typeof rootOpt === "string" ? o.D.querySelector(rootOpt) || o.D.body : rootOpt;
|
|
4079
|
+
}
|
|
4080
|
+
const sel = recording.observeRoot;
|
|
4081
|
+
return sel ? o.D.querySelector(sel) || o.D.body : o.D.body;
|
|
4082
|
+
};
|
|
4083
|
+
const rootEl = runAssertions ? resolveRoot() : null;
|
|
4084
|
+
const actionScope = rootOpt != null ? resolveRoot() : o.D;
|
|
4085
|
+
|
|
4086
|
+
const actions = recording.actions;
|
|
4087
|
+
const assertions = recording.assertions || [];
|
|
4088
|
+
|
|
4089
|
+
const assertionsByAction = {};
|
|
4090
|
+
for (const a of assertions) {
|
|
4091
|
+
const k = a.actionIdx;
|
|
4092
|
+
if (!assertionsByAction[k]) assertionsByAction[k] = [];
|
|
4093
|
+
assertionsByAction[k].push(a);
|
|
4094
|
+
}
|
|
4095
|
+
if (o.recordingAssertionDebug && runAssertions && typeof console !== "undefined" && console.log) {
|
|
4096
|
+
const summary = actions.map((act, i) => ({
|
|
4097
|
+
i,
|
|
4098
|
+
action: act.type + " " + (act.target || ""),
|
|
4099
|
+
assertions: (assertionsByAction[i] || []).length,
|
|
4100
|
+
assertionDetails: (assertionsByAction[i] || []).map((x) => ({
|
|
4101
|
+
type: x.type,
|
|
4102
|
+
index: x.index,
|
|
4103
|
+
text: (x.text || "").slice(0, 30),
|
|
4104
|
+
})),
|
|
4105
|
+
}));
|
|
4106
|
+
console.log("[playRecording] assertions by action:", summary);
|
|
4107
|
+
}
|
|
4108
|
+
const manualByAction = {};
|
|
4109
|
+
for (const mc of manualChecks) {
|
|
4110
|
+
const k = mc.afterAction;
|
|
4111
|
+
if (!manualByAction[k]) manualByAction[k] = [];
|
|
4112
|
+
manualByAction[k].push(mc);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
const testCases = [];
|
|
4116
|
+
let assertionAccum = { passed: 0, total: 0, failures: [] };
|
|
4117
|
+
|
|
4118
|
+
for (let i = 0; i < actions.length; i++) {
|
|
4119
|
+
const action = actions[i];
|
|
4120
|
+
testCases.push([
|
|
4121
|
+
`${action.type} on ${action.target}`,
|
|
4122
|
+
async () => {
|
|
4123
|
+
let el = null;
|
|
4124
|
+
const scope = actionScope;
|
|
4125
|
+
if (action.target) {
|
|
4126
|
+
if (action.listSelector != null && action.targetIndex != null) {
|
|
4127
|
+
const items = scope.querySelectorAll(action.listSelector);
|
|
4128
|
+
const item = items[action.targetIndex];
|
|
4129
|
+
if (item) {
|
|
4130
|
+
el =
|
|
4131
|
+
action.target !== action.listSelector
|
|
4132
|
+
? item.querySelector(action.target)
|
|
4133
|
+
: item;
|
|
4134
|
+
if (!el && action.target !== action.listSelector) el = item;
|
|
4135
|
+
}
|
|
4136
|
+
} else {
|
|
4137
|
+
el = scope.querySelector(action.target);
|
|
3504
4138
|
}
|
|
3505
|
-
} else {
|
|
3506
|
-
el = o.D.querySelector(action.target);
|
|
3507
4139
|
}
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
el.dispatchEvent(new Event(action.type, { bubbles: true }));
|
|
3518
|
-
} else {
|
|
3519
|
-
if (action.type === "click") {
|
|
3520
|
-
el.click();
|
|
4140
|
+
if (!el && action.type !== "scroll") {
|
|
4141
|
+
return `element not found: ${action.target}`;
|
|
4142
|
+
}
|
|
4143
|
+
if (action.type === "scroll") {
|
|
4144
|
+
window.scrollTo(0, action.scrollY || 0);
|
|
4145
|
+
} else if (action.type === "input" || action.type === "change") {
|
|
4146
|
+
if (action.value !== undefined) el.value = action.value;
|
|
4147
|
+
if (action.checked !== undefined) el.checked = action.checked;
|
|
4148
|
+
el.dispatchEvent(new Event(action.type, { bubbles: true }));
|
|
3521
4149
|
} else {
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
4150
|
+
if (action.type === "click") {
|
|
4151
|
+
el.click();
|
|
4152
|
+
} else {
|
|
4153
|
+
el.dispatchEvent(
|
|
4154
|
+
new MouseEvent(action.type, { bubbles: true, cancelable: true }),
|
|
4155
|
+
);
|
|
4156
|
+
}
|
|
3525
4157
|
}
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
4158
|
+
if (actionDelay > 0) await o.sleep(actionDelay);
|
|
4159
|
+
return true;
|
|
4160
|
+
},
|
|
4161
|
+
]);
|
|
4162
|
+
const asserted = assertionsByAction[i];
|
|
4163
|
+
if (runAssertions && asserted && asserted.length > 0) {
|
|
4164
|
+
testCases.push([
|
|
4165
|
+
`assert after ${action.type}`,
|
|
4166
|
+
() =>
|
|
4167
|
+
new Promise((resolve) => {
|
|
4168
|
+
const run = () => {
|
|
4169
|
+
const r = o.runRecordingAssertions(recording, rootEl, i, {
|
|
4170
|
+
assertions: asserted,
|
|
4171
|
+
removedElements: recording.removedElements,
|
|
4172
|
+
});
|
|
4173
|
+
assertionAccum.passed += r.passed;
|
|
4174
|
+
assertionAccum.total += r.total;
|
|
4175
|
+
assertionAccum.failures.push(...r.failures);
|
|
4176
|
+
resolve(
|
|
4177
|
+
r.passed === r.total
|
|
4178
|
+
? true
|
|
4179
|
+
: r.failures.map((f) => f.selector + ": " + f.message).join("; "),
|
|
4180
|
+
);
|
|
4181
|
+
};
|
|
4182
|
+
requestAnimationFrame(() => requestAnimationFrame(run));
|
|
4183
|
+
}),
|
|
4184
|
+
]);
|
|
4185
|
+
}
|
|
4186
|
+
for (const mc of manualByAction[i] || []) {
|
|
4187
|
+
testCases.push([
|
|
4188
|
+
`Manual: ${mc.label}`,
|
|
4189
|
+
() =>
|
|
4190
|
+
typeof o.testConfirm === "function"
|
|
4191
|
+
? o.testConfirm(mc.label, mc.items || [])
|
|
4192
|
+
: { ok: true },
|
|
4193
|
+
]);
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
for (const mc of manualByAction["end"] || []) {
|
|
4197
|
+
testCases.push([
|
|
4198
|
+
`Manual: ${mc.label}`,
|
|
4199
|
+
() =>
|
|
4200
|
+
typeof o.testConfirm === "function"
|
|
4201
|
+
? o.testConfirm(mc.label, mc.items || [])
|
|
4202
|
+
: { ok: true },
|
|
4203
|
+
]);
|
|
4204
|
+
}
|
|
3530
4205
|
|
|
3531
|
-
const
|
|
4206
|
+
const onComplete = isOptions && opts.onComplete;
|
|
4207
|
+
const testId = o.test("Recorded playback", ...testCases, { sync: true }, (testId) => {
|
|
3532
4208
|
window.fetch = origFetch;
|
|
4209
|
+
const assertionResult = runAssertions && assertions.length > 0 ? assertionAccum : undefined;
|
|
4210
|
+
if (assertionResult?.failures?.length > 0) {
|
|
4211
|
+
o.tRes[testId] = false;
|
|
4212
|
+
const failLines = assertionResult.failures
|
|
4213
|
+
.map((f) => `${f.selector}: ${f.message}`)
|
|
4214
|
+
.join("; ");
|
|
4215
|
+
const suffix = o.tStyled
|
|
4216
|
+
? o.tPre + o.tXx + "Assertions failed: " + failLines + o.tDc
|
|
4217
|
+
: "\n✘ Assertions failed: " + failLines;
|
|
4218
|
+
o.tLog[testId] = (o.tLog[testId] || "") + suffix;
|
|
4219
|
+
}
|
|
4220
|
+
if (typeof onComplete === "function") onComplete(assertionResult);
|
|
3533
4221
|
});
|
|
3534
|
-
|
|
4222
|
+
|
|
4223
|
+
return runAssertions ? { testId } : testId;
|
|
3535
4224
|
};
|
|
3536
4225
|
|
|
3537
4226
|
// ─── Test results overlay (all builds — for assessors to see auto + manual results) ───
|
|
@@ -3570,6 +4259,78 @@ o.testOverlay = () => {
|
|
|
3570
4259
|
});
|
|
3571
4260
|
};
|
|
3572
4261
|
|
|
4262
|
+
const innerHTML =
|
|
4263
|
+
`<div style="display:flex;align-items:center;gap:12px;">` +
|
|
4264
|
+
`<span class="o-test-overlay-summary" style="flex:1;font-size:13px;cursor:grab;">Tests: 0/0</span>` +
|
|
4265
|
+
`<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
|
|
4266
|
+
`<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">×</button>` +
|
|
4267
|
+
`</div>` +
|
|
4268
|
+
`<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:240px;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`;
|
|
4269
|
+
const box = o.overlay({
|
|
4270
|
+
innerHTML,
|
|
4271
|
+
removeExisting: false,
|
|
4272
|
+
className: "o-test-overlay",
|
|
4273
|
+
id: btnId,
|
|
4274
|
+
excludeDragSelector: ".o-test-overlay-close, .o-test-overlay-toggle, #" + panelId,
|
|
4275
|
+
});
|
|
4276
|
+
|
|
4277
|
+
const refreshSummary = () => {
|
|
4278
|
+
const summary = o(".o-test-overlay-summary");
|
|
4279
|
+
if (summary.els.length)
|
|
4280
|
+
summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
|
|
4281
|
+
};
|
|
4282
|
+
|
|
4283
|
+
box.first(".o-test-overlay-toggle").on("click", () => {
|
|
4284
|
+
const panel = o("#" + panelId);
|
|
4285
|
+
if (!panel.el) return;
|
|
4286
|
+
const isOpen = panel.el.style.display !== "none";
|
|
4287
|
+
panel.css({ display: isOpen ? "none" : "block" });
|
|
4288
|
+
if (!isOpen) updatePanel();
|
|
4289
|
+
});
|
|
4290
|
+
|
|
4291
|
+
box.first(".o-test-overlay-close").on("click", () => {
|
|
4292
|
+
box._overlayCleanup();
|
|
4293
|
+
});
|
|
4294
|
+
|
|
4295
|
+
o.testOverlay.showPanel = () => {
|
|
4296
|
+
const panel = o("#" + panelId);
|
|
4297
|
+
if (!panel.el) return;
|
|
4298
|
+
panel.css({ display: "block" });
|
|
4299
|
+
updatePanel();
|
|
4300
|
+
refreshSummary();
|
|
4301
|
+
};
|
|
4302
|
+
|
|
4303
|
+
if (!o._testOverlayBase) o._testOverlayBase = o.test;
|
|
4304
|
+
o.test = (...args) => {
|
|
4305
|
+
const id = o._testOverlayBase(...args);
|
|
4306
|
+
const origFn = o.tFns[id];
|
|
4307
|
+
o.tFns[id] = (n) => {
|
|
4308
|
+
if (typeof origFn === "function") origFn(n);
|
|
4309
|
+
const panel = o("#" + panelId);
|
|
4310
|
+
if (panel.el && panel.el.style.display !== "none") updatePanel();
|
|
4311
|
+
refreshSummary();
|
|
4312
|
+
};
|
|
4313
|
+
return id;
|
|
4314
|
+
};
|
|
4315
|
+
};
|
|
4316
|
+
|
|
4317
|
+
/**
|
|
4318
|
+
* Common draggable overlay — shared by testConfirm, testOverlay, confirmOnFailure.
|
|
4319
|
+
* @param {{ innerHTML: string, onClose?: (result?: any) => void, timeout?: number, excludeDragSelector?: string }} opts
|
|
4320
|
+
* @returns {Object} box instance (Objs element)
|
|
4321
|
+
*/
|
|
4322
|
+
o.overlay = (opts = {}) => {
|
|
4323
|
+
const {
|
|
4324
|
+
innerHTML,
|
|
4325
|
+
onClose,
|
|
4326
|
+
timeout,
|
|
4327
|
+
excludeDragSelector,
|
|
4328
|
+
removeExisting = true,
|
|
4329
|
+
className = "o-overlay-common",
|
|
4330
|
+
id,
|
|
4331
|
+
} = opts;
|
|
4332
|
+
if (removeExisting) o("." + className).remove();
|
|
4333
|
+
else if (id && o("#" + id).el) return o("#" + id);
|
|
3573
4334
|
const overlayStyle = {
|
|
3574
4335
|
position: "fixed",
|
|
3575
4336
|
left: "50%",
|
|
@@ -3579,31 +4340,27 @@ o.testOverlay = () => {
|
|
|
3579
4340
|
width: "fit-content",
|
|
3580
4341
|
"max-width": "min(90vw, 420px)",
|
|
3581
4342
|
"font-family": "system-ui,sans-serif",
|
|
3582
|
-
cursor: "grab",
|
|
3583
4343
|
"user-select": "text",
|
|
3584
4344
|
};
|
|
3585
|
-
|
|
4345
|
+
const countdownId = "o-overlay-countdown";
|
|
4346
|
+
const barHtml =
|
|
4347
|
+
`<div class="o-overlay-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;min-width:200px;max-height:90vh;overflow-y:auto;">` +
|
|
4348
|
+
innerHTML +
|
|
4349
|
+
(timeout
|
|
4350
|
+
? `<div id="${countdownId}" style="margin-top:6px;font-size:11px;color:#94a3b8;"></div>`
|
|
4351
|
+
: "") +
|
|
4352
|
+
"</div>";
|
|
3586
4353
|
const box = o
|
|
3587
4354
|
.initState({
|
|
3588
4355
|
tag: "div",
|
|
3589
|
-
|
|
3590
|
-
|
|
4356
|
+
className,
|
|
4357
|
+
id: id || undefined,
|
|
3591
4358
|
style:
|
|
3592
|
-
"position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,420px);font-family:system-ui,sans-serif;
|
|
3593
|
-
html:
|
|
3594
|
-
`<div class="o-test-overlay-bar" style="display:flex;flex-direction:column;align-items:stretch;padding:10px 14px;background:#1e293b;color:#e2e8f0;border:1px solid #334155;border-radius:8px;font-size:14px;cursor:grab;min-width:200px;">` +
|
|
3595
|
-
`<div style="display:flex;align-items:center;gap:12px;">` +
|
|
3596
|
-
`<span class="o-test-overlay-summary" style="flex:1;font-size:13px;">Tests: 0/0</span>` +
|
|
3597
|
-
`<button type="button" class="o-test-overlay-toggle" style="padding:6px 10px;background:#334155;color:#e2e8f0;border:none;border-radius:6px;cursor:pointer;font-size:12px;">List</button>` +
|
|
3598
|
-
`<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">×</button>` +
|
|
3599
|
-
`</div></div>` +
|
|
3600
|
-
`<div id="${panelId}" style="display:none;margin-top:4px;padding:8px;background:#fff;border:1px solid #334155;border-radius:6px;max-height:60vh;overflow-y:auto;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:11px;user-select:text;cursor:text;"></div>`,
|
|
4359
|
+
"position:fixed;left:50%;bottom:50px;transform:translateX(-50%);z-index:999999;width:fit-content;max-width:min(90vw,420px);font-family:system-ui,sans-serif;user-select:text;",
|
|
4360
|
+
html: barHtml,
|
|
3601
4361
|
})
|
|
3602
4362
|
.appendInside("body");
|
|
3603
|
-
|
|
3604
|
-
const applyOverlayStyle = () => {
|
|
3605
|
-
box.css(overlayStyle);
|
|
3606
|
-
};
|
|
4363
|
+
const applyStyle = () => box.css(overlayStyle);
|
|
3607
4364
|
let drag = null;
|
|
3608
4365
|
const onMove = (e) => {
|
|
3609
4366
|
if (!drag) return;
|
|
@@ -3611,71 +4368,49 @@ o.testOverlay = () => {
|
|
|
3611
4368
|
overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
|
|
3612
4369
|
delete overlayStyle.bottom;
|
|
3613
4370
|
overlayStyle.transform = "none";
|
|
3614
|
-
|
|
4371
|
+
applyStyle();
|
|
3615
4372
|
};
|
|
3616
4373
|
const onUp = () => {
|
|
3617
4374
|
if (drag) {
|
|
3618
|
-
overlayStyle.cursor
|
|
3619
|
-
|
|
4375
|
+
delete overlayStyle.cursor;
|
|
4376
|
+
applyStyle();
|
|
3620
4377
|
}
|
|
3621
4378
|
drag = null;
|
|
3622
4379
|
};
|
|
3623
4380
|
box.on("mousedown", (e) => {
|
|
3624
|
-
if (
|
|
3625
|
-
e.target.closest(".o-test-overlay-close") ||
|
|
3626
|
-
e.target.closest(".o-test-overlay-toggle") ||
|
|
3627
|
-
e.target.closest("#" + panelId)
|
|
3628
|
-
)
|
|
3629
|
-
return;
|
|
4381
|
+
if (excludeDragSelector && e.target.closest(excludeDragSelector)) return;
|
|
3630
4382
|
const r = box.el.getBoundingClientRect();
|
|
3631
4383
|
drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
|
|
3632
4384
|
overlayStyle.cursor = "grabbing";
|
|
3633
|
-
|
|
4385
|
+
applyStyle();
|
|
3634
4386
|
});
|
|
3635
4387
|
o.D.addEventListener("mousemove", onMove);
|
|
3636
4388
|
o.D.addEventListener("mouseup", onUp);
|
|
3637
|
-
|
|
3638
|
-
const
|
|
3639
|
-
const summary = o(".o-test-overlay-summary");
|
|
3640
|
-
if (summary.els.length)
|
|
3641
|
-
summary.innerText(`Tests: ${o.tRes.filter(Boolean).length}/${o.tRes.length}`);
|
|
3642
|
-
};
|
|
3643
|
-
|
|
3644
|
-
box.first(".o-test-overlay-toggle").on("click", () => {
|
|
3645
|
-
const panel = o("#" + panelId);
|
|
3646
|
-
if (!panel.el) return;
|
|
3647
|
-
const isOpen = panel.el.style.display !== "none";
|
|
3648
|
-
panel.css({ display: isOpen ? "none" : "block" });
|
|
3649
|
-
if (!isOpen) updatePanel();
|
|
3650
|
-
});
|
|
3651
|
-
|
|
3652
|
-
box.first(".o-test-overlay-close").on("click", () => {
|
|
4389
|
+
let timerId;
|
|
4390
|
+
const cleanup = () => {
|
|
3653
4391
|
o.D.removeEventListener("mousemove", onMove);
|
|
3654
4392
|
o.D.removeEventListener("mouseup", onUp);
|
|
4393
|
+
if (timerId) clearInterval(timerId);
|
|
3655
4394
|
box.remove();
|
|
3656
|
-
});
|
|
3657
|
-
|
|
3658
|
-
o.testOverlay.showPanel = () => {
|
|
3659
|
-
const panel = o("#" + panelId);
|
|
3660
|
-
if (!panel.el) return;
|
|
3661
|
-
panel.css({ display: "block" });
|
|
3662
|
-
updatePanel();
|
|
3663
|
-
refreshSummary();
|
|
3664
|
-
};
|
|
3665
|
-
|
|
3666
|
-
// Single patch of o.test to refresh panel when tests complete (use base so we don't stack)
|
|
3667
|
-
if (!o._testOverlayBase) o._testOverlayBase = o.test;
|
|
3668
|
-
o.test = (...args) => {
|
|
3669
|
-
const id = o._testOverlayBase(...args);
|
|
3670
|
-
const origFn = o.tFns[id];
|
|
3671
|
-
o.tFns[id] = (n) => {
|
|
3672
|
-
if (typeof origFn === "function") origFn(n);
|
|
3673
|
-
const panel = o("#" + panelId);
|
|
3674
|
-
if (panel.el && panel.el.style.display !== "none") updatePanel();
|
|
3675
|
-
refreshSummary();
|
|
3676
|
-
};
|
|
3677
|
-
return id;
|
|
3678
4395
|
};
|
|
4396
|
+
if (timeout && timeout > 0) {
|
|
4397
|
+
let remaining = Math.ceil(timeout / 1000);
|
|
4398
|
+
const cd = o("#" + countdownId);
|
|
4399
|
+
if (cd.el) cd.el.textContent = remaining ? `Continue in ${remaining}s` : "";
|
|
4400
|
+
timerId = setInterval(() => {
|
|
4401
|
+
remaining -= 1;
|
|
4402
|
+
if (cd.el) cd.el.textContent = remaining > 0 ? `Continue in ${remaining}s` : "";
|
|
4403
|
+
if (remaining <= 0) {
|
|
4404
|
+
clearInterval(timerId);
|
|
4405
|
+
timerId = null;
|
|
4406
|
+
cleanup();
|
|
4407
|
+
if (typeof onClose === "function") onClose({ ok: false, errors: ["timeout"] });
|
|
4408
|
+
}
|
|
4409
|
+
}, 1000);
|
|
4410
|
+
}
|
|
4411
|
+
box._overlayCleanup = cleanup;
|
|
4412
|
+
box._overlayOnClose = onClose;
|
|
4413
|
+
return box;
|
|
3679
4414
|
};
|
|
3680
4415
|
|
|
3681
4416
|
/**
|
|
@@ -3683,12 +4418,11 @@ o.testOverlay = () => {
|
|
|
3683
4418
|
* Only available in dev builds. NOT referenced in exportPlaywrightTest.
|
|
3684
4419
|
* @param {string} label - Test title (shown as "Test title: Paused")
|
|
3685
4420
|
* @param {string[]} [items] - Optional checklist for the operator (e.g. hover effects to verify); use labels so clicking text toggles checkbox
|
|
3686
|
-
* @param {{ confirm?: string }} [opts] - Continue button label (default "Continue")
|
|
4421
|
+
* @param {{ confirm?: string, timeout?: number }} [opts] - Continue button label (default "Continue"); timeout in ms for countdown
|
|
3687
4422
|
* @returns {Promise<{ ok: boolean, errors?: string[] }>} ok true if all items checked; errors = list of unchecked item texts when ok false
|
|
3688
4423
|
*/
|
|
3689
4424
|
o.testConfirm = (label, items = [], opts = {}) =>
|
|
3690
4425
|
new Promise((resolve) => {
|
|
3691
|
-
o(".o-tc-overlay").remove();
|
|
3692
4426
|
const btnLabel = opts.confirm || "Continue";
|
|
3693
4427
|
const hasCheckboxes = items.length > 0;
|
|
3694
4428
|
const btnBg = hasCheckboxes ? "#dc2626" : "#2563eb";
|
|
@@ -3697,7 +4431,7 @@ o.testConfirm = (label, items = [], opts = {}) =>
|
|
|
3697
4431
|
".o-tc-item-cb{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:2px solid #ef4444;border-radius:3px;background:#fef2f2;flex-shrink:0;cursor:pointer;}" +
|
|
3698
4432
|
".o-tc-item-cb:checked{border-color:#22c55e;background:#22c55e;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E\");background-size:12px 12px;background-position:center;}";
|
|
3699
4433
|
const itemsHtml = hasCheckboxes
|
|
3700
|
-
? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;">` +
|
|
4434
|
+
? `<style>${checkboxStyle}</style><ul class="o-tc-list" style="margin:8px 0 0;padding:0;list-style:none;font-size:13px;color:#cbd5e1;cursor:grab;">` +
|
|
3701
4435
|
items
|
|
3702
4436
|
.map(
|
|
3703
4437
|
(i, idx) =>
|
|
@@ -3706,23 +4440,18 @@ o.testConfirm = (label, items = [], opts = {}) =>
|
|
|
3706
4440
|
.join("") +
|
|
3707
4441
|
"</ul>"
|
|
3708
4442
|
: "";
|
|
3709
|
-
const
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
itemsHtml +
|
|
3722
|
-
`</div>`,
|
|
3723
|
-
})
|
|
3724
|
-
.appendInside("body");
|
|
3725
|
-
|
|
4443
|
+
const innerHTML =
|
|
4444
|
+
`<div style="display:flex;align-items:center;gap:12px;">` +
|
|
4445
|
+
`<span class="o-tc-label" style="flex:1;cursor:grab;">${label}: Paused</span>` +
|
|
4446
|
+
`<button type="button" class="o-tc-ok" style="padding:6px 14px;background:${btnBg};color:#fff;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;flex-shrink:0;">${btnLabel}</button>` +
|
|
4447
|
+
`</div>` +
|
|
4448
|
+
itemsHtml;
|
|
4449
|
+
const box = o.overlay({
|
|
4450
|
+
innerHTML,
|
|
4451
|
+
timeout: opts.timeout,
|
|
4452
|
+
excludeDragSelector: ".o-tc-ok",
|
|
4453
|
+
onClose: (r) => resolve(r || { ok: true }),
|
|
4454
|
+
});
|
|
3726
4455
|
const okBtnStyles = {
|
|
3727
4456
|
padding: "6px 14px",
|
|
3728
4457
|
background: hasCheckboxes ? "#dc2626" : "#2563eb",
|
|
@@ -3736,70 +4465,23 @@ o.testConfirm = (label, items = [], opts = {}) =>
|
|
|
3736
4465
|
};
|
|
3737
4466
|
if (hasCheckboxes) {
|
|
3738
4467
|
const okBtn = box.first(".o-tc-ok");
|
|
3739
|
-
const cbs = o(".o-
|
|
4468
|
+
const cbs = o(".o-overlay-common .o-tc-item-cb");
|
|
3740
4469
|
const updateBtn = () => {
|
|
3741
4470
|
const allChecked = cbs.length > 0 && cbs.els.every((el) => el.checked);
|
|
3742
4471
|
okBtn.css({ ...okBtnStyles, background: allChecked ? "#16a34a" : "#dc2626" });
|
|
3743
4472
|
};
|
|
3744
4473
|
cbs.on("change", updateBtn);
|
|
3745
4474
|
}
|
|
3746
|
-
|
|
3747
|
-
let drag = null;
|
|
3748
|
-
const overlayStyle = {
|
|
3749
|
-
position: "fixed",
|
|
3750
|
-
left: "50%",
|
|
3751
|
-
bottom: "50px",
|
|
3752
|
-
transform: "translateX(-50%)",
|
|
3753
|
-
"z-index": "999999",
|
|
3754
|
-
width: "fit-content",
|
|
3755
|
-
"max-width": "min(90vw, 400px)",
|
|
3756
|
-
"font-family": "system-ui,sans-serif",
|
|
3757
|
-
cursor: "grab",
|
|
3758
|
-
"user-select": "text",
|
|
3759
|
-
};
|
|
3760
|
-
const applyOverlayStyle = () => {
|
|
3761
|
-
box.css(overlayStyle);
|
|
3762
|
-
};
|
|
3763
|
-
const onMove = (e) => {
|
|
3764
|
-
if (!drag) return;
|
|
3765
|
-
overlayStyle.left = drag.left + (e.clientX - drag.startX) + "px";
|
|
3766
|
-
overlayStyle.top = drag.top + (e.clientY - drag.startY) + "px";
|
|
3767
|
-
delete overlayStyle.bottom;
|
|
3768
|
-
overlayStyle.transform = "none";
|
|
3769
|
-
applyOverlayStyle();
|
|
3770
|
-
};
|
|
3771
|
-
const onUp = () => {
|
|
3772
|
-
if (drag) {
|
|
3773
|
-
overlayStyle.cursor = "grab";
|
|
3774
|
-
applyOverlayStyle();
|
|
3775
|
-
}
|
|
3776
|
-
drag = null;
|
|
3777
|
-
};
|
|
3778
|
-
box.on("mousedown", (e) => {
|
|
3779
|
-
if (e.target.closest(".o-tc-ok")) return;
|
|
3780
|
-
const r = box.el.getBoundingClientRect();
|
|
3781
|
-
drag = { startX: e.clientX, startY: e.clientY, left: r.left, top: r.top };
|
|
3782
|
-
overlayStyle.cursor = "grabbing";
|
|
3783
|
-
applyOverlayStyle();
|
|
3784
|
-
});
|
|
3785
|
-
o.D.addEventListener("mousemove", onMove);
|
|
3786
|
-
o.D.addEventListener("mouseup", onUp);
|
|
3787
|
-
|
|
3788
4475
|
box.first(".o-tc-ok").on("click", () => {
|
|
3789
|
-
o.D.removeEventListener("mousemove", onMove);
|
|
3790
|
-
o.D.removeEventListener("mouseup", onUp);
|
|
3791
4476
|
let unchecked = [];
|
|
3792
4477
|
if (hasCheckboxes) {
|
|
3793
|
-
const cbsList = o(".o-
|
|
3794
|
-
cbsList.els.
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
box.remove();
|
|
3799
|
-
if (unchecked.length === 0) {
|
|
3800
|
-
resolve({ ok: true });
|
|
3801
|
-
} else {
|
|
3802
|
-
resolve({ ok: false, errors: unchecked });
|
|
4478
|
+
const cbsList = o(".o-overlay-common .o-tc-item-cb");
|
|
4479
|
+
if (cbsList.els.length)
|
|
4480
|
+
cbsList.els.forEach((el, idx) => {
|
|
4481
|
+
if (!el.checked && items[idx] !== undefined) unchecked.push(items[idx]);
|
|
4482
|
+
});
|
|
3803
4483
|
}
|
|
4484
|
+
box._overlayCleanup();
|
|
4485
|
+
resolve(unchecked.length === 0 ? { ok: true } : { ok: false, errors: unchecked });
|
|
3804
4486
|
});
|
|
3805
4487
|
});
|