@specific.dev/spectest 0.28.2 → 0.29.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/dist/browser.d.ts +1 -0
- package/dist/browser.js +81 -0
- package/dist/daemon.js +3 -3
- package/dist/locator.js +4 -2
- package/dist/recorder.d.ts +2 -0
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
package/dist/browser.js
CHANGED
|
@@ -550,6 +550,87 @@ const RRWEB_BOOTSTRAP = `
|
|
|
550
550
|
}, true);
|
|
551
551
|
} catch (e) { log("init FAIL " + (e && e.message)); }
|
|
552
552
|
})();
|
|
553
|
+
// ── Record form resets as synthetic Input events ───────────────────
|
|
554
|
+
// rrweb learns that a control's value changed through exactly two
|
|
555
|
+
// channels: the \`input\`/\`change\` events, and setters it hooks on the
|
|
556
|
+
// HTMLInput/TextArea/SelectElement prototypes. A **form reset** uses
|
|
557
|
+
// neither — the browser clears the controls internally — so the
|
|
558
|
+
// recording keeps the last typed value forever while the real page
|
|
559
|
+
// shows an empty field. Measured, not assumed: \`el.value = x\` IS
|
|
560
|
+
// captured (even through React's own per-node \`value\` accessor, since
|
|
561
|
+
// that delegates to the prototype descriptor we hooked first), and
|
|
562
|
+
// \`form.reset()\` is NOT. The shape is everywhere in chat/prompt UIs —
|
|
563
|
+
// shadcn/ai-elements' PromptInput resets the form on submit, as does
|
|
564
|
+
// React 19's automatic \`<form action>\` reset — where it reads as
|
|
565
|
+
// "clicking send didn't clear the box".
|
|
566
|
+
//
|
|
567
|
+
// The \`reset\` event is the hook: it fires (cancelable) BEFORE the
|
|
568
|
+
// controls are reset, for both \`form.reset()\` and a user-clicked
|
|
569
|
+
// \`type="reset"\` button. Snapshot the values there, diff on the next
|
|
570
|
+
// task, and push an Input event per control that actually moved —
|
|
571
|
+
// byte-identical in shape to what rrweb emits for a real input, so
|
|
572
|
+
// the replayer applies it the same way. A prevented or no-op reset
|
|
573
|
+
// changes nothing and therefore emits nothing. We never touch the
|
|
574
|
+
// page's DOM to provoke a recording (\`el.value = el.value\` would
|
|
575
|
+
// work, but it sets the control's dirty-value flag and would change
|
|
576
|
+
// how the app's own later attribute writes render).
|
|
577
|
+
(function () {
|
|
578
|
+
if (window.__spectestFormReset) return;
|
|
579
|
+
window.__spectestFormReset = true;
|
|
580
|
+
var INPUT_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
|
|
581
|
+
function snap(form) {
|
|
582
|
+
var out = [], els;
|
|
583
|
+
try { els = form.elements; } catch (e) { return out; }
|
|
584
|
+
if (!els) return out;
|
|
585
|
+
for (var i = 0; i < els.length; i++) {
|
|
586
|
+
var el = els[i];
|
|
587
|
+
if (!el || !el.tagName) continue;
|
|
588
|
+
if (INPUT_TAGS.indexOf(String(el.tagName).toUpperCase()) < 0) continue;
|
|
589
|
+
out.push({ el: el, value: el.value, checked: !!el.checked });
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
function emitChanged(before) {
|
|
594
|
+
var mirror = rec && rec.mirror;
|
|
595
|
+
if (!mirror || typeof mirror.getId !== "function") return;
|
|
596
|
+
for (var i = 0; i < before.length; i++) {
|
|
597
|
+
var el = before[i].el;
|
|
598
|
+
if (el.value === before[i].value && !!el.checked === before[i].checked) continue;
|
|
599
|
+
if (el.classList && el.classList.contains("rr-ignore")) continue;
|
|
600
|
+
var id = mirror.getId(el);
|
|
601
|
+
if (!(id > 0)) continue;
|
|
602
|
+
var type = el.type ? String(el.type).toLowerCase() : "";
|
|
603
|
+
var text = String(el.value);
|
|
604
|
+
// rrweb masks password inputs by default (maskInputOptions
|
|
605
|
+
// \`{ password: true }\`, default maskInputFn = same-length '*').
|
|
606
|
+
if (type === "password") {
|
|
607
|
+
var masked = "";
|
|
608
|
+
for (var k = 0; k < text.length; k++) masked += "*";
|
|
609
|
+
text = masked;
|
|
610
|
+
}
|
|
611
|
+
window.__spectestRrwebEvents.push({
|
|
612
|
+
type: 3,
|
|
613
|
+
data: {
|
|
614
|
+
source: 5,
|
|
615
|
+
text: text,
|
|
616
|
+
isChecked: (type === "radio" || type === "checkbox") ? !!el.checked : false,
|
|
617
|
+
id: id,
|
|
618
|
+
},
|
|
619
|
+
timestamp: Date.now(),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
document.addEventListener("reset", function (ev) {
|
|
625
|
+
var form = ev.target;
|
|
626
|
+
if (!form || !form.tagName || String(form.tagName).toUpperCase() !== "FORM") return;
|
|
627
|
+
var before = snap(form);
|
|
628
|
+
if (!before.length) return;
|
|
629
|
+
// The controls are reset after dispatch returns — diff then.
|
|
630
|
+
setTimeout(function () { emitChanged(before); }, 0);
|
|
631
|
+
}, true);
|
|
632
|
+
} catch (e) { /* recording continues without reset capture. */ }
|
|
633
|
+
})();
|
|
553
634
|
})();
|
|
554
635
|
`;
|
|
555
636
|
// The `;` between parts is load-bearing: a vendored bundle that ends
|
package/dist/daemon.js
CHANGED
|
@@ -374,8 +374,8 @@ function progressDone() {
|
|
|
374
374
|
}
|
|
375
375
|
// BuildKit (docker buildx) gives per-step timing via `--progress=plain`,
|
|
376
376
|
// parallel stages, and `RUN --mount=type=cache`. Detected once: where the
|
|
377
|
-
// buildx plugin isn't installed
|
|
378
|
-
//
|
|
377
|
+
// buildx plugin isn't installed we fall back to the legacy builder,
|
|
378
|
+
// which takes no `--progress` flag.
|
|
379
379
|
let _buildxAvailable;
|
|
380
380
|
async function hasBuildx() {
|
|
381
381
|
if (_buildxAvailable === undefined) {
|
|
@@ -3497,7 +3497,7 @@ async function runOne(testCase) {
|
|
|
3497
3497
|
//
|
|
3498
3498
|
// Unlike browsers, we don't auto-close terminals at test end. A
|
|
3499
3499
|
// `docker exec` subprocess is cheap to keep alive (a few KB), and
|
|
3500
|
-
//
|
|
3500
|
+
// the VM snapshot captures it cleanly along with the container
|
|
3501
3501
|
// — so leaving it running between tests doesn't leak in any
|
|
3502
3502
|
// meaningful sense. Auto-closing only added a noisy `close` step
|
|
3503
3503
|
// at the end of every test that used `openTerminal`.
|
package/dist/locator.js
CHANGED
|
@@ -203,7 +203,7 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
203
203
|
// One recorded event, result NOT wrapped (void/action).
|
|
204
204
|
const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
|
|
205
205
|
// One recorded event, result provenance-wrapped for expect().
|
|
206
|
-
const read = (action, fn) => backend.pageOp(action, { selector: label }, (page) => fn(lower(page, chain)), {
|
|
206
|
+
const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
|
|
207
207
|
wrap: true,
|
|
208
208
|
});
|
|
209
209
|
// Silent, non-recorded reads for expect(locator) matchers to poll.
|
|
@@ -263,7 +263,9 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
263
263
|
innerText: (opts) => read("innerText", (l) => l.innerText({ timeout: opts?.timeout })),
|
|
264
264
|
innerHTML: (opts) => read("innerHTML", (l) => l.innerHTML({ timeout: opts?.timeout })),
|
|
265
265
|
inputValue: (opts) => read("inputValue", (l) => l.inputValue({ timeout: opts?.timeout })),
|
|
266
|
-
getAttribute: (name, opts) => read("getAttribute", (l) => l.getAttribute(name, { timeout: opts?.timeout })
|
|
266
|
+
getAttribute: (name, opts) => read("getAttribute", (l) => l.getAttribute(name, { timeout: opts?.timeout }), {
|
|
267
|
+
attribute: name,
|
|
268
|
+
}),
|
|
267
269
|
isVisible: () => read("isVisible", (l) => l.isVisible()),
|
|
268
270
|
isHidden: () => read("isHidden", (l) => l.isHidden()),
|
|
269
271
|
isEnabled: (opts) => read("isEnabled", (l) => l.isEnabled({ timeout: opts?.timeout })),
|
package/dist/recorder.d.ts
CHANGED
|
@@ -358,6 +358,8 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
358
358
|
textTruncated?: boolean;
|
|
359
359
|
/** Key name (press). */
|
|
360
360
|
key?: string;
|
|
361
|
+
/** Attribute name (getAttribute). */
|
|
362
|
+
attribute?: string;
|
|
361
363
|
/** Scroll/click coordinates. */
|
|
362
364
|
dx?: number;
|
|
363
365
|
dy?: number;
|