@specific.dev/spectest 0.18.0 → 0.19.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/mobile.ts +44 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/mobile.ts CHANGED
@@ -68,11 +68,17 @@ interface LocatorDesc {
68
68
  regex?: { source: string; flags: string };
69
69
  name?: string;
70
70
  nameExact?: boolean;
71
+ /** Narrowing applied after resolution (`.first()`/`.last()`/`.nth(i)`) —
72
+ * the explicit escape hatch from strict mode. */
73
+ nth?: number | "first" | "last";
71
74
  }
72
75
 
73
76
  /** A lazy reference to a single element, resolved (with auto-wait) at the
74
77
  * moment an action runs. Mirrors the select-then-act idiom shared by
75
- * Playwright, Detox, and RN Testing Library. */
78
+ * Playwright, Detox, and RN Testing Library — including Playwright's
79
+ * STRICT mode: a locator matching multiple elements throws an
80
+ * element-listing error rather than acting on one of them. Narrow with
81
+ * `exact: true`, a testID, or {@link first}/{@link nth}. */
76
82
  export interface MobileLocator {
77
83
  /** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
78
84
  * then touch-tap its center. `durationMs` overrides the touch dwell. */
@@ -94,13 +100,23 @@ export interface MobileLocator {
94
100
  /** The element's text content, wrapped. Waits (default 5s) for the
95
101
  * element to become visible; throws if it never does. */
96
102
  textContent(): Promise<Wrapped<string | null>>;
103
+ /** Narrow to the first resolved element — the explicit opt-out from
104
+ * strict mode when multiple matches are intentional. */
105
+ first(): MobileLocator;
106
+ /** Narrow to the last resolved element. */
107
+ last(): MobileLocator;
108
+ /** Narrow to the i-th resolved element (0-based). */
109
+ nth(index: number): MobileLocator;
97
110
  }
98
111
 
99
- /** Map a locator descriptor onto a playwright Locator. The trailing
100
- * `.filter({ visible: true }).first()` preserves the pre-Playwright
101
- * selection rule: the first VISIBLE match wins, and a hidden duplicate
102
- * (e.g. an offscreen menu item with the same label) neither gets picked
103
- * nor trips playwright's strict-mode multi-match error. */
112
+ /** Map a locator descriptor onto a playwright Locator — STRICT, exactly
113
+ * like stock Playwright: a locator that resolves to multiple elements
114
+ * throws an element-listing "strict mode violation" at action time
115
+ * instead of silently picking one. (An earlier `.filter({visible})
116
+ * .first()` auto-pick deterministically tapped the WRONG element when
117
+ * case-insensitive substring matching made two leaves qualify —
118
+ * `getByText("Men")` also matches "Wo**men**". Loud beats lucky.)
119
+ * Disambiguate with `exact: true`, a testID, or `.first()`/`.nth(i)`. */
104
120
  function pwLocator(page: Page, desc: LocatorDesc): Locator {
105
121
  let base: Locator;
106
122
  switch (desc.kind) {
@@ -126,18 +142,25 @@ function pwLocator(page: Page, desc: LocatorDesc): Locator {
126
142
  );
127
143
  break;
128
144
  }
129
- return base.filter({ visible: true }).first();
145
+ if (desc.nth === "first") return base.first();
146
+ if (desc.nth === "last") return base.last();
147
+ if (typeof desc.nth === "number") return base.nth(desc.nth);
148
+ return base;
130
149
  }
131
150
 
132
151
  /** Short human label for a descriptor, used in event descriptions. */
133
152
  function descLabel(desc: LocatorDesc): string {
153
+ let base: string;
134
154
  if (desc.kind === "text") {
135
- return desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
155
+ base = desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
156
+ } else if (desc.kind === "role") {
157
+ base = desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
158
+ } else {
159
+ base = `${desc.kind} ${JSON.stringify(desc.value)}`;
136
160
  }
137
- if (desc.kind === "role") {
138
- return desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
139
- }
140
- return `${desc.kind} ${JSON.stringify(desc.value)}`;
161
+ if (desc.nth === "first" || desc.nth === "last") return `${base} .${desc.nth}()`;
162
+ if (typeof desc.nth === "number") return `${base} .nth(${desc.nth})`;
163
+ return base;
141
164
  }
142
165
 
143
166
  /** Default wait for a locator action's target to become visible. Override
@@ -223,6 +246,15 @@ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
223
246
  pwLocator(page, desc).textContent({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
224
247
  ) as Promise<Wrapped<string | null>>;
225
248
  },
249
+ first() {
250
+ return makeLocator(backend, { ...desc, nth: "first" });
251
+ },
252
+ last() {
253
+ return makeLocator(backend, { ...desc, nth: "last" });
254
+ },
255
+ nth(index: number) {
256
+ return makeLocator(backend, { ...desc, nth: index });
257
+ },
226
258
  };
227
259
  }
228
260