@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 +1 -1
- package/src/browser.ts +77 -33
- package/src/components/expo.ts +8 -1
- package/src/daemon.ts +2 -0
- package/src/index.ts +40 -4
- package/src/locator.ts +21 -3
- package/src/mobile.ts +13 -3
- package/src/recorder.ts +1 -1
package/package.json
CHANGED
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
|
-
/**
|
|
959
|
-
*
|
|
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)
|
|
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>,
|
package/src/components/expo.ts
CHANGED
|
@@ -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
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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", "
|
|
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;
|