@specific.dev/spectest 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.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
@@ -78,6 +78,16 @@ export interface BrowserOptions {
78
78
  frame?: "browser" | "mobile";
79
79
  /** Initial URL to navigate to before the constructor returns. */
80
80
  url?: string;
81
+ /**
82
+ * Script installed (before the session's first navigation) to run in every
83
+ * document loaded from now on, BEFORE the document's own scripts — the
84
+ * deterministic way to plant shims (reduced-motion, `Notification`, …) that
85
+ * must beat the app bundle. Unlike installing one after the session is
86
+ * handed back, this wins the race on the FIRST document too, so no relaunch
87
+ * is needed. Rides snapshots into `dependsOn` children. For a mobile app,
88
+ * declare it once on the handle instead — `expo({ initScript })`.
89
+ */
90
+ initScript?: string;
81
91
  /**
82
92
  * Sink that receives rrweb event chunks. Each Browser op (navigate,
83
93
  * click, …) calls `recordStep` with the events that landed in
@@ -212,16 +222,6 @@ export interface Browser {
212
222
  fn: string | ((arg?: unknown) => T | Promise<T>),
213
223
  arg?: unknown,
214
224
  ): Promise<Wrapped<T>>;
215
- /**
216
- * Install a script that runs in every document loaded from now on, BEFORE
217
- * the document's own scripts (CDP `Page.addScriptToEvaluateOnNewDocument` —
218
- * Playwright's `addInitScript`). The deterministic way to plant shims that
219
- * must beat the app bundle. Does NOT run in the *current* document — call it
220
- * before the `goto`/`location.assign` whose document needs it. Persists for
221
- * the session's lifetime (rides snapshots into `dependsOn` children).
222
- * `description` labels the step.
223
- */
224
- addInitScript(description: string, source: string): Promise<void>;
225
225
  /**
226
226
  * Poll `fn` in the page until it returns a truthy value (Playwright's
227
227
  * `page.waitForFunction`), recorded as ONE step with the total wait + poll
@@ -307,6 +307,22 @@ export interface MobileBackend extends Browser {
307
307
  * single recorded event; `touchscreen.tap` is the recorded public twin.
308
308
  */
309
309
  rawTap(x: number, y: number, durationMs?: number): Promise<void>;
310
+ /**
311
+ * Record ONE settled browser event for an `expect(locator)` web-first
312
+ * matcher and return its seq. The matcher already read the value by polling
313
+ * {@link silentRead} (one event per retry would flood the timeline); this
314
+ * emits the single timeline step — the locator label + the session seek
315
+ * point (`sessionTimestamp`) to the settled frame — so the assertion the
316
+ * caller records next nests under it via `sourceSeq`, exactly as
317
+ * `expect(await loc.isVisible())` does. Drains rrweb like any recorded op.
318
+ * Returns `undefined` when nothing is recording.
319
+ */
320
+ recordSettled(
321
+ action: BrowserAction,
322
+ fields: Partial<RecordableFields>,
323
+ waitedMs: number,
324
+ error?: string,
325
+ ): Promise<number | undefined>;
310
326
  }
311
327
 
312
328
  // Default extra flags for headless Chromium inside a Firecracker microVM.
@@ -955,8 +971,9 @@ interface ViewHolder {
955
971
  * views or when the CDP override is unavailable). Stamped onto the
956
972
  * session record so the replay can mirror them. */
957
973
  safeAreaInsets: SafeAreaInsets | null;
958
- /** User scripts installed via `addInitScript`, kept so the DNS-recovery
959
- * rebuild can re-install them on the replacement page. */
974
+ /** Declared init scripts installed on this session (via
975
+ * `BrowserOptions.initScript` / a mobile app's `initScript`), kept so the
976
+ * DNS-recovery rebuild can re-install them on the replacement page. */
960
977
  initScripts: string[];
961
978
  }
962
979
 
