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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-cmds.mjs CHANGED
@@ -6324,6 +6324,7 @@ var FAILURE_CODES = {
6324
6324
  CHROMIUM_ELECTRON_EXITED_BEFORE_READY: "CHROMIUM_ELECTRON_EXITED_BEFORE_READY",
6325
6325
  KEYBOARD_KEY_UNSUPPORTED: "KEYBOARD_KEY_UNSUPPORTED",
6326
6326
  KEYBOARD_CHARACTER_UNSUPPORTED: "KEYBOARD_CHARACTER_UNSUPPORTED",
6327
+ SECRET_PLACEHOLDER_UNKNOWN: "SECRET_PLACEHOLDER_UNKNOWN",
6327
6328
  SCREENSHOT_DIFF_INPUT_INVALID: "SCREENSHOT_DIFF_INPUT_INVALID",
6328
6329
  BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID",
6329
6330
  BOOT_IOS_UNSUPPORTED_HOST: "BOOT_IOS_UNSUPPORTED_HOST",
@@ -16231,6 +16231,7 @@ var FAILURE_CODES = {
16231
16231
  CHROMIUM_ELECTRON_EXITED_BEFORE_READY: "CHROMIUM_ELECTRON_EXITED_BEFORE_READY",
16232
16232
  KEYBOARD_KEY_UNSUPPORTED: "KEYBOARD_KEY_UNSUPPORTED",
16233
16233
  KEYBOARD_CHARACTER_UNSUPPORTED: "KEYBOARD_CHARACTER_UNSUPPORTED",
16234
+ SECRET_PLACEHOLDER_UNKNOWN: "SECRET_PLACEHOLDER_UNKNOWN",
16234
16235
  SCREENSHOT_DIFF_INPUT_INVALID: "SCREENSHOT_DIFF_INPUT_INVALID",
16235
16236
  BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID",
16236
16237
  BOOT_IOS_UNSUPPORTED_HOST: "BOOT_IOS_UNSUPPORTED_HOST",
@@ -17750,6 +17750,7 @@ var FAILURE_CODES = {
17750
17750
  CHROMIUM_ELECTRON_EXITED_BEFORE_READY: "CHROMIUM_ELECTRON_EXITED_BEFORE_READY",
17751
17751
  KEYBOARD_KEY_UNSUPPORTED: "KEYBOARD_KEY_UNSUPPORTED",
17752
17752
  KEYBOARD_CHARACTER_UNSUPPORTED: "KEYBOARD_CHARACTER_UNSUPPORTED",
17753
+ SECRET_PLACEHOLDER_UNKNOWN: "SECRET_PLACEHOLDER_UNKNOWN",
17753
17754
  SCREENSHOT_DIFF_INPUT_INVALID: "SCREENSHOT_DIFF_INPUT_INVALID",
17754
17755
  BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID",
17755
17756
  BOOT_IOS_UNSUPPORTED_HOST: "BOOT_IOS_UNSUPPORTED_HOST",
@@ -18981,6 +18982,14 @@ var DEFAULT_DELAY_MS = 1400;
18981
18982
  function autoScreenshotEnabled(options) {
18982
18983
  return !isFlagEnabled("disable-auto-screenshot", options);
18983
18984
  }
18985
+ var SECRET_PLACEHOLDER_MARKER = "{{secret:";
18986
+ function containsSecretPlaceholder(args) {
18987
+ try {
18988
+ return JSON.stringify(args)?.includes(SECRET_PLACEHOLDER_MARKER) ?? false;
18989
+ } catch {
18990
+ return true;
18991
+ }
18992
+ }
18984
18993
  function getUdidFromArgs(args) {
18985
18994
  if (args && typeof args === "object" && "udid" in args && typeof args.udid === "string") {
18986
18995
  return args.udid;
@@ -19209,7 +19218,15 @@ async function startMcpServer(options) {
19209
19218
  content = await toMcpContent(result, outputHint, ctx, params.arguments);
19210
19219
  }
19211
19220
  const udid = getUdidFromArgs(params.arguments);
19212
- if (autoScreenshotOn && udid && shouldAutoScreenshot(params.name)) {
19221
+ if (autoScreenshotOn && udid && shouldAutoScreenshot(params.name) && containsSecretPlaceholder(params.arguments)) {
19222
+ content = [
19223
+ ...content,
19224
+ {
19225
+ type: "text",
19226
+ text: "Auto-screenshot skipped: the input contains a {{secret:\u2026}} placeholder, and a screenshot of this screen could reveal the typed secret. Submit or navigate away first, then verify the resulting screen as usual."
19227
+ }
19228
+ ];
19229
+ } else if (autoScreenshotOn && udid && shouldAutoScreenshot(params.name)) {
19213
19230
  const maxWaitMs = getAutoScreenshotDelayMs(params.name);
19214
19231
  if (maxWaitMs > 0) {
19215
19232
  try {
@@ -404,6 +404,7 @@ var init_failure_codes = __esm({
404
404
  CHROMIUM_ELECTRON_EXITED_BEFORE_READY: "CHROMIUM_ELECTRON_EXITED_BEFORE_READY",
405
405
  KEYBOARD_KEY_UNSUPPORTED: "KEYBOARD_KEY_UNSUPPORTED",
406
406
  KEYBOARD_CHARACTER_UNSUPPORTED: "KEYBOARD_CHARACTER_UNSUPPORTED",
407
+ SECRET_PLACEHOLDER_UNKNOWN: "SECRET_PLACEHOLDER_UNKNOWN",
407
408
  SCREENSHOT_DIFF_INPUT_INVALID: "SCREENSHOT_DIFF_INPUT_INVALID",
408
409
  BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID",
409
410
  BOOT_IOS_UNSUPPORTED_HOST: "BOOT_IOS_UNSUPPORTED_HOST",
@@ -126687,6 +126688,54 @@ Fails if the device backend is not reachable \u2014 the simulator-server for iOS
126687
126688
  // ../tool-server/src/tools/keyboard/index.ts
126688
126689
  init_zod();
126689
126690
 
126691
+ // ../tool-server/src/utils/secrets.ts
126692
+ init_src();
126693
+ var SECRET_ENV_PREFIX = "ARGENT_SECRET_";
126694
+ var SECRET_PLACEHOLDER_MARKER = "{{secret:";
126695
+ var PLACEHOLDER_RE = /\{\{secret:([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
126696
+ function availableSecretNames(env = process.env) {
126697
+ return Object.keys(env).filter((k) => k.startsWith(SECRET_ENV_PREFIX) && env[k] !== void 0).map((k) => k.slice(SECRET_ENV_PREFIX.length)).sort();
126698
+ }
126699
+ var REDUNDANT_PREFIX_RE = /^argent_secret_/i;
126700
+ function resolveSecretPlaceholders(text, env = process.env) {
126701
+ const secrets = [];
126702
+ const resolved = text.replace(PLACEHOLDER_RE, (placeholder, rawName) => {
126703
+ let name = rawName;
126704
+ let value = env[SECRET_ENV_PREFIX + name];
126705
+ if (value === void 0 && REDUNDANT_PREFIX_RE.test(name)) {
126706
+ name = name.replace(REDUNDANT_PREFIX_RE, "");
126707
+ value = env[SECRET_ENV_PREFIX + name];
126708
+ }
126709
+ if (value === void 0) {
126710
+ const names = availableSecretNames(env);
126711
+ throw new InvalidToolInputError(
126712
+ `Unknown secret "${rawName}" \u2014 no ${SECRET_ENV_PREFIX}${name} environment variable is set on the machine running the tool-server. Available secrets: ${names.length ? names.join(", ") : "(none)"}. To make it available, ask the user to export ${SECRET_ENV_PREFIX}${name} in the tool-server's environment \u2014 never ask the user for the secret value itself.`,
126713
+ {
126714
+ error_code: FAILURE_CODES.SECRET_PLACEHOLDER_UNKNOWN,
126715
+ failure_stage: "secret_placeholder_resolution",
126716
+ error_kind: "validation"
126717
+ }
126718
+ );
126719
+ }
126720
+ if (!secrets.some((s) => s.name === name)) secrets.push({ name, value });
126721
+ return value;
126722
+ });
126723
+ return { text: resolved, secrets };
126724
+ }
126725
+ function redactSecretsFromError(err, secrets) {
126726
+ const scrub = (s) => secrets.reduce(
126727
+ (acc, { name, value }) => value ? acc.split(value).join(`${SECRET_PLACEHOLDER_MARKER}${name}}}`) : acc,
126728
+ s
126729
+ );
126730
+ if (err instanceof Error) {
126731
+ err.message = scrub(err.message);
126732
+ if (err.stack) err.stack = scrub(err.stack);
126733
+ return err;
126734
+ }
126735
+ if (typeof err === "string") return scrub(err);
126736
+ return err;
126737
+ }
126738
+
126690
126739
  // ../tool-server/src/tools/keyboard/simulator-server-keys.ts
126691
126740
  init_src();
126692
126741
 
@@ -127165,7 +127214,9 @@ var zodSchema24 = external_exports.object({
127165
127214
  udid: external_exports.string().describe(
127166
127215
  "Target device id from `list-devices` (iOS UDID, Android serial, Vega serial, or Chromium id)."
127167
127216
  ),
127168
- text: external_exports.string().optional().describe("Text to type character by character. Handles uppercase and common punctuation."),
127217
+ text: external_exports.string().optional().describe(
127218
+ 'Text to type character by character. Handles uppercase and common punctuation. To type a credential without its plaintext ever entering your context, use a secret placeholder: `{{secret:<NAME>}}` types the value of the `ARGENT_SECRET_<NAME>` environment variable set on the machine running the tool-server \u2014 e.g. text: "{{secret:APP_PASSWORD}}" types the value of `ARGENT_SECRET_APP_PASSWORD`. Only env vars with the `ARGENT_SECRET_` prefix are resolvable. Placeholders can be embedded in longer text and are never echoed back resolved. If the secret you need is not set, ask the user to export it as `ARGENT_SECRET_<NAME>` and restart the session \u2014 NEVER ask the user to paste the secret value into the conversation.'
127219
+ ),
127169
127220
  key: external_exports.string().optional().describe(
127170
127221
  "Named key to press: enter, escape, backspace, tab, space, arrow-up, arrow-down, arrow-left, arrow-right, f1\u2013f12. When combined with `text`, the key is pressed AFTER the text is typed (so text + enter types and submits). Not supported on TV targets \u2014 move focus with `tv-remote` (up/down/left/right) instead."
127171
127222
  ),
@@ -127181,12 +127232,21 @@ var capability16 = {
127181
127232
  vega: { vvd: true }
127182
127233
  };
127183
127234
  function createKeyboardTool(registry2) {
127235
+ const dispatch = dispatchByPlatform({
127236
+ toolId: "keyboard",
127237
+ capability: capability16,
127238
+ ios: makeIosImpl3(registry2),
127239
+ iosRemote: makeIosRemoteImpl(registry2),
127240
+ android: makeAndroidImpl(registry2),
127241
+ chromium: makeChromiumImpl(registry2),
127242
+ vega: vegaImpl4
127243
+ });
127184
127244
  return {
127185
127245
  id: "keyboard",
127186
127246
  description: `Type text or press special keys on the device (iOS simulator, Android emulator or device, Chromium app, Vega Virtual Device, or Apple TV / Android TV) using keyboard events.
127187
127247
  Use when you need to enter text or trigger a named key such as enter, escape, or arrow keys. On Vega and Apple TV / Android TV, prefer the remote tools for D-pad navigation; use keyboard to type into a focused text field (e.g. a search or login box).
127188
127248
  Returns { typed: string, keys: number }. Fails if an unsupported key name is provided or the device's input backend is not reachable.
127189
- - text: types a string (supports uppercase, digits, common punctuation)
127249
+ - text: types a string (supports uppercase, digits, common punctuation). To type a credential, use \`{{secret:<NAME>}}\` \u2014 resolved server-side from the \`ARGENT_SECRET_<NAME>\` env var (prefix mandatory; \`{{secret:APP_PASSWORD}}\` \u2194 \`ARGENT_SECRET_APP_PASSWORD\`), so the plaintext never enters agent context; the result echoes the placeholder, not the value, and the after-typing auto-screenshot is skipped.
127190
127250
  - key: presses a single named key (enter, escape, backspace, tab, arrow-up/down/left/right, f1\u2013f12) \u2014 NOT supported on TV targets; move focus with \`tv-remote\` instead.
127191
127251
  On a TV target (runtimeKind 'tv') only \`text\` applies \u2014 focus a text field first (with \`tv-remote\`), then type into it (injected HID keyboard on Apple TV, \`adb input text\` on Android TV).
127192
127252
  Provide text, key, or both \u2014 when both are given, the text is typed first and the key is pressed after it (text + key:"enter" types and submits).`,
@@ -127197,15 +127257,17 @@ Provide text, key, or both \u2014 when both are given, the text is typed first a
127197
127257
  // simulator-server, CDP, or Vega adb), since distinguishing a TV target is
127198
127258
  // async and a tvOS udid must never resolve simulator-server.
127199
127259
  services: () => ({}),
127200
- execute: dispatchByPlatform({
127201
- toolId: "keyboard",
127202
- capability: capability16,
127203
- ios: makeIosImpl3(registry2),
127204
- iosRemote: makeIosRemoteImpl(registry2),
127205
- android: makeAndroidImpl(registry2),
127206
- chromium: makeChromiumImpl(registry2),
127207
- vega: vegaImpl4
127208
- })
127260
+ execute: async (services, params, options) => {
127261
+ if (params.text === void 0) return dispatch(services, params, options);
127262
+ const { text, secrets } = resolveSecretPlaceholders(params.text);
127263
+ if (secrets.length === 0) return dispatch(services, params, options);
127264
+ try {
127265
+ const result = await dispatch(services, { ...params, text }, options);
127266
+ return { ...result, typed: params.text };
127267
+ } catch (err) {
127268
+ throw redactSecretsFromError(err, secrets);
127269
+ }
127270
+ }
127209
127271
  };
127210
127272
  }
127211
127273
 
@@ -133168,7 +133230,7 @@ async function describeVega(_serial) {
133168
133230
 
133169
133231
  // ../tool-server/src/utils/ui-tree-match.ts
133170
133232
  init_zod();
133171
- var selectorSchema = external_exports.object({
133233
+ var selectorFieldsSchema = external_exports.object({
133172
133234
  text: external_exports.string().min(1).optional().describe("Case-insensitive substring of the element's visible label or value."),
133173
133235
  identifier: external_exports.string().min(1).optional().describe(
133174
133236
  "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 +133238,13 @@ var selectorSchema = external_exports.object({
133176
133238
  role: external_exports.string().min(1).optional().describe(
133177
133239
  "Case-insensitive substring of the element's role (e.g. AXButton, button, TextView)."
133178
133240
  )
133179
- }).refine((s) => Boolean(s.text || s.identifier || s.role), {
133180
- message: "selector needs at least one of text, identifier, or role"
133181
- });
133241
+ }).strict();
133242
+ var selectorSchema = selectorFieldsSchema.refine(
133243
+ (s) => Boolean(s.text || s.identifier || s.role),
133244
+ {
133245
+ message: "selector needs at least one of text, identifier, or role"
133246
+ }
133247
+ );
133182
133248
  function nodeText(node) {
133183
133249
  return [node.label, node.value].filter(Boolean).join(" ");
133184
133250
  }
@@ -133195,15 +133261,32 @@ function identifierMatches(actual, needle) {
133195
133261
  if (!actual) return false;
133196
133262
  return equalsCI(actual, needle) || actual.toLowerCase().endsWith(`:id/${needle.toLowerCase()}`);
133197
133263
  }
133264
+ var uiTreeMatchInternals = {
133265
+ createRegExp(pattern) {
133266
+ return new RegExp(pattern);
133267
+ }
133268
+ };
133269
+ function regexMatchesNonEmpty(regex, actual) {
133270
+ if (!actual) return false;
133271
+ return regex.test(actual);
133272
+ }
133198
133273
  function textMatches(actual, expected, mode) {
133274
+ if (mode === "matches") {
133275
+ return regexMatchesNonEmpty(uiTreeMatchInternals.createRegExp(expected), actual);
133276
+ }
133199
133277
  return mode === "equals" ? equalsCI(actual, expected) : includesCI(actual, expected);
133200
133278
  }
133201
- function matchNode(node, selector) {
133279
+ function matchNodeWithRegex(node, selector, textRegex) {
133202
133280
  if (selector.text !== void 0) {
133203
133281
  if (!includesCI(node.label, selector.text) && !includesCI(node.value, selector.text)) {
133204
133282
  return false;
133205
133283
  }
133206
133284
  }
133285
+ if (textRegex !== void 0) {
133286
+ if (!regexMatchesNonEmpty(textRegex, node.label) && !regexMatchesNonEmpty(textRegex, node.value)) {
133287
+ return false;
133288
+ }
133289
+ }
133207
133290
  if (selector.identifier !== void 0 && !identifierMatches(node.identifier, selector.identifier)) {
133208
133291
  return false;
133209
133292
  }
@@ -133212,13 +133295,17 @@ function matchNode(node, selector) {
133212
133295
  }
133213
133296
  return true;
133214
133297
  }
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);
133298
+ function selectorTextRegex(selector) {
133299
+ return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(selector.textMatches);
133300
+ }
133301
+ function collectMatches(node, selector, textRegex, acc) {
133302
+ if (matchNodeWithRegex(node, selector, textRegex)) acc.push(node);
133303
+ for (const child of node.children) collectMatches(child, selector, textRegex, acc);
133218
133304
  }
133219
133305
  function findAll(root2, selector) {
133220
133306
  const acc = [];
133221
- for (const child of root2.children) collectMatches(child, selector, acc);
133307
+ const textRegex = selectorTextRegex(selector);
133308
+ for (const child of root2.children) collectMatches(child, selector, textRegex, acc);
133222
133309
  return acc;
133223
133310
  }
133224
133311
  function isVisible(node) {
@@ -133282,21 +133369,29 @@ function nodeAtPoint(root2, point) {
133282
133369
  for (const child of root2.children) walk(child);
133283
133370
  return best;
133284
133371
  }
133285
- function exactFieldCount(node, selector) {
133372
+ function fullConsumptionRegex(selector) {
133373
+ return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(`^(?:${selector.textMatches})$`);
133374
+ }
133375
+ function exactFieldCount(node, selector, fullTextRegex) {
133286
133376
  let count = 0;
133287
133377
  if (selector.text !== void 0 && (equalsCI(node.label, selector.text) || equalsCI(node.value, selector.text))) {
133288
133378
  count++;
133289
133379
  }
133380
+ if (fullTextRegex !== void 0 && (regexMatchesNonEmpty(fullTextRegex, node.label) || regexMatchesNonEmpty(fullTextRegex, node.value))) {
133381
+ count++;
133382
+ }
133290
133383
  if (selector.identifier !== void 0 && equalsCI(node.identifier, selector.identifier)) count++;
133291
133384
  if (selector.role !== void 0 && equalsCI(node.role, selector.role)) count++;
133292
133385
  return count;
133293
133386
  }
133294
133387
  function selectorToFrame(root2, selector) {
133295
133388
  const visible = findAll(root2, selector).filter(isVisible);
133389
+ if (visible.length === 0) return void 0;
133390
+ const fullTextRegex = fullConsumptionRegex(selector);
133296
133391
  let best;
133297
133392
  let bestExact = -1;
133298
133393
  for (const n of visible) {
133299
- const exact = exactFieldCount(n, selector);
133394
+ const exact = exactFieldCount(n, selector, fullTextRegex);
133300
133395
  if (best === void 0 || exact !== bestExact) {
133301
133396
  if (exact > bestExact) {
133302
133397
  best = n;
@@ -133589,6 +133684,7 @@ Allowed tools and their args (udid is auto-injected, do NOT include it in args):
133589
133684
  gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } [ios only]
133590
133685
  button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } [ios/android]
133591
133686
  keyboard: { text?: string, key?: string, delayMs?: number } (key pressed after text; TV: text only) [ios/android/chromium/vega/tv]
133687
+ text supports {{secret:<NAME>}} placeholders, resolved server-side from ARGENT_SECRET_<NAME> env vars (prefix mandatory) \u2014 credentials never enter agent context
133592
133688
  rotate: { orientation: "Portrait"|"LandscapeLeft"|"LandscapeRight"|"PortraitUpsideDown" } [ios/android]
133593
133689
  tv-remote: { button: <remote button | array of them>, repeat?: number } [apple tv/android tv/vega]
133594
133690
  buttons: up/down/left/right/select/back/home/menu/playPause (+ rewind/fastForward/next/previous/volumeUp/volumeDown/mute \u2014 work on Android TV and Vega; rejected on the Apple TV simulator)
@@ -144519,14 +144615,67 @@ function chromiumLaunchSpec(launch) {
144519
144615
  return typeof c === "string" ? { path: c } : { path: c.path, args: c.args };
144520
144616
  }
144521
144617
  function selectorToYaml(sel) {
144618
+ if (sel.text !== void 0 && sel.textMatches !== void 0) {
144619
+ throw new Error(
144620
+ '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.'
144621
+ );
144622
+ }
144623
+ if (sel.loose && sel.text !== void 0 && (typeof sel.text !== "string" || sel.text.length === 0)) {
144624
+ throw new Error(
144625
+ "Cannot serialize loose flow selector: `text` must be a non-empty string so bare-string YAML can round-trip through selector validation."
144626
+ );
144627
+ }
144628
+ if (sel.loose && (sel.text === void 0 || sel.textMatches !== void 0 || sel.identifier !== void 0 || sel.role !== void 0)) {
144629
+ const incompatible = [
144630
+ sel.textMatches !== void 0 ? "textMatches" : void 0,
144631
+ sel.identifier !== void 0 ? "identifier" : void 0,
144632
+ sel.role !== void 0 ? "role" : void 0
144633
+ ].filter((field) => field !== void 0);
144634
+ throw new Error(
144635
+ "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(", ")}` : "") + "."
144636
+ );
144637
+ }
144522
144638
  if (sel.loose && sel.text !== void 0 && sel.identifier === void 0 && sel.role === void 0) {
144523
144639
  return sel.text;
144524
144640
  }
144525
- const { loose: _loose, identifier, ...rest } = sel;
144526
- return identifier === void 0 ? { ...rest } : { ...rest, id: identifier };
144641
+ const { loose: _loose, identifier, textMatches: textMatches2, ...rest } = sel;
144642
+ const out = { ...rest };
144643
+ if (textMatches2 !== void 0) out.text = { matches: textMatches2 };
144644
+ if (identifier !== void 0) out.id = identifier;
144645
+ return out;
144527
144646
  }
144528
144647
  function describeSelector(s) {
144529
- return Object.entries(s).filter(([k]) => k !== "loose").map(([k, v]) => `${k === "identifier" ? "id" : k}="${v}"`).join(" ");
144648
+ return Object.entries(s).filter(([k]) => k !== "loose").map(
144649
+ ([k, v]) => k === "textMatches" ? `text=/${v}/` : `${k === "identifier" ? "id" : k}="${v}"`
144650
+ ).join(" ");
144651
+ }
144652
+ function describeTextExpectation(expectedText, textMatch, verbForm = "mode") {
144653
+ const expected = expectedText ?? "";
144654
+ const mode = textMatch ?? "contains";
144655
+ switch (mode) {
144656
+ case "contains":
144657
+ return `${verbForm === "infinitive" ? "contain" : mode} ${JSON.stringify(expected)}`;
144658
+ case "equals":
144659
+ return `${verbForm === "infinitive" ? "equal" : mode} ${JSON.stringify(expected)}`;
144660
+ case "matches":
144661
+ return `${verbForm === "infinitive" ? "match" : mode} /${expected}/`;
144662
+ }
144663
+ }
144664
+ function textWaitToYaml(selector, expectedText, textMatch) {
144665
+ const expected = expectedText ?? "";
144666
+ const mode = textMatch ?? "contains";
144667
+ switch (mode) {
144668
+ case "contains":
144669
+ return { text: { in: selector, contains: expected } };
144670
+ case "equals":
144671
+ return { text: { in: selector, equals: expected } };
144672
+ case "matches":
144673
+ return { text: { in: selector, matches: expected } };
144674
+ default: {
144675
+ const exhaustive = mode;
144676
+ throw new Error(`Unsupported text match mode: ${exhaustive}`);
144677
+ }
144678
+ }
144530
144679
  }
144531
144680
  function waitToYaml(condition, selector, expectedText, textMatch, timeoutMs) {
144532
144681
  const sel = selectorToYaml(selector);
@@ -144542,7 +144691,7 @@ function waitToYaml(condition, selector, expectedText, textMatch, timeoutMs) {
144542
144691
  body = { hidden: sel };
144543
144692
  break;
144544
144693
  case "text":
144545
- body = textMatch === "equals" ? { text: { in: sel, equals: expectedText ?? "" } } : { text: { in: sel, contains: expectedText ?? "" } };
144694
+ body = textWaitToYaml(sel, expectedText, textMatch);
144546
144695
  break;
144547
144696
  }
144548
144697
  if (timeoutMs !== void 0) body.timeout = timeoutMs;
@@ -144635,6 +144784,16 @@ function badEntry(raw, detail) {
144635
144784
  error_kind: "validation"
144636
144785
  });
144637
144786
  }
144787
+ function validatePattern(raw, pattern, where) {
144788
+ try {
144789
+ new RegExp(pattern);
144790
+ } catch (err) {
144791
+ badEntry(
144792
+ raw,
144793
+ `${where} \`matches\` is not a valid regular expression: ${err instanceof Error ? err.message : String(err)}`
144794
+ );
144795
+ }
144796
+ }
144638
144797
  function editDistance(a, b) {
144639
144798
  let prevPrev = new Array(b.length + 1);
144640
144799
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
@@ -144698,11 +144857,46 @@ function parseSelector(raw, where) {
144698
144857
  }
144699
144858
  normalized = { ...rest, identifier: id };
144700
144859
  }
144860
+ if (normalized !== null && typeof normalized === "object") {
144861
+ const { text, ...rest } = normalized;
144862
+ if (text !== null && typeof text === "object") {
144863
+ const keys = Object.keys(text);
144864
+ if (!Array.isArray(text)) {
144865
+ rejectUnknownKeys(
144866
+ raw,
144867
+ text,
144868
+ ["matches"],
144869
+ `${where}: text matcher`
144870
+ );
144871
+ }
144872
+ const pattern = text.matches;
144873
+ if (keys.length !== 1 || keys[0] !== "matches") {
144874
+ badEntry(
144875
+ raw,
144876
+ `${where}: a text matcher takes exactly { matches: '<regex>' } \u2014 for a substring, use the plain-string form (text: "\u2026")`
144877
+ );
144878
+ }
144879
+ if (typeof pattern !== "string" || pattern.length === 0) {
144880
+ badEntry(raw, `${where}: text matcher needs a non-empty \`matches\` pattern`);
144881
+ }
144882
+ validatePattern(raw, pattern, `${where}: text`);
144883
+ const fields = selectorFieldsSchema.safeParse(rest);
144884
+ if (!fields.success) {
144885
+ badEntry(raw, `${where}: ${fields.error.issues[0]?.message ?? "invalid selector"}`);
144886
+ }
144887
+ return { ...fields.data, textMatches: pattern };
144888
+ }
144889
+ }
144701
144890
  const r = selectorSchema.safeParse(normalized);
144702
144891
  if (!r.success) badEntry(raw, `${where}: ${r.error.issues[0]?.message ?? "invalid selector"}`);
144703
144892
  return r.data;
144704
144893
  }
144705
144894
  var WAIT_CONDITIONS = ["exists", "visible", "hidden", "text"];
144895
+ var TEXT_MATCH_MODES = Object.keys({
144896
+ contains: true,
144897
+ equals: true,
144898
+ matches: true
144899
+ });
144706
144900
  var SCROLL_DIRECTIONS = ["up", "down", "left", "right"];
144707
144901
  function parseWaitFields(raw, kind) {
144708
144902
  if (raw === null || typeof raw !== "object") {
@@ -144742,22 +144936,30 @@ function parseWaitFields(raw, kind) {
144742
144936
  if (condition === "text") {
144743
144937
  const t = b.text;
144744
144938
  if (t === null || typeof t !== "object") {
144745
- badEntry({ [kind]: b }, `${kind} text needs { in: <selector>, contains|equals: <string> }`);
144939
+ badEntry(
144940
+ { [kind]: b },
144941
+ `${kind} text needs { in: <selector>, contains|equals|matches: <string> }`
144942
+ );
144746
144943
  }
144747
144944
  const tb = t;
144748
144945
  if (!Array.isArray(tb)) {
144749
- rejectUnknownKeys({ [kind]: b }, tb, ["in", "contains", "equals"], `${kind}.text`);
144946
+ rejectUnknownKeys({ [kind]: b }, tb, ["in", ...TEXT_MATCH_MODES], `${kind}.text`);
144750
144947
  }
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\``);
144948
+ const comparators = TEXT_MATCH_MODES.filter((mode) => mode in tb);
144949
+ if (comparators.length !== 1) {
144950
+ badEntry(
144951
+ { [kind]: b },
144952
+ `${kind} text needs exactly one of \`contains\`, \`equals\`, or \`matches\``
144953
+ );
144755
144954
  }
144756
- const textMatch = hasEquals ? "equals" : "contains";
144757
- const expected = hasEquals ? tb.equals : tb.contains;
144955
+ const textMatch = comparators[0];
144956
+ const expected = tb[textMatch];
144758
144957
  if (typeof expected !== "string" || expected.length === 0) {
144759
144958
  badEntry({ [kind]: b }, `${kind} text needs a non-empty \`${textMatch}\``);
144760
144959
  }
144960
+ if (textMatch === "matches") {
144961
+ validatePattern({ [kind]: b }, expected, `${kind} text`);
144962
+ }
144761
144963
  return {
144762
144964
  condition: "text",
144763
144965
  selector: parseSelector(tb.in, `${kind}.text.in`),
@@ -145869,7 +146071,14 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps
145869
146071
  return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
145870
146072
  case "await":
145871
146073
  case "assert": {
145872
- const tail = step.condition === "text" ? `text ${selectorLabel(step.selector)} == "${step.expectedText ?? ""}"` : `${step.condition} ${selectorLabel(step.selector)}`;
146074
+ let tail;
146075
+ if (step.condition !== "text") {
146076
+ tail = `${step.condition} ${selectorLabel(step.selector)}`;
146077
+ } else {
146078
+ const selector = selectorLabel(step.selector);
146079
+ const expected = step.expectedText ?? "";
146080
+ tail = step.textMatch === "matches" ? `text ${selector} matches /${expected}/` : step.textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
146081
+ }
145873
146082
  return `${n}. ${step.kind}: ${tail}`;
145874
146083
  }
145875
146084
  case "wait":
@@ -146263,11 +146472,11 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
146263
146472
  case "text": {
146264
146473
  const first = firstInReadingOrder(matches2.filter(isVisible)) ?? firstInReadingOrder(matches2);
146265
146474
  if (!first) return `no element matched selector ${sel}`;
146266
- const wanted = textMatch === "equals" ? "equal" : "contain";
146475
+ const wanted = describeTextExpectation(expectedText, textMatch, "infinitive");
146267
146476
  const shown = assertText(first);
146268
146477
  const own = nodeText(first);
146269
146478
  const ownNote = own && own !== shown ? ` (own text "${own}")` : "";
146270
- return `element matched ${sel} but its text was "${shown}"${ownNote} (wanted to ${wanted} "${expectedText}")`;
146479
+ return `element matched ${sel} but its text was "${shown}"${ownNote} (wanted to ${wanted})`;
146271
146480
  }
146272
146481
  default:
146273
146482
  return `assertion failed for selector ${sel}`;
@@ -148763,6 +148972,7 @@ function pushReport(state3, report) {
148763
148972
  function selectorLabel2(sel) {
148764
148973
  const parts = [];
148765
148974
  if (sel.text !== void 0) parts.push(`"${sel.text}"`);
148975
+ if (sel.textMatches !== void 0) parts.push(`/${sel.textMatches}/`);
148766
148976
  if (sel.identifier) parts.push(`id=${sel.identifier}`);
148767
148977
  if (sel.role) parts.push(`role=${sel.role}`);
148768
148978
  return parts.join(" ");
@@ -148781,7 +148991,7 @@ function stepTarget(step) {
148781
148991
  case "assert": {
148782
148992
  const sel = selectorLabel2(step.selector);
148783
148993
  if (step.condition === "text") {
148784
- return `${sel} ${step.textMatch ?? "contains"} "${step.expectedText ?? ""}"`;
148994
+ return `${sel} ${describeTextExpectation(step.expectedText, step.textMatch)}`;
148785
148995
  }
148786
148996
  return `${step.condition} ${sel}`;
148787
148997
  }
@@ -149682,7 +149892,7 @@ function normLabel(s) {
149682
149892
  return (s || "").toLowerCase().replace(/-/g, "").replace(/[\s,]+/g, " ").trim();
149683
149893
  }
149684
149894
  var MAX_FRAME_AREA = 0.85;
149685
- function matchNode2(n, match, needle) {
149895
+ function matchNode(n, match, needle) {
149686
149896
  const label = normLabel(n.label);
149687
149897
  const ident = normLabel(n.identifier);
149688
149898
  const value = normLabel(n.value);
@@ -149709,7 +149919,7 @@ function findElementMatch(tree, match) {
149709
149919
  const candidates = [];
149710
149920
  const walk = (n) => {
149711
149921
  if (!n || typeof n !== "object") return;
149712
- const m = matchNode2(n, match, needle);
149922
+ const m = matchNode(n, match, needle);
149713
149923
  if (m && n.frame) {
149714
149924
  const f = n.frame;
149715
149925
  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.7",
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`.
@@ -57,6 +60,8 @@ For a custom poll interval or bundleId, drop to an explicit `- tool: await-ui-el
57
60
 
58
61
  `type` presses Enter after typing to commit the value and dismiss the keyboard, so it can't cover later targets. For a chained form whose fields feed one explicit submit — e.g. email then password then a `tap: "Log in"` — set `submit: false` on the intermediate fields so a premature Enter doesn't fire the form early: `type: { into: password, text: "hunter2", submit: false }`.
59
62
 
63
+ 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.
64
+
60
65
  `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.
61
66
 
62
67
  ### TV targets (Vega)
@@ -171,6 +171,18 @@ Values: `home`, `back`, `power`, `volumeUp`, `volumeDown`, `appSwitch`, `actionB
171
171
 
172
172
  Special keys: `enter`, `escape`, `backspace`, `tab`, `space`, `arrow-up`, `arrow-down`, `arrow-left`, `arrow-right`, `f1`–`f12`. Optional: `"delayMs": 100` between keystrokes (default 50ms) — applies to the iOS simulator and Chromium; it is ignored on Android phones/tablets (typed via `adb input text`, no per-key cadence), on Vega, and on TV targets.
173
173
 
174
+ **Typing secrets.** To enter a credential without its plaintext ever entering your context, transcript, or logs, use a secret placeholder in `text` (works in `keyboard`, `paste`, `run-sequence` keyboard steps, and flow `type` steps):
175
+
176
+ ```json
177
+ { "udid": "<UDID>", "text": "{{secret:APP_PASSWORD}}", "key": "enter" }
178
+ ```
179
+
180
+ The placeholder is resolved on the machine running the tool-server from the `ARGENT_SECRET_<NAME>` environment variable (here `ARGENT_SECRET_APP_PASSWORD`) — the CI-native pattern: expose the secret under that prefix in the environment that starts the tool-server. Rules:
181
+
182
+ - The result echoes the placeholder, never the value. An unknown name fails with the list of available secret _names_.
183
+ - The auto-screenshot after the call is skipped so the typed value cannot re-enter your context as pixels. Do **not** `describe` or `screenshot` a non-secure field you just filled with a secret — submit or navigate away first, then verify the resulting screen.
184
+ - Only `ARGENT_SECRET_*` variables are resolvable; never ask the user to paste a secret value into the conversation — ask them to export the env var instead.
185
+
174
186
  ### rotate — Change orientation
175
187
 
176
188
  ```json