@swmansion/argent 0.16.1-next.2 → 0.16.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.
package/dist/cli-cmds.mjs CHANGED
@@ -8489,6 +8489,14 @@ function parseRunArgs(argv) {
8489
8489
  }
8490
8490
  return out;
8491
8491
  }
8492
+ function renderEchoLine(s) {
8493
+ if (!s.message) return void 0;
8494
+ if (s.status === "skip") {
8495
+ const reason = s.reason ? ` \u2014 ${s.reason}` : "";
8496
+ return ` ${STATUS_GLYPH.skip} \u203A ${s.message}${reason}`;
8497
+ }
8498
+ return ` \u203A ${s.message}`;
8499
+ }
8492
8500
  function renderStepLine(s, n2, topFlow) {
8493
8501
  const where = s.flow && s.flow !== topFlow ? ` [${s.flow}]` : "";
8494
8502
  const what = s.tool ?? s.target;
@@ -8576,7 +8584,8 @@ function renderReport(report) {
8576
8584
  let n2 = 0;
8577
8585
  for (const s of report.steps) {
8578
8586
  if (s.kind === "echo") {
8579
- if (s.message) lines.push(` \u203A ${s.message}`);
8587
+ const line = renderEchoLine(s);
8588
+ if (line) lines.push(line);
8580
8589
  continue;
8581
8590
  }
8582
8591
  n2++;
@@ -8653,7 +8662,8 @@ async function flow(argv, options) {
8653
8662
  if (liveSteps === 0) console.log(`Flow "${flowName}"`);
8654
8663
  liveSteps++;
8655
8664
  if (s.kind === "echo") {
8656
- if (s.message) console.log(` \u203A ${s.message}`);
8665
+ const line = renderEchoLine(s);
8666
+ if (line) console.log(line);
8657
8667
  return;
8658
8668
  }
8659
8669
  liveIndex++;
@@ -144776,6 +144776,16 @@ function toYamlStep(step) {
144776
144776
  return { launch: step.app };
144777
144777
  case "run":
144778
144778
  return { run: step.flow };
144779
+ case "when": {
144780
+ const when = step.condition.kind === "platform" ? { platform: step.condition.platform } : waitToYaml(
144781
+ step.condition.condition,
144782
+ step.condition.selector,
144783
+ step.condition.expectedText,
144784
+ step.condition.textMatch,
144785
+ void 0
144786
+ );
144787
+ return { when, steps: step.steps.map(toYamlStep) };
144788
+ }
144779
144789
  case "tap": {
144780
144790
  const target = targetToYaml(step);
144781
144791
  return { tap: step.times !== void 0 ? { on: target, times: step.times } : target };
@@ -144843,7 +144853,13 @@ function toYamlStep(step) {
144843
144853
  }
144844
144854
  }
144845
144855
  function badEntry(raw, detail) {
144846
- throw new FailureError(`Unrecognized flow entry (${detail}): ${JSON.stringify(raw)}`, {
144856
+ let rendered;
144857
+ try {
144858
+ rendered = JSON.stringify(raw);
144859
+ } catch {
144860
+ rendered = "[cyclic entry]";
144861
+ }
144862
+ throw new FailureError(`Unrecognized flow entry (${detail}): ${rendered}`, {
144847
144863
  error_code: FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED,
144848
144864
  failure_stage: "flow_file_parse_step",
144849
144865
  failure_area: "tool_server",
@@ -145086,6 +145102,7 @@ var STEP_DIRECTIVE_KEYS = [
145086
145102
  "echo",
145087
145103
  "launch",
145088
145104
  "run",
145105
+ "when",
145089
145106
  "tool",
145090
145107
  "tap",
145091
145108
  "long-press",
@@ -145190,8 +145207,85 @@ function parseLongPress(body, entry) {
145190
145207
  }
145191
145208
  return { kind: "long-press", ...parseTarget(body, "long-press") };
145192
145209
  }
145193
- function fromYamlStep(raw) {
145210
+ function parseWhenCondition(raw) {
145211
+ const conditionKeys = `${WAIT_CONDITIONS.join(", ")}, platform`;
145212
+ if (raw === null || typeof raw !== "object") {
145213
+ badEntry({ when: raw }, `when needs exactly one condition key (${conditionKeys})`);
145214
+ }
145215
+ const b = raw;
145216
+ const present = [...WAIT_CONDITIONS, "platform"].filter((c) => c in b);
145217
+ if (present.length !== 1) {
145218
+ badEntry({ when: raw }, `when needs exactly one condition key (${conditionKeys})`);
145219
+ }
145220
+ if ("timeout" in b) {
145221
+ badEntry(
145222
+ { when: raw },
145223
+ "when takes no timeout \u2014 the guard is evaluated with the short assert grace so a skipped block never adds a full await wait"
145224
+ );
145225
+ }
145226
+ if (present[0] === "platform") {
145227
+ if (Object.keys(b).length !== 1) {
145228
+ badEntry({ when: raw }, "when.platform takes no other keys");
145229
+ }
145230
+ const p = b.platform;
145231
+ if (typeof p !== "string" || !LAUNCH_PLATFORMS.includes(p)) {
145232
+ badEntry({ when: raw }, `when.platform must be one of ${LAUNCH_PLATFORMS.join(", ")}`);
145233
+ }
145234
+ return { kind: "platform", platform: p };
145235
+ }
145236
+ const { timeout: _timeout, ...cond } = parseWaitFields(raw, "when");
145237
+ const { selector, expectedText } = cond;
145238
+ for (const s of [
145239
+ expectedText,
145240
+ selector.text,
145241
+ selector.textMatches,
145242
+ selector.identifier,
145243
+ selector.role
145244
+ ]) {
145245
+ if (s !== void 0 && s.includes(SECRET_PLACEHOLDER_MARKER)) {
145246
+ badEntry(
145247
+ { when: raw },
145248
+ "when takes no {{secret:\u2026}} placeholder \u2014 secrets resolve only in text-entry steps (`type:`), never in condition evaluation, so the guard tests literal placeholder text that is never on screen: permanently false (for `hidden`, vacuously true); use the literal on-screen text instead"
145249
+ );
145250
+ }
145251
+ }
145252
+ return { kind: "ui", ...cond };
145253
+ }
145254
+ var MAX_WHEN_DEPTH = 20;
145255
+ function parseWhenStep(raw, depth) {
145256
+ if (depth >= MAX_WHEN_DEPTH) {
145257
+ badEntry(
145258
+ raw,
145259
+ `when blocks nest deeper than ${MAX_WHEN_DEPTH} levels \u2014 check for a cyclic YAML alias (\`steps: &s \u2026 steps: *s\`)`
145260
+ );
145261
+ }
145262
+ if ("else" in raw) {
145263
+ badEntry(
145264
+ raw,
145265
+ "when has no else \u2014 paths may only reconverge, never diverge; two genuinely different paths are two flows"
145266
+ );
145267
+ }
145268
+ if (!Object.keys(raw).every((k) => k === "when" || k === "steps")) {
145269
+ badEntry(raw, "a when step takes exactly { when: <condition>, steps: [...] }");
145270
+ }
145271
+ const condition = parseWhenCondition(raw.when);
145272
+ if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
145273
+ badEntry(raw, "when needs a non-empty steps list to guard");
145274
+ }
145275
+ const steps = raw.steps.map((s) => {
145276
+ if (s !== null && typeof s === "object") return fromYamlStep(s, depth + 1);
145277
+ return badEntry(s, "step must be an object");
145278
+ });
145279
+ return { kind: "when", condition, steps };
145280
+ }
145281
+ function fromYamlStep(raw, whenDepth = 0) {
145194
145282
  const entry = raw;
145283
+ if ("optional" in raw) {
145284
+ badEntry(
145285
+ raw,
145286
+ "optional is not supported \u2014 guard the step with a when: block instead (`when: { visible: <target> }` + `steps:`)"
145287
+ );
145288
+ }
145195
145289
  const kinds = STEP_DIRECTIVE_KEYS.filter((k) => k in entry);
145196
145290
  if (kinds.length === 0) {
145197
145291
  const hint = Object.keys(entry).map((k) => closestKey(k, STEP_DIRECTIVE_KEYS)).find((h) => h !== null);
@@ -145204,17 +145298,20 @@ function fromYamlStep(raw) {
145204
145298
  );
145205
145299
  }
145206
145300
  const kind = kinds[0];
145207
- const siblings = kind === "tool" ? ["tool", "args", "delayMs"] : [kind];
145208
- const extras = Object.keys(entry).filter((k) => !siblings.includes(k));
145209
- if (extras.length > 0) {
145210
- badEntry(
145211
- raw,
145212
- `a \`${kind}\` step has ${describeUnknownKeys(extras, siblings)}` + (kind === "tool" ? " \u2014 a tool step takes only `tool`, `args`, `delayMs`" : ` \u2014 step options go inside the \`${kind}:\` value, not beside it`)
145213
- );
145301
+ if (kind !== "when") {
145302
+ const siblings = kind === "tool" ? ["tool", "args", "delayMs"] : [kind];
145303
+ const extras = Object.keys(entry).filter((k) => !siblings.includes(k));
145304
+ if (extras.length > 0) {
145305
+ badEntry(
145306
+ raw,
145307
+ `a \`${kind}\` step has ${describeUnknownKeys(extras, siblings)}` + (kind === "tool" ? " \u2014 a tool step takes only `tool`, `args`, `delayMs`" : ` \u2014 step options go inside the \`${kind}:\` value, not beside it`)
145308
+ );
145309
+ }
145214
145310
  }
145215
145311
  if ("echo" in raw) return { kind: "echo", message: String(raw.echo) };
145216
145312
  if ("launch" in raw) return { kind: "launch", app: parseLaunch(raw.launch) };
145217
145313
  if ("run" in raw) return { kind: "run", flow: String(raw.run) };
145314
+ if ("when" in raw) return parseWhenStep(entry, whenDepth);
145218
145315
  if ("tap" in raw) return parseTap(raw.tap, raw);
145219
145316
  if ("long-press" in raw) {
145220
145317
  return parseLongPress(raw["long-press"], raw);
@@ -146115,6 +146212,11 @@ var fs38 = __toESM(require("node:fs/promises"));
146115
146212
  function selectorLabel(sel) {
146116
146213
  return JSON.stringify(selectorToYaml(sel));
146117
146214
  }
146215
+ function textConditionLabel(sel, expectedText, textMatch) {
146216
+ const selector = selectorLabel(sel);
146217
+ const expected = expectedText ?? "";
146218
+ return textMatch === "matches" ? `text ${selector} matches /${expected}/` : textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
146219
+ }
146118
146220
  var zodSchema61 = external_exports.object({});
146119
146221
  var flowFinishRecordingTool = {
146120
146222
  id: "flow-finish-recording",
@@ -146152,18 +146254,20 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps
146152
146254
  return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
146153
146255
  case "await":
146154
146256
  case "assert": {
146155
- let tail;
146156
- if (step.condition !== "text") {
146157
- tail = `${step.condition} ${selectorLabel(step.selector)}`;
146158
- } else {
146159
- const selector = selectorLabel(step.selector);
146160
- const expected = step.expectedText ?? "";
146161
- tail = step.textMatch === "matches" ? `text ${selector} matches /${expected}/` : step.textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
146162
- }
146257
+ const tail = step.condition === "text" ? textConditionLabel(step.selector, step.expectedText, step.textMatch) : `${step.condition} ${selectorLabel(step.selector)}`;
146163
146258
  return `${n}. ${step.kind}: ${tail}`;
146164
146259
  }
146165
146260
  case "wait":
146166
146261
  return `${n}. wait: ${step.ms}ms`;
146262
+ case "when": {
146263
+ const cond = step.condition.kind === "platform" ? `platform ${step.condition.platform}` : step.condition.condition === "text" ? textConditionLabel(
146264
+ step.condition.selector,
146265
+ step.condition.expectedText,
146266
+ step.condition.textMatch
146267
+ ) : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`;
146268
+ const count = step.steps.length;
146269
+ return `${n}. when: ${cond} (${count} step${count === 1 ? "" : "s"})`;
146270
+ }
146167
146271
  case "scroll-to":
146168
146272
  return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
146169
146273
  case "snapshot":
@@ -146235,6 +146339,10 @@ function axisFullyInside(frame, direction, clip2) {
146235
146339
  return direction === "down" || direction === "right" ? fEnd <= clipEnd - EDGE_EPS && fStart >= clipStart - EDGE_EPS : fStart >= clipStart + EDGE_EPS && fEnd <= clipEnd + EDGE_EPS;
146236
146340
  }
146237
146341
  var DEFAULT_ASSERT_TIMEOUT_MS = 1e3;
146342
+ var CONDITION_DARK_TAIL_TOLERANCE_MS = POLL_INTERVAL_MS * 2;
146343
+ function probeWhenCondition(env, cond) {
146344
+ return waitForCondition(env, cond, DEFAULT_ASSERT_TIMEOUT_MS);
146345
+ }
146238
146346
  function selectorAlternatives(sel) {
146239
146347
  return sel.loose && sel.text !== void 0 ? [{ identifier: sel.text }, { text: sel.text }] : [sel];
146240
146348
  }
@@ -146495,7 +146603,8 @@ async function waitForCondition(env, step, timeoutMs) {
146495
146603
  let lastMatches = [];
146496
146604
  let fetchError;
146497
146605
  let everMatched = false;
146498
- let everTrustedRead = false;
146606
+ let lastTrustedReadAt;
146607
+ let lastReadTrusted;
146499
146608
  let finalPoll = false;
146500
146609
  for (; ; ) {
146501
146610
  if (env.signal?.aborted) return ABORTED_OUTCOME;
@@ -146505,12 +146614,14 @@ async function waitForCondition(env, step, timeoutMs) {
146505
146614
  fetchError = void 0;
146506
146615
  everMatched ||= lastMatches.length > 0;
146507
146616
  const blind = data.tree.children.length === 0 && Boolean(data.hint || data.should_restart || everMatched);
146508
- everTrustedRead ||= !blind;
146617
+ if (!blind) lastTrustedReadAt = Date.now();
146618
+ lastReadTrusted = !blind;
146509
146619
  if (!blind && evaluateCondition(step.condition, step.expectedText, lastMatches, step.textMatch)) {
146510
146620
  return { ok: true };
146511
146621
  }
146512
146622
  } catch (err) {
146513
146623
  fetchError = err instanceof Error ? err.message : String(err);
146624
+ lastReadTrusted = false;
146514
146625
  }
146515
146626
  if (Date.now() >= deadline) {
146516
146627
  if (finalPoll) break;
@@ -146522,22 +146633,34 @@ async function waitForCondition(env, step, timeoutMs) {
146522
146633
  return ABORTED_OUTCOME;
146523
146634
  }
146524
146635
  }
146525
- if (fetchError) return { ok: false, reason: `could not read the UI tree: ${fetchError}` };
146526
- if (step.condition === "hidden" && !everTrustedRead) {
146636
+ if (lastTrustedReadAt === void 0) {
146527
146637
  return {
146528
146638
  ok: false,
146529
- reason: "could not confirm the element is hidden \u2014 the UI tree was empty or unreadable"
146639
+ indeterminate: true,
146640
+ reason: fetchError ? `could not read the UI tree: ${fetchError}` : "could not evaluate the condition \u2014 every read of the UI tree was empty or degraded"
146530
146641
  };
146531
146642
  }
146643
+ if (!lastReadTrusted) {
146644
+ if (step.condition === "hidden") {
146645
+ return {
146646
+ ok: false,
146647
+ indeterminate: true,
146648
+ reason: fetchError ? `could not confirm the element is hidden \u2014 it was visible earlier, but the last UI read failed: ${fetchError}` : "could not confirm the element is hidden \u2014 it was visible earlier, but the last UI reads were empty"
146649
+ };
146650
+ }
146651
+ const darkTailMs = Date.now() - lastTrustedReadAt;
146652
+ if (darkTailMs > CONDITION_DARK_TAIL_TOLERANCE_MS) {
146653
+ return {
146654
+ ok: false,
146655
+ indeterminate: true,
146656
+ reason: fetchError ? `could not evaluate the condition \u2014 the UI tree was unreadable for the final ${darkTailMs}ms of the window: ${fetchError}` : `could not evaluate the condition \u2014 the UI tree reads were empty or degraded for the final ${darkTailMs}ms of the window`
146657
+ };
146658
+ }
146659
+ }
146660
+ const blipNote = !lastReadTrusted && fetchError ? ` (the final poll could not read the UI tree: ${fetchError})` : "";
146532
146661
  return {
146533
146662
  ok: false,
146534
- reason: assertReason(
146535
- step.condition,
146536
- step.selector,
146537
- step.expectedText,
146538
- step.textMatch,
146539
- lastMatches
146540
- )
146663
+ reason: assertReason(step.condition, step.selector, step.expectedText, step.textMatch, lastMatches) + blipNote
146541
146664
  };
146542
146665
  }
146543
146666
  function assertReason(condition, selector, expectedText, textMatch, matches2) {
@@ -146548,7 +146671,7 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
146548
146671
  case "visible":
146549
146672
  return matches2.length > 0 ? `element(s) matched ${sel} but none was visible (zero-area frame)` : `no element matched selector ${sel}`;
146550
146673
  case "hidden":
146551
- return matches2.some(isVisible) ? `an element matching ${sel} was still visible` : `could not confirm the element is hidden \u2014 it was visible earlier, but the last UI reads returned an empty tree`;
146674
+ return `an element matching ${sel} was still visible`;
146552
146675
  case "text": {
146553
146676
  const first = firstInReadingOrder(matches2.filter(isVisible)) ?? firstInReadingOrder(matches2);
146554
146677
  if (!first) return `no element matched selector ${sel}`;
@@ -148784,7 +148907,7 @@ var zodSchema62 = external_exports.object({
148784
148907
  device: external_exports.string().optional().describe(
148785
148908
  "Device id to run against (iOS UDID, Android/Vega serial, Chromium id). Auto-detected when omitted."
148786
148909
  ),
148787
- platform: external_exports.enum(["ios", "android", "chromium", "vega"]).optional().describe("Restrict auto-detection to this platform when several devices are booted."),
148910
+ platform: external_exports.enum(LAUNCH_PLATFORMS).optional().describe("Restrict auto-detection to this platform when several devices are booted."),
148788
148911
  updateBaselines: external_exports.boolean().optional().describe(
148789
148912
  "Write/refresh screenshot baselines for `snapshot` steps instead of diffing against them."
148790
148913
  ),
@@ -148919,6 +149042,9 @@ holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 b
148919
149042
  condition; \`wait\` pauses for a fixed number of milliseconds; \`assert\` checks one now; \`snapshot\`
148920
149043
  diffs a screenshot against a stored baseline (a missing baseline fails the step \u2014 set updateBaselines
148921
149044
  to adopt the current screen); \`echo\` annotates; \`run\` executes a referenced fragment inline.
149045
+ A \`when:\` block (condition + \`steps:\`, no else) runs its steps only if the condition holds \u2014
149046
+ checked once with the short assert grace \u2014 for one-sided divergences like interstitials and coach
149047
+ marks; a skipped block reports distinctly and failures inside an entered block are real failures.
148922
149048
  A flow that begins with a \`launch\` step is a self-contained e2e flow; one that doesn't runs against the
148923
149049
  device's current state. Device id is injected by the runner (flows store none) \u2014 pass \`device\` or
148924
149050
  \`platform\` to pick one, else the single booted device is used. For a Chromium e2e flow the \`launch\`
@@ -148962,13 +149088,15 @@ returns a notice with the prerequisite instead of running.`,
148962
149088
  chromiumLaunched: false,
148963
149089
  ...ctx?.emitProgress ? { onStepReport: ctx.emitProgress } : {}
148964
149090
  };
149091
+ let aborted2;
148965
149092
  try {
148966
149093
  await execSteps(state3, flow.steps, params.name, [params.name]);
148967
149094
  } finally {
149095
+ aborted2 = state3.signal?.aborted === true;
148968
149096
  if (state3.pinned) await restoreStatusBar(device);
148969
149097
  if (resolved.booted) await teardownBootedChromium(registry2, resolved.booted);
148970
149098
  }
148971
- return summarize(params.name, device.id, flow.executionPrerequisite, state3.reports);
149099
+ return summarize(params.name, device.id, flow.executionPrerequisite, state3.reports, aborted2);
148972
149100
  }
148973
149101
  };
148974
149102
  }
@@ -149024,14 +149152,12 @@ async function frontChromiumPage(registry2, device) {
149024
149152
  } catch {
149025
149153
  }
149026
149154
  }
149027
- function summarize(flowName, deviceId, executionPrerequisite, steps) {
149155
+ function summarize(flowName, deviceId, executionPrerequisite, steps, aborted2) {
149028
149156
  let passed = 0;
149029
149157
  let failed = 0;
149030
149158
  let skipped = 0;
149031
149159
  let errored = 0;
149032
- let hasSkippedReport = false;
149033
149160
  for (const s of steps) {
149034
- hasSkippedReport ||= s.status === "skip";
149035
149161
  if (s.kind === "echo") continue;
149036
149162
  if (s.status === "pass") passed++;
149037
149163
  else if (s.status === "fail") failed++;
@@ -149042,11 +149168,13 @@ function summarize(flowName, deviceId, executionPrerequisite, steps) {
149042
149168
  flow: flowName,
149043
149169
  device: deviceId,
149044
149170
  executionPrerequisite,
149045
- // A skip is never a successful omission: the runner only emits skips after
149046
- // a hard stop or cancellation. The former already has a fail/error report;
149047
- // the latter may contain skips alone, so include them in the verdict or an
149048
- // aborted run would be reported as PASS.
149049
- ok: failed === 0 && errored === 0 && !hasSkippedReport,
149171
+ // A cancelled run must never read as PASS it may contain skips alone
149172
+ // (no fail/error report), so the verdict folds the abort in directly. A
149173
+ // skip by itself is NOT a failure: an unmet `when:` guard skips its block
149174
+ // as a successful omission, and a hard stop already carries its own
149175
+ // fail/error report.
149176
+ ok: failed === 0 && errored === 0 && !aborted2,
149177
+ ...aborted2 ? { aborted: true } : {},
149050
149178
  passed,
149051
149179
  failed,
149052
149180
  skipped,
@@ -149066,6 +149194,13 @@ function selectorLabel2(sel) {
149066
149194
  if (sel.role) parts.push(`role=${sel.role}`);
149067
149195
  return parts.join(" ");
149068
149196
  }
149197
+ function conditionLabel(cond, renderSelector) {
149198
+ const sel = renderSelector(cond.selector);
149199
+ if (cond.condition === "text") {
149200
+ return `${sel} ${describeTextExpectation(cond.expectedText, cond.textMatch)}`;
149201
+ }
149202
+ return `${cond.condition} ${sel}`;
149203
+ }
149069
149204
  function stepTarget(step) {
149070
149205
  switch (step.kind) {
149071
149206
  case "tap":
@@ -149076,13 +149211,10 @@ function stepTarget(step) {
149076
149211
  case "type":
149077
149212
  return `into ${selectorLabel2(step.into)}`;
149078
149213
  case "await":
149079
- case "assert": {
149080
- const sel = selectorLabel2(step.selector);
149081
- if (step.condition === "text") {
149082
- return `${sel} ${describeTextExpectation(step.expectedText, step.textMatch)}`;
149083
- }
149084
- return `${step.condition} ${sel}`;
149085
- }
149214
+ case "assert":
149215
+ return conditionLabel(step, selectorLabel2);
149216
+ case "when":
149217
+ return step.condition.kind === "platform" ? `platform ${step.condition.platform}` : conditionLabel(step.condition, selectorLabel2);
149086
149218
  case "scroll-to": {
149087
149219
  const dir = step.direction !== "down" ? ` (${step.direction})` : "";
149088
149220
  return `${selectorLabel2(step.target)}${dir}`;
@@ -149102,8 +149234,12 @@ async function execSteps(state3, steps, sourceFlow, runStack) {
149102
149234
  kind: step.kind,
149103
149235
  status: "skip",
149104
149236
  flow: sourceFlow,
149105
- target: stepTarget(step)
149237
+ target: stepTarget(step),
149238
+ // Carry the echo's message so a skipped narration renders as a skip
149239
+ // line rather than vanishing — matching reportBlockSkipped.
149240
+ ...step.kind === "echo" ? { message: step.message } : {}
149106
149241
  });
149242
+ if (step.kind === "when") reportBlockSkipped(state3, step.steps, sourceFlow);
149107
149243
  continue;
149108
149244
  }
149109
149245
  if (state3.signal?.aborted) {
@@ -149114,19 +149250,106 @@ async function execSteps(state3, steps, sourceFlow, runStack) {
149114
149250
  status: "skip",
149115
149251
  reason: "run aborted",
149116
149252
  flow: sourceFlow,
149117
- target: stepTarget(step)
149253
+ target: stepTarget(step),
149254
+ ...step.kind === "echo" ? { message: step.message } : {}
149118
149255
  });
149256
+ if (step.kind === "when") reportBlockSkipped(state3, step.steps, sourceFlow, "run aborted");
149119
149257
  continue;
149120
149258
  }
149121
149259
  if (step.kind === "run") {
149122
149260
  await execRunStep(state3, step, runStack);
149123
149261
  continue;
149124
149262
  }
149263
+ if (step.kind === "when") {
149264
+ await execWhenStep(state3, step, sourceFlow, runStack);
149265
+ continue;
149266
+ }
149125
149267
  const report = await execLeafStep(state3, step, index, sourceFlow);
149126
149268
  pushReport(state3, report);
149127
149269
  if (report.status === "fail" || report.status === "error") state3.stopped = true;
149128
149270
  }
149129
149271
  }
149272
+ function describeWhenCondition(cond) {
149273
+ if (cond.kind === "platform") return `platform ${cond.platform}`;
149274
+ return conditionLabel(cond, describeSelector);
149275
+ }
149276
+ function reportBlockSkipped(state3, steps, sourceFlow, reason) {
149277
+ for (const step of steps) {
149278
+ pushReport(state3, {
149279
+ index: state3.reports.length,
149280
+ kind: step.kind,
149281
+ status: "skip",
149282
+ reason,
149283
+ // A `run:` line is attributed to the fragment it names, matching the
149284
+ // executed marker in execRunStep; everything else belongs to the
149285
+ // enclosing flow.
149286
+ flow: step.kind === "run" ? step.flow : sourceFlow,
149287
+ target: stepTarget(step),
149288
+ ...step.kind === "echo" ? { message: step.message } : {}
149289
+ });
149290
+ if (step.kind === "when") reportBlockSkipped(state3, step.steps, sourceFlow, reason);
149291
+ }
149292
+ }
149293
+ async function execWhenStep(state3, step, sourceFlow, runStack) {
149294
+ const index = state3.reports.length;
149295
+ const label = describeWhenCondition(step.condition);
149296
+ const target = stepTarget(step);
149297
+ let met;
149298
+ if (step.condition.kind === "platform") {
149299
+ const platform = state3.device.platform === "ios-remote" ? "ios" : state3.device.platform;
149300
+ met = platform === step.condition.platform;
149301
+ } else {
149302
+ const probe3 = await probeWhenCondition(state3, step.condition);
149303
+ if (probe3.aborted) {
149304
+ pushReport(state3, {
149305
+ index,
149306
+ kind: "when",
149307
+ status: "skip",
149308
+ reason: "run aborted",
149309
+ flow: sourceFlow,
149310
+ target
149311
+ });
149312
+ reportBlockSkipped(state3, step.steps, sourceFlow, "run aborted");
149313
+ return;
149314
+ }
149315
+ if (!probe3.ok && probe3.indeterminate) {
149316
+ pushReport(state3, {
149317
+ index,
149318
+ kind: "when",
149319
+ status: "error",
149320
+ reason: `could not evaluate when guard (${label}): ${probe3.reason}`,
149321
+ flow: sourceFlow,
149322
+ target
149323
+ });
149324
+ state3.stopped = true;
149325
+ reportBlockSkipped(state3, step.steps, sourceFlow, "when guard errored");
149326
+ return;
149327
+ }
149328
+ met = probe3.ok;
149329
+ }
149330
+ if (!met) {
149331
+ const n = step.steps.length;
149332
+ pushReport(state3, {
149333
+ index,
149334
+ kind: "when",
149335
+ status: "skip",
149336
+ reason: `condition not met (${label}) \u2014 block skipped (${n} step${n === 1 ? "" : "s"})`,
149337
+ flow: sourceFlow,
149338
+ target
149339
+ });
149340
+ reportBlockSkipped(state3, step.steps, sourceFlow, "when block skipped");
149341
+ return;
149342
+ }
149343
+ pushReport(state3, {
149344
+ index,
149345
+ kind: "when",
149346
+ status: "pass",
149347
+ reason: `condition met (${label})`,
149348
+ flow: sourceFlow,
149349
+ target
149350
+ });
149351
+ await execSteps(state3, step.steps, sourceFlow, runStack);
149352
+ }
149130
149353
  async function execRunStep(state3, step, runStack) {
149131
149354
  const index = state3.reports.length;
149132
149355
  const target = step.flow;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.16.1-next.2",
3
+ "version": "0.16.1-next.3",
4
4
  "description": "MCP server for iOS Simulator and Android Emulator control",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,6 +32,7 @@ Beyond raw `tool:` steps and `echo:`, flows support declarative directives inter
32
32
  | `assert` | `- assert: { visible: Welcome }` | check a condition, hard-fail if it never holds |
33
33
  | `snapshot` | `- snapshot: home` or `- snapshot: { name: home, maxMismatch: 0.5 }` | diff a screenshot against a stored baseline |
34
34
  | `run` | `- run: login` | execute another flow's steps inline (fragment or e2e) |
35
+ | `when` | `- when: { visible: "What's new" }` + `steps: [...]` | run a guarded step block only when the condition holds (no else) |
35
36
 
36
37
  ### Selectors
37
38
 
@@ -159,6 +160,7 @@ The top-level is an object with `steps` (array) and — fragments only — `exec
159
160
 
160
161
  - `- echo: <message>` — a label printed during replay
161
162
  - `- tool: <name>` with optional `args:` — a raw tool call. A tool step may also carry `delayMs: <ms>` to sleep that long before it runs. (`await-ui-element` is an ordinary tool step; see _flow-add-step arguments_ and _Making flows resilient_ for when to gate a transition with one.)
163
+ - **`when:` blocks** handle one-sided divergences (interstitials, coach marks): `- when: { visible: "What's new" }` with a sibling `steps: [...]` list runs the block only if the condition holds — checked once with the short assert grace (~1s), so a skipped block barely costs a clean run. Guards are one condition key (`exists`/`visible`/`hidden`/`text`, the await/assert shapes) or `platform: ios|android|chromium|vega`. **No else** (parse-rejected): a block exists to dismiss the divergence and reconverge, never to test two paths — two paths are two flows. Failures inside an entered block are real failures; a skipped block reports `skip` lines. Tap-if-present is a one-step block (`when: { visible: "Got it" }` + `steps: [tap: "Got it"]`); there is NO per-step `optional:` key — it is rejected at parse with a pointer to `when:`.
162
164
 
163
165
  The polished result of the example session above:
164
166