@qawolf/cli 1.9.0 → 1.9.2

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)
@@ -175909,7 +175909,7 @@ function startUpdateCheck(deps) {
175909
175909
  // package.json
175910
175910
  var package_default = {
175911
175911
  name: "@qawolf/cli",
175912
- version: "1.9.0",
175912
+ version: "1.9.2",
175913
175913
  description: "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
175914
175914
  keywords: [
175915
175915
  "automation",
@@ -175975,7 +175975,6 @@ var package_default = {
175975
175975
  "@qawolf/flows": "0.1.4",
175976
175976
  "@qawolf/testkit": "1.1.1",
175977
175977
  appium: "2.11.3",
175978
- "appium-uiautomator2-driver": "3.7.0",
175979
175978
  commander: "14.0.3",
175980
175979
  "env-paths": "4.0.0",
175981
175980
  "expect-webdriverio": "5.6.5",
@@ -175994,6 +175993,7 @@ var package_default = {
175994
175993
  "@tsconfig/strictest": "2.0.8",
175995
175994
  "@types/bun": "1.3.14",
175996
175995
  "@types/picomatch": "4.0.3",
175996
+ "appium-uiautomator2-driver": "4.2.9",
175997
175997
  knip: "6.16.1",
175998
175998
  oxfmt: "0.54.0",
175999
175999
  oxlint: "1.69.0",
@@ -176719,7 +176719,7 @@ var playwrightVersion = "1.62.0";
176719
176719
  var emailsVersion = "1.1.1";
176720
176720
  var testkitVersion = "1.1.1";
176721
176721
  var appiumVersion = "2.11.3";
176722
- var appiumUiautomator2DriverVersion = "3.7.0";
176722
+ var appiumUiautomator2DriverVersion = "4.2.9";
176723
176723
  var expectWebdriverioVersion = "5.6.5";
176724
176724
 
176725
176725
  // src/domains/doctor/checks/playwright.ts
@@ -177813,10 +177813,6 @@ var pinnedPackages = [
177813
177813
  { name: "@qawolf/emails", version: emailsVersion },
177814
177814
  { name: "@qawolf/testkit", version: testkitVersion },
177815
177815
  { name: "appium", version: appiumVersion },
177816
- {
177817
- name: "appium-uiautomator2-driver",
177818
- version: appiumUiautomator2DriverVersion
177819
- },
177820
177816
  { name: "expect-webdriverio", version: expectWebdriverioVersion }
177821
177817
  ];
177822
177818
 
@@ -186223,6 +186219,9 @@ function describeRunFilesCheck(check2) {
186223
186219
  }
186224
186220
  }
186225
186221
 
186222
+ // src/domains/interactiveRunner/runnerCallOptions.ts
186223
+ var runnerCallOptions = { timeoutMs: 60000 };
186224
+
186226
186225
  // src/domains/interactiveRunner/runnerIds.ts
186227
186226
  function parseRunnerId(id) {
186228
186227
  const parsed = runnerIdSchema.safeParse(id);
@@ -186242,10 +186241,10 @@ async function launchRunner(ctx, options) {
186242
186241
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.launch, {
186243
186242
  id: options.id,
186244
186243
  ...options.runnerName ? { runnerName: options.runnerName } : {}
186245
- });
186244
+ }, runnerCallOptions);
186246
186245
  if (!result.ok) {
186247
186246
  return {
186248
- error: result.error,
186247
+ ...failureFields(result),
186249
186248
  exitCode: exitCodes.network,
186250
186249
  mayHaveArrived: result.mayHaveArrived ?? false,
186251
186250
  ok: false
@@ -186270,10 +186269,8 @@ async function launchAndRemember(ctx, options, deps) {
186270
186269
  });
186271
186270
  }
186272
186271
  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
186272
+ ...launched,
186273
+ error: launched.mayHaveArrived ? interactiveRunnerMessages.launchLost(options.id, launched.error) : interactiveRunnerMessages.launchFailed(options.id, launched.error)
186277
186274
  };
186278
186275
  }
186279
186276
  async function handleRunnerLaunch(ctx, options, deps) {
@@ -186287,7 +186284,7 @@ async function handleRunnerLaunch(ctx, options, deps) {
186287
186284
  }
186288
186285
  const launched = await launchAndRemember(ctx, { id: id.id, runnerName: runnerName?.runnerName }, deps);
186289
186286
  if (!launched.ok) {
186290
- return { error: launched.error, exitCode: launched.exitCode };
186287
+ return { ...failureFields(launched), exitCode: launched.exitCode };
186291
186288
  }
186292
186289
  ctx.ui.output(launched.value, launched.value.outcome === "launched" ? interactiveRunnerMessages.launched(launched.value.id) : interactiveRunnerMessages.alreadyRunning(launched.value.id));
186293
186290
  return;
@@ -186323,7 +186320,7 @@ async function resolveRunner(ctx, options, deps) {
186323
186320
  const launched = await launchAndRemember(ctx, { id: deps.makeRunnerId(), runnerName: undefined }, deps);
186324
186321
  if (!launched.ok) {
186325
186322
  return {
186326
- error: launched.error,
186323
+ ...failureFields(launched),
186327
186324
  exitCode: launched.exitCode,
186328
186325
  type: "failed"
186329
186326
  };
@@ -186374,7 +186371,7 @@ async function handleRunnerExec(ctx, options, deps) {
186374
186371
  return { error: scope.error, exitCode: exitCodes.invalidArgs };
186375
186372
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
186376
186373
  if (resolved.type === "failed") {
186377
- return { error: resolved.error, exitCode: resolved.exitCode };
186374
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186378
186375
  }
186379
186376
  announceRunner(ctx, resolved);
186380
186377
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.evaluateSnippet, {
@@ -186382,7 +186379,7 @@ async function handleRunnerExec(ctx, options, deps) {
186382
186379
  id: resolved.runnerId,
186383
186380
  ...scope.filePath === undefined ? {} : { filePath: scope.filePath },
186384
186381
  ...scope.files === undefined ? {} : { files: scope.files }
186385
- });
186382
+ }, runnerCallOptions);
186386
186383
  if (!result.ok) {
186387
186384
  return { ...failureFields(result), exitCode: exitCodes.network };
186388
186385
  }
@@ -186473,10 +186470,10 @@ async function handleRunnerAct(ctx, options, deps) {
186473
186470
  return { error: built.error, exitCode: exitCodes.invalidArgs };
186474
186471
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
186475
186472
  if (resolved.type === "failed") {
186476
- return { error: resolved.error, exitCode: resolved.exitCode };
186473
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186477
186474
  }
186478
186475
  announceRunner(ctx, resolved);
186479
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.performAction, { action: built.action, id: resolved.runnerId });
186476
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.performAction, { action: built.action, id: resolved.runnerId }, runnerCallOptions);
186480
186477
  if (!result.ok) {
186481
186478
  const fields = failureFields(result);
186482
186479
  return {
@@ -186527,9 +186524,9 @@ async function handleRunnerScreenshot(ctx, options, deps) {
186527
186524
  runner: options.runner
186528
186525
  }, deps);
186529
186526
  if (resolved.type === "failed") {
186530
- return { error: resolved.error, exitCode: resolved.exitCode };
186527
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186531
186528
  }
186532
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.takeScreenshot, { id: resolved.runnerId });
186529
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.takeScreenshot, { id: resolved.runnerId }, runnerCallOptions);
186533
186530
  if (!result.ok) {
186534
186531
  return { ...failureFields(result), exitCode: exitCodes.network };
186535
186532
  }
@@ -186745,7 +186742,7 @@ async function readJournal(ctx, runnerId, request) {
186745
186742
  ...request.runId === undefined ? {} : { runId: request.runId },
186746
186743
  ...request.sinceSequence === undefined ? {} : { sinceSequence: request.sinceSequence },
186747
186744
  ...request.tail === undefined ? {} : { tail: request.tail }
186748
- });
186745
+ }, runnerCallOptions);
186749
186746
  if (!result.ok) {
186750
186747
  return {
186751
186748
  ...failureFields(result),
@@ -186763,7 +186760,7 @@ async function readJournal(ctx, runnerId, request) {
186763
186760
  async function handleRunnerKeepalive(ctx, options, deps) {
186764
186761
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186765
186762
  if (resolved.type === "failed") {
186766
- return { error: resolved.error, exitCode: resolved.exitCode };
186763
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186767
186764
  }
186768
186765
  const window2 = await readJournal(ctx, resolved.runnerId, {
186769
186766
  stream: "run-status",
@@ -186781,11 +186778,11 @@ async function handleRunnerKeepalive(ctx, options, deps) {
186781
186778
  async function handleRunnerStop(ctx, options, deps) {
186782
186779
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186783
186780
  if (resolved.type === "failed") {
186784
- return { error: resolved.error, exitCode: resolved.exitCode };
186781
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186785
186782
  }
186786
186783
  const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.stop, {
186787
186784
  id: resolved.runnerId
186788
- });
186785
+ }, runnerCallOptions);
186789
186786
  if (!result.ok) {
186790
186787
  return { ...failureFields(result), exitCode: exitCodes.network };
186791
186788
  }
@@ -186942,7 +186939,7 @@ async function handleRunnerEvents(ctx, options, deps) {
186942
186939
  }
186943
186940
  const resolved = await resolveRunner(ctx, { autoLaunch: false, runner: options.runner }, deps);
186944
186941
  if (resolved.type === "failed") {
186945
- return { error: resolved.error, exitCode: resolved.exitCode };
186942
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
186946
186943
  }
186947
186944
  const known = knownJournalStreams;
186948
186945
  if (!known.includes(parsed.value.stream)) {
@@ -187094,10 +187091,10 @@ async function handleRunnerRun(ctx, options, deps) {
187094
187091
  }
187095
187092
  const resolved = await resolveRunner(ctx, { autoLaunch: true, runner: options.runner }, deps);
187096
187093
  if (resolved.type === "failed") {
187097
- return { error: resolved.error, exitCode: resolved.exitCode };
187094
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
187098
187095
  }
187099
187096
  announceRunner(ctx, resolved);
187100
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.runFlow, { entryPointPath, files, id: resolved.runnerId });
187097
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.runFlow, { entryPointPath, files, id: resolved.runnerId }, runnerCallOptions);
187101
187098
  if (!result.ok) {
187102
187099
  return { ...failureFields(result), exitCode: exitCodes.network };
187103
187100
  }
@@ -187208,4 +187205,4 @@ createProgram({ signals }).parseAsync().catch(() => {
187208
187205
  process.exitCode = 1;
187209
187206
  }).finally(() => flushAndExit(typeof process.exitCode === "number" ? process.exitCode : 0));
187210
187207
 
187211
- //# debugId=B4115D163429543264756E2164756E21
187208
+ //# debugId=80A0CD4106C384B664756E2164756E21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qawolf/cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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,280 @@
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 streams the run's logs
170
+ and ends on the settled status, never on the logs, so a run that prints nothing
171
+ still terminates the follow and a run that dies mid-sentence still reports how.
172
+ Exit code `1` means the run did not pass.
173
+
174
+ ```sh
175
+ qawolf runner run flows/checkout.flow.ts --follow
176
+ ```
177
+
178
+ If you would rather submit and come back later, note that `--follow` on `events`
179
+ does not end when the run settles — it runs until its own `--timeout`, an hour
180
+ by default — so it cannot be used to wait for a run. Poll instead, and decide
181
+ with the same rule the CLI uses: `status` is `in-progress` while the run is
182
+ going, and any other value means it has settled.
183
+
184
+ ```sh
185
+ qawolf runner run flows/checkout.flow.ts --json # -> {"runId":"...","runnerId":"..."}
186
+ qawolf runner events run-status --run <runId> --tail 1 | jq -r '.status'
187
+ ```
188
+
189
+ The one expensive mistake on this surface: **if `run` reports that the runner
190
+ could not be reached, that does not mean the run did not start.** The runner may
191
+ have accepted it and been too slow to answer, and resubmitting bills and journals
192
+ a second run.
193
+
194
+ There is no clean recovery here, so it is worth being plain about it. The journal
195
+ lives on the same pod, so while the runner stays unreachable a `run-status` read
196
+ fails the same way and cannot tell you whether a run is going. Wait for the
197
+ runner to answer again, then read `run-status` without `--run` and look at the
198
+ newest `runId`. Nothing ties that id back to your submission: `run` never
199
+ answered, so you have no id to match it against, and a runner takes work from
200
+ anyone addressing it. Treat the newest id as your run only if you know nothing
201
+ else submits to this runner; otherwise follow it to see what it is before acting
202
+ on it. An empty read is not proof the run did not start, though:
203
+ `run` returns the moment the run is accepted, and its first `run-status` entry
204
+ may not be written yet, so a run accepted just before the runner went quiet can
205
+ still be in flight with nothing to show. `runFlow` has no idempotency key, so a
206
+ resubmit always risks a second billed run. Prefer polling `run-status` a while
207
+ longer over resubmitting; only submit again once you are willing to accept that
208
+ risk.
209
+
210
+ ## Reading history
211
+
212
+ Everything observable is an append-only stream on the pod, read by cursor or
213
+ tail rather than subscribed to, so attaching late still gets you the history that
214
+ is still there. It is not unbounded: a size cap drops the oldest entries on a
215
+ long-lived runner, and a `--tail N` read can stop early and hand back fewer than
216
+ N even when more matched. Both are warned about on stderr — dropped entries only
217
+ once a read holds a cursor, a stopped-early read with a pointer at `--since` —
218
+ so watch stderr, treat a short answer as "at least this" rather than "all there
219
+ was", and read what you care about as you go rather than at the end. QA Wolf writes `recorder`, `console`, `run-events`,
220
+ `run-logs` and `run-status`; a stream nobody has written reads as empty rather
221
+ than as an error, and a stream this CLI version does not know about is still
222
+ readable by name.
223
+
224
+ One payload per line, so shell tools compose:
225
+
226
+ ```sh
227
+ qawolf runner events console --tail 20 | jq -r '.message'
228
+ qawolf runner events run-logs --run <runId> --follow > run.log
229
+ ```
230
+
231
+ `--tail N` takes the newest N, `--since <sequence>` reads everything after a
232
+ cursor, and `--run <id>` narrows the run-scoped streams.
233
+
234
+ `--follow` polls and prints as entries arrive. It is `tail -f` with a bound: it
235
+ ends only at its `--timeout` (an hour by default, exit `6`), because reading
236
+ keeps the runner alive and billing. Redirect it to a file and stop it yourself,
237
+ or use repeated `--since` reads when you need the command to end sooner.
238
+
239
+ Where it does win is the cursor. The pod reports how far a read scanned rather
240
+ than how far it matched, and `--follow` carries that number, so a filtered read
241
+ that matched nothing still moves forward. A caller paging by hand cannot see it,
242
+ because the CLI does not print it, and the best available substitute is the
243
+ highest `sequence` you actually saw. So a narrow `--run` filter over a busy
244
+ stream stalls: with nothing matching, there is no new `sequence` to move on to,
245
+ and you re-read the same window until something matches (NOVA-1397).
246
+
247
+ ## Staying alive
248
+
249
+ A runner is reaped after a period of inactivity, and every command that talks to
250
+ the runner counts as activity, including a journal read.
251
+ `qawolf runner keepalive` exists for the gap that creates: a harness that thinks,
252
+ or waits on a human, for minutes between actions would otherwise come back to a
253
+ pod that is gone. It resets the clock and tells you the runner is still there.
254
+
255
+ It is listed as a `read`, but it is the one read with a cost: keeping the clock
256
+ reset keeps a billed pod alive. Call it while you are genuinely still working, not
257
+ on a timer you forget, and call `qawolf runner stop` when you are done rather
258
+ than leaving a pod to time out. A loop that keeps a runner alive and never stops
259
+ it bills until someone notices.
260
+
261
+ ## End to end
262
+
263
+ Run from a directory holding a flow and a `package.json`. The run is what starts
264
+ the screen, so it is not optional even though the goal here is to drive by hand.
265
+
266
+ ```sh
267
+ export QAWOLF_API_KEY=... # the only credential
268
+ export QAWOLF_RUNNER_ID=agent-1 # so no command below needs --runner
269
+
270
+ qawolf runner launch --id agent-1 --json # --id, not the variable; read .outcome
271
+ qawolf runner run flows/smoke.flow.ts --follow # starts the screen; exit 1 if it failed
272
+
273
+ qawolf runner act navigate --url https://example.com/login
274
+ qawolf runner screenshot --out page.jpg # then read page.jpg yourself
275
+ qawolf runner act click --button left --x 480 --y 260
276
+ qawolf runner act type --text "someone@example.com"
277
+
278
+ qawolf runner events recorder --tail 5 | jq -r '[.locator] + (.alternates // []) | @tsv'
279
+ qawolf runner stop
280
+ ```