@swmansion/argent 0.15.1-next.5 → 0.15.1-next.6

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.
@@ -133168,7 +133168,7 @@ async function describeVega(_serial) {
133168
133168
 
133169
133169
  // ../tool-server/src/utils/ui-tree-match.ts
133170
133170
  init_zod();
133171
- var selectorSchema = external_exports.object({
133171
+ var selectorFieldsSchema = external_exports.object({
133172
133172
  text: external_exports.string().min(1).optional().describe("Case-insensitive substring of the element's visible label or value."),
133173
133173
  identifier: external_exports.string().min(1).optional().describe(
133174
133174
  "The element's identifier (accessibilityIdentifier / resource-id / testid), matched case-insensitively as the exact identifier or the unqualified resource-id name ('submit' matches 'com.example.app:id/submit')."
@@ -133176,9 +133176,13 @@ var selectorSchema = external_exports.object({
133176
133176
  role: external_exports.string().min(1).optional().describe(
133177
133177
  "Case-insensitive substring of the element's role (e.g. AXButton, button, TextView)."
133178
133178
  )
133179
- }).refine((s) => Boolean(s.text || s.identifier || s.role), {
133180
- message: "selector needs at least one of text, identifier, or role"
133181
- });
133179
+ }).strict();
133180
+ var selectorSchema = selectorFieldsSchema.refine(
133181
+ (s) => Boolean(s.text || s.identifier || s.role),
133182
+ {
133183
+ message: "selector needs at least one of text, identifier, or role"
133184
+ }
133185
+ );
133182
133186
  function nodeText(node) {
133183
133187
  return [node.label, node.value].filter(Boolean).join(" ");
133184
133188
  }
@@ -133195,15 +133199,32 @@ function identifierMatches(actual, needle) {
133195
133199
  if (!actual) return false;
133196
133200
  return equalsCI(actual, needle) || actual.toLowerCase().endsWith(`:id/${needle.toLowerCase()}`);
133197
133201
  }
133202
+ var uiTreeMatchInternals = {
133203
+ createRegExp(pattern) {
133204
+ return new RegExp(pattern);
133205
+ }
133206
+ };
133207
+ function regexMatchesNonEmpty(regex, actual) {
133208
+ if (!actual) return false;
133209
+ return regex.test(actual);
133210
+ }
133198
133211
  function textMatches(actual, expected, mode) {
133212
+ if (mode === "matches") {
133213
+ return regexMatchesNonEmpty(uiTreeMatchInternals.createRegExp(expected), actual);
133214
+ }
133199
133215
  return mode === "equals" ? equalsCI(actual, expected) : includesCI(actual, expected);
133200
133216
  }
133201
- function matchNode(node, selector) {
133217
+ function matchNodeWithRegex(node, selector, textRegex) {
133202
133218
  if (selector.text !== void 0) {
133203
133219
  if (!includesCI(node.label, selector.text) && !includesCI(node.value, selector.text)) {
133204
133220
  return false;
133205
133221
  }
133206
133222
  }
133223
+ if (textRegex !== void 0) {
133224
+ if (!regexMatchesNonEmpty(textRegex, node.label) && !regexMatchesNonEmpty(textRegex, node.value)) {
133225
+ return false;
133226
+ }
133227
+ }
133207
133228
  if (selector.identifier !== void 0 && !identifierMatches(node.identifier, selector.identifier)) {
133208
133229
  return false;
133209
133230
  }
@@ -133212,13 +133233,17 @@ function matchNode(node, selector) {
133212
133233
  }
133213
133234
  return true;
133214
133235
  }
133215
- function collectMatches(node, selector, acc) {
133216
- if (matchNode(node, selector)) acc.push(node);
133217
- for (const child of node.children) collectMatches(child, selector, acc);
133236
+ function selectorTextRegex(selector) {
133237
+ return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(selector.textMatches);
133238
+ }
133239
+ function collectMatches(node, selector, textRegex, acc) {
133240
+ if (matchNodeWithRegex(node, selector, textRegex)) acc.push(node);
133241
+ for (const child of node.children) collectMatches(child, selector, textRegex, acc);
133218
133242
  }
133219
133243
  function findAll(root2, selector) {
133220
133244
  const acc = [];
133221
- for (const child of root2.children) collectMatches(child, selector, acc);
133245
+ const textRegex = selectorTextRegex(selector);
133246
+ for (const child of root2.children) collectMatches(child, selector, textRegex, acc);
133222
133247
  return acc;
133223
133248
  }
133224
133249
  function isVisible(node) {
@@ -133282,21 +133307,29 @@ function nodeAtPoint(root2, point) {
133282
133307
  for (const child of root2.children) walk(child);
133283
133308
  return best;
133284
133309
  }
133285
- function exactFieldCount(node, selector) {
133310
+ function fullConsumptionRegex(selector) {
133311
+ return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(`^(?:${selector.textMatches})$`);
133312
+ }
133313
+ function exactFieldCount(node, selector, fullTextRegex) {
133286
133314
  let count = 0;
133287
133315
  if (selector.text !== void 0 && (equalsCI(node.label, selector.text) || equalsCI(node.value, selector.text))) {
133288
133316
  count++;
133289
133317
  }
133318
+ if (fullTextRegex !== void 0 && (regexMatchesNonEmpty(fullTextRegex, node.label) || regexMatchesNonEmpty(fullTextRegex, node.value))) {
133319
+ count++;
133320
+ }
133290
133321
  if (selector.identifier !== void 0 && equalsCI(node.identifier, selector.identifier)) count++;
133291
133322
  if (selector.role !== void 0 && equalsCI(node.role, selector.role)) count++;
133292
133323
  return count;
133293
133324
  }
133294
133325
  function selectorToFrame(root2, selector) {
133295
133326
  const visible = findAll(root2, selector).filter(isVisible);
133327
+ if (visible.length === 0) return void 0;
133328
+ const fullTextRegex = fullConsumptionRegex(selector);
133296
133329
  let best;
133297
133330
  let bestExact = -1;
133298
133331
  for (const n of visible) {
133299
- const exact = exactFieldCount(n, selector);
133332
+ const exact = exactFieldCount(n, selector, fullTextRegex);
133300
133333
  if (best === void 0 || exact !== bestExact) {
133301
133334
  if (exact > bestExact) {
133302
133335
  best = n;
@@ -144519,14 +144552,67 @@ function chromiumLaunchSpec(launch) {
144519
144552
  return typeof c === "string" ? { path: c } : { path: c.path, args: c.args };
144520
144553
  }
144521
144554
  function selectorToYaml(sel) {
144555
+ if (sel.text !== void 0 && sel.textMatches !== void 0) {
144556
+ throw new Error(
144557
+ 'Cannot serialize flow selector without losing constraints: both `text` and `textMatches` are set, but flow YAML can represent only one `text` constraint (a literal string or `{ matches: "<regex>" }`). Use either literal or regex text matching.'
144558
+ );
144559
+ }
144560
+ if (sel.loose && sel.text !== void 0 && (typeof sel.text !== "string" || sel.text.length === 0)) {
144561
+ throw new Error(
144562
+ "Cannot serialize loose flow selector: `text` must be a non-empty string so bare-string YAML can round-trip through selector validation."
144563
+ );
144564
+ }
144565
+ if (sel.loose && (sel.text === void 0 || sel.textMatches !== void 0 || sel.identifier !== void 0 || sel.role !== void 0)) {
144566
+ const incompatible = [
144567
+ sel.textMatches !== void 0 ? "textMatches" : void 0,
144568
+ sel.identifier !== void 0 ? "identifier" : void 0,
144569
+ sel.role !== void 0 ? "role" : void 0
144570
+ ].filter((field) => field !== void 0);
144571
+ throw new Error(
144572
+ "Cannot serialize loose flow selector without changing its meaning: bare-string YAML can represent only a loose text-only selector" + (incompatible.length > 0 ? `; incompatible fields: ${incompatible.join(", ")}` : "") + "."
144573
+ );
144574
+ }
144522
144575
  if (sel.loose && sel.text !== void 0 && sel.identifier === void 0 && sel.role === void 0) {
144523
144576
  return sel.text;
144524
144577
  }
144525
- const { loose: _loose, identifier, ...rest } = sel;
144526
- return identifier === void 0 ? { ...rest } : { ...rest, id: identifier };
144578
+ const { loose: _loose, identifier, textMatches: textMatches2, ...rest } = sel;
144579
+ const out = { ...rest };
144580
+ if (textMatches2 !== void 0) out.text = { matches: textMatches2 };
144581
+ if (identifier !== void 0) out.id = identifier;
144582
+ return out;
144527
144583
  }
144528
144584
  function describeSelector(s) {
144529
- return Object.entries(s).filter(([k]) => k !== "loose").map(([k, v]) => `${k === "identifier" ? "id" : k}="${v}"`).join(" ");
144585
+ return Object.entries(s).filter(([k]) => k !== "loose").map(
144586
+ ([k, v]) => k === "textMatches" ? `text=/${v}/` : `${k === "identifier" ? "id" : k}="${v}"`
144587
+ ).join(" ");
144588
+ }
144589
+ function describeTextExpectation(expectedText, textMatch, verbForm = "mode") {
144590
+ const expected = expectedText ?? "";
144591
+ const mode = textMatch ?? "contains";
144592
+ switch (mode) {
144593
+ case "contains":
144594
+ return `${verbForm === "infinitive" ? "contain" : mode} ${JSON.stringify(expected)}`;
144595
+ case "equals":
144596
+ return `${verbForm === "infinitive" ? "equal" : mode} ${JSON.stringify(expected)}`;
144597
+ case "matches":
144598
+ return `${verbForm === "infinitive" ? "match" : mode} /${expected}/`;
144599
+ }
144600
+ }
144601
+ function textWaitToYaml(selector, expectedText, textMatch) {
144602
+ const expected = expectedText ?? "";
144603
+ const mode = textMatch ?? "contains";
144604
+ switch (mode) {
144605
+ case "contains":
144606
+ return { text: { in: selector, contains: expected } };
144607
+ case "equals":
144608
+ return { text: { in: selector, equals: expected } };
144609
+ case "matches":
144610
+ return { text: { in: selector, matches: expected } };
144611
+ default: {
144612
+ const exhaustive = mode;
144613
+ throw new Error(`Unsupported text match mode: ${exhaustive}`);
144614
+ }
144615
+ }
144530
144616
  }
144531
144617
  function waitToYaml(condition, selector, expectedText, textMatch, timeoutMs) {
144532
144618
  const sel = selectorToYaml(selector);
@@ -144542,7 +144628,7 @@ function waitToYaml(condition, selector, expectedText, textMatch, timeoutMs) {
144542
144628
  body = { hidden: sel };
144543
144629
  break;
144544
144630
  case "text":
144545
- body = textMatch === "equals" ? { text: { in: sel, equals: expectedText ?? "" } } : { text: { in: sel, contains: expectedText ?? "" } };
144631
+ body = textWaitToYaml(sel, expectedText, textMatch);
144546
144632
  break;
144547
144633
  }
144548
144634
  if (timeoutMs !== void 0) body.timeout = timeoutMs;
@@ -144635,6 +144721,16 @@ function badEntry(raw, detail) {
144635
144721
  error_kind: "validation"
144636
144722
  });
144637
144723
  }
144724
+ function validatePattern(raw, pattern, where) {
144725
+ try {
144726
+ new RegExp(pattern);
144727
+ } catch (err) {
144728
+ badEntry(
144729
+ raw,
144730
+ `${where} \`matches\` is not a valid regular expression: ${err instanceof Error ? err.message : String(err)}`
144731
+ );
144732
+ }
144733
+ }
144638
144734
  function editDistance(a, b) {
144639
144735
  let prevPrev = new Array(b.length + 1);
144640
144736
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
@@ -144698,11 +144794,46 @@ function parseSelector(raw, where) {
144698
144794
  }
144699
144795
  normalized = { ...rest, identifier: id };
144700
144796
  }
144797
+ if (normalized !== null && typeof normalized === "object") {
144798
+ const { text, ...rest } = normalized;
144799
+ if (text !== null && typeof text === "object") {
144800
+ const keys = Object.keys(text);
144801
+ if (!Array.isArray(text)) {
144802
+ rejectUnknownKeys(
144803
+ raw,
144804
+ text,
144805
+ ["matches"],
144806
+ `${where}: text matcher`
144807
+ );
144808
+ }
144809
+ const pattern = text.matches;
144810
+ if (keys.length !== 1 || keys[0] !== "matches") {
144811
+ badEntry(
144812
+ raw,
144813
+ `${where}: a text matcher takes exactly { matches: '<regex>' } \u2014 for a substring, use the plain-string form (text: "\u2026")`
144814
+ );
144815
+ }
144816
+ if (typeof pattern !== "string" || pattern.length === 0) {
144817
+ badEntry(raw, `${where}: text matcher needs a non-empty \`matches\` pattern`);
144818
+ }
144819
+ validatePattern(raw, pattern, `${where}: text`);
144820
+ const fields = selectorFieldsSchema.safeParse(rest);
144821
+ if (!fields.success) {
144822
+ badEntry(raw, `${where}: ${fields.error.issues[0]?.message ?? "invalid selector"}`);
144823
+ }
144824
+ return { ...fields.data, textMatches: pattern };
144825
+ }
144826
+ }
144701
144827
  const r = selectorSchema.safeParse(normalized);
144702
144828
  if (!r.success) badEntry(raw, `${where}: ${r.error.issues[0]?.message ?? "invalid selector"}`);
144703
144829
  return r.data;
144704
144830
  }
144705
144831
  var WAIT_CONDITIONS = ["exists", "visible", "hidden", "text"];
144832
+ var TEXT_MATCH_MODES = Object.keys({
144833
+ contains: true,
144834
+ equals: true,
144835
+ matches: true
144836
+ });
144706
144837
  var SCROLL_DIRECTIONS = ["up", "down", "left", "right"];
144707
144838
  function parseWaitFields(raw, kind) {
144708
144839
  if (raw === null || typeof raw !== "object") {
@@ -144742,22 +144873,30 @@ function parseWaitFields(raw, kind) {
144742
144873
  if (condition === "text") {
144743
144874
  const t = b.text;
144744
144875
  if (t === null || typeof t !== "object") {
144745
- badEntry({ [kind]: b }, `${kind} text needs { in: <selector>, contains|equals: <string> }`);
144876
+ badEntry(
144877
+ { [kind]: b },
144878
+ `${kind} text needs { in: <selector>, contains|equals|matches: <string> }`
144879
+ );
144746
144880
  }
144747
144881
  const tb = t;
144748
144882
  if (!Array.isArray(tb)) {
144749
- rejectUnknownKeys({ [kind]: b }, tb, ["in", "contains", "equals"], `${kind}.text`);
144883
+ rejectUnknownKeys({ [kind]: b }, tb, ["in", ...TEXT_MATCH_MODES], `${kind}.text`);
144750
144884
  }
144751
- const hasContains = "contains" in tb;
144752
- const hasEquals = "equals" in tb;
144753
- if (hasContains === hasEquals) {
144754
- badEntry({ [kind]: b }, `${kind} text needs exactly one of \`contains\` or \`equals\``);
144885
+ const comparators = TEXT_MATCH_MODES.filter((mode) => mode in tb);
144886
+ if (comparators.length !== 1) {
144887
+ badEntry(
144888
+ { [kind]: b },
144889
+ `${kind} text needs exactly one of \`contains\`, \`equals\`, or \`matches\``
144890
+ );
144755
144891
  }
144756
- const textMatch = hasEquals ? "equals" : "contains";
144757
- const expected = hasEquals ? tb.equals : tb.contains;
144892
+ const textMatch = comparators[0];
144893
+ const expected = tb[textMatch];
144758
144894
  if (typeof expected !== "string" || expected.length === 0) {
144759
144895
  badEntry({ [kind]: b }, `${kind} text needs a non-empty \`${textMatch}\``);
144760
144896
  }
144897
+ if (textMatch === "matches") {
144898
+ validatePattern({ [kind]: b }, expected, `${kind} text`);
144899
+ }
144761
144900
  return {
144762
144901
  condition: "text",
144763
144902
  selector: parseSelector(tb.in, `${kind}.text.in`),
@@ -145869,7 +146008,14 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps
145869
146008
  return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
145870
146009
  case "await":
145871
146010
  case "assert": {
145872
- const tail = step.condition === "text" ? `text ${selectorLabel(step.selector)} == "${step.expectedText ?? ""}"` : `${step.condition} ${selectorLabel(step.selector)}`;
146011
+ let tail;
146012
+ if (step.condition !== "text") {
146013
+ tail = `${step.condition} ${selectorLabel(step.selector)}`;
146014
+ } else {
146015
+ const selector = selectorLabel(step.selector);
146016
+ const expected = step.expectedText ?? "";
146017
+ tail = step.textMatch === "matches" ? `text ${selector} matches /${expected}/` : step.textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
146018
+ }
145873
146019
  return `${n}. ${step.kind}: ${tail}`;
145874
146020
  }
145875
146021
  case "wait":
@@ -146263,11 +146409,11 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
146263
146409
  case "text": {
146264
146410
  const first = firstInReadingOrder(matches2.filter(isVisible)) ?? firstInReadingOrder(matches2);
146265
146411
  if (!first) return `no element matched selector ${sel}`;
146266
- const wanted = textMatch === "equals" ? "equal" : "contain";
146412
+ const wanted = describeTextExpectation(expectedText, textMatch, "infinitive");
146267
146413
  const shown = assertText(first);
146268
146414
  const own = nodeText(first);
146269
146415
  const ownNote = own && own !== shown ? ` (own text "${own}")` : "";
146270
- return `element matched ${sel} but its text was "${shown}"${ownNote} (wanted to ${wanted} "${expectedText}")`;
146416
+ return `element matched ${sel} but its text was "${shown}"${ownNote} (wanted to ${wanted})`;
146271
146417
  }
146272
146418
  default:
146273
146419
  return `assertion failed for selector ${sel}`;
@@ -148763,6 +148909,7 @@ function pushReport(state3, report) {
148763
148909
  function selectorLabel2(sel) {
148764
148910
  const parts = [];
148765
148911
  if (sel.text !== void 0) parts.push(`"${sel.text}"`);
148912
+ if (sel.textMatches !== void 0) parts.push(`/${sel.textMatches}/`);
148766
148913
  if (sel.identifier) parts.push(`id=${sel.identifier}`);
148767
148914
  if (sel.role) parts.push(`role=${sel.role}`);
148768
148915
  return parts.join(" ");
@@ -148781,7 +148928,7 @@ function stepTarget(step) {
148781
148928
  case "assert": {
148782
148929
  const sel = selectorLabel2(step.selector);
148783
148930
  if (step.condition === "text") {
148784
- return `${sel} ${step.textMatch ?? "contains"} "${step.expectedText ?? ""}"`;
148931
+ return `${sel} ${describeTextExpectation(step.expectedText, step.textMatch)}`;
148785
148932
  }
148786
148933
  return `${step.condition} ${sel}`;
148787
148934
  }
@@ -149682,7 +149829,7 @@ function normLabel(s) {
149682
149829
  return (s || "").toLowerCase().replace(/-/g, "").replace(/[\s,]+/g, " ").trim();
149683
149830
  }
149684
149831
  var MAX_FRAME_AREA = 0.85;
149685
- function matchNode2(n, match, needle) {
149832
+ function matchNode(n, match, needle) {
149686
149833
  const label = normLabel(n.label);
149687
149834
  const ident = normLabel(n.identifier);
149688
149835
  const value = normLabel(n.value);
@@ -149709,7 +149856,7 @@ function findElementMatch(tree, match) {
149709
149856
  const candidates = [];
149710
149857
  const walk = (n) => {
149711
149858
  if (!n || typeof n !== "object") return;
149712
- const m = matchNode2(n, match, needle);
149859
+ const m = matchNode(n, match, needle);
149713
149860
  if (m && n.frame) {
149714
149861
  const f = n.frame;
149715
149862
  const cx = f.x + f.width / 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.15.1-next.5",
3
+ "version": "0.15.1-next.6",
4
4
  "description": "MCP server for iOS Simulator and Android Emulator control",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,7 +37,9 @@ Beyond raw `tool:` steps and `echo:`, flows support declarative directives inter
37
37
 
38
38
  A **selector** is `{ text?, id?, role? }` (all-must-match; `text`/`role` are case-insensitive substrings, `id` matches the element's testID / accessibilityIdentifier / resource-id exactly, case-insensitive, also accepting the unqualified Android resource-id name — `submit` matches `com.example.app:id/submit`) — the same semantics `await-ui-element` uses, though that tool spells the `id` field `identifier` (flow YAML also accepts `identifier` as an alias for `id`, but `id` is the canonical spelling and what the recorder writes). A bare string is a _loose_ selector: it resolves **identifier-first, then falls back to text** (label/value), so `tap: Login` matches a `testID="Login"` or, failing that, visible text "Login" — no need to know which. Loose fallback applies uniformly to every selector slot (`tap`, `type.into`, `await`, `assert`, `scroll-to`). Use the map form to be strict: `{ id: submit-btn }` (identifier only) or `{ text: Login }` (text only, no fallback).
39
39
 
40
- Selectors resolve against the **full native hierarchy** (iOS: the UIView tree; Android: the complete accessibility hierarchy including not-important views)strictly more than `describe` or the raw `await-ui-element` tool see (both use the trimmed tree), with complete `testID`/`resource-id` coverage. So an `id` selector works even when `describe` collapses or omits the element don't fall back to coordinate taps just because a testID isn't visible in `describe` output. And when several elements match a container aggregates its descendants' text, so `text: "Inner"` matches the wrapping containers too the action directives (`tap`, `type`, `scroll-to`) pick the **most specific** match: an exact text/identifier match beats a substring hit, then the smallest frame wins.
40
+ `text` also takes a **regex matcher map** `{ text: { matches: '^Order #\d+$' } }`, in any selector slot for dynamic text no literal can pin. It tests each node's native **own** label/value (not the adapter-hoisted `subtreeText`), though on iOS a container's own label may itself aggregate descendant text, so a wrapper and its leaf can both match. Same regex rules as `text.in`'s `matches` (see _`await` and `assert`_): unanchored, **case-sensitive**, single-quoted, invalid pattern fails at parse. So `assert: { visible: { text: { matches: '^Taps: \d+$' } } }` asserts a counter is on screen with no locator at all, and `tap: { text: { matches: '^Order #\d+$' } }` taps a dynamic row — though a stable `id` stays the more robust action target.
41
+
42
+ Selectors resolve against the **full native hierarchy** (iOS: the UIView tree; Android: the complete accessibility hierarchy including not-important views) — strictly more than `describe` or the raw `await-ui-element` tool see (both use the trimmed tree), with complete `testID`/`resource-id` coverage. So an `id` selector works even when `describe` collapses or omits the element — don't fall back to coordinate taps just because a testID isn't visible in `describe` output. And when several elements match — including wrappers whose native text aggregates descendant content — the action directives (`tap`, `type`, `scroll-to`) pick the **most specific** match: an exact text/identifier match beats a substring hit (for a regex matcher, a pattern consuming the element's whole text counts as exact), then the smallest frame wins.
41
43
 
42
44
  **Quote strings YAML would mangle.** An unquoted `#` starts a YAML comment — `tap: Order #1234` silently parses as `tap: Order` — and bare `yes`/`no`/`on`/`off`/numbers coerce to non-strings. When a selector or typed text contains `#`, `:`, quotes, or could read as a boolean/number, wrap it: `tap: "Order #1234"`.
43
45
 
@@ -47,6 +49,7 @@ The **condition is the key**, and its value is the selector:
47
49
 
48
50
  - `{ visible: Home }`, `{ exists: { id: row } }`, `{ hidden: spinner }`
49
51
  - `{ text: { in: <selector>, contains: "Taps:" } }` or `{ text: { in: <selector>, equals: "Taps: 0" } }` — `text` locates an element (`in`) and checks its rendered content against exactly one of `contains` (case-insensitive substring) or `equals` (case-insensitive exact match — use it when boundaries matter: `contains: "Taps: 3"` is also satisfied by "Taps: 30"). Reach for `text` only when the locator is an identifier/role; to assert a string is simply on screen, prefer `{ visible: "Taps: 0" }`.
52
+ - `{ text: { in: total, matches: 'Total: \$\d+\.\d{2}' } }` — the third comparator: a JS regex for dynamic content (counters, prices, dates) that neither literal mode can pin. Unanchored like `contains` (anchor with `^…$` for the `equals` analog) and — unlike the literal modes — **case-sensitive**: the pattern carries its own semantics. An invalid pattern fails at parse time. **Quote the pattern in single quotes**: single-quoted and plain YAML scalars keep backslashes; double quotes would need `\\d`. To assert a dynamic string is simply on screen with no locator, prefer a regex **selector** — `{ visible: { text: { matches: '^Taps: \d+$' } } }` (see Selectors); `text.in` + `matches` is for checking a specific element's aggregated text.
50
53
  - A container's text aggregates its descendants' text (space-joined), so `text` can assert what a testID wrapper visibly shows even when the string lives in a child node. That also means `equals` against a wrapper must match _everything_ it shows or exactly the wrapper's own label/value — targeting the leaf holding exactly the value (or using `contains`) stays the clearer spelling.
51
54
 
52
55
  This condition-as-key form is the only spelling. `await` also accepts an optional `timeout` sibling key in milliseconds — `- await: { visible: Home, timeout: 15000 }` — for a transition that legitimately needs longer than the default budget. **Omit `timeout` by default**: the default budget covers normal transitions, and a habitual generous override just delays failure reporting on every broken step. Add one only after a step demonstrably needs it — it timed out at the default and the wait is legitimately slow (a cold start, a network round-trip, a long animation). `assert` has no timeout override: a check that needs seconds to become true is a wait — spell it `await`.