@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 CHANGED
@@ -311,4 +311,5 @@ export interface RecordableFields {
311
311
  format: string;
312
312
  attempts: number;
313
313
  artifactId: string;
314
+ attribute: string;
314
315
  }
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 (e.g. a Freestyle base without it) we fall
378
- // back to the legacy builder, which takes no `--progress` flag.
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
- // Freestyle's snapshot captures it cleanly along with the container
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 })),
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",