@swmansion/argent 0.27.1-next.16 → 0.27.1-next.18

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageName": "com.argent.androiddevtools",
3
3
  "instrumentationRunner": "com.argent.androiddevtools/.SnapshotInstrumentation",
4
- "versionName": "0.1.0",
5
- "versionCode": 1,
4
+ "versionName": "0.2.0",
5
+ "versionCode": 2,
6
6
  "installFlags": ["-r", "-t"]
7
7
  }
Binary file
Binary file
Binary file
Binary file
package/dist/cli-cmds.mjs CHANGED
@@ -21916,7 +21916,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
21916
21916
  var SESSION_ID2 = randomUUID5();
21917
21917
  function readCliVersion() {
21918
21918
  if (true) {
21919
- return "0.27.1-next.16";
21919
+ return "0.27.1-next.18";
21920
21920
  }
21921
21921
  return "0.0.0";
21922
21922
  }
@@ -23267,8 +23267,11 @@ file, or a device it cannot resolve. A transport failure, a rejection the server
23267
23267
  does not mark as validation, or a reply that is not a report stops the batch and
23268
23268
  counts the remaining flows skipped.
23269
23269
 
23270
- Runs require the auto-started local tool server;
23271
- ARGENT_TOOLS_URL and \`argent link\` routing are not supported.
23270
+ The CLI sends a run to its tool-server: the local one that starts
23271
+ automatically, or the one that \`argent link\` or ARGENT_TOOLS_URL names. With
23272
+ either, the CLI uploads the flow file, and the tool-server rejects a flow with
23273
+ run:, script: or snapshot: steps, or with tool: steps that take a file or
23274
+ record a flow, before the first step. With neither, all step kinds run.
23272
23275
 
23273
23276
  Subcommands:
23274
23277
  run <flow|flow.yaml|dir> Run a saved flow by name, a YAML file by path, or
@@ -23282,6 +23285,7 @@ Options (run):
23282
23285
  auto-detection (ios never picks a remote simulator)
23283
23286
  --update-baselines Write/refresh screenshot baselines instead of diffing
23284
23287
  --output <dir> Also write failed snapshot images (baseline/current/diff)
23288
+ and failed-step screenshots (step-<n>-screen.png)
23285
23289
  under <dir>/<flow>/ \u2014 a stable path for CI artifact
23286
23290
  upload; a directory run keys nested flows as
23287
23291
  <dir>/<subdir>/<flow>/. A different flow file with the
@@ -23557,23 +23561,34 @@ async function exportFailureArtifacts(report, outputDir, flowPath, ctx) {
23557
23561
  return;
23558
23562
  }
23559
23563
  let dir = null;
23564
+ let n2 = 0;
23560
23565
  for (const s of report.steps) {
23561
- if (s.kind !== "snapshot" || s.status !== "fail" || !s.artifacts) continue;
23562
- const key = s.snapshotKey ?? keyFromBaselinePath(s.artifacts);
23563
- if (!key || !SAFE_ARTIFACT_NAME.test(key)) continue;
23564
- const { result } = await materializeArtifacts(s.artifacts, ctx);
23565
- s.artifacts = result;
23566
- for (const [role, value] of Object.entries(s.artifacts)) {
23566
+ if (s.kind !== "echo") n2++;
23567
+ if (!s.artifacts) continue;
23568
+ let artifacts;
23569
+ let fileName;
23570
+ if (s.kind === "snapshot" && s.status === "fail") {
23571
+ const key = s.snapshotKey ?? keyFromBaselinePath(s.artifacts);
23572
+ if (!key || !SAFE_ARTIFACT_NAME.test(key)) continue;
23573
+ artifacts = s.artifacts;
23574
+ const remote = s.snapshotRemote === true ? "-remote" : "";
23575
+ fileName = (role) => `${key}${remote}-${role}.png`;
23576
+ } else if ((s.status === "fail" || s.status === "error") && s.artifacts.screen != null) {
23577
+ artifacts = { screen: s.artifacts.screen };
23578
+ fileName = () => `step-${n2}-screen.png`;
23579
+ } else {
23580
+ continue;
23581
+ }
23582
+ const { result } = await materializeArtifacts(artifacts, ctx);
23583
+ Object.assign(s.artifacts, result);
23584
+ for (const [role, value] of Object.entries(result)) {
23567
23585
  if (typeof value !== "string") continue;
23568
23586
  if (dir === null) {
23569
23587
  const dirName = await claimExportDirName(outputDir, flowPath, stem);
23570
23588
  if (dirName === null) return;
23571
23589
  dir = path14.join(outputDir, dirName);
23572
23590
  }
23573
- const dest = path14.join(
23574
- dir,
23575
- `${key}${s.snapshotRemote === true ? "-remote" : ""}-${role}.png`
23576
- );
23591
+ const dest = path14.join(dir, fileName(role));
23577
23592
  const rel = path14.relative(outputDir, dest);
23578
23593
  if (rel.startsWith("..") || path14.isAbsolute(rel)) continue;
23579
23594
  try {
@@ -23700,13 +23715,6 @@ async function collectFlowFiles(dir, recursive) {
23700
23715
  await walk(dir, "");
23701
23716
  return found.sort();
23702
23717
  }
23703
- async function requireLocalToolServer() {
23704
- const routing = await getResolvedToolsUrl();
23705
- if (routing.source === "none") return void 0;
23706
- const recovery = routing.source === "env" ? routing.shadowedLink ? `Unset ARGENT_TOOLS_URL and run \`argent unlink\`, then try again \u2014 a link to ${routing.shadowedLink.url} is also configured and takes over once the env var is unset.` : "Unset ARGENT_TOOLS_URL and try again." : "Run `argent unlink` and try again.";
23707
- return `argent flow run requires the auto-started local tool server; ${routing.source} routing is configured.
23708
- ${recovery}`;
23709
- }
23710
23718
  function buildRunPayload(flowPath, projectRoot, args) {
23711
23719
  const payload = {
23712
23720
  flow_path: flowPath,
@@ -23722,16 +23730,51 @@ function buildRunPayload(flowPath, projectRoot, args) {
23722
23730
  function writeJsonStreamRecord(record2) {
23723
23731
  console.log(JSON.stringify(record2));
23724
23732
  }
23733
+ var ToolServerUnreachableError = class extends Error {
23734
+ errorCode = "TOOL_SERVER_UNREACHABLE";
23735
+ errorKind = "network";
23736
+ };
23737
+ var UNREACHABLE_VERDICT = "not run (tool-server unreachable)";
23738
+ var CONNECT_FAILURES = /* @__PURE__ */ new Map([
23739
+ ["ECONNREFUSED", "connection refused"],
23740
+ ["ENOTFOUND", "host not found"],
23741
+ ["EAI_AGAIN", "host not found"],
23742
+ ["ETIMEDOUT", "timed out"],
23743
+ ["UND_ERR_CONNECT_TIMEOUT", "timed out"],
23744
+ ["EHOSTUNREACH", "host unreachable"],
23745
+ ["ENETUNREACH", "host unreachable"]
23746
+ ]);
23747
+ async function toolServerUnreachable(err) {
23748
+ if (!(err instanceof TypeError)) return void 0;
23749
+ const cause = err.cause;
23750
+ const reason = typeof cause?.code === "string" ? CONNECT_FAILURES.get(cause.code) : void 0;
23751
+ const syscall = cause?.syscall;
23752
+ if (!reason || syscall !== void 0 && syscall !== "connect" && syscall !== "getaddrinfo") {
23753
+ return void 0;
23754
+ }
23755
+ const routing = await getResolvedToolsUrl();
23756
+ if (routing.source === "none") return void 0;
23757
+ const recovery = routing.source === "link" ? "Start the tool-server on the linked machine, or run `argent unlink`." : routing.shadowedLink ? `Start that tool-server, or unset ARGENT_TOOLS_URL and run \`argent unlink\` to use the local one. A link to ${routing.shadowedLink.url} is also configured and takes over once the env var is unset.` : "Start that tool-server, or unset ARGENT_TOOLS_URL to use the local one.";
23758
+ const setBy = routing.source === "env" ? "ARGENT_TOOLS_URL" : "argent link";
23759
+ return new ToolServerUnreachableError(
23760
+ `Could not reach the tool-server at ${routing.url} (set by ${setBy}): ${reason}.
23761
+ ${recovery}`
23762
+ );
23763
+ }
23725
23764
  function failureSignal2(err) {
23726
- if (!(err instanceof ToolInvocationError)) return {};
23765
+ if (!(err instanceof ToolInvocationError) && !(err instanceof ToolServerUnreachableError)) {
23766
+ return {};
23767
+ }
23727
23768
  return {
23728
23769
  ...err.errorCode ? { error_code: err.errorCode } : {},
23729
23770
  ...err.errorKind ? { error_kind: err.errorKind } : {}
23730
23771
  };
23731
23772
  }
23773
+ function errorRecord(err, message = err instanceof Error ? err.message : String(err)) {
23774
+ return { event: "error", error: message, ...failureSignal2(err) };
23775
+ }
23732
23776
  function writeJsonStreamError(err) {
23733
- const message = err instanceof Error ? err.message : String(err);
23734
- writeJsonStreamRecord({ event: "error", error: message, ...failureSignal2(err) });
23777
+ writeJsonStreamRecord(errorRecord(err));
23735
23778
  }
23736
23779
  async function exportAndResolveArtifacts(report, outputDir, flowPath, baseUrl) {
23737
23780
  if (outputDir) {
@@ -23757,22 +23800,23 @@ function isFlowReport(data) {
23757
23800
  return Array.isArray(steps) && steps.every((step) => !!step && typeof step === "object");
23758
23801
  }
23759
23802
  async function runFlowDirectory(dir, args, projectRoot, options) {
23803
+ const printError = (message, err = message) => {
23804
+ console.error(args.json ? JSON.stringify(errorRecord(err, message)) : message);
23805
+ };
23806
+ const reject = (message) => {
23807
+ printError(message);
23808
+ return exitAfterFlush(2);
23809
+ };
23760
23810
  let flows;
23761
23811
  try {
23762
23812
  flows = await collectFlowFiles(dir, args.recursive);
23763
23813
  } catch {
23764
- console.error(`Could not read flow directory: ${dir}`);
23765
- return exitAfterFlush(2);
23814
+ return reject(`Could not read flow directory: ${dir}`);
23766
23815
  }
23767
23816
  if (flows.length === 0) {
23768
- console.error(`No flows found in ${dir}`);
23769
- if (!args.recursive) console.error("Pass -r/--recursive to include subdirectories.");
23770
- return exitAfterFlush(2);
23771
- }
23772
- const refusal = await requireLocalToolServer();
23773
- if (refusal) {
23774
- console.error(refusal);
23775
- return exitAfterFlush(2);
23817
+ return reject(
23818
+ `No flows found in ${dir}` + (args.recursive ? "" : "\nPass -r/--recursive to include subdirectories.")
23819
+ );
23776
23820
  }
23777
23821
  const { callTool, baseUrl } = createToolsClient({ paths: options.paths });
23778
23822
  const outputBase = args.output ? path14.resolve(args.output) : void 0;
@@ -23780,6 +23824,7 @@ async function runFlowDirectory(dir, args, projectRoot, options) {
23780
23824
  const failures = [];
23781
23825
  const batchStartedAt = Date.now();
23782
23826
  let stopped = false;
23827
+ let unreachable = false;
23783
23828
  for (const [i2, rel] of flows.entries()) {
23784
23829
  if (!args.json) console.log(`[${i2 + 1}/${flows.length}] ${rel}`);
23785
23830
  if (stopped) {
@@ -23793,23 +23838,26 @@ async function runFlowDirectory(dir, args, projectRoot, options) {
23793
23838
  try {
23794
23839
  const resp = await callTool("flow-execute", buildRunPayload(flowPath, projectRoot, args));
23795
23840
  if (isFlowReport(resp.data)) report = resp.data;
23796
- } catch (err) {
23841
+ } catch (thrown) {
23842
+ const unreachableErr = await toolServerUnreachable(thrown);
23843
+ const err = unreachableErr ?? thrown;
23797
23844
  const message = err instanceof Error ? err.message : String(err);
23798
23845
  const toolErr = err instanceof ToolInvocationError ? err : void 0;
23799
23846
  const rejectedThisFlowOnly = toolErr?.errorKind === "validation";
23800
- const verdict = rejectedThisFlowOnly ? rejectionVerdict(toolErr?.errorCode) : "did not finish (run error)";
23847
+ const verdict = unreachableErr ? UNREACHABLE_VERDICT : rejectedThisFlowOnly ? rejectionVerdict(toolErr?.errorCode) : "did not finish (run error)";
23801
23848
  if (!args.json) console.log(` ${STATUS_GLYPH.error} ${verdict}`);
23802
- console.error(message);
23849
+ printError(message, err);
23803
23850
  results.push({ path: rel, status: "fail", error: message, ...failureSignal2(err) });
23804
23851
  failures.push({ path: rel, headline: verdict, detail: message, rerun });
23805
23852
  if (!rejectedThisFlowOnly) stopped = true;
23853
+ if (unreachableErr) unreachable = true;
23806
23854
  continue;
23807
23855
  }
23808
23856
  if (!report) {
23809
23857
  const message = `"${rel}" did not produce a run report.`;
23810
23858
  const verdict = "did not finish (no run report)";
23811
23859
  if (!args.json) console.log(` ${STATUS_GLYPH.error} ${verdict}`);
23812
- console.error(message);
23860
+ printError(message);
23813
23861
  results.push({ path: rel, status: "fail", error: message });
23814
23862
  failures.push({ path: rel, headline: verdict, detail: message, rerun });
23815
23863
  stopped = true;
@@ -23844,7 +23892,7 @@ async function runFlowDirectory(dir, args, projectRoot, options) {
23844
23892
  console.log(`
23845
23893
  ${renderBatchSummary(counts, durationMs)}`);
23846
23894
  }
23847
- return exitAfterFlush(counts.failed === 0 ? 0 : 1);
23895
+ return exitAfterFlush(unreachable ? 2 : counts.failed === 0 ? 0 : 1);
23848
23896
  }
23849
23897
  async function flow(argv, options) {
23850
23898
  const [sub, ...rest] = argv;
@@ -23867,38 +23915,42 @@ async function flow(argv, options) {
23867
23915
  console.error(`Unknown flow subcommand "${sub}". Run \`argent flow --help\`.`);
23868
23916
  return exitAfterFlush(2);
23869
23917
  }
23870
- const jsonStream = rest.some(
23871
- (tok) => tok === "--json-stream" || tok.startsWith("--json-stream=")
23872
- );
23918
+ const flagGiven = (flag) => rest.some((tok) => tok === flag || tok.startsWith(`${flag}=`));
23919
+ const jsonStream = flagGiven("--json-stream");
23920
+ const json2 = !jsonStream && flagGiven("--json");
23873
23921
  if (rest.includes("--help") || rest.includes("-h")) {
23874
23922
  printHelp(jsonStream);
23875
23923
  return;
23876
23924
  }
23877
- const fail = (message, code, err = message) => {
23878
- if (jsonStream) writeJsonStreamError(err);
23879
- console.error(message);
23880
- return exitAfterFlush(code);
23925
+ const usageError = (message, prose) => {
23926
+ if (json2) {
23927
+ console.error(JSON.stringify(errorRecord(message)));
23928
+ return exitAfterFlush(2);
23929
+ }
23930
+ if (jsonStream) writeJsonStreamError(message);
23931
+ console.error(prose);
23932
+ printHelp(jsonStream);
23933
+ return exitAfterFlush(2);
23881
23934
  };
23882
23935
  let args;
23883
23936
  try {
23884
23937
  args = parseRunArgs(rest);
23885
23938
  } catch (err) {
23886
23939
  if (err instanceof FlagParseException) {
23887
- if (jsonStream) writeJsonStreamError(err);
23888
- console.error(`Error: ${err.message}
23940
+ return usageError(err.message, `Error: ${err.message}
23889
23941
  `);
23890
- printHelp(jsonStream);
23891
- return exitAfterFlush(2);
23892
23942
  }
23893
23943
  throw err;
23894
23944
  }
23895
23945
  if (!args.flowRef) {
23896
23946
  const message = "argent flow run <flow|flow.yaml|dir> requires a flow name, a YAML file path, or a directory path.";
23897
- if (jsonStream) writeJsonStreamError(message);
23898
- console.error(message);
23899
- printHelp(jsonStream);
23900
- return exitAfterFlush(2);
23947
+ return usageError(message, message);
23901
23948
  }
23949
+ const fail = (message, code, err = message) => {
23950
+ if (jsonStream) writeJsonStreamError(err);
23951
+ console.error(args.json ? JSON.stringify(errorRecord(err, message)) : message);
23952
+ return exitAfterFlush(code);
23953
+ };
23902
23954
  const projectRoot = process.cwd();
23903
23955
  const { suppliedPath, fromName } = resolveFlowRef(args.flowRef);
23904
23956
  if (suppliedPath.split(/[\\/]+/).includes("..")) {
@@ -24007,8 +24059,6 @@ ${recovery}`,
24007
24059
  2
24008
24060
  );
24009
24061
  }
24010
- const refusal = await requireLocalToolServer();
24011
- if (refusal) return fail(refusal, 2);
24012
24062
  const { callTool, baseUrl } = createToolsClient({ paths: options.paths });
24013
24063
  const payload = buildRunPayload(flowPath, projectRoot, args);
24014
24064
  let liveSteps = 0;
@@ -24040,12 +24090,14 @@ ${recovery}`,
24040
24090
  );
24041
24091
  report = resp.data;
24042
24092
  } catch (err) {
24093
+ const unreachableErr = await toolServerUnreachable(err);
24043
24094
  if (!args.json && !args.jsonStream) {
24044
24095
  if (liveSteps === 0) console.log(`Flow "${flowName}"`);
24045
24096
  console.log(
24046
- ` ${STATUS_GLYPH.error} ` + (err instanceof ToolInvocationError && err.errorKind === "validation" ? rejectionVerdict(err.errorCode) : "did not finish (run error)")
24097
+ ` ${STATUS_GLYPH.error} ` + (unreachableErr ? UNREACHABLE_VERDICT : err instanceof ToolInvocationError && err.errorKind === "validation" ? rejectionVerdict(err.errorCode) : "did not finish (run error)")
24047
24098
  );
24048
24099
  }
24100
+ if (unreachableErr) return fail(unreachableErr.message, 2, unreachableErr);
24049
24101
  return fail(err instanceof Error ? err.message : String(err), 1, err);
24050
24102
  }
24051
24103
  if (!isFlowReport(report)) {
@@ -16730,7 +16730,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
16730
16730
  var SESSION_ID = randomUUID4();
16731
16731
  function readCliVersion() {
16732
16732
  if (true) {
16733
- return "0.27.1-next.16";
16733
+ return "0.27.1-next.18";
16734
16734
  }
16735
16735
  return "0.0.0";
16736
16736
  }
@@ -225,7 +225,7 @@ export interface ArgentToolArgs {
225
225
  project_root: string;
226
226
  /** Path to the flow .yaml as readable by the tool-server. Internal — the argent client derives it from project_root and name automatically; leave unset. */
227
227
  flow_file?: string;
228
- /** Omit when name is set. Absolute path to a co-located flow .yaml on the client and tool server's shared filesystem. This must be supplied through the file-input boundary. For remote execution, pass name + project_root instead. */
228
+ /** Omit when name is set. Absolute path to a flow .yaml on the client. Over a link (argent link or ARGENT_TOOLS_URL), the argent client uploads the file and the tool-server runs the uploaded copy. An uploaded flow must be self-contained: no run:, script: or snapshot: steps, and no tool: steps that take a file or record a flow. Without a link, the tool-server reads the file in place and all step kinds run. */
229
229
  flow_path?: string;
230
230
  /** Device id to run against (iOS UDID, Android/Vega serial, Chromium id) — the id list-devices reports. Auto-detected when omitted, but only when exactly one booted device matches (optionally narrowed by `platform`); with several booted the run fails and lists them, so pass this explicitly whenever more than one device is up. */
231
231
  device?: string;
@@ -249,7 +249,7 @@ export interface ArgentToolArgs {
249
249
  project_root: string;
250
250
  /** Path to the flow .yaml as readable by the tool-server. Internal — the argent client derives it from project_root and name automatically; leave unset. */
251
251
  flow_file?: string;
252
- /** Omit when name is set. Absolute path to a co-located flow .yaml on the client and tool server's shared filesystem. This must be supplied through the file-input boundary. Pass the same flow source here as to flow-execute, so the prerequisite you read belongs to the flow that will run; for remote reads, pass name + project_root instead. */
252
+ /** Omit when name is set. Absolute path to a flow .yaml on the client. Over a link (argent link or ARGENT_TOOLS_URL), the argent client uploads the file and the tool-server reads the uploaded copy; without a link, the tool-server reads the file in place. Pass the path through the argent client; a raw path that the file-input boundary did not verify is rejected. Pass the same flow source here as to flow-execute, so the prerequisite you read belongs to the flow that will run. */
253
253
  flow_path?: string;
254
254
  };
255
255
  "flow-start-recording": {
@@ -1108,6 +1108,18 @@ var init_artifacts = __esm({
1108
1108
  function isFileInputWire(value) {
1109
1109
  return !!value && typeof value === "object" && value[FILE_INPUT_MARKER] === true && typeof value.path === "string";
1110
1110
  }
1111
+ function interpolateFileInputPath(template, args) {
1112
+ let missing = false;
1113
+ const out = template.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_m, name) => {
1114
+ const v = args[name];
1115
+ if (typeof v !== "string" || v.length === 0) {
1116
+ missing = true;
1117
+ return "";
1118
+ }
1119
+ return v;
1120
+ });
1121
+ return missing ? null : out;
1122
+ }
1111
1123
  var FILE_INPUT_MARKER, FLOW_NAME_CHARSET, FLOW_NAME_PATTERN, FLOW_FILE_NAME_PATTERN, SCRIPT_FILE_NAME_PATTERN, CLIENT_FILE_MARKER;
1112
1124
  var init_file_inputs = __esm({
1113
1125
  "../registry/src/file-inputs.ts"() {
@@ -79147,16 +79159,16 @@ var require_path_is_absolute = __commonJS({
79147
79159
  function posix6(path51) {
79148
79160
  return path51.charAt(0) === "/";
79149
79161
  }
79150
- function win32(path51) {
79162
+ function win322(path51) {
79151
79163
  var splitDeviceRe = /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/;
79152
79164
  var result = splitDeviceRe.exec(path51);
79153
79165
  var device = result[1] || "";
79154
79166
  var isUnc = Boolean(device && device.charAt(1) !== ":");
79155
79167
  return Boolean(result[2] || isUnc);
79156
79168
  }
79157
- module2.exports = process.platform === "win32" ? win32 : posix6;
79169
+ module2.exports = process.platform === "win32" ? win322 : posix6;
79158
79170
  module2.exports.posix = posix6;
79159
- module2.exports.win32 = win32;
79171
+ module2.exports.win32 = win322;
79160
79172
  }
79161
79173
  });
79162
79174
 
@@ -94814,7 +94826,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
94814
94826
  var SESSION_ID = (0, import_node_crypto3.randomUUID)();
94815
94827
  function readCliVersion() {
94816
94828
  if (true) {
94817
- return "0.27.1-next.16";
94829
+ return "0.27.1-next.18";
94818
94830
  }
94819
94831
  return "0.0.0";
94820
94832
  }
@@ -113595,26 +113607,30 @@ init_android_binary();
113595
113607
  // ../tool-server/src/utils/android-helper-install.ts
113596
113608
  init_adb();
113597
113609
  async function probeInstalledVersion(serial, packageName) {
113598
- let out;
113599
113610
  try {
113600
- out = await adbShell(serial, `cmd package list packages --show-versioncode ${packageName}`, {
113601
- timeoutMs: 5e3
113602
- });
113603
- } catch {
113604
- try {
113605
- out = await adbShell(serial, `pm list packages ${packageName}`, { timeoutMs: 5e3 });
113606
- } catch {
113607
- return { installed: false, versionCode: null };
113611
+ const out2 = await adbShell(
113612
+ serial,
113613
+ `cmd package list packages --show-versioncode ${packageName}`,
113614
+ { timeoutMs: 5e3 }
113615
+ );
113616
+ for (const line of out2.split("\n")) {
113617
+ const match = line.trim().match(/^package:([^\s]+)\s+versionCode:(\d+)$/);
113618
+ if (match?.[1] === packageName) {
113619
+ return { installed: true, versionCode: parseInt(match[2], 10) };
113620
+ }
113608
113621
  }
113622
+ } catch {
113609
113623
  }
113610
- for (const line of out.split("\n")) {
113611
- const match = line.trim().match(/^package:([^\s]+)(?:\s+versionCode:(\d+))?$/);
113612
- if (!match) continue;
113613
- if (match[1] !== packageName) continue;
113614
- const versionCode = match[2] ? parseInt(match[2], 10) : null;
113615
- return { installed: true, versionCode: Number.isFinite(versionCode) ? versionCode : null };
113624
+ let out;
113625
+ try {
113626
+ out = await adbShell(serial, `dumpsys package ${packageName}`, { timeoutMs: 5e3 });
113627
+ } catch {
113628
+ return { installed: false, versionCode: null };
113616
113629
  }
113617
- return { installed: false, versionCode: null };
113630
+ const block = out.split(`Package [${packageName}]`)[1];
113631
+ if (block === void 0) return { installed: false, versionCode: null };
113632
+ const versionCode = block.match(/versionCode=(\d+)/);
113633
+ return { installed: true, versionCode: versionCode ? parseInt(versionCode[1], 10) : null };
113618
113634
  }
113619
113635
  async function ensureAndroidDevtoolsInstalled(serial, options = {}) {
113620
113636
  const manifest = helperManifest();
@@ -114906,6 +114922,14 @@ async function resolveOne(spec, wire, tempDirs, lookupUpload) {
114906
114922
  `Path "${wire.path}" does not exist on the tool-server host and no upload was provided. Update argent to a version that supports uploads for remote sessions.`
114907
114923
  );
114908
114924
  }
114925
+ if (spec.kind === "file" && typeof wire.content === "string") {
114926
+ const { filePath, dir } = await materializeUpload(wire);
114927
+ tempDirs.push(dir);
114928
+ return {
114929
+ value: filePath,
114930
+ meta: { clientPath: wire.path, presentOnHost: probe3.present, viaUpload: true }
114931
+ };
114932
+ }
114909
114933
  if (meta3.presentOnHost) {
114910
114934
  return { value: wire.path, meta: meta3 };
114911
114935
  }
@@ -114914,11 +114938,6 @@ async function resolveOne(spec, wire, tempDirs, lookupUpload) {
114914
114938
  `Directory "${wire.path}" does not exist on the tool-server host. This tool reads a directory tree from the tool-server's filesystem, which cannot be uploaded with the call \u2014 when the tool-server runs on a different machine, pass a path that exists on that machine (e.g. the server-side checkout of the project).`
114915
114939
  );
114916
114940
  }
114917
- if (typeof wire.content === "string") {
114918
- const { filePath, dir } = await materializeUpload(wire);
114919
- tempDirs.push(dir);
114920
- return { value: filePath, meta: { ...meta3, viaUpload: true } };
114921
- }
114922
114941
  if (wire.contentOmitted === "size-limit") {
114923
114942
  throw new FileInputError(
114924
114943
  `File "${wire.path}" is ${formatBytes(wire.size)} \u2014 larger than the ${formatBytes(MAX_UPLOAD_BYTES)} file-input transfer limit, so the client did not upload it, and it was not found on the tool-server host. Copy the file to the tool-server machine and pass that path, or use a smaller file.`
@@ -147148,7 +147167,22 @@ function selectRotateCandidate(candidates) {
147148
147167
 
147149
147168
  // ../tool-server/src/tools/flows/flow-utils.ts
147150
147169
  var FLOWS_DIR_NAME = path38.join(".argent", "flows");
147170
+ function windowsPathRefusal(label, value) {
147171
+ if (process.platform === "win32" || path38.isAbsolute(value) || !path38.win32.isAbsolute(value)) {
147172
+ return void 0;
147173
+ }
147174
+ return `${label} "${value}" is a Windows path. A tool-server on macOS or Linux cannot open Windows paths. Use argent on the computer that runs the tool-server, or start the tool-server on the Windows computer.`;
147175
+ }
147151
147176
  function assertValidProjectRoot(root) {
147177
+ const windows = windowsPathRefusal("project_root", root);
147178
+ if (windows) {
147179
+ throw new FailureError(windows, {
147180
+ error_code: FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID,
147181
+ failure_stage: "flow_project_root_set",
147182
+ failure_area: "tool_server",
147183
+ error_kind: "validation"
147184
+ });
147185
+ }
147152
147186
  if (!path38.isAbsolute(root)) {
147153
147187
  throw new FailureError(
147154
147188
  `project_root must be an absolute path (got "${root}"). Pass the absolute path to the project root directory \u2014 the same cwd the calling agent is working in.`,
@@ -151775,6 +151809,7 @@ var NESTED_RECORDER_TOOLS = {
151775
151809
  "flow-start-recording": "`flow-start-recording` truncates the flow it names. Recording it as a step would erase this flow at replay; call it directly when you want to start a recording.",
151776
151810
  "flow-finish-recording": "`flow-finish-recording` ends the recording, so it cannot also be a step in it. Call it directly when the walkthrough is complete."
151777
151811
  };
151812
+ var RECORDING_TOOL_IDS = new Set(Object.keys(NESTED_RECORDER_TOOLS));
151778
151813
  function isToolNotFound(err, command) {
151779
151814
  return err instanceof ToolNotFoundError && err.toolId === command;
151780
151815
  }
@@ -156152,7 +156187,7 @@ var zodSchema68 = external_exports.object({
156152
156187
  "Path to the flow .yaml as readable by the tool-server. Internal \u2014 the argent client derives it from project_root and name automatically; leave unset."
156153
156188
  ),
156154
156189
  flow_path: external_exports.string().optional().describe(
156155
- "Omit when name is set. Absolute path to a co-located flow .yaml on the client and tool server's shared filesystem. This must be supplied through the file-input boundary. For remote execution, pass name + project_root instead."
156190
+ "Omit when name is set. Absolute path to a flow .yaml on the client. Over a link (argent link or ARGENT_TOOLS_URL), the argent client uploads the file and the tool-server runs the uploaded copy. An uploaded flow must be self-contained: no run:, script: or snapshot: steps, and no tool: steps that take a file or record a flow. Without a link, the tool-server reads the file in place and all step kinds run."
156156
156191
  ),
156157
156192
  device: external_exports.string().optional().describe(
156158
156193
  "Device id to run against (iOS UDID, Android/Vega serial, Chromium id) \u2014 the id list-devices reports. Auto-detected when omitted, but only when exactly one booted device matches (optionally narrowed by `platform`); with several booted the run fails and lists them, so pass this explicitly whenever more than one device is up."
@@ -156532,42 +156567,69 @@ function findRetiredToolArg(registry2, steps) {
156532
156567
  function retiredArgReason(use) {
156533
156568
  return `${use.where} as written (echo included) passes ${use.tool}'s retired \`${use.key}\` key${use.guidance ? `: ${use.guidance}` : ""}`;
156534
156569
  }
156535
- function assertUploadSelfContained(flow) {
156536
- for (const { step } of walkSteps(flow.steps)) {
156570
+ var UPLOAD_STAGE_BY_KIND = {
156571
+ run: "flow_upload_run_composition",
156572
+ script: "flow_upload_script_step",
156573
+ snapshot: "flow_upload_snapshot_baseline",
156574
+ nested: "flow_upload_nested_flow",
156575
+ toolFile: "flow_upload_tool_file_input",
156576
+ recording: "flow_upload_recording_tool"
156577
+ };
156578
+ function nestedFlowRef(args) {
156579
+ if (typeof args.name === "string") return ` (name: ${args.name})`;
156580
+ if (typeof args.flow_path === "string") return ` (flow_path: ${args.flow_path})`;
156581
+ return "";
156582
+ }
156583
+ function toolStepFilePaths(registry2, tool, args) {
156584
+ const paths = [];
156585
+ for (const spec of registry2.getTool(tool)?.fileInputs ?? []) {
156586
+ if (spec.skipWhenSet !== void 0 && args[spec.skipWhenSet] !== void 0) continue;
156587
+ const filled = interpolateFileInputPath(spec.path, args);
156588
+ if (filled !== null) paths.push(filled);
156589
+ }
156590
+ return paths;
156591
+ }
156592
+ function assertUploadSelfContained(registry2, flow) {
156593
+ const offending = [];
156594
+ for (const { step, where } of walkSteps(flow.steps)) {
156537
156595
  if (step.kind === "run") {
156538
- throw new FailureError(
156539
- `This flow uses run: composition ("run: ${step.flow}"), which requires a co-located client and tool server \u2014 an uploaded flow's referenced files are not available on this host.`,
156540
- {
156541
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
156542
- failure_stage: "flow_upload_run_composition",
156543
- failure_area: "tool_server",
156544
- error_kind: "validation"
156545
- }
156546
- );
156547
- }
156548
- if (step.kind === "script") {
156549
- throw new FailureError(
156550
- `This flow uses a script step ("script: { path: ${step.path} }"), whose .mjs file lives beside the flow's file on the CLIENT \u2014 an uploaded flow carries only its own YAML, so the script is not on this host and never could be. Use name + project_root with a co-located client and tool server for flows that run scripts.`,
156551
- {
156552
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
156553
- failure_stage: "flow_upload_script_step",
156554
- failure_area: "tool_server",
156555
- error_kind: "validation"
156556
- }
156557
- );
156558
- }
156559
- if (step.kind === "snapshot") {
156560
- throw new FailureError(
156561
- `This flow uses a snapshot step ("snapshot: ${step.name}"), whose baselines live beside the flow's file \u2014 an uploaded flow materializes to a fresh temp directory each call, so a plain snapshot can never find a baseline and updateBaselines (--update-baselines) writes PNGs no later run can read. Use name + project_root with a co-located client and tool server for snapshot flows.`,
156562
- {
156563
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
156564
- failure_stage: "flow_upload_snapshot_baseline",
156565
- failure_area: "tool_server",
156566
- error_kind: "validation"
156567
- }
156568
- );
156596
+ offending.push({ kind: "run", line: `${where}: run: ${step.flow}` });
156597
+ } else if (step.kind === "script") {
156598
+ offending.push({ kind: "script", line: `${where}: script: { path: ${step.path} }` });
156599
+ } else if (step.kind === "snapshot") {
156600
+ offending.push({ kind: "snapshot", line: `${where}: snapshot: ${step.name}` });
156601
+ } else if (step.kind === "tool" && step.name === "flow-execute") {
156602
+ offending.push({
156603
+ kind: "nested",
156604
+ line: `${where}: tool: flow-execute${nestedFlowRef(step.args)}`
156605
+ });
156606
+ } else if (step.kind === "tool" && RECORDING_TOOL_IDS.has(step.name)) {
156607
+ offending.push({
156608
+ kind: "recording",
156609
+ line: `${where}: tool: ${step.name} (records a flow)`
156610
+ });
156611
+ } else if (step.kind === "tool") {
156612
+ const paths = toolStepFilePaths(registry2, step.name, step.args);
156613
+ if (paths.length > 0) {
156614
+ offending.push({
156615
+ kind: "toolFile",
156616
+ line: `${where}: tool: ${step.name} (${paths.join(", ")})`
156617
+ });
156618
+ }
156569
156619
  }
156570
156620
  }
156621
+ if (offending.length === 0) return;
156622
+ throw new FailureError(
156623
+ `This flow is not self-contained, and it arrived as an upload. The steps below read or write project files, which stay on the client:
156624
+ ` + offending.map((o) => ` - ${o.line}`).join("\n") + `
156625
+ Run the flow on the computer that runs the tool-server, with no link and no ARGENT_TOOLS_URL, so that the tool-server reads the files in place.`,
156626
+ {
156627
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
156628
+ failure_stage: UPLOAD_STAGE_BY_KIND[offending[0].kind],
156629
+ failure_area: "tool_server",
156630
+ error_kind: "validation"
156631
+ }
156632
+ );
156571
156633
  }
156572
156634
  function createRunFlowTool(registry2) {
156573
156635
  return {
@@ -156597,7 +156659,7 @@ Returns a per-step report: the first failure stops the run and the rest report a
156597
156659
  const canonicalPath = await canonicalFlowPath2(filePath);
156598
156660
  const flowsDir = path46.dirname(canonicalPath);
156599
156661
  const flow = parseFlow(await fs62.readFile(canonicalPath, "utf8"));
156600
- if (viaUpload) assertUploadSelfContained(flow);
156662
+ if (viaUpload) assertUploadSelfContained(registry2, flow);
156601
156663
  const retiredArg = findRetiredToolArg(registry2, flow.steps);
156602
156664
  if (retiredArg) {
156603
156665
  throw new FailureError(`Flow "${flowName}" ${retiredArgReason(retiredArg)}`, {
@@ -156800,7 +156862,7 @@ async function resolveAppPath(specPath, flowDir) {
156800
156862
  async function bootChromiumForFlow(spec, flowDir, viaUpload) {
156801
156863
  if (viaUpload && !path46.isAbsolute(spec.path)) {
156802
156864
  throw new FailureError(
156803
- `A relative chromium app path ("${spec.path}") resolves against the flow file's directory, which requires a co-located client and tool server \u2014 an uploaded flow has no real flow directory on this host. Use an absolute tool-server path instead.`,
156865
+ `A relative chromium app path ("${spec.path}") resolves against the flow file's directory, which requires a run with no link and no ARGENT_TOOLS_URL \u2014 an uploaded flow has no real flow directory on this host. Use an absolute tool-server path instead.`,
156804
156866
  {
156805
156867
  error_code: FAILURE_CODES.FLOW_FILE_INVALID,
156806
156868
  failure_stage: "flow_upload_chromium_app_path",
@@ -157349,6 +157411,55 @@ async function execLeafStep(state3, step, index, scope) {
157349
157411
  function errMsg4(err) {
157350
157412
  return err instanceof Error ? err.message : String(err);
157351
157413
  }
157414
+ function assertFlowPathShape(flowPath) {
157415
+ const windows = windowsPathRefusal("flow_path", flowPath);
157416
+ if (windows) {
157417
+ throw new FailureError(windows, {
157418
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157419
+ failure_stage: "flow_path_absolute",
157420
+ failure_area: "tool_server",
157421
+ error_kind: "validation"
157422
+ });
157423
+ }
157424
+ if (!path46.isAbsolute(flowPath)) {
157425
+ throw new FailureError(
157426
+ `Invalid flow_path "${flowPath}": flow paths must be absolute \u2014 a relative path is resolved against the tool server's working directory, not the caller's. Pass the absolute path to the flow's YAML.`,
157427
+ {
157428
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157429
+ failure_stage: "flow_path_absolute",
157430
+ failure_area: "tool_server",
157431
+ error_kind: "validation"
157432
+ }
157433
+ );
157434
+ }
157435
+ if (flowPath.split(/[\\/]+/).includes("..")) {
157436
+ throw new FailureError(
157437
+ `Invalid flow_path "${flowPath}": flow paths must not contain ".." segments \u2014 a ".." after a symlinked directory can name a different file than the spelling suggests, and the argent client always sends fully resolved paths. Pass the fully resolved absolute path to the flow's YAML.`,
157438
+ {
157439
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157440
+ failure_stage: "flow_path_dotdot",
157441
+ failure_area: "tool_server",
157442
+ error_kind: "validation"
157443
+ }
157444
+ );
157445
+ }
157446
+ }
157447
+ function flowNameOf(clientPath) {
157448
+ const clientExt = path46.extname(clientPath);
157449
+ const bareExtension = path46.basename(clientPath).toLowerCase() === ".yaml";
157450
+ if (!bareExtension && clientExt !== ".yaml") {
157451
+ const detail = clientExt.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${clientExt}".` : `flow files must use the .yaml extension.`;
157452
+ throw new FailureError(`Invalid flow_path "${clientPath}": ${detail}`, {
157453
+ error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157454
+ failure_stage: "flow_path_extension",
157455
+ failure_area: "tool_server",
157456
+ error_kind: "validation"
157457
+ });
157458
+ }
157459
+ const flowName = bareExtension ? "" : path46.basename(clientPath, ".yaml");
157460
+ assertSafeFlowName(flowName);
157461
+ return flowName;
157462
+ }
157352
157463
  async function resolveFlowSource(params, fileInput, flowPathInput) {
157353
157464
  if (params.name === void 0 === (params.flow_path === void 0)) {
157354
157465
  throw new FailureError("Pass exactly one flow source: name or flow_path.", {
@@ -157361,15 +157472,9 @@ async function resolveFlowSource(params, fileInput, flowPathInput) {
157361
157472
  assertValidProjectRoot(params.project_root);
157362
157473
  if (params.flow_path !== void 0) {
157363
157474
  if (flowPathInput?.viaUpload) {
157364
- throw new FailureError(
157365
- `Invalid flow_path "${flowPathInput.clientPath}": explicit flow paths require a co-located client and tool server with a shared filesystem, and this one arrived as an upload \u2014 sibling run: files, baselines, and baseline write-back all resolve beside the copy this server materialized, alone in a temp directory. Pass name + project_root to run a self-contained flow from a remote client; name uploads the same way, so a flow with run:, script: or snapshot: steps needs the client and tool server on one filesystem.`,
157366
- {
157367
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157368
- failure_stage: "flow_path_shared_filesystem",
157369
- failure_area: "tool_server",
157370
- error_kind: "validation"
157371
- }
157372
- );
157475
+ const clientPath2 = flowPathInput.clientPath;
157476
+ assertFlowPathShape(clientPath2);
157477
+ return { filePath: params.flow_path, flowName: flowNameOf(clientPath2), viaUpload: true };
157373
157478
  }
157374
157479
  const isVerifiedHostPath = flowPathInput?.presentOnHost === true && flowPathInput.statVerified === true && path46.resolve(params.flow_path) === path46.resolve(flowPathInput.clientPath);
157375
157480
  if (!isVerifiedHostPath) {
@@ -157383,42 +157488,9 @@ async function resolveFlowSource(params, fileInput, flowPathInput) {
157383
157488
  }
157384
157489
  );
157385
157490
  }
157386
- if (!path46.isAbsolute(params.flow_path)) {
157387
- throw new FailureError(
157388
- `Invalid flow_path "${params.flow_path}": flow paths must be absolute \u2014 a relative path is resolved against the tool server's working directory, not the caller's. Pass the absolute path to the flow's YAML.`,
157389
- {
157390
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157391
- failure_stage: "flow_path_absolute",
157392
- failure_area: "tool_server",
157393
- error_kind: "validation"
157394
- }
157395
- );
157396
- }
157397
- if (params.flow_path.split(/[\\/]+/).includes("..")) {
157398
- throw new FailureError(
157399
- `Invalid flow_path "${params.flow_path}": flow paths must not contain ".." segments \u2014 a ".." after a symlinked directory can name a different file than the spelling suggests, and the argent client always sends fully resolved paths. Pass the fully resolved absolute path to the flow's YAML.`,
157400
- {
157401
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157402
- failure_stage: "flow_path_dotdot",
157403
- failure_area: "tool_server",
157404
- error_kind: "validation"
157405
- }
157406
- );
157407
- }
157491
+ assertFlowPathShape(params.flow_path);
157408
157492
  const clientPath = flowPathInput.clientPath;
157409
- const clientExt = path46.extname(clientPath);
157410
- const bareExtension = path46.basename(clientPath).toLowerCase() === ".yaml";
157411
- if (!bareExtension && clientExt !== ".yaml") {
157412
- const detail = clientExt.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${clientExt}".` : `flow files must use the .yaml extension.`;
157413
- throw new FailureError(`Invalid flow_path "${clientPath}": ${detail}`, {
157414
- error_code: FAILURE_CODES.FLOW_FILE_INVALID,
157415
- failure_stage: "flow_path_extension",
157416
- failure_area: "tool_server",
157417
- error_kind: "validation"
157418
- });
157419
- }
157420
- const flowName2 = bareExtension ? "" : path46.basename(clientPath, ".yaml");
157421
- assertSafeFlowName(flowName2);
157493
+ const flowName2 = flowNameOf(clientPath);
157422
157494
  const suppliedBase = path46.basename(clientPath);
157423
157495
  const spelling2 = await classifyOnDiskSpelling(path46.dirname(params.flow_path), suppliedBase);
157424
157496
  if (spelling2.state !== "listed") {
@@ -157481,7 +157553,7 @@ var zodSchema69 = external_exports.object({
157481
157553
  "Path to the flow .yaml as readable by the tool-server. Internal \u2014 the argent client derives it from project_root and name automatically; leave unset."
157482
157554
  ),
157483
157555
  flow_path: external_exports.string().optional().describe(
157484
- "Omit when name is set. Absolute path to a co-located flow .yaml on the client and tool server's shared filesystem. This must be supplied through the file-input boundary. Pass the same flow source here as to flow-execute, so the prerequisite you read belongs to the flow that will run; for remote reads, pass name + project_root instead."
157556
+ "Omit when name is set. Absolute path to a flow .yaml on the client. Over a link (argent link or ARGENT_TOOLS_URL), the argent client uploads the file and the tool-server reads the uploaded copy; without a link, the tool-server reads the file in place. Pass the path through the argent client; a raw path that the file-input boundary did not verify is rejected. Pass the same flow source here as to flow-execute, so the prerequisite you read belongs to the flow that will run."
157485
157557
  )
157486
157558
  }).superRefine((params, ctx) => {
157487
157559
  if (params.name === void 0 === (params.flow_path === void 0)) {
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.27.1-next.16",
3
+ "version": "0.27.1-next.18",
4
4
  "mcpName": "io.github.software-mansion/argent",
5
5
  "description": "MCP server for iOS Simulator and Android Emulator control",
6
6
  "license": "Apache-2.0",
@@ -266,7 +266,7 @@ Resolve every hit and confirm:
266
266
 
267
267
  Run `flow-execute` on the complete YAML with the absolute project root. For a fragment, verify its prerequisite before setting `prerequisiteAcknowledged: true`.
268
268
 
269
- `flow-execute` takes exactly one flow source: `name`, for a flow saved under `.argent/flows/`, or `flow_path`, an absolute path to any flow `.yaml`. `run:` targets and baselines resolve on the tool server's filesystem, beside the YAML it actually reads. `flow_path` therefore requires the agent and the tool server to share a filesystem and is refused when they do not. `name` still runs remotely, but the server receives only that one YAML in a fresh temp directory. It checks the whole flow first and refuses a `run:`, `script:`, or `snapshot:` step at any depth, naming the missing co-location rather than a missing fragment, script, or baseline. Replay self-contained flows remotely; a composing, scripting, or snapshotting flow needs one shared filesystem.
269
+ `flow-execute` takes exactly one flow source: `name`, for a flow saved under `.argent/flows/`, or `flow_path`, an absolute path to any flow `.yaml`. Over `argent link` or `ARGENT_TOOLS_URL`, either source sends only that one YAML file, and the tool server runs that copy. The server checks the whole flow before it runs the first step. It refuses a flow with a `run:`, `script:`, or `snapshot:` step, or with a `tool:` step that takes a file (such as `screenshot-diff`, `reinstall-app`, or `flow-execute`) or records a flow, at any depth. The error lists each such step. Replay only a self-contained flow over a link. A flow that composes, runs a script, or takes a snapshot runs only with no link and no `ARGENT_TOOLS_URL`, on the computer that runs the tool server.
270
270
 
271
271
  Manual rescue invalidates the pass. An `errored` step was never evaluated: an `idle` wait whose tree source could not be read, a step that threw, an unresolvable `run:` target, or a `launch:` that did not start the app. Read the reason — most name the environment, but a failed `launch:` is a verdict about the app. Unconfirmed focus is not in this class at all: the replay focus poll has no failure return, so a `type:` step whose focus was never confirmed is scored a **pass**, and only the value check after typing catches it.
272
272