@@ -1096,6 +1113,7 @@ export async function openMobileBackend(
1096
1113
  // We deliberately don't forward `opts.url` to the constructor — going
1097
1114
  // through our own `navigate()` keeps the recorder log uniform (one
1098
1115
  // event per navigation, with timing) and drains rrweb after the load.
1116
+ if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
1099
1117
  if (opts.url !== undefined) {
1100
1118
  await backend.goto(opts.url);
1101
1119
  }
@@ -1141,6 +1159,20 @@ async function newHolder(
1141
1159
  return holder;
1142
1160
  }
1143
1161
 
1162
+ /**
1163
+ * Install a declared init script (from `BrowserOptions.initScript` / a mobile
1164
+ * app's `initScript`) on a fresh holder's page: it runs before every document
1165
+ * loaded from now on, ahead of the document's own scripts. Called BEFORE the
1166
+ * session's first navigation, so it wins the race on the first document too.
1167
+ * Recorded via `holder.initScripts` so a DNS-recovery page rebuild
1168
+ * (`rebuildView`) re-installs it. Declared config, not a test action, so it
1169
+ * emits no timeline event.
1170
+ */
1171
+ async function installInitScript(holder: ViewHolder, source: string): Promise<void> {
1172
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
1173
+ holder.initScripts.push(source);
1174
+ }
1175
+
1144
1176
  /**
1145
1177
  * What acquiring a persistent session returns. `detach` is the test-end
1146
1178
  * hook (final rrweb drain, stop writing to this test's recorder, keep the
@@ -1211,6 +1243,12 @@ export async function acquirePersistentBrowser(
1211
1243
  if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
1212
1244
  },
1213
1245
  });
1246
+ // Fresh session only: an attached view already carries the init script on
1247
+ // its (forked) holder, and first-call-wins means a later call's options
1248
+ // don't retroactively apply.
1249
+ if (!attached && opts.initScript !== undefined) {
1250
+ await installInitScript(holder, opts.initScript);
1251
+ }
1214
1252
  if (!attached && opts.url !== undefined) await backend.goto(opts.url);
1215
1253
  return { browser: backend, attached, detach };
1216
1254
  }
@@ -1222,6 +1260,7 @@ export async function acquirePersistentBrowser(
1222
1260
  export async function acquirePersistentMobileBackend(
1223
1261
  url: string,
1224
1262
  recorder: BrowserSessionRecorder | null,
1263
+ initScript?: string,
1225
1264
  ): Promise<PersistentBrowser> {
1226
1265
  const existing = SHARED_MOBILE.get(url);
1227
1266
  const holder =
@@ -1242,7 +1281,12 @@ export async function acquirePersistentMobileBackend(
1242
1281
  if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
1243
1282
  },
1244
1283
  });
1245
- if (!existing) await backend.goto(url);
1284
+ if (!existing) {
1285
+ // Fresh session: install the app's init script before the first
1286
+ // navigation so its first document already has the shims.
1287
+ if (initScript !== undefined) await installInitScript(holder, initScript);
1288
+ await backend.goto(url);
1289
+ }
1246
1290
  return { browser: backend, attached: existing !== undefined, detach };
1247
1291
  }
1248
1292
 
@@ -1541,26 +1585,6 @@ function buildBackend(
1541
1585
  { wrap: true },
1542
1586
  ) as Promise<Wrapped<T>>;
1543
1587
  },
1544
- addInitScript(description: string, source: string): Promise<void> {
1545
- const truncated = truncateUtf8(source);
1546
- return instrumented(
1547
- "addInitScript",
1548
- {
1549
- description,
1550
- script: truncated.value,
1551
- scriptTruncated: truncated.truncated,
1552
- },
1553
- async () => {
1554
- // Raw CDP (not context.addInitScript) so the script stays scoped
1555
- // to THIS page — the desktop context is shared across views.
1556
- await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1557
- source,
1558
- });
1559
- // Remember it so a DNS-recovery page rebuild re-installs it.
1560
- holder.initScripts.push(source);
1561
- },
1562
- );
1563
- },
1564
1588
  async waitForFunction<T = unknown>(
1565
1589
  description: string,
1566
1590
  fn: string | ((arg?: unknown) => T),
@@ -1732,6 +1756,26 @@ function buildBackend(
1732
1756
  silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T> {
1733
1757
  return fn(holder.page);
1734
1758
  },
1759
+ async recordSettled(action, fields, waitedMs, error) {
1760
+ // No page work — the matcher already read the value via silentRead. We
1761
+ // only mint the timeline anchor: the seq the assertion nests under, plus
1762
+ // `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
1763
+ // replay to the frame the assertion observed.
1764
+ const endT = Date.now();
1765
+ const seq = recordBrowser({
1766
+ action,
1767
+ ...fields,
1768
+ ...(recorder
1769
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1770
+ : {}),
1771
+ durationMs: waitedMs,
1772
+ ...(error ? { error } : {}),
1773
+ });
1774
+ // Drain the rrweb the page buffered while the matcher waited into this
1775
+ // step's chunk, so `settledTarget` has bounds to seek into.
1776
+ await drain(action);
1777
+ return seq;
1778
+ },
1735
1779
  pageOp<T>(
1736
1780
  action: BrowserAction,
1737
1781
  fields: Partial<RecordableFields>,
@@ -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/index.ts CHANGED
@@ -1608,6 +1608,32 @@ export interface DefinedEnvironment<
1608
1608
  project(opts: ProjectOpts<S, F>): Project<S, F>;
1609
1609
  }
1610
1610
 
1611
+ /**
1612
+ * The `ctx` type for an environment, for typing shared test helpers without
1613
+ * `any`. Instantiate with the environment from `defineEnvironment`:
1614
+ *
1615
+ * ```ts
1616
+ * const env = defineEnvironment({ ... });
1617
+ * export type AppCtx = Ctx<typeof env>;
1618
+ *
1619
+ * // A helper reaching into ctx.svc / ctx.fakes stays fully typed:
1620
+ * async function runJob(ctx: AppCtx) {
1621
+ * await ctx.svc.db.client`SELECT 1`;
1622
+ * }
1623
+ * ```
1624
+ *
1625
+ * This is the same `ctx` an `env.test(...)` callback receives. The parent
1626
+ * return type is left as `unknown` (helpers rarely touch `ctx.parent` — read
1627
+ * it in the test body and pass the value in). Prefer this over `ctx: any`:
1628
+ * an `any`-typed ctx also defeats the `expect(...)` overloads, silently
1629
+ * resolving `expect(value)` to the `expect(locator)` overload so value
1630
+ * matchers like `.toBe(...)` disappear.
1631
+ */
1632
+ export type Ctx<E> =
1633
+ E extends DefinedEnvironment<infer S, infer F>
1634
+ ? TestContext<unknown, S, F>
1635
+ : never;
1636
+
1611
1637
  /**
1612
1638
  * Define an environment and get back a builder you can hang tests off.
1613
1639
  * The builder's `.test(...)` returns test cases typed against the
@@ -1918,9 +1944,14 @@ function buildLocatorMatchers(
1918
1944
  const probe = getLocatorProbe(loc);
1919
1945
 
1920
1946
  // Poll `check` (a silent, non-recorded read) until the desired condition
1921
- // holds or the deadline elapses, then record ONE assertion event (like a
1922
- // value expect) and throw on failure. `check` returns whether the base
1923
- // condition is satisfied plus the observed value for the timeline.
1947
+ // holds or the deadline elapses, then emit ONE settled browser step (the
1948
+ // locator label + replay seek point) and record ONE assertion nested under
1949
+ // it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
1950
+ // the same provenance a value assertion does (`expect(await loc.isVisible())`
1951
+ // renders identically). Without the anchor step these assertions floated as
1952
+ // disconnected top-level "value ✓" rows with no element and no replay seek.
1953
+ // `check` returns whether the base condition is satisfied plus the observed
1954
+ // value for the timeline.
1924
1955
  const run = async (
1925
1956
  matcher: string,
1926
1957
  timeout: number | undefined,
@@ -1928,7 +1959,8 @@ function buildLocatorMatchers(
1928
1959
  describe: (actual: unknown) => string,
1929
1960
  expected?: unknown,
1930
1961
  ): Promise<void> => {
1931
- const deadline = Date.now() + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
1962
+ const started = Date.now();
1963
+ const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
1932
1964
  let actual: unknown;
1933
1965
  for (;;) {
1934
1966
  let satisfied: boolean;
@@ -1946,6 +1978,7 @@ function buildLocatorMatchers(
1946
1978
  }
1947
1979
  const passed = satisfied !== negated;
1948
1980
  if (passed) {
1981
+ const sourceSeq = await probe.settle(matcher, Date.now() - started);
1949
1982
  recordAssertion({
1950
1983
  matcher,
1951
1984
  negated,
@@ -1953,11 +1986,13 @@ function buildLocatorMatchers(
1953
1986
  actual: safeSerialize(actual),
1954
1987
  expected: expected === undefined ? undefined : safeSerialize(expected),
1955
1988
  message,
1989
+ sourceSeq,
1956
1990
  });
1957
1991
  return;
1958
1992
  }
1959
1993
  if (Date.now() >= deadline) {
1960
1994
  const msg = describe(actual);
1995
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
1961
1996
  recordAssertion({
1962
1997
  matcher,
1963
1998
  negated,
@@ -1966,6 +2001,7 @@ function buildLocatorMatchers(
1966
2001
  expected: expected === undefined ? undefined : safeSerialize(expected),
1967
2002
  error: msg,
1968
2003
  message,
2004
+ sourceSeq,
1969
2005
  });
1970
2006
  throw new ExpectationError(msg);
1971
2007
  }
package/src/locator.ts CHANGED
@@ -279,6 +279,15 @@ export interface LocatorBackend {
279
279
  silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T>;
280
280
  /** CDP touch tap with press dwell (mobile only). */
281
281
  rawTap(x: number, y: number, durationMs?: number): Promise<void>;
282
+ /** Record ONE settled browser event for an `expect(locator)` matcher (label
283
+ * + session seek point) and return its seq, so the assertion nests under it.
284
+ * See {@link "./browser".MobileBackend.recordSettled}. */
285
+ recordSettled(
286
+ action: string,
287
+ fields: Partial<RecordableFields>,
288
+ waitedMs: number,
289
+ error?: string,
290
+ ): Promise<number | undefined>;
282
291
  }
