@qawolf/cli 1.9.0 → 1.10.0

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.js CHANGED
@@ -20363,7 +20363,7 @@ var require_readdir_glob = __commonJS((exports, module) => {
20363
20363
  readdirGlob.ReaddirGlob = ReaddirGlob;
20364
20364
  });
20365
20365
 
20366
- // node_modules/async/dist/async.js
20366
+ // node_modules/archiver/node_modules/async/dist/async.js
20367
20367
  var require_async = __commonJS((exports, module) => {
20368
20368
  (function(global2, factory) {
20369
20369
  typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global2 = typeof globalThis !== "undefined" ? globalThis : global2 || self, factory(global2.async = {}));
@@ -87848,7 +87848,7 @@ var require_src2 = __commonJS((exports, module) => {
87848
87848
  }
87849
87849
  });
87850
87850
 
87851
- // node_modules/agent-base/dist/helpers.js
87851
+ // node_modules/webdriver/node_modules/https-proxy-agent/node_modules/agent-base/dist/helpers.js
87852
87852
  var require_helpers = __commonJS((exports) => {
87853
87853
  var __createBinding = exports && exports.__createBinding || (Object.create ? function(o2, m4, k2, k22) {
87854
87854
  if (k22 === undefined)
@@ -87920,7 +87920,7 @@ var require_helpers = __commonJS((exports) => {
87920
87920
  exports.req = req;
87921
87921
  });
87922
87922
 
87923
- // node_modules/agent-base/dist/index.js
87923
+ // node_modules/webdriver/node_modules/https-proxy-agent/node_modules/agent-base/dist/index.js
87924
87924
  var require_dist3 = __commonJS((exports) => {
87925
87925
  var __createBinding = exports && exports.__createBinding || (Object.create ? function(o2, m4, k2, k22) {
87926
87926
  if (k22 === undefined)
@@ -88071,7 +88071,7 @@ var require_dist3 = __commonJS((exports) => {
88071
88071
  exports.Agent = Agent2;
88072
88072
  });
88073
88073
 
88074
- // node_modules/https-proxy-agent/dist/parse-proxy-response.js
88074
+ // node_modules/webdriver/node_modules/https-proxy-agent/dist/parse-proxy-response.js
88075
88075
  var require_parse_proxy_response = __commonJS((exports) => {
88076
88076
  var __importDefault = exports && exports.__importDefault || function(mod) {
88077
88077
  return mod && mod.__esModule ? mod : { default: mod };
@@ -88167,7 +88167,7 @@ var require_parse_proxy_response = __commonJS((exports) => {
88167
88167
  exports.parseProxyResponse = parseProxyResponse;
88168
88168
  });
88169
88169
 
88170
- // node_modules/https-proxy-agent/dist/index.js
88170
+ // node_modules/webdriver/node_modules/https-proxy-agent/dist/index.js
88171
88171
  var require_dist4 = __commonJS((exports) => {
88172
88172
  var __createBinding = exports && exports.__createBinding || (Object.create ? function(o2, m4, k2, k22) {
88173
88173
  if (k22 === undefined)
@@ -156412,12 +156412,14 @@ var interactiveRunnerMessages = {
156412
156412
  launched: (id) => `Launched runner ${id}.`,
156413
156413
  launchedForCommand: (id) => `No runner was given, so launched ${id} for this command. Its browser is fresh: nothing has been run on it and nothing is signed in. It bills until it is stopped or idles out, so stop it with qawolf runner stop --runner ${id} when you are done.`,
156414
156414
  followEventsTimedOut: (stream, seconds) => `Stopped following ${stream} after ${formatSeconds(seconds * 1000)}: reading keeps the runner alive and billing, so a follow does not run unbounded. Pass --timeout to wait longer, or follow again to continue.`,
156415
+ followEndCutShort: "The run settled, but the last window of its followed streams could not be read, so the output above may be missing its final lines.",
156415
156416
  followTimedOut: (runId, runnerId, seconds) => `Stopped following run ${runId} after ${formatSeconds(seconds * 1000)}. The run may still be going: read it with qawolf runner events run-status --run ${runId}, and stop the runner with qawolf runner stop --runner ${runnerId} when you are done. Pass --timeout to wait longer.`,
156416
156417
  missingPackageJson: "No package.json in the current directory. A run reads its npm dependencies from one, so it has to travel with the flow.",
156417
156418
  noRunnerIdForScreenshot: `${noRunnerId} A screenshot also needs a screen, which a runner gets from its first run: run a flow on it with qawolf runner run.`,
156418
156419
  noRunnerId,
156419
156420
  notRunning: (id) => `Runner ${id} was not running.`,
156420
156421
  runFailed: (errorMessage2) => errorMessage2 === undefined ? "The run failed and reported no reason." : `The run failed: ${errorMessage2}`,
156422
+ runInProgress: "The run is in progress.",
156421
156423
  runPassed: "The run passed.",
156422
156424
  runSettledUnknown: (status) => `The run settled as "${status}", which this version of the CLI does not recognize. Upgrade to read it.`,
156423
156425
  runSubmitAnsweredUnknown: (outcome) => `The runner answered the submission with "${outcome}", which this version of the CLI does not recognize. Upgrade to read it.`,
@@ -175909,7 +175911,7 @@ function startUpdateCheck(deps) {
175909
175911
  // package.json
175910
175912
  var package_default = {
175911
175913
  name: "@qawolf/cli",
175912
- version: "1.9.0",
175914
+ version: "1.10.0",
175913
175915
  description: "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
175914
175916
  keywords: [
175915
175917
  "automation",
@@ -175975,7 +175977,6 @@ var package_default = {
175975
175977
  "@qawolf/flows": "0.1.4",
175976
175978
  "@qawolf/testkit": "1.1.1",
175977
175979
  appium: "2.11.3",
175978
- "appium-uiautomator2-driver": "3.7.0",
175979
175980
  commander: "14.0.3",
175980
175981
  "env-paths": "4.0.0",
175981
175982
  "expect-webdriverio": "5.6.5",
@@ -175994,6 +175995,7 @@ var package_default = {
175994
175995
  "@tsconfig/strictest": "2.0.8",
175995
175996
  "@types/bun": "1.3.14",
175996
175997
  "@types/picomatch": "4.0.3",
175998
+ "appium-uiautomator2-driver": "4.2.9",
175997
175999
  knip: "6.16.1",
175998
176000
  oxfmt: "0.54.0",
175999
176001
  oxlint: "1.69.0",
@@ -176719,7 +176721,7 @@ var playwrightVersion = "1.62.0";
176719
176721
  var emailsVersion = "1.1.1";
176720
176722
  var testkitVersion = "1.1.1";
176721
176723
  var appiumVersion = "2.11.3";
176722
- var appiumUiautomator2DriverVersion = "3.7.0";
176724
+ var appiumUiautomator2DriverVersion = "4.2.9";
176723
176725
  var expectWebdriverioVersion = "5.6.5";
176724
176726
 
176725
176727
  // src/domains/doctor/checks/playwright.ts
@@ -177813,10 +177815,6 @@ var pinnedPackages = [
177813
177815
  { name: "@qawolf/emails", version: emailsVersion },
177814
177816
  { name: "@qawolf/testkit", version: testkitVersion },
177815
177817
  { name: "appium", version: appiumVersion },
177816
- {
177817
- name: "appium-uiautomator2-driver",
177818
- version: appiumUiautomator2DriverVersion
177819
- },
177820
177818
  { name: "expect-webdriverio", version: expectWebdriverioVersion }
177821
177819
  ];
177822
177820
 
@@ -186223,6 +186221,9 @@ function describeRunFilesCheck(check2) {
186223
186221
  }
186224
186222
  }
186225
186223
 
186224
+ // src/domains/interactiveRunner/runnerCallOptions.ts
186225
+ var runnerCallOptions = { timeoutMs: 60000 };
186226
+
186226
186227
  // src/domains/interactiveRunner/runnerIds.ts
186227
186228
  function parseRunnerId(id) {
186228
186229
  const parsed = runnerIdSchema.safeParse(id);
@@ -186242,10 +186243,10 @@ async function launchRunner(ctx, options) {
186242
186243
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.launch, {
186243
186244
  id: options.id,
186244
186245
  ...options.runnerName ? { runnerName: options.runnerName } : {}
186245
- });
186246
+ }, runnerCallOptions);
186246
186247
  if (!result.ok) {
186247
186248
  return {
186248
- error: result.error,
186249
+ ...failureFields(result),
186249
186250
  exitCode: exitCodes.network,
186250
186251
  mayHaveArrived: result.mayHaveArrived ?? false,
186251
186252
  ok: false
@@ -186270,10 +186271,8 @@ async function launchAndRemember(ctx, options, deps) {
186270
186271
  });
186271
186272
  }
186272
186273
  return {
186273
- error: launched.mayHaveArrived ? interactiveRunnerMessages.launchLost(options.id, launched.error) : interactiveRunnerMessages.launchFailed(options.id, launched.error),
186274
- exitCode: launched.exitCode,
186275
- mayHaveArrived: launched.mayHaveArrived,
186276
- ok: false
186274
+ ...launched,
186275
+ error: launched.mayHaveArrived ? interactiveRunnerMessages.launchLost(options.id, launched.error) : interactiveRunnerMessages.launchFailed(options.id, launched.error)
186277
186276
  };
186278
186277
  }
186279
186278
  async function handleRunnerLaunch(ctx, options, deps) {
@@ -186287,7 +186286,7 @@ async function handleRunnerLaunch(ctx, options, deps) {
186287
186286
  }
186288
186287
  const launched = await launchAndRemember(ctx, { id: id.id, runnerName: runnerName?.runnerName }, deps);
186289
186288
  if (!launched.ok) {
186290
- return { error: launched.error, exitCode: launched.exitCode };
186289
+ return { ...failureFields(launched), exitCode: launched.exitCode };
186291
186290
  }
186292
186291
  ctx.ui.output(launched.value, launched.value.outcome === "launched" ? interactiveRunnerMessages.launched(launched.value.id) : interactiveRunnerMessages.alreadyRunning(launched.value.id));
186293
186292
  return;
@@ -186323,7 +186322,7 @@ async function resolveRunner(ctx, options, deps) {
186323
186322
  const launched = await launchAndRemember(ctx, { id: deps.makeRunnerId(), runnerName: undefined }, deps);
186324
186323
  if (!launched.ok) {
186325
186324
  return {
186326
- error: launched.error,
186325
+ ...failureFields(launched),
186327
186326
  exitCode: launched.exitCode,
186328
186327
  type: "failed"
186329
186328
  };
@@ -186374,7 +186373,7 @@ async function handleRunnerExec(ctx, options, deps) {
186374
186373
  return { error: scope.error, exitCode: exitCodes.invalidArgs };
186375
186374
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
186376
186375
  if (resolved.type === "failed") {
186377
- return { error: resolved.error, exitCode: resolved.exitCode };
186376
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186378
186377
  }
186379
186378
  announceRunner(ctx, resolved);
186380
186379
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.evaluateSnippet, {
@@ -186382,7 +186381,7 @@ async function handleRunnerExec(ctx, options, deps) {
186382
186381
  id: resolved.runnerId,
186383
186382
  ...scope.filePath === undefined ? {} : { filePath: scope.filePath },
186384
186383
  ...scope.files === undefined ? {} : { files: scope.files }
186385
- });
186384
+ }, runnerCallOptions);
186386
186385
  if (!result.ok) {
186387
186386
  return { ...failureFields(result), exitCode: exitCodes.network };
186388
186387
  }
@@ -186473,10 +186472,10 @@ async function handleRunnerAct(ctx, options, deps) {
186473
186472
  return { error: built.error, exitCode: exitCodes.invalidArgs };
186474
186473
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
186475
186474
  if (resolved.type === "failed") {
186476
- return { error: resolved.error, exitCode: resolved.exitCode };
186475
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186477
186476
  }
186478
186477
  announceRunner(ctx, resolved);
186479
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.performAction, { action: built.action, id: resolved.runnerId });
186478
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.performAction, { action: built.action, id: resolved.runnerId }, runnerCallOptions);
186480
186479
  if (!result.ok) {
186481
186480
  const fields = failureFields(result);
186482
186481
  return {
@@ -186527,9 +186526,9 @@ async function handleRunnerScreenshot(ctx, options, deps) {
186527
186526
  runner: options.runner
186528
186527
  }, deps);
186529
186528
  if (resolved.type === "failed") {
186530
- return { error: resolved.error, exitCode: resolved.exitCode };
186529
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186531
186530
  }
186532
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.takeScreenshot, { id: resolved.runnerId });
186531
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.takeScreenshot, { id: resolved.runnerId }, runnerCallOptions);
186533
186532
  if (!result.ok) {
186534
186533
  return { ...failureFields(result), exitCode: exitCodes.network };
186535
186534
  }
@@ -186745,7 +186744,7 @@ async function readJournal(ctx, runnerId, request) {
186745
186744
  ...request.runId === undefined ? {} : { runId: request.runId },
186746
186745
  ...request.sinceSequence === undefined ? {} : { sinceSequence: request.sinceSequence },
186747
186746
  ...request.tail === undefined ? {} : { tail: request.tail }
186748
- });
186747
+ }, runnerCallOptions);
186749
186748
  if (!result.ok) {
186750
186749
  return {
186751
186750
  ...failureFields(result),
@@ -186763,7 +186762,7 @@ async function readJournal(ctx, runnerId, request) {
186763
186762
  async function handleRunnerKeepalive(ctx, options, deps) {
186764
186763
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186765
186764
  if (resolved.type === "failed") {
186766
- return { error: resolved.error, exitCode: resolved.exitCode };
186765
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186767
186766
  }
186768
186767
  const window2 = await readJournal(ctx, resolved.runnerId, {
186769
186768
  stream: "run-status",
@@ -186781,11 +186780,11 @@ async function handleRunnerKeepalive(ctx, options, deps) {
186781
186780
  async function handleRunnerStop(ctx, options, deps) {
186782
186781
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186783
186782
  if (resolved.type === "failed") {
186784
- return { error: resolved.error, exitCode: resolved.exitCode };
186783
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186785
186784
  }
186786
186785
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.stop, {
186787
186786
  id: resolved.runnerId
186788
- });
186787
+ }, runnerCallOptions);
186789
186788
  if (!result.ok) {
186790
186789
  return { ...failureFields(result), exitCode: exitCodes.network };
186791
186790
  }
@@ -186856,6 +186855,20 @@ function readRunSettlement(payload) {
186856
186855
  type: "settled"
186857
186856
  };
186858
186857
  }
186858
+ function findSettlement(entries) {
186859
+ for (const entry of entries) {
186860
+ const settlement = readRunSettlement(entry.payload);
186861
+ if (settlement.type !== "settled")
186862
+ continue;
186863
+ if (settlement.status === "passed")
186864
+ return { type: "passed" };
186865
+ if (settlement.status === "failed") {
186866
+ return { errorMessage: settlement.errorMessage, type: "failed" };
186867
+ }
186868
+ return { status: settlement.status, type: "unrecognized" };
186869
+ }
186870
+ return;
186871
+ }
186859
186872
  var runLogPayloadSchema = exports_external.object({ message: exports_external.string() });
186860
186873
  function formatRunLogLine(payload) {
186861
186874
  const parsed = runLogPayloadSchema.safeParse(payload);
@@ -186918,6 +186931,18 @@ function createJournalCursor(ctx, runnerId, request) {
186918
186931
  return { entries: window2.value.entries, type: "entries" };
186919
186932
  };
186920
186933
  }
186934
+ function createPrintingCursor(ctx, runnerId, request, format) {
186935
+ const read = createJournalCursor(ctx, runnerId, request);
186936
+ return async () => {
186937
+ const window2 = await read();
186938
+ if (window2.type !== "entries")
186939
+ return window2;
186940
+ for (const entry of window2.entries) {
186941
+ ctx.ui.stream(entry, format(entry.payload));
186942
+ }
186943
+ return window2;
186944
+ };
186945
+ }
186921
186946
  var unreachableGraceMs = 60000;
186922
186947
  function createUnreachableBudget(pollIntervalMs2) {
186923
186948
  const limit = Math.ceil(unreachableGraceMs / pollIntervalMs2);
@@ -186942,7 +186967,7 @@ async function handleRunnerEvents(ctx, options, deps) {
186942
186967
  }
186943
186968
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186944
186969
  if (resolved.type === "failed") {
186945
- return { error: resolved.error, exitCode: resolved.exitCode };
186970
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186946
186971
  }
186947
186972
  const known = knownJournalStreams;
186948
186973
  if (!known.includes(parsed.value.stream)) {
@@ -186999,22 +187024,49 @@ function readErrorPath(error51) {
186999
187024
  return typeof path9 === "string" ? path9 : undefined;
187000
187025
  }
187001
187026
 
187002
- // src/domains/interactiveRunner/followRun.ts
187003
- var pollIntervalMs3 = 1000;
187004
- function findSettlement(entries) {
187005
- for (const entry of entries) {
187006
- const settlement = readRunSettlement(entry.payload);
187007
- if (settlement.type !== "settled")
187008
- continue;
187009
- if (settlement.status === "passed")
187010
- return { type: "passed" };
187011
- if (settlement.status === "failed") {
187012
- return { errorMessage: settlement.errorMessage, type: "failed" };
187027
+ // src/domains/interactiveRunner/followPrinters.ts
187028
+ var anchorPollIntervalMs = 1000;
187029
+ async function resolveRecorderAnchor(ctx, resolved, deps) {
187030
+ if (resolved.type === "launched")
187031
+ return { ok: true, sinceSequence: 0 };
187032
+ return anchorRecorderCursor(ctx, resolved.runnerId, deps);
187033
+ }
187034
+ async function anchorRecorderCursor(ctx, runnerId, deps) {
187035
+ const unreachable = createUnreachableBudget(anchorPollIntervalMs);
187036
+ for (;; ) {
187037
+ const anchor = await readJournal(ctx, runnerId, {
187038
+ stream: "recorder",
187039
+ tail: 1
187040
+ });
187041
+ if (anchor.type === "read") {
187042
+ return { ok: true, sinceSequence: anchor.value.nextSequence };
187013
187043
  }
187014
- return { status: settlement.status, type: "unrecognized" };
187044
+ if (anchor.type === "failed") {
187045
+ return { failure: journalReadFailure(anchor), ok: false };
187046
+ }
187047
+ if (unreachable.exhausted()) {
187048
+ return { failure: { ...unreachableFailure }, ok: false };
187049
+ }
187050
+ await deps.sleep(anchorPollIntervalMs);
187015
187051
  }
187016
- return;
187017
187052
  }
187053
+ function createFollowPrinters(ctx, options) {
187054
+ const jsonLine = (payload) => JSON.stringify(payload);
187055
+ const printers = [];
187056
+ if (options.logs) {
187057
+ printers.push(createPrintingCursor(ctx, options.runnerId, { runId: options.runId, stream: "run-logs" }, formatRunLogLine));
187058
+ }
187059
+ if (options.runEvents) {
187060
+ printers.push(createPrintingCursor(ctx, options.runnerId, { runId: options.runId, stream: "run-events" }, jsonLine));
187061
+ }
187062
+ if (options.recorderSinceSequence !== undefined) {
187063
+ printers.push(createPrintingCursor(ctx, options.runnerId, { sinceSequence: options.recorderSinceSequence, stream: "recorder" }, jsonLine));
187064
+ }
187065
+ return printers;
187066
+ }
187067
+
187068
+ // src/domains/interactiveRunner/followRun.ts
187069
+ var pollIntervalMs3 = 1000;
187018
187070
  function reportSettlement(ctx, settlement) {
187019
187071
  if (settlement.type === "passed") {
187020
187072
  ctx.ui.success(interactiveRunnerMessages.runPassed);
@@ -187026,40 +187078,49 @@ function reportSettlement(ctx, settlement) {
187026
187078
  };
187027
187079
  }
187028
187080
  async function followRun(ctx, options, deps) {
187029
- const readLogs = createJournalCursor(ctx, options.runnerId, {
187030
- runId: options.runId,
187031
- stream: "run-logs"
187032
- });
187081
+ const printers = createFollowPrinters(ctx, options);
187033
187082
  const readStatus = createJournalCursor(ctx, options.runnerId, {
187034
187083
  runId: options.runId,
187035
187084
  stream: "run-status"
187036
187085
  });
187037
187086
  const unreachable = createUnreachableBudget(pollIntervalMs3);
187038
- const printLogs = async () => {
187039
- const logs = await readLogs();
187040
- if (logs.type !== "entries")
187041
- return logs;
187042
- for (const entry of logs.entries) {
187043
- ctx.ui.stream(entry, formatRunLogLine(entry.payload));
187087
+ const printAll = async () => {
187088
+ for (const print of printers) {
187089
+ const window2 = await print();
187090
+ if (window2.type !== "entries")
187091
+ return window2;
187044
187092
  }
187045
- return logs;
187093
+ return;
187094
+ };
187095
+ let progressReported = false;
187096
+ const printProgress = (entries) => {
187097
+ if (printers.length > 0 || progressReported)
187098
+ return;
187099
+ const entry = entries.find((e) => readRunSettlement(e.payload).type === "in-progress");
187100
+ if (entry === undefined)
187101
+ return;
187102
+ progressReported = true;
187103
+ ctx.ui.stream(entry, interactiveRunnerMessages.runInProgress);
187046
187104
  };
187047
187105
  const maxPolls = Math.max(1, Math.ceil(options.timeoutSeconds * 1000 / pollIntervalMs3));
187048
187106
  for (let poll = 1;; poll++) {
187049
- const logs = await printLogs();
187050
- if (logs.type === "failed")
187051
- return journalReadFailure(logs);
187052
- const status = logs.type === "unreachable" ? logs : await readStatus();
187107
+ const interrupted = await printAll();
187108
+ if (interrupted?.type === "failed")
187109
+ return journalReadFailure(interrupted);
187110
+ const status = interrupted?.type === "unreachable" ? interrupted : await readStatus();
187053
187111
  if (status.type === "failed")
187054
187112
  return journalReadFailure(status);
187055
- if (logs.type === "unreachable" || status.type === "unreachable") {
187113
+ if (status.type === "unreachable") {
187056
187114
  if (unreachable.exhausted())
187057
187115
  return { ...unreachableFailure };
187058
187116
  } else {
187059
187117
  unreachable.reset();
187118
+ printProgress(status.entries);
187060
187119
  const settlement = findSettlement(status.entries);
187061
187120
  if (settlement !== undefined) {
187062
- await printLogs();
187121
+ if (await printAll() !== undefined) {
187122
+ ctx.ui.warn(interactiveRunnerMessages.followEndCutShort);
187123
+ }
187063
187124
  return reportSettlement(ctx, settlement);
187064
187125
  }
187065
187126
  }
@@ -187094,10 +187155,17 @@ async function handleRunnerRun(ctx, options, deps) {
187094
187155
  }
187095
187156
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
187096
187157
  if (resolved.type === "failed") {
187097
- return { error: resolved.error, exitCode: resolved.exitCode };
187158
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
187098
187159
  }
187099
187160
  announceRunner(ctx, resolved);
187100
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.runFlow, { entryPointPath, files, id: resolved.runnerId });
187161
+ let recorderSinceSequence;
187162
+ if (options.recorderEvents) {
187163
+ const anchor = await resolveRecorderAnchor(ctx, resolved, deps);
187164
+ if (!anchor.ok)
187165
+ return { ...anchor.failure };
187166
+ recorderSinceSequence = anchor.sinceSequence;
187167
+ }
187168
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.runFlow, { entryPointPath, files, id: resolved.runnerId }, runnerCallOptions);
187101
187169
  if (!result.ok) {
187102
187170
  return { ...failureFields(result), exitCode: exitCodes.network };
187103
187171
  }
@@ -187114,12 +187182,16 @@ async function handleRunnerRun(ctx, options, deps) {
187114
187182
  };
187115
187183
  case "submitted": {
187116
187184
  const runId = result.value.runId;
187117
- if (!options.follow) {
187185
+ const follow = options.follow || options.logs || options.runEvents || options.recorderEvents;
187186
+ if (!follow) {
187118
187187
  ctx.ui.output({ runId, runnerId: resolved.runnerId }, interactiveRunnerMessages.runSubmitted(runId));
187119
187188
  return;
187120
187189
  }
187121
187190
  ctx.ui.info(interactiveRunnerMessages.runSubmitted(runId));
187122
187191
  return followRun(ctx, {
187192
+ logs: options.logs,
187193
+ recorderSinceSequence,
187194
+ runEvents: options.runEvents,
187123
187195
  runId,
187124
187196
  runnerId: resolved.runnerId,
187125
187197
  timeoutSeconds: timeout.seconds
@@ -187139,16 +187211,20 @@ async function handleRunnerRun(ctx, options, deps) {
187139
187211
  var runExamples2 = `
187140
187212
  Examples:
187141
187213
  $ qawolf runner run flows/checkout.flow.ts
187142
- $ qawolf runner run flows/checkout.flow.ts --follow`;
187214
+ $ qawolf runner run flows/checkout.flow.ts --follow
187215
+ $ qawolf runner run flows/checkout.flow.ts --follow --logs`;
187143
187216
  var eventsExamples = `
187144
187217
  Examples:
187145
187218
  $ qawolf runner events recorder --tail 5
187146
187219
  $ qawolf runner events run-logs --run <runId> --follow
187147
187220
  $ qawolf runner events console --since 120 --json`;
187148
187221
  function registerRunnerRunCommands(runner, signals) {
187149
- declareCommandKind(runner.command("run <file>"), "write").description("Run a flow on an interactive runner, shipping the current directory's files with it").option("--follow", "Stream the run's logs until it settles", false).option("--runner <id>", runnerFlagDescription).option("--timeout <seconds>", "Give up following after this long. Following keeps the runner alive, so a run that never settles would otherwise bill until the terminal closed", String(defaultFollowTimeoutSeconds)).addHelpText("after", runExamples2).action((file2, opts, command) => withAuthContext(signals, (ctx) => handleRunnerRun(ctx, {
187222
+ declareCommandKind(runner.command("run <file>"), "write").description("Run a flow on an interactive runner, shipping the current directory's files with it").option("--follow", "Report the run's status until it settles: in progress, then passed or failed", false).option("--logs", "Stream every log line the run produces while following. Implies --follow", false).option("--run-events", "Stream the run's progress events as JSON lines while following. Implies --follow", false).option("--recorder-events", "Stream the browser actions the runner records as JSON lines while following, from an anchor taken just before submission: the recorder is runner-wide, not run-scoped. Implies --follow", false).option("--runner <id>", runnerFlagDescription).option("--timeout <seconds>", "Give up following after this long. Following keeps the runner alive, so a run that never settles would otherwise bill until the terminal closed", String(defaultFollowTimeoutSeconds)).addHelpText("after", runExamples2).action((file2, opts, command) => withAuthContext(signals, (ctx) => handleRunnerRun(ctx, {
187150
187223
  entryPoint: file2,
187151
187224
  follow: opts.follow,
187225
+ logs: opts.logs,
187226
+ recorderEvents: opts.recorderEvents,
187227
+ runEvents: opts.runEvents,
187152
187228
  runner: opts.runner,
187153
187229
  timeout: opts.timeout
187154
187230
  }, runnerDeps(ctx)))(opts, command));
@@ -187208,4 +187284,4 @@ createProgram({ signals }).parseAsync().catch(() => {
187208
187284
  process.exitCode = 1;
187209
187285
  }).finally(() => flushAndExit(typeof process.exitCode === "number" ? process.exitCode : 0));
187210
187286
 
187211
- //# debugId=B4115D163429543264756E2164756E21
187287
+ //# debugId=8CB687F9691544E564756E2164756E21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qawolf/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
5
5
  "keywords": [
6
6
  "automation",
@@ -66,7 +66,6 @@
66
66
  "@qawolf/flows": "0.1.4",
67
67
  "@qawolf/testkit": "1.1.1",
68
68
  "appium": "2.11.3",
69
- "appium-uiautomator2-driver": "3.7.0",
70
69
  "commander": "14.0.3",
71
70
  "env-paths": "4.0.0",
72
71
  "expect-webdriverio": "5.6.5",
@@ -85,6 +84,7 @@
85
84
  "@tsconfig/strictest": "2.0.8",
86
85
  "@types/bun": "1.3.14",
87
86
  "@types/picomatch": "4.0.3",
87
+ "appium-uiautomator2-driver": "4.2.9",
88
88
  "knip": "6.16.1",
89
89
  "oxfmt": "0.54.0",
90
90
  "oxlint": "1.69.0",
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: qawolf-cli
3
- description: Manage QA Wolf through the qawolf CLI. Use when asked which QA Wolf environment variables are available or to list, set, or delete them; manage environments, flows, runs, tags, or issues; authenticate; install; or perform other QA Wolf operations from a shell.
3
+ description: Manage QA Wolf through the qawolf CLI. Use when asked which QA Wolf environment variables are available or to list, set, or delete them; manage environments, flows, runs, tags, or issues; authenticate; install; run or list flows; or drive a live cloud browser (launch a runner, screenshot it, click and type on it, read its recorder) from a shell.
4
4
  license: Apache-2.0
5
5
  compatibility: Requires the qawolf CLI on PATH. Install it from @qawolf/cli or use a standalone binary from GitHub Releases.
6
6
  ---
7
7
 
8
8
  # QA Wolf CLI
9
9
 
10
- `qawolf` runs QA Wolf flows locally and calls the QA Wolf public API.
10
+ `qawolf` runs QA Wolf flows locally, calls the QA Wolf public API, and drives
11
+ interactive runners: live cloud pods holding a browser you can see and act on.
11
12
 
12
13
  This file is an overview, not a reference. Before first using a command whose
13
14
  flags are not shown here, run `qawolf <command> --help` once. The installed CLI
@@ -22,6 +23,11 @@ environment variable (or stored credentials from `qawolf auth login`).
22
23
  their table entry notes a flag that switches them to `read`.
23
24
  Verify with `qawolf auth whoami`. Never print or log the key.
24
25
 
26
+ A team API key in the environment is the whole credential, including for the
27
+ `runner` group. Nothing needs a browser login, a session token or a held
28
+ connection, so a sandbox that can set one environment variable and make
29
+ requests to one host can do everything below.
30
+
25
31
  Commands use `https://app.qawolf.com` by default. Set `QAWOLF_HOST_URL` to
26
32
  target another deployment host, for example
27
33
  `https://app.staging.example.com`. `QAWOLF_API_URL` is a separate API endpoint
@@ -61,6 +67,11 @@ Human-formatted output is not stable across versions. Errors go to stderr;
61
67
  a non-zero exit code means the command failed. Reuse successful read results
62
68
  within a task unless a relevant write or target change could make them stale.
63
69
 
70
+ One exception to know about: on `qawolf runner events`, `--json` also switches
71
+ each printed line from the payload alone to the whole envelope (`sequence`,
72
+ `recordedAt`, `payload`). Both are JSON. Pass it when you want to page by
73
+ sequence, omit it when you want the payloads themselves.
74
+
64
75
  ## Safety: reads vs writes
65
76
 
66
77
  Read commands do not change team data, but some have operational effects noted
@@ -71,6 +82,12 @@ successful write response is confirmation, so do not read immediately only to
71
82
  verify it. Never blind-retry a write on timeout: it may have reached the server
72
83
  the first time.
73
84
 
85
+ Two runner-specific costs to keep in mind. Launching a runner starts a billed
86
+ pod, so reuse one id rather than minting new ones per step, and stop a runner
87
+ when you are done. And `run`, `act` and `exec` may all have taken effect even
88
+ when their answer never arrives, so none of them is safe to blind-retry; `run`
89
+ is the expensive one, because a second submission bills a second run.
90
+
74
91
  ## Git-backed workflows
75
92
 
76
93
  Inspect `git status` before publishing. Stage and commit only files changed for
@@ -130,3 +147,18 @@ Kinds: `read` calls the QA Wolf API without changing anything; `write`
130
147
  changes team state; `local` only affects this machine. A parenthesized
131
148
  note like `local (read with --remote)` means that flag makes the command
132
149
  call the QA Wolf API and require auth.
150
+
151
+ ## Driving a browser: the `runner` group
152
+
153
+ The `runner` commands drive a live cloud browser: `launch` one, `screenshot` to
154
+ see it, `act` to click and type, `run` a flow on it, `exec` a snippet against its
155
+ page, `events` to read its journal (including the `recorder` stream, which turns
156
+ your actions into Playwright locators), `keepalive` to hold it open, and `stop`
157
+ when done. Everything is a plain request to one host, so a shell with an API key
158
+ and its own vision model can close the see-and-act loop with no other tooling.
159
+
160
+ The full workflow is its own guide: how a runner is billed, why the first call
161
+ must be a run, the order the commands go in, the see-and-act loop, `exec`, the
162
+ recorder, reading history, staying alive, and an end-to-end example. **Read
163
+ [`references/runner.md`](references/runner.md) before driving a runner for the
164
+ first time.**
@@ -0,0 +1,290 @@
1
+ # Driving a runner from the terminal
2
+
3
+ An interactive runner is a live pod with a browser in it. You launch one, look
4
+ at it, act on it, run flows on it, and read what it recorded. Everything is a
5
+ plain request to one host, so there is no connection to hold open.
6
+
7
+ ## Getting one
8
+
9
+ Runner ids are yours to choose and are scoped to your team, so `agent-1` is a
10
+ fine id. Launching an id that is already running attaches to that runner instead
11
+ of starting and billing a second one, and the answer says which happened: read
12
+ `outcome` for `launched` or `already-running`. Reusing one id is therefore the
13
+ cheap and safe pattern, and the same id with a different `--name` is refused
14
+ rather than silently ignored.
15
+
16
+ Commands that target a runner find one in this order: `--runner`, then
17
+ `QAWOLF_RUNNER_ID`, then the runner stored for the current directory (which
18
+ `qawolf runner launch` sets). Setting the environment variable once is the most
19
+ robust for a harness whose working directory may not be stable, but it comes
20
+ with two catches worth knowing before you rely on it.
21
+
22
+ `qawolf runner launch` is not in that order: it takes its id from `--id` and
23
+ never reads `QAWOLF_RUNNER_ID`. Bare `qawolf runner launch` invents a random id,
24
+ bills a pod under it and stores it, so a harness that exported the variable and
25
+ then launched without `--id` ends up with a pod it is not addressing. Pass
26
+ `--id` whenever you have an id in mind.
27
+
28
+ And a runner id that is set is treated as found, whether or not anything is
29
+ running under it. So exporting `QAWOLF_RUNNER_ID=agent-1` turns off the
30
+ auto-launch described next: instead of starting `agent-1`, commands try to reach
31
+ it and fail with exit code `4`, which reads as "retry" and never succeeds.
32
+ Launch that id once yourself and the rest follows.
33
+
34
+ If nothing names a runner, the commands that change something will launch one
35
+ and say so on stderr, naming it: `run`, `act` and `exec`. **Read that
36
+ announcement.** The browser it just started is fresh: nothing has been run on it,
37
+ nothing is signed in, and no page is open. Acting as though your earlier setup
38
+ survived is the single most likely way to drive the wrong page.
39
+
40
+ No `read` command ever launches a runner. `screenshot`, `events` and `keepalive`
41
+ tell you there is no runner rather than quietly billing one, and so does `stop`,
42
+ since starting a pod in order to stop it would be absurd.
43
+
44
+ ## The order that matters
45
+
46
+ A freshly launched runner has no screen. The virtual desktop starts with the
47
+ runner's **first run** and nothing else starts it, so until you have run
48
+ something:
49
+
50
+ - `screenshot` and `act` fail with exit code `2`, except `navigate`, which
51
+ fails with exit code `1` (`action-failed`): it skips the screen but still
52
+ needs the runner to have run something
53
+ - `exec` fails with exit code `4`
54
+ - `events recorder` reads as empty
55
+
56
+ None of that is a fault, and none of it clears on its own. **Only
57
+ `qawolf runner run <flow>` starts the screen.** A bare navigate does not: it
58
+ fails until the first run, however long you wait.
59
+
60
+ So the first call on a new runner has to be a run. That means a flow file and a
61
+ `package.json` on disk, even if all you want is to drive the browser by hand;
62
+ there is no "just give me a screen" call. Once one run has happened, the
63
+ screenshot-and-act loop below works for the rest of the runner's life.
64
+
65
+ Retry on the exit code, not on the message text:
66
+
67
+ - `4` is usually transient. The screen is up but cannot serve this instant:
68
+ restarting after a display-size change, or busy with another request. Retry in
69
+ a second or two — but bound the retries, because `4` also covers a runner that
70
+ was reaped after inactivity, which no amount of retrying brings back. If `4`
71
+ persists past a few tries, relaunch the id.
72
+ - `2` will not clear on its own. Either nothing has run on this runner yet, so
73
+ run a flow, or the runner has no browser at all, so launch with
74
+ `--name node20WithPlaywright` instead. The message says which.
75
+
76
+ The one exception is `exec`, which reports both as `4`; read its message to tell
77
+ them apart.
78
+
79
+ ## Seeing and acting: the loop is yours
80
+
81
+ Two primitives, and you close the loop with your own model. There is no hosted
82
+ vision loop on this surface.
83
+
84
+ `qawolf runner screenshot --out page.jpg` writes a real JPEG to disk, decoded,
85
+ because every coding harness can open an image file. Read it with whatever
86
+ vision you have.
87
+
88
+ `qawolf runner act <action>` performs exactly one action per call, in the
89
+ computer-use tool vocabulary a vision model already emits: `click`,
90
+ `double_click`, `scroll`, `move`, `drag`, `keypress`, `navigate`, `type`. The
91
+ names and the field names are unchanged from that vocabulary on purpose, so you
92
+ can forward a tool call rather than translate it:
93
+
94
+ ```sh
95
+ echo '{"type":"click","button":"left","x":480,"y":260}' | qawolf runner act -
96
+ ```
97
+
98
+ Coordinates are pixels on the same screenshot you just read. The runner serves
99
+ one see-or-act request at a time, so decide what to do next from each answer
100
+ rather than firing several. Bounds are checked before anything is sent, so an
101
+ over-long `--text` or an out-of-range coordinate comes back immediately naming
102
+ the limit instead of occupying the runner and then failing.
103
+
104
+ `act`, `run` and `exec` are the three commands whose lost answer may still have
105
+ taken effect. On a `4` from `act`, take a screenshot before repeating a click.
106
+ `exec`'s message says the snippet could not be evaluated, but a lost answer
107
+ looks the same from outside, so treat a `4` from a snippet that changes something
108
+ as "may have run" rather than "did not run".
109
+
110
+ ## The recorder: what you cannot get from pixels
111
+
112
+ `qawolf runner events recorder` is the capability that has no equivalent in a
113
+ screenshot. As you drive the browser, the runner records each interaction and
114
+ publishes `locator` (the real Playwright locator it resolved), `alternates` (the
115
+ others that matched the same element) and `code` (the generated Playwright call),
116
+ alongside `type`, `sourceUrl` and `timestamp`.
117
+
118
+ ```sh
119
+ qawolf runner events recorder --tail 5 | jq -r '.code // .type' # what happened
120
+ qawolf runner events recorder --tail 5 | jq -r '[.locator] + (.alternates // []) | @tsv'
121
+ ```
122
+
123
+ `code` is absent on events with no call of their own, such as a navigation, which
124
+ is why the first line falls back to `type`. Use these to turn a session you drove
125
+ by pixel coordinates into durable selectors, and to check that a click landed on
126
+ the element you meant rather than near it. The stream is empty until the session
127
+ has a browser context, so an early empty answer means "not yet", not "broken".
128
+ Do not add `--json` here: it wraps each line in an envelope and these field paths
129
+ stop matching.
130
+
131
+ ## Reading the page: `exec`
132
+
133
+ `qawolf runner exec <file>` evaluates a snippet against whatever the runner's
134
+ browser is showing, which is how you read a value out of the page rather than
135
+ looking at it. Two things to know, because neither is guessable:
136
+
137
+ It does not return what the snippet evaluated to, only whether it ran. To get a
138
+ value back, print it and read the `console` stream. Print it behind a marker you
139
+ chose, and match on that rather than taking the newest line: the page logs to the
140
+ same stream, so anything it prints after your snippet would be what `--tail 1`
141
+ hands back. Entries carry `source`, which is `serverConsole` for your snippet and
142
+ `browserConsole` for the page, so filtering on both is what pins the value down.
143
+
144
+ ```sh
145
+ echo 'console.log("qw-title:", await page.title())' | qawolf runner exec -
146
+ qawolf runner events console --tail 20 \
147
+ | jq -r 'select(.source == "serverConsole" and (.message | contains("qw-title:"))) | .message'
148
+ ```
149
+
150
+ And the snippet imports nothing of yours by default. Pass `--file <path>` to
151
+ evaluate it in that file's scope, which also ships the directory's other files,
152
+ so the snippet can use your own page objects and helpers.
153
+
154
+ ## Running a flow
155
+
156
+ `qawolf runner run <file>` ships the current directory's runnable files with the
157
+ request. The runner holds no copy of your project, so what runs is exactly what
158
+ is on disk at that moment, uncommitted edits included. A `package.json` has to
159
+ be there, since the run reads its npm dependencies from it, and the files may
160
+ carry at most 4 MiB in total: run from a directory holding the flow and what it
161
+ imports rather than from the root of a large monorepo. A missing file, a missing
162
+ `package.json` and files over the cap are all refused before any runner is
163
+ resolved or launched, so a typo costs nothing.
164
+
165
+ The call answers with a run id as soon as the run is accepted. **The outcome is
166
+ not in that answer**, it is in the `run-status` stream, whose entries carry
167
+ `runId`, `status` and an `errorMessage` when there is one.
168
+
169
+ **Pass `--follow` to `run` and let it wait for you.** It reports the run's
170
+ status — in progress, then passed or failed — and ends on the settled status.
171
+ Exit code `1` means the run did not pass. Three flags mirror more streams into
172
+ the follow, and each implies `--follow` on its own: `--logs` streams every log
173
+ line the run produces, `--run-events` streams the run's progress events as JSON
174
+ lines, and `--recorder-events` streams the browser actions the runner records
175
+ as JSON lines — the recorder is runner-wide rather than run-scoped, so that one
176
+ carries whatever is recorded after an anchor taken just before submission. Whatever mirrors are on, the
177
+ follow still ends on the status, never on them, so a run that prints nothing
178
+ still terminates the follow and a run that dies mid-sentence still reports how.
179
+ Combining mirror flags interleaves their lines with nothing saying which stream
180
+ a line came from — fine for eyeballs; when parsing, follow one stream at a time.
181
+
182
+ ```sh
183
+ qawolf runner run flows/checkout.flow.ts --follow
184
+ qawolf runner run flows/checkout.flow.ts --follow --logs
185
+ qawolf runner run flows/checkout.flow.ts --follow --recorder-events
186
+ ```
187
+
188
+ If you would rather submit and come back later, note that `--follow` on `events`
189
+ does not end when the run settles — it runs until its own `--timeout`, an hour
190
+ by default — so it cannot be used to wait for a run. Poll instead, and decide
191
+ with the same rule the CLI uses: `status` is `in-progress` while the run is
192
+ going, and any other value means it has settled.
193
+
194
+ ```sh
195
+ qawolf runner run flows/checkout.flow.ts --json # -> {"runId":"...","runnerId":"..."}
196
+ qawolf runner events run-status --run <runId> --tail 1 | jq -r '.status'
197
+ ```
198
+
199
+ The one expensive mistake on this surface: **if `run` reports that the runner
200
+ could not be reached, that does not mean the run did not start.** The runner may
201
+ have accepted it and been too slow to answer, and resubmitting bills and journals
202
+ a second run.
203
+
204
+ There is no clean recovery here, so it is worth being plain about it. The journal
205
+ lives on the same pod, so while the runner stays unreachable a `run-status` read
206
+ fails the same way and cannot tell you whether a run is going. Wait for the
207
+ runner to answer again, then read `run-status` without `--run` and look at the
208
+ newest `runId`. Nothing ties that id back to your submission: `run` never
209
+ answered, so you have no id to match it against, and a runner takes work from
210
+ anyone addressing it. Treat the newest id as your run only if you know nothing
211
+ else submits to this runner; otherwise follow it to see what it is before acting
212
+ on it. An empty read is not proof the run did not start, though:
213
+ `run` returns the moment the run is accepted, and its first `run-status` entry
214
+ may not be written yet, so a run accepted just before the runner went quiet can
215
+ still be in flight with nothing to show. `runFlow` has no idempotency key, so a
216
+ resubmit always risks a second billed run. Prefer polling `run-status` a while
217
+ longer over resubmitting; only submit again once you are willing to accept that
218
+ risk.
219
+
220
+ ## Reading history
221
+
222
+ Everything observable is an append-only stream on the pod, read by cursor or
223
+ tail rather than subscribed to, so attaching late still gets you the history that
224
+ is still there. It is not unbounded: a size cap drops the oldest entries on a
225
+ long-lived runner, and a `--tail N` read can stop early and hand back fewer than
226
+ N even when more matched. Both are warned about on stderr — dropped entries only
227
+ once a read holds a cursor, a stopped-early read with a pointer at `--since` —
228
+ so watch stderr, treat a short answer as "at least this" rather than "all there
229
+ was", and read what you care about as you go rather than at the end. QA Wolf writes `recorder`, `console`, `run-events`,
230
+ `run-logs` and `run-status`; a stream nobody has written reads as empty rather
231
+ than as an error, and a stream this CLI version does not know about is still
232
+ readable by name.
233
+
234
+ One payload per line, so shell tools compose:
235
+
236
+ ```sh
237
+ qawolf runner events console --tail 20 | jq -r '.message'
238
+ qawolf runner events run-logs --run <runId> --follow > run.log
239
+ ```
240
+
241
+ `--tail N` takes the newest N, `--since <sequence>` reads everything after a
242
+ cursor, and `--run <id>` narrows the run-scoped streams.
243
+
244
+ `--follow` polls and prints as entries arrive. It is `tail -f` with a bound: it
245
+ ends only at its `--timeout` (an hour by default, exit `6`), because reading
246
+ keeps the runner alive and billing. Redirect it to a file and stop it yourself,
247
+ or use repeated `--since` reads when you need the command to end sooner.
248
+
249
+ Where it does win is the cursor. The pod reports how far a read scanned rather
250
+ than how far it matched, and `--follow` carries that number, so a filtered read
251
+ that matched nothing still moves forward. A caller paging by hand cannot see it,
252
+ because the CLI does not print it, and the best available substitute is the
253
+ highest `sequence` you actually saw. So a narrow `--run` filter over a busy
254
+ stream stalls: with nothing matching, there is no new `sequence` to move on to,
255
+ and you re-read the same window until something matches (NOVA-1397).
256
+
257
+ ## Staying alive
258
+
259
+ A runner is reaped after a period of inactivity, and every command that talks to
260
+ the runner counts as activity, including a journal read.
261
+ `qawolf runner keepalive` exists for the gap that creates: a harness that thinks,
262
+ or waits on a human, for minutes between actions would otherwise come back to a
263
+ pod that is gone. It resets the clock and tells you the runner is still there.
264
+
265
+ It is listed as a `read`, but it is the one read with a cost: keeping the clock
266
+ reset keeps a billed pod alive. Call it while you are genuinely still working, not
267
+ on a timer you forget, and call `qawolf runner stop` when you are done rather
268
+ than leaving a pod to time out. A loop that keeps a runner alive and never stops
269
+ it bills until someone notices.
270
+
271
+ ## End to end
272
+
273
+ Run from a directory holding a flow and a `package.json`. The run is what starts
274
+ the screen, so it is not optional even though the goal here is to drive by hand.
275
+
276
+ ```sh
277
+ export QAWOLF_API_KEY=... # the only credential
278
+ export QAWOLF_RUNNER_ID=agent-1 # so no command below needs --runner
279
+
280
+ qawolf runner launch --id agent-1 --json # --id, not the variable; read .outcome
281
+ qawolf runner run flows/smoke.flow.ts --follow # starts the screen; exit 1 if it failed
282
+
283
+ qawolf runner act navigate --url https://example.com/login
284
+ qawolf runner screenshot --out page.jpg # then read page.jpg yourself
285
+ qawolf runner act click --button left --x 480 --y 260
286
+ qawolf runner act type --text "someone@example.com"
287
+
288
+ qawolf runner events recorder --tail 5 | jq -r '[.locator] + (.alternates // []) | @tsv'
289
+ qawolf runner stop
290
+ ```