@swmansion/argent 0.17.1-next.2 → 0.17.1-next.3

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.
@@ -133654,6 +133654,9 @@ function textMatches(actual, expected, mode) {
133654
133654
  }
133655
133655
  return mode === "equals" ? equalsCI(actual, expected) : includesCI(actual, expected);
133656
133656
  }
133657
+ function hasOwnConstraint(selector) {
133658
+ return selector.text !== void 0 || selector.textMatches !== void 0 || selector.identifier !== void 0 || selector.role !== void 0;
133659
+ }
133657
133660
  function matchNodeWithRegex(node, selector, textRegex) {
133658
133661
  if (selector.text !== void 0) {
133659
133662
  if (!includesCI(node.label, selector.text) && !includesCI(node.value, selector.text)) {
@@ -133676,15 +133679,111 @@ function matchNodeWithRegex(node, selector, textRegex) {
133676
133679
  function selectorTextRegex(selector) {
133677
133680
  return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(selector.textMatches);
133678
133681
  }
133679
- function collectMatches(node, selector, textRegex, acc) {
133680
- if (matchNodeWithRegex(node, selector, textRegex)) acc.push(node);
133681
- for (const child of node.children) collectMatches(child, selector, textRegex, acc);
133682
+ var WITHIN_EPS = 5e-3;
133683
+ function frameWithin(inner, outer) {
133684
+ return inner.x >= outer.x - WITHIN_EPS && inner.y >= outer.y - WITHIN_EPS && inner.x + inner.width <= outer.x + outer.width + WITHIN_EPS && inner.y + inner.height <= outer.y + outer.height + WITHIN_EPS;
133685
+ }
133686
+ var CONTAINMENT_GRID_N = 16;
133687
+ var CONTAINMENT_GRID_MIN = 32;
133688
+ function gridCell(coord) {
133689
+ const c = Math.floor(coord * CONTAINMENT_GRID_N);
133690
+ return c < 0 ? 0 : c >= CONTAINMENT_GRID_N ? CONTAINMENT_GRID_N - 1 : c;
133691
+ }
133692
+ function containmentTester(containers) {
133693
+ if (containers.length < CONTAINMENT_GRID_MIN) {
133694
+ return (node) => containers.some((c) => c !== node && frameWithin(node.frame, c.frame));
133695
+ }
133696
+ const cells = /* @__PURE__ */ new Map();
133697
+ for (const c of containers) {
133698
+ const f = c.frame;
133699
+ const colEnd = gridCell(f.x + f.width + WITHIN_EPS);
133700
+ const rowEnd = gridCell(f.y + f.height + WITHIN_EPS);
133701
+ for (let row = gridCell(f.y - WITHIN_EPS); row <= rowEnd; row++) {
133702
+ for (let col = gridCell(f.x - WITHIN_EPS); col <= colEnd; col++) {
133703
+ const key = row * CONTAINMENT_GRID_N + col;
133704
+ const bucket = cells.get(key);
133705
+ if (bucket) bucket.push(c);
133706
+ else cells.set(key, [c]);
133707
+ }
133708
+ }
133709
+ }
133710
+ return (node) => {
133711
+ const bucket = cells.get(gridCell(node.frame.y) * CONTAINMENT_GRID_N + gridCell(node.frame.x));
133712
+ return bucket !== void 0 && bucket.some((c) => c !== node && frameWithin(node.frame, c.frame));
133713
+ };
133714
+ }
133715
+ function frameAbove(a, b) {
133716
+ return a.y + a.height <= b.y + WITHIN_EPS;
133717
+ }
133718
+ function followKind(node, anchor) {
133719
+ const below = frameAbove(anchor, node);
133720
+ const above = frameAbove(node, anchor);
133721
+ if (below !== above) return below ? "below" : "no";
133722
+ const right = anchor.x + anchor.width <= node.x + WITHIN_EPS;
133723
+ const left = node.x + node.width <= anchor.x + WITHIN_EPS;
133724
+ return right !== left && right ? "band" : "no";
133725
+ }
133726
+ function frameAfter(node, anchor) {
133727
+ return followKind(node, anchor) !== "no";
133728
+ }
133729
+ function afterTester(anchors) {
133730
+ return (node) => anchors.some((a) => a !== node && frameAfter(node.frame, a.frame));
133731
+ }
133732
+ function comparePick(a, b) {
133733
+ return frameArea(a) - frameArea(b) || a.width - b.width || a.height - b.height;
133734
+ }
133735
+ function compareBandPick(a, b) {
133736
+ return a.x - b.x || a.y - b.y || comparePick(a, b);
133737
+ }
133738
+ function compareBelowPick(a, b) {
133739
+ return a.y - b.y || a.x - b.x || comparePick(a, b);
133740
+ }
133741
+ function nearestAfter(candidates, anchors) {
133742
+ if (candidates.length === 0 || anchors.length === 0) return [];
133743
+ const picked = /* @__PURE__ */ new Set();
133744
+ for (const anchor of anchors) {
133745
+ const af = anchor.frame;
133746
+ let band;
133747
+ let below;
133748
+ for (const c of candidates) {
133749
+ if (c === anchor) continue;
133750
+ const f = c.frame;
133751
+ const kind = followKind(f, af);
133752
+ if (kind === "band") {
133753
+ if (band === void 0 || compareBandPick(f, band.frame) < 0) band = c;
133754
+ } else if (kind === "below") {
133755
+ if (below === void 0 || compareBelowPick(f, below.frame) < 0) below = c;
133756
+ }
133757
+ }
133758
+ const best = band ?? below;
133759
+ if (best !== void 0) picked.add(best);
133760
+ }
133761
+ return candidates.filter((c) => picked.has(c));
133682
133762
  }
133683
133763
  function findAll(root2, selector) {
133684
- const acc = [];
133685
- const textRegex = selectorTextRegex(selector);
133686
- for (const child of root2.children) collectMatches(child, selector, textRegex, acc);
133687
- return acc;
133764
+ const all = [];
133765
+ const collect = (node) => {
133766
+ all.push(node);
133767
+ for (const child of node.children) collect(child);
133768
+ };
133769
+ for (const child of root2.children) collect(child);
133770
+ return resolveSelector(all, selector);
133771
+ }
133772
+ var SELECTOR_RELATIONS = ["within", "after", "next"];
133773
+ var RELATION_RESOLVERS = {
133774
+ within: (matches2, scope) => matches2.filter(containmentTester(scope)),
133775
+ after: (matches2, scope) => matches2.filter(afterTester(scope)),
133776
+ next: (matches2, scope) => nearestAfter(matches2, scope)
133777
+ };
133778
+ function resolveSelector(all, selector) {
133779
+ const regex = selectorTextRegex(selector);
133780
+ let matches2 = all.filter((n) => matchNodeWithRegex(n, selector, regex));
133781
+ for (const relation of SELECTOR_RELATIONS) {
133782
+ const scope = selector[relation];
133783
+ if (scope === void 0) continue;
133784
+ matches2 = RELATION_RESOLVERS[relation](matches2, resolveSelector(all, scope));
133785
+ }
133786
+ return matches2;
133688
133787
  }
133689
133788
  function isVisible(node) {
133690
133789
  return node.frame.width > 0 && node.frame.height > 0;
@@ -133765,6 +133864,13 @@ function exactFieldCount(node, selector, fullTextRegex) {
133765
133864
  function selectorToFrame(root2, selector) {
133766
133865
  const visible = findAll(root2, selector).filter(isVisible);
133767
133866
  if (visible.length === 0) return void 0;
133867
+ if (!hasOwnConstraint(selector)) {
133868
+ let first;
133869
+ for (const n of visible) {
133870
+ if (first === void 0 || compareBelowPick(n.frame, first.frame) < 0) first = n;
133871
+ }
133872
+ return first?.frame;
133873
+ }
133768
133874
  const fullTextRegex = fullConsumptionRegex(selector);
133769
133875
  let best;
133770
133876
  let bestExact = -1;
@@ -146213,6 +146319,18 @@ function clearActiveFlow() {
146213
146319
  activeFlowName = null;
146214
146320
  recordingSession = null;
146215
146321
  }
146322
+ function selectorTree(sel) {
146323
+ const out = [];
146324
+ const walk = (s) => {
146325
+ out.push(s);
146326
+ for (const relation of SELECTOR_RELATIONS) {
146327
+ const nested = s[relation];
146328
+ if (nested !== void 0) walk(nested);
146329
+ }
146330
+ };
146331
+ walk(sel);
146332
+ return out;
146333
+ }
146216
146334
  function isE2eFlow(flow) {
146217
146335
  const first = flow.steps.find((s) => s.kind !== "echo");
146218
146336
  return first?.kind === "launch";
@@ -146246,11 +146364,35 @@ function selectorToYaml(sel) {
146246
146364
  "Cannot serialize flow selector: `text` must contain at least one visible character (icon-font/private-use and zero-width characters render as nothing). Select by identifier or role, or use a coordinate tap."
146247
146365
  );
146248
146366
  }
146249
- if (sel.loose && (sel.text === void 0 || sel.textMatches !== void 0 || sel.identifier !== void 0 || sel.role !== void 0)) {
146367
+ const scopeCount = SELECTOR_RELATIONS.filter((relation) => sel[relation] !== void 0).length;
146368
+ if (sel.any !== void 0) {
146369
+ if (sel.any !== true) {
146370
+ throw new Error(
146371
+ "Cannot serialize flow selector: `any` is the universal selector and takes only `true` \u2014 omit it to select by text/id/role."
146372
+ );
146373
+ }
146374
+ if (sel.text !== void 0 || sel.textMatches !== void 0 || sel.identifier || sel.role) {
146375
+ throw new Error(
146376
+ "Cannot serialize flow selector: `any` already matches every element, so it cannot be combined with text/id/role \u2014 keep one or the other."
146377
+ );
146378
+ }
146379
+ if (scopeCount === 0) {
146380
+ throw new Error(
146381
+ `Cannot serialize flow selector: \`any\` matches every element on screen, so it needs a scope (${SELECTOR_RELATIONS.join("/")}) to narrow what it selects.`
146382
+ );
146383
+ }
146384
+ } else if (scopeCount > 0 && sel.text === void 0 && sel.textMatches === void 0 && !sel.identifier && !sel.role) {
146385
+ throw new Error(
146386
+ `Cannot serialize flow selector: a scope (${SELECTOR_RELATIONS.join("/")}) only narrows where to look \u2014 the selector still needs its own text/id/role naming what to find there, or \`any: true\` for any element.`
146387
+ );
146388
+ }
146389
+ if (sel.loose && (sel.text === void 0 || sel.textMatches !== void 0 || sel.identifier !== void 0 || sel.role !== void 0 || sel.any !== void 0 || SELECTOR_RELATIONS.some((relation) => sel[relation] !== void 0))) {
146250
146390
  const incompatible = [
146251
146391
  sel.textMatches !== void 0 ? "textMatches" : void 0,
146252
146392
  sel.identifier !== void 0 ? "identifier" : void 0,
146253
- sel.role !== void 0 ? "role" : void 0
146393
+ sel.role !== void 0 ? "role" : void 0,
146394
+ sel.any !== void 0 ? "any" : void 0,
146395
+ ...SELECTOR_RELATIONS.map((relation) => sel[relation] !== void 0 ? relation : void 0)
146254
146396
  ].filter((field) => field !== void 0);
146255
146397
  throw new Error(
146256
146398
  "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(", ")}` : "") + "."
@@ -146259,16 +146401,30 @@ function selectorToYaml(sel) {
146259
146401
  if (sel.loose && sel.text !== void 0 && sel.identifier === void 0 && sel.role === void 0) {
146260
146402
  return sel.text;
146261
146403
  }
146262
- const { loose: _loose, identifier, textMatches: textMatches2, ...rest } = sel;
146404
+ const { loose: _loose, any: any2, identifier, textMatches: textMatches2, within, after, next, ...rest } = sel;
146405
+ const scopes = { within, after, next };
146263
146406
  const out = { ...rest };
146407
+ if (any2) out.any = true;
146264
146408
  if (textMatches2 !== void 0) out.text = { matches: textMatches2 };
146265
146409
  if (identifier !== void 0) out.id = identifier;
146410
+ for (const relation of SELECTOR_RELATIONS) {
146411
+ const scope = scopes[relation];
146412
+ if (scope !== void 0) out[relation] = selectorToYaml(scope);
146413
+ }
146266
146414
  return out;
146267
146415
  }
146268
146416
  function describeSelector(s) {
146269
- return Object.entries(s).filter(([k]) => k !== "loose").map(
146417
+ const { loose: _loose, any: any2, within, after, next, ...rest } = s;
146418
+ const scopes = { within, after, next };
146419
+ const fields = Object.entries(rest).map(
146270
146420
  ([k, v]) => k === "textMatches" ? `text=/${v}/` : `${k === "identifier" ? "id" : k}="${v}"`
146271
146421
  ).join(" ");
146422
+ const parts = [any2 ? "*" : void 0, fields || void 0].filter((p) => p !== void 0);
146423
+ for (const relation of SELECTOR_RELATIONS) {
146424
+ const scope = scopes[relation];
146425
+ if (scope !== void 0) parts.push(`${relation} (${describeSelector(scope)})`);
146426
+ }
146427
+ return parts.join(" ");
146272
146428
  }
146273
146429
  function describeTextExpectation(expectedText, textMatch, verbForm = "mode") {
146274
146430
  const expected = expectedText ?? "";
@@ -146510,8 +146666,22 @@ function rejectUnknownKeys(raw, body, allowed, where) {
146510
146666
  `${where} has ${describeUnknownKeys(unknown2, allowed)} \u2014 allowed keys: ${allowed.join(", ")}`
146511
146667
  );
146512
146668
  }
146513
- var SELECTOR_KEYS = ["text", "id", "identifier", "role"];
146514
- function parseSelector(raw, where) {
146669
+ var SELECTOR_KEYS = [
146670
+ "text",
146671
+ "id",
146672
+ "identifier",
146673
+ "role",
146674
+ "any",
146675
+ ...SELECTOR_RELATIONS
146676
+ ];
146677
+ var MAX_SELECTOR_SCOPES = 6;
146678
+ function parseSelector(raw, where, budget = { scopes: MAX_SELECTOR_SCOPES }) {
146679
+ if (budget.scopes < 0) {
146680
+ badEntry(
146681
+ raw,
146682
+ `${where}: a selector carries more than ${MAX_SELECTOR_SCOPES} scopes (${SELECTOR_RELATIONS.join("/")}) in total \u2014 check for a cyclic YAML alias (\`&s { \u2026, within: *s }\`)`
146683
+ );
146684
+ }
146515
146685
  if (typeof raw === "string") {
146516
146686
  const r2 = selectorSchema.safeParse({ text: raw });
146517
146687
  if (!r2.success) badEntry(raw, `${where}: ${r2.error.issues[0]?.message ?? "invalid selector"}`);
@@ -146520,9 +146690,55 @@ function parseSelector(raw, where) {
146520
146690
  if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
146521
146691
  rejectUnknownKeys(raw, raw, SELECTOR_KEYS, `${where}: selector`);
146522
146692
  }
146523
- let normalized = raw;
146524
- if (raw !== null && typeof raw === "object" && "id" in raw) {
146525
- const { id, ...rest } = raw;
146693
+ const scopes = {};
146694
+ let universal = false;
146695
+ let fieldsRaw = raw;
146696
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
146697
+ const restRaw = { ...raw };
146698
+ const present = SELECTOR_RELATIONS.filter((relation) => relation in restRaw);
146699
+ for (const relation of present) {
146700
+ budget.scopes--;
146701
+ scopes[relation] = parseSelector(restRaw[relation], `${where}.${relation}`, budget);
146702
+ delete restRaw[relation];
146703
+ }
146704
+ if ("any" in restRaw) {
146705
+ if (restRaw.any !== true) {
146706
+ badEntry(
146707
+ raw,
146708
+ `${where}: \`any\` takes only \`true\` \u2014 it is the CSS \`*\` universal selector (drop the key to select by text/id/role instead)`
146709
+ );
146710
+ }
146711
+ delete restRaw.any;
146712
+ if (Object.keys(restRaw).length > 0) {
146713
+ badEntry(
146714
+ raw,
146715
+ `${where}: \`any: true\` already matches every element \u2014 drop it, or drop the ${Object.keys(
146716
+ restRaw
146717
+ ).map((k) => `\`${k}\``).join("/")} it makes redundant`
146718
+ );
146719
+ }
146720
+ if (present.length === 0) {
146721
+ badEntry(
146722
+ raw,
146723
+ `${where}: \`any: true\` matches every element on screen \u2014 pair it with a scope (${SELECTOR_RELATIONS.join(
146724
+ "/"
146725
+ )}) so it selects something specific`
146726
+ );
146727
+ }
146728
+ universal = true;
146729
+ } else if (present.length > 0 && Object.keys(restRaw).length === 0) {
146730
+ badEntry(
146731
+ raw,
146732
+ `${where}: a selector's \`${present.join("`/`")}\` only scopes where to look \u2014 the selector still needs its own text/id/role naming what to find there (or \`any: true\` for any element)`
146733
+ );
146734
+ }
146735
+ fieldsRaw = restRaw;
146736
+ }
146737
+ const attachScopes = (sel) => ({ ...sel, ...scopes });
146738
+ if (universal) return attachScopes({ any: true });
146739
+ let normalized = fieldsRaw;
146740
+ if (fieldsRaw !== null && typeof fieldsRaw === "object" && "id" in fieldsRaw) {
146741
+ const { id, ...rest } = fieldsRaw;
146526
146742
  if ("identifier" in rest) {
146527
146743
  badEntry(raw, `${where}: selector takes \`id\` or \`identifier\` (its alias), not both`);
146528
146744
  }
@@ -146555,12 +146771,12 @@ function parseSelector(raw, where) {
146555
146771
  if (!fields.success) {
146556
146772
  badEntry(raw, `${where}: ${fields.error.issues[0]?.message ?? "invalid selector"}`);
146557
146773
  }
146558
- return { ...fields.data, textMatches: pattern };
146774
+ return attachScopes({ ...fields.data, textMatches: pattern });
146559
146775
  }
146560
146776
  }
146561
146777
  const r = selectorSchema.safeParse(normalized);
146562
146778
  if (!r.success) badEntry(raw, `${where}: ${r.error.issues[0]?.message ?? "invalid selector"}`);
146563
- return r.data;
146779
+ return attachScopes(r.data);
146564
146780
  }
146565
146781
  var WAIT_CONDITIONS = ["exists", "visible", "hidden", "text"];
146566
146782
  var TEXT_MATCH_MODES = Object.keys({
@@ -146711,11 +146927,14 @@ function parseTapTimes(raw, entry) {
146711
146927
  }
146712
146928
  return raw === 1 ? void 0 : raw;
146713
146929
  }
146930
+ function hasSelectorField(obj) {
146931
+ return obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0 || obj.any !== void 0 || SELECTOR_RELATIONS.some((relation) => obj[relation] !== void 0);
146932
+ }
146714
146933
  function parseTarget(raw, where) {
146715
146934
  if (raw !== null && typeof raw === "object") {
146716
146935
  const obj = raw;
146717
146936
  if (obj.x !== void 0 || obj.y !== void 0) {
146718
- if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
146937
+ if (hasSelectorField(obj)) {
146719
146938
  badEntry(raw, `${where} takes a selector or x/y coordinates, not both`);
146720
146939
  }
146721
146940
  if (typeof obj.x !== "number" || typeof obj.y !== "number") {
@@ -146738,7 +146957,7 @@ function parseTarget(raw, where) {
146738
146957
  function parseTap(body, entry) {
146739
146958
  const obj = body !== null && typeof body === "object" ? body : {};
146740
146959
  if (obj.on !== void 0 || obj.times !== void 0) {
146741
- if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
146960
+ if (hasSelectorField(obj)) {
146742
146961
  badEntry(
146743
146962
  entry,
146744
146963
  'the tap options form takes a nested selector \u2014 e.g. tap: { on: { text: "Photo" }, times: 2 }'
@@ -146766,7 +146985,7 @@ function parseTap(body, entry) {
146766
146985
  function parseLongPress(body, entry) {
146767
146986
  const obj = body !== null && typeof body === "object" ? body : {};
146768
146987
  if (obj.on !== void 0 || obj.duration !== void 0) {
146769
- if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
146988
+ if (hasSelectorField(obj)) {
146770
146989
  badEntry(
146771
146990
  entry,
146772
146991
  'the long-press options form takes a nested selector \u2014 e.g. long-press: { on: { text: "Row" }, duration: 1200 }'
@@ -146806,7 +147025,7 @@ function parsePinch(body, entry) {
146806
147025
  );
146807
147026
  }
146808
147027
  const obj = body;
146809
- if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
147028
+ if (hasSelectorField(obj)) {
146810
147029
  badEntry(entry, 'pinch takes a nested selector \u2014 e.g. pinch: { on: "Map", scale: 3 }');
146811
147030
  }
146812
147031
  rejectUnknownKeys(entry, obj, ["on", "scale"], "pinch");
@@ -146873,13 +147092,11 @@ function parseWhenCondition(raw) {
146873
147092
  }
146874
147093
  const { timeout: _timeout, ...cond } = parseWaitFields(raw, "when");
146875
147094
  const { selector, expectedText } = cond;
146876
- for (const s of [
146877
- expectedText,
146878
- selector.text,
146879
- selector.textMatches,
146880
- selector.identifier,
146881
- selector.role
146882
- ]) {
147095
+ const guardStrings = [expectedText];
147096
+ for (const s of selectorTree(selector)) {
147097
+ guardStrings.push(s.text, s.textMatches, s.identifier, s.role);
147098
+ }
147099
+ for (const s of guardStrings) {
146883
147100
  if (s !== void 0 && s.includes(SECRET_PLACEHOLDER_MARKER)) {
146884
147101
  badEntry(
146885
147102
  { when: raw },
@@ -147004,6 +147221,12 @@ function fromYamlStep(raw, whenDepth = 0) {
147004
147221
  if (b.direction !== void 0 && (typeof b.direction !== "string" || !SCROLL_DIRECTIONS.includes(b.direction))) {
147005
147222
  badEntry(raw, `scroll-to direction must be one of ${SCROLL_DIRECTIONS.join(", ")}`);
147006
147223
  }
147224
+ if (b.target === void 0) {
147225
+ badEntry(
147226
+ raw,
147227
+ `scroll-to needs a \`target\` \u2014 its own \`within\` only anchors the gesture to a scroll container, e.g. scroll-to: { target: <selector>, within: { id: list } }. A selector scope (${SELECTOR_RELATIONS.join("/")}) goes inside \`target\`.`
147228
+ );
147229
+ }
147007
147230
  const step = {
147008
147231
  kind: "scroll-to",
147009
147232
  target: parseSelector(b.target, "scroll-to.target"),
@@ -148002,7 +148225,16 @@ function probeWhenCondition(env, cond) {
148002
148225
  return waitForCondition(env, cond, DEFAULT_ASSERT_TIMEOUT_MS);
148003
148226
  }
148004
148227
  function selectorAlternatives(sel) {
148005
- return sel.loose && sel.text !== void 0 ? [{ identifier: sel.text }, { text: sel.text }] : [sel];
148228
+ const { loose, any: _any2, within, after, next, ...own } = sel;
148229
+ const scopes = { within, after, next };
148230
+ let alts = loose && own.text !== void 0 ? [{ identifier: own.text }, { text: own.text }] : [own];
148231
+ for (const relation of SELECTOR_RELATIONS) {
148232
+ const scope = scopes[relation];
148233
+ if (scope === void 0) continue;
148234
+ const scopeAlts = selectorAlternatives(scope);
148235
+ alts = alts.flatMap((o) => scopeAlts.map((s) => ({ ...o, [relation]: s })));
148236
+ }
148237
+ return alts;
148006
148238
  }
148007
148239
  function flowFindAll(tree, sel) {
148008
148240
  let fallback = [];
@@ -150893,7 +151125,13 @@ function createRunFlowTool(registry2) {
150893
151125
  Steps run in order: \`launch\` starts an app from scratch (terminate + relaunch) and waits until it is
150894
151126
  ready; \`tool\` calls dispatch through the registry; \`tap\`/\`long-press\`/\`type\` resolve a selector to an
150895
151127
  element and act on it (\`tap: { on, times: 2 }\` double-taps; \`long-press: { on, duration }\` presses and
150896
- holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }\`);
151128
+ holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }\`;
151129
+ any selector may scope its matches geometrically, the CSS combinators read off frames: \`within: <selector>\`
151130
+ (descendant \u2014 inside that container's frame), \`after: <selector>\` (CSS \`~\` \u2014 following it in reading
151131
+ order), \`next: <selector>\` (CSS \`+\` \u2014 the nearest such follower, which unlike CSS reaches past a
151132
+ non-matching neighbour rather than failing), plus \`any: true\` (CSS \`*\` \u2014 legal only WITH a scope and
151133
+ never beside text/id/role). Scopes nest to disambiguate \u2014 \`within: { id: card, within: { id: list } }\`
151134
+ reads "inside card inside list", each container's frame inside the next);
150897
151135
  \`scroll-to\` scrolls (momentum-free) until a target is visible; \`pinch\` zooms
150898
151136
  (\`pinch: { on?, scale }\` \u2014 scale > 1 in, < 1 out; screen center when \`on\` is omitted); \`rotate\` is the
150899
151137
  two-finger rotation gesture (\`rotate: { on?, by }\` \u2014 degrees, + clockwise, within \xB13000\xB0; screen center
@@ -151052,10 +151290,15 @@ function pushReport(state3, report) {
151052
151290
  }
151053
151291
  function selectorLabel2(sel) {
151054
151292
  const parts = [];
151293
+ if (sel.any) parts.push("*");
151055
151294
  if (sel.text !== void 0) parts.push(`"${sel.text}"`);
151056
151295
  if (sel.textMatches !== void 0) parts.push(`/${sel.textMatches}/`);
151057
151296
  if (sel.identifier) parts.push(`id=${sel.identifier}`);
151058
151297
  if (sel.role) parts.push(`role=${sel.role}`);
151298
+ for (const relation of SELECTOR_RELATIONS) {
151299
+ const scope = sel[relation];
151300
+ if (scope !== void 0) parts.push(`${relation} (${selectorLabel2(scope)})`);
151301
+ }
151059
151302
  return parts.join(" ");
151060
151303
  }
151061
151304
  function conditionLabel(cond, renderSelector) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.17.1-next.2",
3
+ "version": "0.17.1-next.3",
4
4
  "description": "MCP server for iOS Simulator and Android Emulator control",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,11 +38,35 @@ Beyond raw `tool:` steps and `echo:`, flows support declarative directives inter
38
38
 
39
39
  ### Selectors
40
40
 
41
- 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).
41
+ A **selector** is `{ text?, id?, role? }` plus the optional scopes below (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).
42
42
 
43
43
  `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.
44
44
 
45
- 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.
45
+ #### ScopesCSS combinators, read off frames
46
+
47
+ A map selector may also carry a **scope**: a nested selector naming another element the match must sit in a given spatial relation to. These are the geometric readings of the CSS combinators that survive a flattened tree — the child combinator `>` has no analog, since parent/child structure does not reach replay. They combine, and nest up to six scopes per selector:
48
+
49
+ | Scope | CSS | Reads as | Example |
50
+ | --------------- | ------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
51
+ | `within: <sel>` | `A B` | the match's frame sits **inside** the scope element's frame | `tap: { text: Delete, within: { id: profile-card } }` |
52
+ | `after: <sel>` | `A ~ B` | the match **follows** the scope element in reading order | `assert: { visible: { role: Button, after: { text: Danger zone } } }` |
53
+ | `next: <sel>` | `A + B` | as `after`, narrowed to the **nearest** follower of each anchor | `tap: { role: Switch, next: { text: Wi-Fi } }` |
54
+
55
+ And `any: true` is the CSS `*` universal selector. It carries no locator of its own, so the parser enforces two rules: it needs **at least one scope** (a bare `any: true` would match the whole screen), and it may **not** sit beside `text`/`id`/`role` (which it would only make redundant — write `{ role: Switch, next: … }`, not `{ any: true, role: Switch, next: … }`). Only the literal `true` is accepted. `assert: { hidden: { any: true, within: { id: empty-state } } }` asserts an empty container. It works for actions too — `tap: { any: true, next: { text: Airplane Mode } }` taps whatever sits right after that label — but on a real tree "whatever" includes spacers and wrappers, so **name the target** (`role`, `id`) whenever you can and keep `any` for conditions and for `next`, which reduces to a single element per anchor.
56
+
57
+ All of it is **visual (frame-based), not tree ancestry** — flow trees are flattened, and "inside the card" / "the switch after this label" mean what the screen shows — the same frame-based reading of "within" that `scroll-to`'s container anchor uses. Every scope needs a **distinct element** (nothing scopes itself, so `{ id: card, within: { id: card } }` needs two nested elements), the synthetic screen root never counts, and a scope only narrows _where_ to look — the selector still needs its own `text`/`id`/`role` naming _what_ to find there, or `any: true`. The nested slot takes every selector form: a bare string keeps the loose identifier-first fallback (`within: profile-card`), the map form stays strict, the regex matcher works (`within: { text: { matches: '^Card \d+$' } }`). Scopes are flow-YAML only; the raw `await-ui-element` tool's selector accepts none of them. Prefer a unique `id` on the target itself when one exists — reach for a scope when the target has no unique locator of its own (repeated row actions, per-card buttons, list cells).
58
+
59
+ **`within`** — `tap: { text: Delete, within: { id: profile-card } }` taps the Delete button in the profile card even when other cards show identical ones; `tap: { text: "Pin feed", within: { text: "For You" } }` picks one card's button out of a whole list. Scope to a container with a **tight frame** (a row, card, dialog, toast): a full-screen wrapper contains everything and scopes nothing. It chains outward: `{ text: Save, within: { id: cards, within: Settings } }` reads "Save inside cards inside Settings", each container's frame inside the next.
60
+
61
+ **`after` / `next`** — reading order is row-band aware: an element **follows** the anchor when it starts below the anchor's bottom edge, _or_ shares its row band and sits entirely to its right. That is what makes `{ role: Switch, next: { text: Wi-Fi } }` resolve the Wi-Fi row's own switch even though the taller switch's frame starts a couple of pixels _higher_ than the label's. `next` keeps only the nearest follower — a match in the anchor's own row beats anything on the rows below, leftmost first — while `after` keeps them all, so `assert: { hidden: { role: Button, after: { text: Danger zone } } }` holds when nothing button-like appears past that heading. Both union over anchors exactly as CSS does: with three rows on screen, `{ role: Switch, next: { role: AXStaticText } }` yields all three switches, one per label. Note that "follows" is **not transitive** — against a tall anchor, an element can follow something that itself follows the anchor without following the anchor directly — so nesting `after` scopes is not the same as chaining CSS `~`: `{ after: { …, after: … } }` can match elements a single `after` excludes. Nest them only when each link is a container-sized step. An element sitting _inside_ the anchor does not follow **that** anchor — containment is not reading order — so scope by `within` for that.
62
+
63
+ **Prefer the map form for a scope's anchor.** A bare-string scope (`next: wifi-row`) keeps the loose identifier-first fallback, and the runner takes the first pass that finds a _visible_ match — so if some unrelated element carries `testID="wifi-row"`, the identifier pass wins and the text pass never runs. Reproduced: with a decoy `testID="Wi-Fi"` elsewhere on screen, `tap: { role: Switch, next: Wi-Fi }` taps the decoy's neighbour and reports a pass. This is the ordinary identifier-first doctrine, but a scope makes a decoy likelier to "succeed": a decoy _container_ only wins if it actually holds a match, while a decoy _anchor_ wins if anything at all sits after it — which on a real screen it usually does. Spell an anchor you care about as a map: `next: { text: Wi-Fi }` or `next: { id: wifi-row }`.
64
+
65
+ One way `next` is deliberately **looser than CSS `+`**: where `A + B` matches nothing unless the very next sibling is a `B`, `next` keeps looking and returns the nearest match further on. That is what makes it survive the wrapper and spacer nodes a flattened tree is full of, but it also means a row that is _missing_ the control you asked for silently resolves to the next row's — `{ role: Switch, next: { text: Wi-Fi } }` on a Wi-Fi row rendered without a switch returns the _Bluetooth_ row's switch rather than failing. When a row may legitimately lack the control, assert it first (`assert: { visible: { role: Switch, within: { id: wifi-row } } }`) or scope by `within` instead.
66
+
67
+ Scopes compose, and it matters which one carries them. `{ role: Button, next: { text: Name, within: { id: card-b } } }` scopes the **anchor** — one label, so one pick, but that pick may land outside card-b. `{ role: Button, next: { text: Name }, within: { id: card-b } }` scopes the **target** — every label is still an anchor, but only card-b's buttons can be picked. They agree on a well-formed screen and diverge when card-b has no button: the first reaches on to the next card's, the second returns nothing. Scope the target when the container is the thing you trust. Conditions honor scopes like any other selector: `assert: { hidden: { text: Saved, within: { id: toast-area } } }` holds when nothing matching "Saved" is inside the toast area — matches elsewhere on screen don't count, and a missing scope element satisfies `hidden` (and fails `visible`/`exists`).
68
+
69
+ 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. (A universal `any: true` selector has no field to be exact about, so its matches rank by reading order instead — the first element in the scope, which is the element a condition reads too. Where two matches share a top-left corner, an action breaks the tie toward the smaller, more specific one and a condition does not, so those two can name different elements.)
46
70
 
47
71
  **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"`.
48
72
 
@@ -65,7 +89,7 @@ For a custom poll interval or bundleId, drop to an explicit `- tool: await-ui-el
65
89
 
66
90
  Never record a real credential into a flow — the YAML is committed to the repo. Use a secret placeholder instead: `type: { into: password, text: "{{secret:APP_PASSWORD}}" }`. The placeholder is stored verbatim (the YAML stays secret-free) and is resolved at run time by the tool-server from the `ARGENT_SECRET_APP_PASSWORD` environment variable — including agent-less `argent flow run` in CI, where the variable comes from the job's secrets.
67
91
 
68
- `scroll-to` takes an optional `direction` (`up` | `down` | `left` | `right`, default `down` — so the common case is just `- scroll-to: <selector>`) and optionally a `within: <selector>` that anchors the scroll inside a specific container — required to drive a **nested** scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). `tap`/`type` do **not** scroll — add a `scroll-to` before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive `scroll-to` costs nothing on replay and keeps the flow working on smaller screens.
92
+ `scroll-to` takes an optional `direction` (`up` | `down` | `left` | `right`, default `down` — so the common case is just `- scroll-to: <selector>`) and optionally a `within: <selector>` that anchors the scroll inside a specific container — required to drive a **nested** scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. This step-level `within` (a sibling of `target`) anchors the _gesture_, and it is the **only** scope key the step body takes — `after:`/`next:`/`any:` beside `target` are rejected. It is distinct from a selector's scopes (see Selectors), which `target` may itself carry — `scroll-to: { target: { text: Delete, within: { id: cards } }, within: { id: settings-list } }` scrolls the settings list until the Delete button _inside the cards container_ is visible. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). `tap`/`type` do **not** scroll — add a `scroll-to` before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive `scroll-to` costs nothing on replay and keeps the flow working on smaller screens.
69
93
 
70
94
  ### `snapshot` cropping
71
95