283
292
 
284
293
  /** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
@@ -292,6 +301,12 @@ export interface LocatorProbe {
292
301
  count(): Promise<number>;
293
302
  isEnabled(timeout?: number): Promise<boolean>;
294
303
  isChecked(timeout?: number): Promise<boolean>;
304
+ /** After the silent poll settles, emit the single settled browser step this
305
+ * locator's `expect(...)` matcher assertion nests under, and return its seq
306
+ * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
307
+ * poll time, `error` marks the step failed on a timed-out matcher. Returns
308
+ * `undefined` when nothing is recording. */
309
+ settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
295
310
  }
296
311
 
297
312
  const PROBE: unique symbol = Symbol.for("spectest.locatorProbe");
@@ -393,11 +408,12 @@ export interface Locator {
393
408
  check(opts?: TimeoutOption): Promise<void>;
394
409
  uncheck(opts?: TimeoutOption): Promise<void>;
395
410
  setChecked(checked: boolean, opts?: TimeoutOption): Promise<void>;
396
- /** Select `<option>`(s) by value/label/index; returns the selected values. */
411
+ /** Select `<option>`(s) by value/label/index; returns the selected values,
412
+ * provenance-wrapped so `expect(...)` on them nests under this step. */
397
413
  selectOption(
398
414
  values: string | string[] | { label?: string; value?: string; index?: number },
399
415
  opts?: TimeoutOption,
400
- ): Promise<string[]>;
416
+ ): Promise<Wrapped<string[]>>;
401
417
  hover(opts?: TimeoutOption): Promise<void>;
402
418
  focus(opts?: TimeoutOption): Promise<void>;
403
419
  blur(opts?: TimeoutOption): Promise<void>;
@@ -479,6 +495,8 @@ export function makeLocator(
479
495
  count: () => backend.silentRead((page) => lower(page, chain).count()),
480
496
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
481
497
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
498
+ settle: (action, waitedMs, error) =>
499
+ backend.recordSettled(action, { selector: label }, waitedMs, error),
482
500
  };
483
501
 
484
502
  const loc: InternalLocator = {
@@ -524,7 +542,7 @@ export function makeLocator(
524
542
  setChecked: (checked, opts) =>
525
543
  act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
526
544
  selectOption: (values, opts) =>
527
- act("selectOption", {}, (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
545
+ read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
528
546
  hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
529
547
  focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
530
548
  blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
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;