@specific.dev/spectest 0.23.0 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.23.0",
3
+ "version": "0.26.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -403,6 +403,12 @@ function deviceContextOptions(d: DevicePreset) {
403
403
  isMobile: d.isMobile,
404
404
  hasTouch: d.hasTouch,
405
405
  userAgent: d.userAgent,
406
+ // Emulate `prefers-reduced-motion: reduce` for replay fidelity — an app
407
+ // that honors it skips entrance animations/transitions, so rrweb's
408
+ // per-op FullSnapshot captures the settled DOM instead of a mid-fade
409
+ // (opacity 0) frame that a paused seek would freeze on. Applied at every
410
+ // newContext site below so all sessions record the same way.
411
+ reducedMotion: "reduce" as const,
406
412
  };
407
413
  }
408
414
 
@@ -411,6 +417,8 @@ async function ensureDesktopContext(): Promise<BrowserContext> {
411
417
  if (DESKTOP_CTX) return DESKTOP_CTX;
412
418
  DESKTOP_CTX = await browser.newContext({
413
419
  viewport: { width: 1280, height: 720 },
420
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
421
+ reducedMotion: "reduce",
414
422
  });
415
423
  DESKTOP_CTX.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
416
424
  DESKTOP_CTX.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
@@ -1031,7 +1039,11 @@ async function contextFor(
1031
1039
  return { context: await ensureDesktopContext(), ownsContext: false };
1032
1040
  }
1033
1041
  const browser = await ensurePlaywrightBrowser();
1034
- const context = await browser.newContext({ viewport: { width, height } });
1042
+ const context = await browser.newContext({
1043
+ viewport: { width, height },
1044
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
1045
+ reducedMotion: "reduce",
1046
+ });
1035
1047
  context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
1036
1048
  context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
1037
1049
  return { context, ownsContext: true };
@@ -35,6 +35,13 @@ export interface ExpoOptions {
35
35
  * too late. Everything else applies to the static-server container only.
36
36
  */
37
37
  env?: Record<string, string>;
38
+ /**
39
+ * Script installed before each `ctx.mobile(ctx.svc.app)` session's first
40
+ * navigation, so it runs ahead of the app bundle on the very first document
41
+ * — no relaunch. Declare shims here once (reduced-motion, `Notification`, …)
42
+ * instead of calling them at the top of every test.
43
+ */
44
+ initScript?: string;
38
45
  }
39
46
 
40
47
  /** The handle `expo()` exposes on `ctx.svc.<name>` — a {@link MobileApp}. */
@@ -154,7 +161,7 @@ ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web
154
161
  // TLS → net::ERR_SSL_PROTOCOL_ERROR), while a dotted host navigates as
155
162
  // plain HTTP. Node-side `fetch` is unaffected, but the mobile session
156
163
  // drives a real browser, so the URL must be browser-navigable.
157
- return mobileApp(`http://${name}.internal:${port}`);
164
+ return mobileApp(`http://${name}.internal:${port}`, opts.initScript);
158
165
  },
159
166
  } satisfies ServiceDefinition<ExpoHelpers>;
160
167
  }
package/src/daemon.ts CHANGED
@@ -3973,6 +3973,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3973
3973
  const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
3974
3974
  url: app.url,
3975
3975
  recorder: session.recorder,
3976
+ initScript: app.initScript,
3976
3977
  });
3977
3978
  if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
3978
3979
  if (attached && session.record.initialUrl === undefined) {
@@ -4295,6 +4296,7 @@ async function evalCode(
4295
4296
  const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
4296
4297
  url: app.url,
4297
4298
  recorder: session.recorder,
4299
+ initScript: app.initScript,
4298
4300
  });
4299
4301
  if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
4300
4302
  if (attached && session.record.initialUrl === undefined) {
package/src/mobile.ts CHANGED
@@ -36,6 +36,11 @@ export interface MobileApp {
36
36
  readonly [MOBILE_APP]: true;
37
37
  /** Resolved in-VM URL of the app's web build (e.g. `http://app.internal:8081`). */
38
38
  readonly url: string;
39
+ /** Optional script installed before the session's first navigation, so it
40
+ * runs ahead of the app bundle on the very first document (no relaunch) —
41
+ * the place to plant reduced-motion / `Notification` shims once for every
42
+ * `ctx.mobile(app)` call. Set via `expo({ initScript })`. */
43
+ readonly initScript?: string;
39
44
  }
40
45
 
41
46
  /** True if `x` is a {@link MobileApp} handle. */
@@ -48,9 +53,10 @@ export function isMobileApp(x: unknown): x is MobileApp {
48
53
  );
49
54
  }
50
55
 
51
- /** Build a {@link MobileApp} handle from a resolved URL. */
52
- export function mobileApp(url: string): MobileApp {
53
- return { [MOBILE_APP]: true, url };
56
+ /** Build a {@link MobileApp} handle from a resolved URL (and an optional
57
+ * init script installed before the session's first navigation). */
58
+ export function mobileApp(url: string, initScript?: string): MobileApp {
59
+ return { [MOBILE_APP]: true, url, initScript };
54
60
  }
55
61
 
56
62
  // ────────────────────────────────────────────────────────────────────────
@@ -102,6 +108,9 @@ export async function openMobile(opts: {
102
108
  export async function openPersistentMobile(opts: {
103
109
  url: string;
104
110
  recorder: BrowserSessionRecorder | null;
111
+ /** Installed before the fresh session's first navigation (ignored on an
112
+ * attached session, which already carries it on the forked holder). */
113
+ initScript?: string;
105
114
  }): Promise<{
106
115
  mobile: Mobile;
107
116
  attached: boolean;
@@ -113,6 +122,7 @@ export async function openPersistentMobile(opts: {
113
122
  const { browser, attached, detach } = await acquirePersistentMobileBackend(
114
123
  opts.url,
115
124
  opts.recorder,
125
+ opts.initScript,
116
126
  );
117
127
  return {
118
128
  mobile: browser,
package/src/recorder.ts CHANGED
@@ -375,7 +375,7 @@ export interface EnvEvent extends BaseEvent {
375
375
  // method name verbatim. Both renderers (control-plane web + CLI) treat it as
376
376
  // an opaque string with a generic `{action} <target>` fallback, so new method
377
377
  // names need no Rust change. Representative values: "goto", "goBack",
378
- // "goForward", "reload", "evaluate", "addInitScript", "waitForFunction",
378
+ // "goForward", "reload", "evaluate", "waitForFunction",
379
379
  // "click", "dblclick", "tap", "fill", "clear", "press", "type", "scroll",
380
380
  // "check", "hover", "textContent", "inputValue", "count", "screenshot".
381
381
  export type BrowserAction = string;