@qawolf/cli 1.29.0 → 1.30.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.
@@ -9463,15 +9463,14 @@ var makeContractsV1 = (ids) => {
9463
9463
  };
9464
9464
  var publicContractsV1 = makeContractsV1(defaultIdSchemas);
9465
9465
 
9466
- // src/core/pluralize.ts
9467
- function pluralize(count, singular, plural = `${singular}s`) {
9468
- return `${String(count)} ${count === 1 ? singular : plural}`;
9469
- }
9470
-
9471
9466
  // src/core/formatSeconds.ts
9472
9467
  function formatSeconds(ms) {
9473
9468
  return `${Math.round(ms / 1000)}s`;
9474
9469
  }
9470
+ // src/core/pluralize.ts
9471
+ function pluralize(count, singular, plural = `${singular}s`) {
9472
+ return `${String(count)} ${count === 1 ? singular : plural}`;
9473
+ }
9475
9474
 
9476
9475
  // src/core/messages/authErrors.ts
9477
9476
  var authErrorMessages = {
@@ -9665,7 +9664,7 @@ var flowsMessages = {
9665
9664
  extractingBundle: "Extracting bundle",
9666
9665
  downloadingTeamStorageAssets: "Downloading team-storage assets",
9667
9666
  downloadingTeamStorageAssetsProgress: (current, total) => `Downloading team-storage assets (${String(current)}/${String(total)})`,
9668
- teamStorageRequiresTeamKey: "Team storage requires a team API key; organization keys are not supported here.",
9667
+ teamStorageRequiresTeam: "Team storage needs a team. Pull an environment to name its team, choose a workspace with 'qawolf auth switch', or use a team API key.",
9669
9668
  summary: (result, assetsAbs) => {
9670
9669
  const flows = pluralize(result.flowCount, "flow");
9671
9670
  const envVars = result.envVarCount === 0 ? "" : ` and ${pluralize(result.envVarCount, "environment variable")}`;
@@ -9815,17 +9814,18 @@ var interactMessages = {
9815
9814
  };
9816
9815
 
9817
9816
  // src/core/messages/interactiveRunner/lifecycle.ts
9817
+ var pageAt = (url) => `Its runner page is at ${url}`;
9818
9818
  var noRunnerId = "No runner id. Pass --runner, set QAWOLF_RUNNER_ID, or run qawolf runner launch first.";
9819
9819
  var lifecycleMessages = {
9820
- alreadyRunning: (id) => `Runner ${id} was already running.`,
9820
+ alreadyRunning: (id, url) => `Runner ${id} was already running. ${pageAt(url)}`,
9821
9821
  defaultNotRemembered: (id) => `Runner ${id} could not be written to .qawolf as this directory's default, so later commands will not find it on their own. Pass --runner ${id}, or set QAWOLF_RUNNER_ID=${id}.`,
9822
9822
  defaultNotForgotten: (id) => `Runner ${id} was terminated, but it could not be removed from .qawolf as this directory's default, so later commands will still be sent to it. Pass --runner, or launch a new runner.`,
9823
9823
  envRunnerIdShadowsLaunch: (launchedId, envId) => `QAWOLF_RUNNER_ID=${envId} is still set, so commands that omit --runner will keep targeting it instead of the runner just launched. Pass --runner ${launchedId} to address this one.`,
9824
9824
  keptAlive: (id) => `Runner ${id} is alive, and its inactivity clock has been reset.`,
9825
9825
  launchFailed: (id, error) => `Launching runner ${id} failed. ${error}`,
9826
9826
  launchLost: (id, error) => `Launching runner ${id} failed. ${error}. The request may still have reached QA Wolf and started a runner, so relaunch the same id with qawolf runner launch --id ${id} to attach to it rather than start a second one, or terminate it with qawolf runner terminate --runner ${id}.`,
9827
- launched: (id) => `Launched runner ${id}.`,
9828
- 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 terminated or idles out, so terminate it with qawolf runner terminate --runner ${id} when you are done.`,
9827
+ launched: (id, url) => `Launched runner ${id}. ${pageAt(url)}`,
9828
+ launchedForCommand: (id, url) => `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 terminated or idles out, so terminate it with qawolf runner terminate --runner ${id} when you are done. ${pageAt(url)}`,
9829
9829
  noRunnerIdForImport: `${noRunnerId} An install also needs a live run to go into, which a runner gets from qawolf runner run.`,
9830
9830
  noRunnerIdForHighlight: `${noRunnerId} Highlighting also needs a live page to draw on, which a runner gets from its first run: run a flow on it with qawolf runner run.`,
9831
9831
  noRunnerIdForInspect: `${noRunnerId} Inspecting also needs a page, which a runner gets from its first run: run a flow on it with qawolf runner run.`,
@@ -10017,7 +10017,8 @@ async function listRunners(ctx, deps) {
10017
10017
  id: runner.id,
10018
10018
  isDefault: runner.id === defaultRunnerId,
10019
10019
  launchedHere: heldIds.has(runner.id),
10020
- runnerName: runner.runnerName
10020
+ runnerName: runner.runnerName,
10021
+ url: runner.url
10021
10022
  }));
10022
10023
  return {
10023
10024
  items: items.sort((left, right) => rank(left) - rank(right) || left.id.localeCompare(right.id)),
@@ -11057,6 +11058,37 @@ import { join as join3 } from "node:path";
11057
11058
  var qawolfDir = ".qawolf";
11058
11059
  var defaultOutputDir = `${qawolfDir}/output`;
11059
11060
 
11061
+ // src/core/parseJson.ts
11062
+ function parseJson(text) {
11063
+ try {
11064
+ return JSON.parse(text);
11065
+ } catch {
11066
+ return;
11067
+ }
11068
+ }
11069
+
11070
+ // src/shell/jsonFile.ts
11071
+ var pendingWrites = 0;
11072
+ async function readJsonFile(fs, path, schema) {
11073
+ const contents = await fs.readFile(path).catch(() => {
11074
+ return;
11075
+ });
11076
+ if (contents === undefined)
11077
+ return;
11078
+ const parsed = schema.safeParse(parseJson(contents));
11079
+ return parsed.success ? parsed.data : undefined;
11080
+ }
11081
+ async function writeJsonFileAtomically(fs, path, value) {
11082
+ const pendingPath = `${path}.${String(process.pid)}.${String(++pendingWrites)}.tmp`;
11083
+ try {
11084
+ await fs.writeFile(pendingPath, `${JSON.stringify(value, undefined, 2)}
11085
+ `);
11086
+ await fs.rename(pendingPath, path);
11087
+ } finally {
11088
+ await fs.rm(pendingPath, { force: true }).catch(() => {});
11089
+ }
11090
+ }
11091
+
11060
11092
  // src/shell/interactiveRunner/runFilesManifest.ts
11061
11093
  var manifestFileName = "runner-files.json";
11062
11094
  var manifestSchema = object({
@@ -11067,33 +11099,14 @@ var manifestSchema = object({
11067
11099
  function makeRunFilesManifestStore(options) {
11068
11100
  const directory = join3(options.cwd, qawolfDir);
11069
11101
  const path = join3(directory, manifestFileName);
11070
- let pendingWrites = 0;
11071
11102
  return {
11072
- async read() {
11073
- const contents = await options.fs.readFile(path).catch(() => {
11074
- return;
11075
- });
11076
- if (contents === undefined)
11077
- return;
11078
- const parsed = manifestSchema.safeParse(parseJson(contents));
11079
- return parsed.success ? parsed.data : undefined;
11080
- },
11103
+ read: () => readJsonFile(options.fs, path, manifestSchema),
11081
11104
  async write(manifest) {
11082
- const pendingPath = `${path}.${String(process.pid)}.${String(++pendingWrites)}.tmp`;
11083
11105
  await options.fs.mkdir(directory, { recursive: true });
11084
- await options.fs.writeFile(pendingPath, `${JSON.stringify(manifest, undefined, 2)}
11085
- `);
11086
- await options.fs.rename(pendingPath, path);
11106
+ await writeJsonFileAtomically(options.fs, path, manifest);
11087
11107
  }
11088
11108
  };
11089
11109
  }
11090
- function parseJson(text) {
11091
- try {
11092
- return JSON.parse(text);
11093
- } catch {
11094
- return;
11095
- }
11096
- }
11097
11110
 
11098
11111
  // src/shell/interactiveRunner/runnerStore.ts
11099
11112
  import { join as join4 } from "node:path";
@@ -11106,34 +11119,15 @@ var storedRunnerSchema = object({
11106
11119
  var storeSchema = object({
11107
11120
  defaultRunnerId: string2().optional()
11108
11121
  });
11109
- function parseJson2(text) {
11110
- try {
11111
- return JSON.parse(text);
11112
- } catch {
11113
- return;
11114
- }
11115
- }
11116
- var pendingWrites = 0;
11117
11122
  function makeRunnerStore(options) {
11118
11123
  const directory = join4(options.cwd, qawolfDir);
11119
11124
  const path = join4(directory, storeFileName);
11120
11125
  const runnersDir = join4(directory, runnersDirName);
11121
- const nextPendingPath = (target) => `${target}.${process.pid}.${++pendingWrites}.tmp`;
11122
11126
  const runnerPath = (runnerId) => join4(runnersDir, `${encodeURIComponent(runnerId)}.json`);
11123
- const writeAtomically = async (target, contents) => {
11124
- const pendingPath = nextPendingPath(target);
11125
- await options.fs.writeFile(pendingPath, `${JSON.stringify(contents, undefined, 2)}
11126
- `);
11127
- await options.fs.rename(pendingPath, target);
11128
- };
11127
+ const writeAtomically = (target, contents) => writeJsonFileAtomically(options.fs, target, contents);
11129
11128
  const readDefaultRunnerId = async () => {
11130
- const contents = await options.fs.readFile(path).catch(() => {
11131
- return;
11132
- });
11133
- if (contents === undefined)
11134
- return;
11135
- const parsed = storeSchema.safeParse(parseJson2(contents));
11136
- return parsed.success ? parsed.data.defaultRunnerId : undefined;
11129
+ const stored = await readJsonFile(options.fs, path, storeSchema);
11130
+ return stored?.defaultRunnerId;
11137
11131
  };
11138
11132
  const writeDefaultRunnerId = async (runnerId) => {
11139
11133
  await options.fs.mkdir(directory, { recursive: true });
@@ -11155,15 +11149,7 @@ function makeRunnerStore(options) {
11155
11149
  },
11156
11150
  readDefaultRunnerId,
11157
11151
  async readRunners() {
11158
- const runners = await Promise.all((await readRunnerFileNames()).map(async (name) => {
11159
- const contents = await options.fs.readFile(join4(runnersDir, name)).catch(() => {
11160
- return;
11161
- });
11162
- if (contents === undefined)
11163
- return;
11164
- const parsed = storedRunnerSchema.safeParse(parseJson2(contents));
11165
- return parsed.success ? parsed.data : undefined;
11166
- }));
11152
+ const runners = await Promise.all((await readRunnerFileNames()).map((name) => readJsonFile(options.fs, join4(runnersDir, name), storedRunnerSchema)));
11167
11153
  return runners.filter((runner) => !!runner).sort((left, right) => left.id.localeCompare(right.id));
11168
11154
  },
11169
11155
  async rememberLaunch(runner) {
@@ -11181,7 +11167,7 @@ function makeRunnerStore(options) {
11181
11167
  });
11182
11168
  if (contents === undefined)
11183
11169
  return;
11184
- if (storedRunnerSchema.safeParse(parseJson2(contents)).success)
11170
+ if (storedRunnerSchema.safeParse(parseJson(contents)).success)
11185
11171
  return;
11186
11172
  await options.fs.rm(runnerFile, { force: true });
11187
11173
  }));
@@ -12800,7 +12786,8 @@ var environmentListItemSchema = object({
12800
12786
  slug: string2()
12801
12787
  });
12802
12788
  var environmentWithVariablesResponseSchema = object({
12803
- environmentVariables: record(string2(), string2())
12789
+ environmentVariables: record(string2(), string2()),
12790
+ teamId: string2()
12804
12791
  });
12805
12792
  var flowListItemSchema = object({
12806
12793
  flowId: string2(),
@@ -13077,7 +13064,10 @@ async function writeTeamStorageAssets(args, deps) {
13077
13064
 
13078
13065
  // src/shell/platform/teamStorageMethods.ts
13079
13066
  function createTeamStorageMethods(trpc, deps, fs, getIdentity) {
13080
- async function list() {
13067
+ async function list(opts) {
13068
+ if (opts?.teamId !== undefined) {
13069
+ return listTeamStorageFiles(trpc, { teamId: opts.teamId }, deps);
13070
+ }
13081
13071
  if (deps.workspaceId !== undefined) {
13082
13072
  return listTeamStorageFiles(trpc, { teamId: deps.workspaceId }, deps);
13083
13073
  }
@@ -13087,7 +13077,7 @@ function createTeamStorageMethods(trpc, deps, fs, getIdentity) {
13087
13077
  if (!("team" in identity.value)) {
13088
13078
  return {
13089
13079
  ok: false,
13090
- error: flowsMessages.pull.teamStorageRequiresTeamKey
13080
+ error: flowsMessages.pull.teamStorageRequiresTeam
13091
13081
  };
13092
13082
  }
13093
13083
  return listTeamStorageFiles(trpc, { teamId: identity.value.team.id }, deps);
@@ -13095,7 +13085,7 @@ function createTeamStorageMethods(trpc, deps, fs, getIdentity) {
13095
13085
  return {
13096
13086
  listTeamStorageFiles: list,
13097
13087
  async syncTeamStorageAssets(assetsAbs, opts) {
13098
- const files = await list();
13088
+ const files = await list({ teamId: opts?.teamId });
13099
13089
  if (!files.ok)
13100
13090
  return files;
13101
13091
  return downloadTeamStorageAssets({ assetsAbs, files: files.value }, { fetch: deps.fetch, fs, onProgress: opts?.onProgress });
@@ -13125,16 +13115,14 @@ function createPlatformClient(apiKey, deps) {
13125
13115
  ...createTeamStorageMethods(trpc, deps, fs, identityMethods.getIdentity),
13126
13116
  getFlowsBundleUrl: getFlowsBundleUrlImpl,
13127
13117
  callPublicApi: makeCallPublicApiMethod(trpc, deps, requestBackoffMs2),
13128
- async getEnvVars(envId) {
13118
+ async getEnvironmentWithVariables(envId) {
13129
13119
  const result = await requestWithRetry({
13130
13120
  call: () => trpc.query("environment.getEnvironmentWithVariables", { id: envId }, environmentWithVariablesResponseSchema),
13131
13121
  backoffMs: requestBackoffMs2,
13132
13122
  describe: (err) => describeRequestError(err, deps.baseUrl, "env-vars"),
13133
13123
  sleep: deps.sleep
13134
13124
  });
13135
- if (!result.ok)
13136
- return result;
13137
- return { ok: true, value: result.value.environmentVariables };
13125
+ return result;
13138
13126
  },
13139
13127
  async downloadBundle(envId) {
13140
13128
  const urlResult = await getFlowsBundleUrlImpl(envId);
@@ -13863,4 +13851,4 @@ export {
13863
13851
  createRunnerSdk
13864
13852
  };
13865
13853
 
13866
- //# debugId=A460E98091C1C6D964756E2164756E21
13854
+ //# debugId=395E9F8ECC554DB664756E2164756E21
@@ -124,6 +124,8 @@ export type ListedRunner = {
124
124
  isDefault: boolean;
125
125
  launchedHere: boolean;
126
126
  runnerName: string;
127
+ /** The QA Wolf page for this runner, showing its screen once it has one. */
128
+ url: string;
127
129
  };
128
130
  export type KeptAlive = {
129
131
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qawolf/cli",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
5
5
  "keywords": [
6
6
  "automation",
@@ -75,7 +75,7 @@
75
75
  "@qawolf/emails": "1.1.1",
76
76
  "@qawolf/flow-targets": "1.0.0",
77
77
  "@qawolf/flows": "0.1.4",
78
- "@qawolf/testkit": "1.1.1",
78
+ "@qawolf/testkit": "1.2.1",
79
79
  "commander": "14.0.3",
80
80
  "env-paths": "4.0.0",
81
81
  "picomatch": "4.0.4",
@@ -125,8 +125,8 @@ that `url`; never guess a route and never send a repository link in its place.
125
125
  <!-- prettier-ignore -->
126
126
  | Command | Kind | What it does |
127
127
  | --- | --- | --- |
128
- | `qawolf agent get` | read | Monitor a QA Wolf AI session by reading its status and replies. After agent.send, share the returned session URL before monitoring. Wait 30 to 60 seconds between checks; do not call this in a tight loop. Pass the nextCursor from one response as the cursor on the next check; it then reads only what is new, and only for the session that minted it. Continue monitoring silently when the status is unchanged and no replies come back; do not narrate waiting, announce the next check, or ask whether to keep monitoring. Report only substantive new progress, questions, blockers, or the final outcome. A status of "waiting-for-you" means the last reply is a question the work is blocked on, and answering it with agent.send is what unblocks it. Surface an explicit request for user input even if the status still says "working". Include the session URL when reporting a blocker or final outcome. On "completed", stop status checks and verify the requested result before claiming success. For new flows, validation, publication in the target environment, and readiness are separate checks; a Git push or final reply does not prove the flow is active. If every requested result is verified but status remains "working", report the mismatch and stop monitoring. Stop on "failed" or "cancelled" and report any confirmed partial result. |
129
- | `qawolf agent send` | write | Start or continue work with the QA Wolf AI and return a live session URL to share with the user. Use it to cover a user journey, investigate a failing run, or fix a broken flow. This is the one verb that starts work from nothing: every other write acts on a flow, run or issue that already exists. Returns sessionId, status, and url as soon as the request is accepted; work can take minutes to tens of minutes. After each send, make the next action a normal user-visible assistant message containing the exact returned url, before any tool call or wait. Tool output and internal reasoning do not count as sharing the link. Do not run a timer or monitoring call alongside this send. Acceptance does not mean the work is complete. Then monitor the session with agent.get, reporting new progress, blockers, and the final outcome rather than unchanged status. Send here again to answer a question or add context to the same session. |
128
+ | `qawolf agent get` | read | Read what the QA Wolf AI has said and whether it is still working |
129
+ | `qawolf agent send` | write | Ask the QA Wolf AI to do a piece of work, such as covering a journey or fixing a broken flow |
130
130
  | `qawolf auth login` | local | Authenticate with QA Wolf in a browser or with an API key |
131
131
  | `qawolf auth logout` | local | Remove stored credentials |
132
132
  | `qawolf auth switch` | local | Choose which workspace to work in |
@@ -209,6 +209,60 @@ changes team state; `local` only affects this machine. A parenthesized
209
209
  note like `local (read with --remote)` means that flag makes the command
210
210
  call the QA Wolf API and require auth.
211
211
 
212
+ ## Asking QA Wolf to do the work: the `agent` group
213
+
214
+ `qawolf agent send "<what you want>"` is the one verb that starts work from
215
+ nothing. Every other write acts on a flow, run or issue that already exists.
216
+ Name the journey, the part of the app it covers, and anything the AI cannot
217
+ discover for itself, such as a test account, a feature flag, or how to reach a
218
+ staging environment. For a long message, put it in a file and pass
219
+ `"$(cat prompt.md)"`, so shell quoting cannot split it into arguments.
220
+
221
+ `--environment-id <id-or-alias>` picks the environment and reads
222
+ `QAWOLF_ENVIRONMENT`. A credential that is not bound to one workspace, such as
223
+ an organization or user API key, needs `--workspace-id`.
224
+
225
+ To give the AI a file, such as a spreadsheet of journeys, upload it first with
226
+ `qawolf file requestUpload --file-name <name>`, PUT the bytes to the returned
227
+ URL with the returned content type, then pass the returned path as
228
+ `--file-paths <path>` on the send. The AI reads it from storage, so a plan of
229
+ hundreds of journeys costs nothing to send. Up to 20 paths per send.
230
+
231
+ The work runs for minutes to tens of minutes. **Pass `--follow` and do not poll.**
232
+ The CLI reads the session for you, prints each reply once as it arrives, and
233
+ exits when the session settles: 0 when the work is complete, non-zero when it
234
+ failed or was cancelled. Looping `qawolf agent get` yourself costs a round trip
235
+ per tick and shows you replies you have already seen.
236
+
237
+ A session can stop and ask a question. With `--follow` in `--agent` or `--json`
238
+ mode the CLI prints the question and **exits 0** — a session that asked something
239
+ has handed the work back, it has not failed. Answer it, then pick the session
240
+ back up:
241
+
242
+ ```bash
243
+ qawolf agent send "<your answer>" --session <sessionId>
244
+ qawolf agent get --follow
245
+ ```
246
+
247
+ In `--agent` or `--json` mode a follow ends with one JSON line holding the whole
248
+ session: `sessionId`, `status`, `url` and `replies`. It is the same object a
249
+ plain `qawolf agent get` answers with. Read the final `status` from that line;
250
+ the exit code alone does not tell a blocked session from a completed one.
251
+
252
+ `agent send` remembers the session it started, so a later `agent get --follow`
253
+ in the same directory needs no id. `--session <id>` or `QAWOLF_SESSION_ID`
254
+ override that.
255
+
256
+ Every session has a `url`, which the CLI prints when it starts or attaches.
257
+ A session opens in whichever workspace the credential is pointed at, which is not
258
+ always the one the user has open in the app, so send that `url` when you report
259
+ on a session rather than describing where it went.
260
+
261
+ Expect quiet stretches. QA Wolf reports a session as working and says nothing
262
+ more until the AI speaks, so several minutes with no output is the session
263
+ working, not the command hanging. `--timeout` bounds the wait; it is 30 minutes
264
+ by default.
265
+
212
266
  ## Driving a browser: the `runner` group
213
267
 
214
268
  The `runner` commands drive a live cloud browser: `launch` one, `screenshot` to
@@ -13,6 +13,8 @@ of starting and billing a second one, and the answer says which happened: read
13
13
  cheap and safe pattern, and the same id with a different `--name` is refused
14
14
  rather than silently ignored.
15
15
 
16
+ Either answer carries a `url`, which `qawolf runner launch` prints, as does a command that launched its own runner. It is a QA Wolf page showing what the runner is doing, where a person can also take over with their own mouse and keyboard. Hand it to a person who asks what your runner is up to. The page opens for anyone on the runner's team, however the runner was launched. There is nothing to see until the runner's first run starts its screen, so the page waits until then. You read the screen with `screenshot`, not with the page.
17
+
16
18
  Commands that target a runner find one in this order: `--runner`, then
17
19
  `QAWOLF_RUNNER_ID`, then the runner stored for the current directory (which
18
20
  `qawolf runner launch` sets). Setting the environment variable once is the most
@@ -83,6 +85,12 @@ directory did not launch it, so a harness handed a runner sees it alongside the
83
85
  ones it started itself. Use the `id` column with `--runner` to address any of
84
86
  them; addressing one does not make it the default.
85
87
 
88
+ The table leaves out the page address, which beside a 63-character id outgrows a terminal. `--json` carries it as `url` on every runner:
89
+
90
+ ```sh
91
+ qawolf runner list --json | jq -r '.[] | [.id, .url] | @tsv'
92
+ ```
93
+
86
94
  ## The order that matters
87
95
 
88
96
  A freshly launched runner has no screen. The virtual desktop starts with the
@@ -586,7 +594,7 @@ the screen, so it is not optional even though the goal here is to drive by hand.
586
594
  export QAWOLF_API_KEY=... # the only credential
587
595
  export QAWOLF_RUNNER_ID=agent-1 # so no command below needs --runner
588
596
 
589
- qawolf runner launch --id agent-1 --json # --id, not the variable; read .alreadyRunning
597
+ qawolf runner launch --id agent-1 --json # --id, not the variable; read .alreadyRunning and .url
590
598
  qawolf runner run flows/smoke.flow.ts --follow # starts the screen; exit 1 if it failed
591
599
 
592
600
  qawolf runner act navigate --url https://example.com/login --screenshot step-1.jpg # then read step-1.jpg yourself