@specific.dev/spectest 0.26.0 → 0.27.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,260 @@
1
+ import type { Page, Locator as PWLocator } from "playwright-core";
2
+ import type { RecordableFields } from "./browser.js";
3
+ import type { Wrapped } from "./inspect.js";
4
+ /** Default deadline for a locator action/read's target to become actionable.
5
+ * Playwright's own default is 30s — far too slow-failing for tests; 5s
6
+ * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
7
+ * `undefined` falls through to the context default (also set to this in
8
+ * browser.ts's `contextFor`). Navigations keep a longer deadline. */
9
+ export declare const DEFAULT_ACTION_TIMEOUT_MS = 5000;
10
+ export interface GetByTextOptions {
11
+ /** Whole-string, case-sensitive match instead of the default
12
+ * case-insensitive substring. Ignored when the query is a RegExp. */
13
+ exact?: boolean;
14
+ }
15
+ export interface GetByRoleOptions {
16
+ /** Match by accessible name. */
17
+ name?: string | RegExp;
18
+ /** Whole-string, case-sensitive name match. */
19
+ exact?: boolean;
20
+ }
21
+ export interface FilterOptions {
22
+ /** Keep only elements whose subtree contains this text. */
23
+ hasText?: string | RegExp;
24
+ /** Drop elements whose subtree contains this text. */
25
+ hasNotText?: string | RegExp;
26
+ /** Keep only elements that contain a match for this locator. */
27
+ has?: Locator;
28
+ /** Drop elements that contain a match for this locator. */
29
+ hasNot?: Locator;
30
+ }
31
+ export interface ClickOptions {
32
+ timeout?: number;
33
+ force?: boolean;
34
+ button?: "left" | "right" | "middle";
35
+ clickCount?: number;
36
+ delay?: number;
37
+ position?: {
38
+ x: number;
39
+ y: number;
40
+ };
41
+ modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
42
+ }
43
+ export interface TimeoutOption {
44
+ timeout?: number;
45
+ }
46
+ export interface BoundingBox {
47
+ x: number;
48
+ y: number;
49
+ width: number;
50
+ height: number;
51
+ }
52
+ export type WaitForState = "attached" | "detached" | "visible" | "hidden";
53
+ type Step = {
54
+ m: "locator";
55
+ args: [string];
56
+ } | {
57
+ m: "getByRole";
58
+ args: [string, GetByRoleOptions?];
59
+ } | {
60
+ m: "getByText";
61
+ args: [string | RegExp, GetByTextOptions?];
62
+ } | {
63
+ m: "getByLabel";
64
+ args: [string | RegExp, GetByTextOptions?];
65
+ } | {
66
+ m: "getByPlaceholder";
67
+ args: [string | RegExp, GetByTextOptions?];
68
+ } | {
69
+ m: "getByAltText";
70
+ args: [string | RegExp, GetByTextOptions?];
71
+ } | {
72
+ m: "getByTitle";
73
+ args: [string | RegExp, GetByTextOptions?];
74
+ } | {
75
+ m: "getByTestId";
76
+ args: [string];
77
+ } | {
78
+ m: "filter";
79
+ args: [FilterOptions];
80
+ } | {
81
+ m: "and";
82
+ args: [Locator];
83
+ } | {
84
+ m: "or";
85
+ args: [Locator];
86
+ } | {
87
+ m: "first";
88
+ args: [];
89
+ } | {
90
+ m: "last";
91
+ args: [];
92
+ } | {
93
+ m: "nth";
94
+ args: [number];
95
+ };
96
+ interface Chain {
97
+ steps: Step[];
98
+ }
99
+ /** True if `x` is a spectest {@link Locator}. */
100
+ export declare function isLocator(x: unknown): x is Locator;
101
+ /** Fold a chain onto a live page, producing a throwaway playwright Locator.
102
+ * Nested spectest locators (`filter({has})`, `and`, `or`) are lowered
103
+ * recursively against the same page. */
104
+ export declare function lower(page: Page, chain: Chain): PWLocator;
105
+ /** Short human label for a chain, e.g.
106
+ * `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
107
+ * Root first; narrowing steps appended in reading order. */
108
+ export declare function chainLabel(chain: Chain): string;
109
+ /** The slice of the browser backend a locator needs. `pageOp` records one
110
+ * browser event and (when `wrap`) provenance-wraps the result so a later
111
+ * `expect(...)` nests under the step. `silentRead` runs a lowered read with
112
+ * NO recording/drain — the poll path for `expect(locator)` matchers, which
113
+ * must not emit one browser event per retry. */
114
+ export interface LocatorBackend {
115
+ pageOp<T>(action: string, fields: Partial<RecordableFields>, fn: (page: Page) => Promise<T>, opts?: {
116
+ wrap?: boolean;
117
+ }): Promise<T>;
118
+ silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T>;
119
+ /** CDP touch tap with press dwell (mobile only). */
120
+ rawTap(x: number, y: number, durationMs?: number): Promise<void>;
121
+ /** Record ONE settled browser event for an `expect(locator)` matcher (label
122
+ * + session seek point) and return its seq, so the assertion nests under it.
123
+ * See {@link "./browser".MobileBackend.recordSettled}. */
124
+ recordSettled(action: string, fields: Partial<RecordableFields>, waitedMs: number, error?: string): Promise<number | undefined>;
125
+ }
126
+ /** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
127
+ * poll. See {@link getLocatorProbe}. */
128
+ export interface LocatorProbe {
129
+ /** Human label of the locator (for failure messages). */
130
+ readonly label: string;
131
+ isVisible(): Promise<boolean>;
132
+ textContent(timeout?: number): Promise<string | null>;
133
+ inputValue(timeout?: number): Promise<string>;
134
+ count(): Promise<number>;
135
+ isEnabled(timeout?: number): Promise<boolean>;
136
+ isChecked(timeout?: number): Promise<boolean>;
137
+ /** After the silent poll settles, emit the single settled browser step this
138
+ * locator's `expect(...)` matcher assertion nests under, and return its seq
139
+ * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
140
+ * poll time, `error` marks the step failed on a timed-out matcher. Returns
141
+ * `undefined` when nothing is recording. */
142
+ settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
143
+ }
144
+ /** The silent-read probe for a locator — the seam `expect(locator)` matchers
145
+ * poll (in index.ts) without pulling playwright types or the backend into
146
+ * that module. */
147
+ export declare function getLocatorProbe(loc: Locator): LocatorProbe;
148
+ /** Per-session flavor of the `tap()` gesture. Desktop and mobile differ only
149
+ * here; every other locator method is platform-agnostic. */
150
+ export interface ActionStrategy {
151
+ /** Whether this session supports touch. Desktop → `tap()` throws. */
152
+ readonly touch: boolean;
153
+ /** Perform a touch tap on the resolved element (mobile only). */
154
+ tap(backend: LocatorBackend, loc: PWLocator, opts?: {
155
+ timeout?: number;
156
+ duration?: number;
157
+ }): Promise<void>;
158
+ }
159
+ /** Desktop: no touchscreen. `tap()` is a mobile gesture — steer authors to
160
+ * `click()`. */
161
+ export declare const desktopStrategy: ActionStrategy;
162
+ /** Mobile: resolve the element's center and dispatch a real CDP touch with
163
+ * the press dwell RN Pressables need (playwright's `tap()` has no dwell). */
164
+ export declare const mobileStrategy: ActionStrategy;
165
+ /**
166
+ * A lazy reference to element(s), resolved with auto-waiting at the moment an
167
+ * action runs. Mirrors playwright-core's `Locator`: STRICT by default — a
168
+ * locator resolving to multiple elements throws a strict-mode violation at
169
+ * action time rather than silently acting on one. Narrow with `exact: true`,
170
+ * a testid, `filter(...)`, or the explicit `first()`/`last()`/`nth(i)`
171
+ * opt-outs.
172
+ */
173
+ export interface Locator {
174
+ /** Descendant CSS query, scoped to this locator's subtree. */
175
+ locator(css: string): Locator;
176
+ getByRole(role: string, opts?: GetByRoleOptions): Locator;
177
+ getByText(text: string | RegExp, opts?: GetByTextOptions): Locator;
178
+ getByLabel(text: string | RegExp, opts?: GetByTextOptions): Locator;
179
+ getByPlaceholder(text: string | RegExp, opts?: GetByTextOptions): Locator;
180
+ getByAltText(text: string | RegExp, opts?: GetByTextOptions): Locator;
181
+ getByTitle(text: string | RegExp, opts?: GetByTextOptions): Locator;
182
+ getByTestId(testId: string): Locator;
183
+ /** Narrow the current match set by content or by a descendant locator. */
184
+ filter(opts: FilterOptions): Locator;
185
+ /** Intersect with another locator (both must match the same element). */
186
+ and(other: Locator): Locator;
187
+ /** Union with another locator (either may match). */
188
+ or(other: Locator): Locator;
189
+ /** Explicit strict-mode opt-out: the first resolved element. */
190
+ first(): Locator;
191
+ /** Explicit strict-mode opt-out: the last resolved element. */
192
+ last(): Locator;
193
+ /** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
194
+ nth(index: number): Locator;
195
+ click(opts?: ClickOptions): Promise<void>;
196
+ dblclick(opts?: ClickOptions): Promise<void>;
197
+ /** Touch-tap (mobile sessions only; throws on desktop). `duration`
198
+ * overrides the press dwell. */
199
+ tap(opts?: {
200
+ timeout?: number;
201
+ duration?: number;
202
+ }): Promise<void>;
203
+ /** Set an input/textarea's value (React-safe: native setter + input event). */
204
+ fill(value: string, opts?: TimeoutOption): Promise<void>;
205
+ /** Clear an input's value (`fill("")`). */
206
+ clear(opts?: TimeoutOption): Promise<void>;
207
+ /** Focus and press a single key or chord (`"Enter"`, `"Control+A"`). */
208
+ press(key: string, opts?: TimeoutOption): Promise<void>;
209
+ /** Focus and type character-by-character (fires keydown/keyup per char). */
210
+ pressSequentially(text: string, opts?: {
211
+ timeout?: number;
212
+ delay?: number;
213
+ }): Promise<void>;
214
+ check(opts?: TimeoutOption): Promise<void>;
215
+ uncheck(opts?: TimeoutOption): Promise<void>;
216
+ setChecked(checked: boolean, opts?: TimeoutOption): Promise<void>;
217
+ /** Select `<option>`(s) by value/label/index; returns the selected values,
218
+ * provenance-wrapped so `expect(...)` on them nests under this step. */
219
+ selectOption(values: string | string[] | {
220
+ label?: string;
221
+ value?: string;
222
+ index?: number;
223
+ }, opts?: TimeoutOption): Promise<Wrapped<string[]>>;
224
+ hover(opts?: TimeoutOption): Promise<void>;
225
+ focus(opts?: TimeoutOption): Promise<void>;
226
+ blur(opts?: TimeoutOption): Promise<void>;
227
+ scrollIntoViewIfNeeded(opts?: TimeoutOption): Promise<void>;
228
+ dragTo(target: Locator, opts?: TimeoutOption): Promise<void>;
229
+ textContent(opts?: TimeoutOption): Promise<Wrapped<string | null>>;
230
+ innerText(opts?: TimeoutOption): Promise<Wrapped<string>>;
231
+ innerHTML(opts?: TimeoutOption): Promise<Wrapped<string>>;
232
+ inputValue(opts?: TimeoutOption): Promise<Wrapped<string>>;
233
+ getAttribute(name: string, opts?: TimeoutOption): Promise<Wrapped<string | null>>;
234
+ isVisible(): Promise<Wrapped<boolean>>;
235
+ isHidden(): Promise<Wrapped<boolean>>;
236
+ isEnabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
237
+ isDisabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
238
+ isChecked(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
239
+ isEditable(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
240
+ count(): Promise<Wrapped<number>>;
241
+ /** Resolve to one locator per current match (handle-free — each is pinned
242
+ * with `nth(i)`). One recorded event. */
243
+ all(): Promise<Locator[]>;
244
+ allTextContents(): Promise<Wrapped<string[]>>;
245
+ allInnerTexts(): Promise<Wrapped<string[]>>;
246
+ boundingBox(opts?: TimeoutOption): Promise<Wrapped<BoundingBox | null>>;
247
+ /** Evaluate a function against the resolved element. `description` labels
248
+ * the step in the timeline (spectest keeps it for readability). */
249
+ evaluate<T = unknown>(description: string, fn: string | ((el: Element, arg?: unknown) => T), arg?: unknown): Promise<Wrapped<T>>;
250
+ /** Wait until the element reaches `state` (default `"visible"`). */
251
+ waitFor(opts?: {
252
+ state?: WaitForState;
253
+ timeout?: number;
254
+ }): Promise<void>;
255
+ }
256
+ /** Build a lazy {@link Locator} over `chain`. `backend`/`strategy` are the
257
+ * session seams; composition methods extend the chain, terminal methods run
258
+ * through `backend.pageOp`. */
259
+ export declare function makeLocator(backend: LocatorBackend, strategy: ActionStrategy, chain: Chain): Locator;
260
+ export {};
@@ -0,0 +1,293 @@
1
+ // Playwright-native locators for the browser/mobile test surface.
2
+ //
3
+ // A `Locator` mirrors playwright-core's `Locator` shape — the same method
4
+ // names, arguments, and semantics (STRICT mode included) — but it is our own
5
+ // type, not playwright's, so we control exactly what's exposed. Everything
6
+ // here is recordable in the dashboard timeline; playwright methods that bypass
7
+ // the recorder (`elementHandle()`, `page()`, `contentFrame()`) are
8
+ // deliberately absent.
9
+ //
10
+ // The implementation is a **lazy call-chain**. A locator carries a list of
11
+ // `Step`s (getByRole → filter → nth …) and never touches a page until an
12
+ // action runs. At action time `lower(page, chain)` folds the steps onto the
13
+ // live playwright `Page`, producing a real playwright `Locator` that is used
14
+ // once and discarded. This is load-bearing: the persistent browser session
15
+ // swaps its underlying `page` on DNS-recovery (`rebuildView` in browser.ts),
16
+ // so a locator must re-resolve against the *current* page every time — it can
17
+ // never pin a playwright `Locator`/`Page` at creation.
18
+ //
19
+ // Composition (`locator`, `getBy*`, `filter`, `and`, `or`, `first`/`last`/
20
+ // `nth`) returns a new lazy `Locator` with an extended chain. Terminal methods
21
+ // (actions, reads, `waitFor`) run inside `backend.pageOp`, so each
22
+ // author-facing call is exactly one recorded browser event (with its rrweb
23
+ // drain), whatever playwright work it composes underneath.
24
+ import { truncateUtf8 } from "./recorder.js";
25
+ /** Default deadline for a locator action/read's target to become actionable.
26
+ * Playwright's own default is 30s — far too slow-failing for tests; 5s
27
+ * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
28
+ * `undefined` falls through to the context default (also set to this in
29
+ * browser.ts's `contextFor`). Navigations keep a longer deadline. */
30
+ export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
31
+ // Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
32
+ // them (locators are never serialized) while runtime code can still detect a
33
+ // locator and read a nested one's chain during lowering.
34
+ const LOCATOR_BRAND = Symbol.for("spectest.locator");
35
+ const CHAIN = Symbol.for("spectest.locatorChain");
36
+ /** True if `x` is a spectest {@link Locator}. */
37
+ export function isLocator(x) {
38
+ return (typeof x === "object" &&
39
+ x !== null &&
40
+ x[LOCATOR_BRAND] === true);
41
+ }
42
+ function chainOf(loc) {
43
+ return loc[CHAIN];
44
+ }
45
+ // ────────────────────────────────────────────────────────────────────────
46
+ // Lowering: chain → live playwright Locator
47
+ // ────────────────────────────────────────────────────────────────────────
48
+ /** Fold a chain onto a live page, producing a throwaway playwright Locator.
49
+ * Nested spectest locators (`filter({has})`, `and`, `or`) are lowered
50
+ * recursively against the same page. */
51
+ export function lower(page, chain) {
52
+ let cur = page;
53
+ for (const step of chain.steps) {
54
+ cur = applyStep(page, cur, step);
55
+ }
56
+ return cur;
57
+ }
58
+ function applyStep(page, cur, step) {
59
+ switch (step.m) {
60
+ case "locator":
61
+ return cur.locator(step.args[0]);
62
+ case "getByRole":
63
+ return cur.getByRole(step.args[0], step.args[1]);
64
+ case "getByText":
65
+ return cur.getByText(step.args[0], step.args[1]);
66
+ case "getByLabel":
67
+ return cur.getByLabel(step.args[0], step.args[1]);
68
+ case "getByPlaceholder":
69
+ return cur.getByPlaceholder(step.args[0], step.args[1]);
70
+ case "getByAltText":
71
+ return cur.getByAltText(step.args[0], step.args[1]);
72
+ case "getByTitle":
73
+ return cur.getByTitle(step.args[0], step.args[1]);
74
+ case "getByTestId":
75
+ return cur.getByTestId(step.args[0]);
76
+ case "filter": {
77
+ const o = step.args[0];
78
+ return cur.filter({
79
+ hasText: o.hasText,
80
+ hasNotText: o.hasNotText,
81
+ has: o.has ? lower(page, chainOf(o.has)) : undefined,
82
+ hasNot: o.hasNot ? lower(page, chainOf(o.hasNot)) : undefined,
83
+ });
84
+ }
85
+ case "and":
86
+ return cur.and(lower(page, chainOf(step.args[0])));
87
+ case "or":
88
+ return cur.or(lower(page, chainOf(step.args[0])));
89
+ case "first":
90
+ return cur.first();
91
+ case "last":
92
+ return cur.last();
93
+ case "nth":
94
+ return cur.nth(step.args[0]);
95
+ }
96
+ }
97
+ // ────────────────────────────────────────────────────────────────────────
98
+ // Human labels (event `selector` field / dashboard timeline)
99
+ // ────────────────────────────────────────────────────────────────────────
100
+ function textArg(v) {
101
+ return v instanceof RegExp ? `/${v.source}/${v.flags}` : JSON.stringify(v);
102
+ }
103
+ function stepLabel(step) {
104
+ switch (step.m) {
105
+ case "locator":
106
+ return `css ${JSON.stringify(step.args[0])}`;
107
+ case "getByRole": {
108
+ const name = step.args[1]?.name;
109
+ return name !== undefined
110
+ ? `role ${step.args[0]} ${textArg(name)}`
111
+ : `role ${step.args[0]}`;
112
+ }
113
+ case "getByText":
114
+ return `text ${textArg(step.args[0])}`;
115
+ case "getByLabel":
116
+ return `label ${textArg(step.args[0])}`;
117
+ case "getByPlaceholder":
118
+ return `placeholder ${textArg(step.args[0])}`;
119
+ case "getByAltText":
120
+ return `alt ${textArg(step.args[0])}`;
121
+ case "getByTitle":
122
+ return `title ${textArg(step.args[0])}`;
123
+ case "getByTestId":
124
+ return `testid ${JSON.stringify(step.args[0])}`;
125
+ case "filter": {
126
+ const o = step.args[0];
127
+ if (o.has)
128
+ return `filter(has: ${chainLabel(chainOf(o.has))})`;
129
+ if (o.hasNot)
130
+ return `filter(hasNot: ${chainLabel(chainOf(o.hasNot))})`;
131
+ if (o.hasText !== undefined)
132
+ return `filter(hasText: ${textArg(o.hasText)})`;
133
+ if (o.hasNotText !== undefined)
134
+ return `filter(hasNotText: ${textArg(o.hasNotText)})`;
135
+ return "filter()";
136
+ }
137
+ case "and":
138
+ return `and(${chainLabel(chainOf(step.args[0]))})`;
139
+ case "or":
140
+ return `or(${chainLabel(chainOf(step.args[0]))})`;
141
+ case "first":
142
+ return ".first()";
143
+ case "last":
144
+ return ".last()";
145
+ case "nth":
146
+ return `.nth(${step.args[0]})`;
147
+ }
148
+ }
149
+ /** Short human label for a chain, e.g.
150
+ * `role button "15" ‹ filter(has: text "July 2026") ‹ css .calendar`.
151
+ * Root first; narrowing steps appended in reading order. */
152
+ export function chainLabel(chain) {
153
+ const [root, ...rest] = chain.steps;
154
+ if (!root)
155
+ return "<empty>";
156
+ let out = stepLabel(root);
157
+ for (const s of rest) {
158
+ // first/last/nth read naturally appended ("… .first()"); the rest are
159
+ // narrowing relations, joined with a left-arrow.
160
+ out += s.m === "first" || s.m === "last" || s.m === "nth"
161
+ ? ` ${stepLabel(s)}`
162
+ : ` ‹ ${stepLabel(s)}`;
163
+ }
164
+ return out;
165
+ }
166
+ const PROBE = Symbol.for("spectest.locatorProbe");
167
+ /** The silent-read probe for a locator — the seam `expect(locator)` matchers
168
+ * poll (in index.ts) without pulling playwright types or the backend into
169
+ * that module. */
170
+ export function getLocatorProbe(loc) {
171
+ return loc[PROBE];
172
+ }
173
+ /** Desktop: no touchscreen. `tap()` is a mobile gesture — steer authors to
174
+ * `click()`. */
175
+ export const desktopStrategy = {
176
+ touch: false,
177
+ async tap() {
178
+ throw new Error("tap() is a mobile-session gesture (ctx.mobile). On a desktop ctx.browser() session use click().");
179
+ },
180
+ };
181
+ /** Mobile: resolve the element's center and dispatch a real CDP touch with
182
+ * the press dwell RN Pressables need (playwright's `tap()` has no dwell). */
183
+ export const mobileStrategy = {
184
+ touch: true,
185
+ async tap(backend, loc, opts) {
186
+ await loc.waitFor({ state: "visible", timeout: opts?.timeout });
187
+ await loc.scrollIntoViewIfNeeded({ timeout: opts?.timeout });
188
+ const box = await loc.boundingBox();
189
+ if (!box)
190
+ throw new Error("tap: element vanished before it could be tapped");
191
+ await backend.rawTap(box.x + box.width / 2, box.y + box.height / 2, opts?.duration);
192
+ },
193
+ };
194
+ // ────────────────────────────────────────────────────────────────────────
195
+ // Factory
196
+ // ────────────────────────────────────────────────────────────────────────
197
+ /** Build a lazy {@link Locator} over `chain`. `backend`/`strategy` are the
198
+ * session seams; composition methods extend the chain, terminal methods run
199
+ * through `backend.pageOp`. */
200
+ export function makeLocator(backend, strategy, chain) {
201
+ const label = chainLabel(chain);
202
+ const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
203
+ // One recorded event, result NOT wrapped (void/action).
204
+ const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
205
+ // One recorded event, result provenance-wrapped for expect().
206
+ const read = (action, fn) => backend.pageOp(action, { selector: label }, (page) => fn(lower(page, chain)), {
207
+ wrap: true,
208
+ });
209
+ // Silent, non-recorded reads for expect(locator) matchers to poll.
210
+ const probe = {
211
+ label,
212
+ isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
213
+ textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
214
+ inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
215
+ count: () => backend.silentRead((page) => lower(page, chain).count()),
216
+ isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
217
+ isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
218
+ settle: (action, waitedMs, error) => backend.recordSettled(action, { selector: label }, waitedMs, error),
219
+ };
220
+ const loc = {
221
+ [LOCATOR_BRAND]: true,
222
+ [CHAIN]: chain,
223
+ // composition
224
+ locator: (css) => extend({ m: "locator", args: [css] }),
225
+ getByRole: (role, opts) => extend({ m: "getByRole", args: [role, opts] }),
226
+ getByText: (text, opts) => extend({ m: "getByText", args: [text, opts] }),
227
+ getByLabel: (text, opts) => extend({ m: "getByLabel", args: [text, opts] }),
228
+ getByPlaceholder: (text, opts) => extend({ m: "getByPlaceholder", args: [text, opts] }),
229
+ getByAltText: (text, opts) => extend({ m: "getByAltText", args: [text, opts] }),
230
+ getByTitle: (text, opts) => extend({ m: "getByTitle", args: [text, opts] }),
231
+ getByTestId: (id) => extend({ m: "getByTestId", args: [id] }),
232
+ filter: (opts) => extend({ m: "filter", args: [opts] }),
233
+ and: (other) => extend({ m: "and", args: [other] }),
234
+ or: (other) => extend({ m: "or", args: [other] }),
235
+ first: () => extend({ m: "first", args: [] }),
236
+ last: () => extend({ m: "last", args: [] }),
237
+ nth: (i) => extend({ m: "nth", args: [i] }),
238
+ // actions
239
+ click: (opts) => act("click", {}, (l) => l.click(opts)),
240
+ dblclick: (opts) => act("dblclick", {}, (l) => l.dblclick(opts)),
241
+ tap: (opts) => act("tap", {}, (l) => strategy.tap(backend, l, opts)),
242
+ fill: (value, opts) => {
243
+ const t = truncateUtf8(value);
244
+ return act("fill", { text: t.value, textTruncated: t.truncated }, (l) => l.fill(value, { timeout: opts?.timeout }));
245
+ },
246
+ clear: (opts) => act("clear", {}, (l) => l.clear({ timeout: opts?.timeout })),
247
+ press: (key, opts) => act("press", { key }, (l) => l.press(key, { timeout: opts?.timeout })),
248
+ pressSequentially: (text, opts) => {
249
+ const t = truncateUtf8(text);
250
+ return act("pressSequentially", { text: t.value, textTruncated: t.truncated }, (l) => l.pressSequentially(text, { timeout: opts?.timeout, delay: opts?.delay }));
251
+ },
252
+ check: (opts) => act("check", {}, (l) => l.check({ timeout: opts?.timeout })),
253
+ uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
254
+ setChecked: (checked, opts) => act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
255
+ selectOption: (values, opts) => read("selectOption", (l) => l.selectOption(values, { timeout: opts?.timeout })),
256
+ hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
257
+ focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
258
+ blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
259
+ scrollIntoViewIfNeeded: (opts) => act("scrollIntoViewIfNeeded", {}, (l) => l.scrollIntoViewIfNeeded({ timeout: opts?.timeout })),
260
+ dragTo: (target, opts) => act("dragTo", {}, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
261
+ // reads
262
+ textContent: (opts) => read("textContent", (l) => l.textContent({ timeout: opts?.timeout })),
263
+ innerText: (opts) => read("innerText", (l) => l.innerText({ timeout: opts?.timeout })),
264
+ innerHTML: (opts) => read("innerHTML", (l) => l.innerHTML({ timeout: opts?.timeout })),
265
+ inputValue: (opts) => read("inputValue", (l) => l.inputValue({ timeout: opts?.timeout })),
266
+ getAttribute: (name, opts) => read("getAttribute", (l) => l.getAttribute(name, { timeout: opts?.timeout })),
267
+ isVisible: () => read("isVisible", (l) => l.isVisible()),
268
+ isHidden: () => read("isHidden", (l) => l.isHidden()),
269
+ isEnabled: (opts) => read("isEnabled", (l) => l.isEnabled({ timeout: opts?.timeout })),
270
+ isDisabled: (opts) => read("isDisabled", (l) => l.isDisabled({ timeout: opts?.timeout })),
271
+ isChecked: (opts) => read("isChecked", (l) => l.isChecked({ timeout: opts?.timeout })),
272
+ isEditable: (opts) => read("isEditable", (l) => l.isEditable({ timeout: opts?.timeout })),
273
+ count: () => read("count", (l) => l.count()),
274
+ allTextContents: () => read("allTextContents", (l) => l.allTextContents()),
275
+ allInnerTexts: () => read("allInnerTexts", (l) => l.allInnerTexts()),
276
+ boundingBox: (opts) => read("boundingBox", (l) => l.boundingBox({ timeout: opts?.timeout })),
277
+ async all() {
278
+ // count() lowered once (one event), then one child locator per match,
279
+ // each pinned with nth(i). Stays lazy + handle-free.
280
+ const n = await act("all", {}, (l) => l.count());
281
+ const out = [];
282
+ for (let i = 0; i < n; i++)
283
+ out.push(extend({ m: "nth", args: [i] }));
284
+ return out;
285
+ },
286
+ evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => lower(page, chain).evaluate(fn, arg), { wrap: true }),
287
+ waitFor: (opts) => act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
288
+ };
289
+ // Attach the silent-read probe under its symbol (kept off the typed literal
290
+ // so declaration order stays simple).
291
+ loc[PROBE] = probe;
292
+ return loc;
293
+ }
@@ -0,0 +1,71 @@
1
+ import type { Browser, BrowserSessionRecorder, SafeAreaInsets, Touchscreen } from "./browser.js";
2
+ /** Branded handle a mobile-app component (e.g. `expo()`) exposes on
3
+ * `ctx.svc.<name>`. The brand is a `Symbol.for` key so `JSON.stringify`
4
+ * drops it (wire-invisible) while `ctx.mobile(...)` can still type-check
5
+ * against it. */
6
+ export declare const MOBILE_APP: unique symbol;
7
+ /** What `ctx.mobile(...)` accepts — produced by a mobile-app component's
8
+ * `helpers` factory. Carries the in-VM URL the session auto-navigates to. */
9
+ export interface MobileApp {
10
+ readonly [MOBILE_APP]: true;
11
+ /** Resolved in-VM URL of the app's web build (e.g. `http://app.internal:8081`). */
12
+ readonly url: string;
13
+ /** Optional script installed before the session's first navigation, so it
14
+ * runs ahead of the app bundle on the very first document (no relaunch) —
15
+ * the place to plant reduced-motion / `Notification` shims once for every
16
+ * `ctx.mobile(app)` call. Set via `expo({ initScript })`. */
17
+ readonly initScript?: string;
18
+ }
19
+ /** True if `x` is a {@link MobileApp} handle. */
20
+ export declare function isMobileApp(x: unknown): x is MobileApp;
21
+ /** Build a {@link MobileApp} handle from a resolved URL (and an optional
22
+ * init script installed before the session's first navigation). */
23
+ export declare function mobileApp(url: string, initScript?: string): MobileApp;
24
+ /**
25
+ * A phone-emulated app session. The full {@link Browser} surface (locators,
26
+ * `keyboard`/`mouse`, `evaluate`, …) plus a `touchscreen` and `swipe`. Opened
27
+ * via `ctx.mobile(app)` already on the app.
28
+ *
29
+ * The device is fixed (latest iPhone: viewport, DPR, mobile UA, touch). A
30
+ * locator's `tap()` on this session uses a real CDP touch with the RN press
31
+ * dwell; `getByTestId` targets `testID`, `getByLabel` targets
32
+ * `accessibilityLabel`.
33
+ */
34
+ export interface Mobile extends Browser {
35
+ /** Coordinate touch taps (mobile escape hatch — prefer locator `tap()`). */
36
+ readonly touchscreen: Touchscreen;
37
+ /** Swipe the screen in a direction (a touch drag from the center). */
38
+ swipe(direction: "up" | "down" | "left" | "right", opts?: {
39
+ distance?: number;
40
+ }): Promise<void>;
41
+ }
42
+ /**
43
+ * Open an EPHEMERAL phone-emulated session pointed at `url` (`close()`
44
+ * destroys it). Library callers only — the daemon's `ctx.mobile(app)` goes
45
+ * through {@link openPersistentMobile} so sessions survive across tests.
46
+ */
47
+ export declare function openMobile(opts: {
48
+ url?: string;
49
+ recorder: BrowserSessionRecorder | null;
50
+ }): Promise<Mobile>;
51
+ /**
52
+ * Acquire the persistent phone-emulated session for an app (one per app URL,
53
+ * created on first use). The daemon calls this from `ctx.mobile(app)` with a
54
+ * per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
55
+ * the dashboard renders a phone bezel. `detach` is the test-end hook;
56
+ * `mobile.close()` destroys the session for real.
57
+ */
58
+ export declare function openPersistentMobile(opts: {
59
+ url: string;
60
+ recorder: BrowserSessionRecorder | null;
61
+ /** Installed before the fresh session's first navigation (ignored on an
62
+ * attached session, which already carries it on the forked holder). */
63
+ initScript?: string;
64
+ }): Promise<{
65
+ mobile: Mobile;
66
+ attached: boolean;
67
+ detach(): Promise<void>;
68
+ /** Safe-area insets emulated on the view (`null` when the CDP override
69
+ * is unavailable) — the daemon stamps them onto the session record. */
70
+ safeAreaInsets: SafeAreaInsets | null;
71
+ }>;