@qawolf/cli 1.15.0 → 1.17.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
@@ -326098,11 +326098,13 @@ var interactMessages = {
326098
326098
  actionFlagsWithStdin: '"-" reads the whole action from stdin, so an action flag passed alongside it would be ignored. Pipe the action in on its own, or name the action type and use flags.',
326099
326099
  actionMayHaveHappened: "The runner could not be reached, which does not mean the action was not performed: it may have stopped answering mid-action. Take a screenshot before repeating it.",
326100
326100
  actionNotJson: `Stdin did not hold a JSON action. Pipe one object, for example '{"type":"click","button":"left","x":480,"y":260}'.`,
326101
+ actionNotSupportedOnMobile: (type) => `A mobile runner has a touchscreen, so it cannot perform ${type} as asked. It taps with a left-button click, swipes with drag, and types into whatever the last tap focused.`,
326101
326102
  actionPerformed: (type) => `Performed ${type}.`,
326102
326103
  actionAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`,
326103
326104
  screenshotAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`,
326104
326105
  runnerHasNoScreen: "This runner does not run a browser on a virtual desktop, so there is nothing about it to see or drive. Retrying will never help: launch a playwright runner instead.",
326105
326106
  runnerHasNoScreenToEvaluate: "The runner could not evaluate the snippet. This covers a runner that is still starting or busy, and also one with no live page to evaluate against: a freshly launched runner has no page until a run opens one, so run a flow on it with qawolf runner run first. If it is not a runner that runs a browser at all, this will never clear. It is not proof the snippet did not run, so do not resubmit one that mutates the page without reading qawolf runner events console first.",
326107
+ inspectNeedsABrowserRunner: "This runner is a mobile device, and element and page HTML are a browser's shapes. Retrying will never help. Launch a playwright runner to inspect one.",
326106
326108
  nothingToInspect: (errorMessage2) => `The runner had nothing to inspect${errorMessage2 === undefined ? "" : `: ${errorMessage2}`}. There is no live page, nothing matched the selector, or no variable has that name; a runner cannot tell those apart. Run a flow on it first, or check the selector or name.`,
326107
326109
  inspectAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`,
326108
326110
  screenNeedsARun: "This runner has not run anything yet, so its screen has never started. Waiting will not clear this and there is nothing to retry: run a flow on it with qawolf runner run, then ask again. Evaluating a snippet does not start a screen.",
@@ -326121,7 +326123,18 @@ var interactMessages = {
326121
326123
  packageInstalled: (name, version) => `Installed ${name}@${version} into the runner's live run.`,
326122
326124
  packageJsonUnreadable: (reason) => `package.json could not be read because ${reason}. Fix it and try again.`,
326123
326125
  installFailed: (name, version, errorMessage2) => `npm could not install ${name}@${version}${errorMessage2 === undefined ? "" : `. ${errorMessage2}`}. Check the name and the version; waiting will not change this.`,
326124
- importAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`
326126
+ importAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`,
326127
+ highlightCleared: "Cleared the runner's highlight. Its next screenshot shows the page undrawn on.",
326128
+ highlightMatched: (matchCount, targetPage) => `Highlighted ${pluralize(matchCount, "element", "elements")}${targetPage === undefined ? "" : ` on ${targetPage}`}. The highlight stays until it is replaced or cleared, so take a screenshot to see it.`,
326129
+ highlightMatchedNothing: (selector) => `The page read "${selector}" as a selector and nothing matched it, so nothing is highlighted. The selector is well formed, so this is the page rather than the syntax: check what is actually on it with qawolf runner inspect page-html.`,
326130
+ highlightSelectorInvalid: (selector) => `The page could not read "${selector}" as a selector, so nothing was highlighted. This is the syntax rather than the page, and retrying will not change it.`,
326131
+ highlightNoAnswer: "The page did not answer, so nothing is highlighted. A highlight runs inside the page, so a page that is gone or part-way through navigating does not answer at all rather than answering slowly. Take a screenshot to see where the runner is, then try again.",
326132
+ runnerCannotHighlightSelectors: "This runner has no browser to draw on, so there is nothing to highlight. Retrying will never help: launch a playwright runner instead.",
326133
+ highlightAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`,
326134
+ snapshotPromoted: (screenshotPath, baselinePath) => `Promoted ${screenshotPath} to the baseline at ${baselinePath}.`,
326135
+ snapshotNotFound: (screenshotPath) => `The run wrote no screenshot at "${screenshotPath}", so nothing was changed. The two paths have to be the ones an image diff reported: read them from the image-diff-artifact entry on qawolf runner events run-events.`,
326136
+ runnerCannotPromoteSnapshots: "This runner stores no screenshots, so it holds no baseline to replace. Retrying will never help.",
326137
+ promoteSnapshotAnsweredUnknown: (failureReason) => `The runner answered "${failureReason}", which this version of the CLI does not know how to report. Upgrade with npm install -g @qawolf/cli.`
326125
326138
  };
326126
326139
 
326127
326140
  // src/core/messages/interactiveRunner/lifecycle.ts
@@ -326136,7 +326149,9 @@ var lifecycleMessages = {
326136
326149
  launched: (id) => `Launched runner ${id}.`,
326137
326150
  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.`,
326138
326151
  noRunnerIdForImport: `${noRunnerId} An install also needs a live run to go into, which a runner gets from qawolf runner run.`,
326152
+ 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.`,
326139
326153
  noRunnerIdForInspect: `${noRunnerId} Inspecting also needs a page, which a runner gets from its first run: run a flow on it with qawolf runner run.`,
326154
+ noRunnerIdForPromoteSnapshot: `${noRunnerId} A baseline is replaced on the runner that produced the screenshot, so it has to be the runner the diff came from.`,
326140
326155
  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.`,
326141
326156
  noRunnerId,
326142
326157
  wasNotRunning: (id) => `Runner ${id} was not running.`,
@@ -326150,6 +326165,8 @@ var lifecycleMessages = {
326150
326165
  var runMessages = {
326151
326166
  envFileUnparseable: (reason) => `The --env-file could not be read. ${reason}`,
326152
326167
  envFileUnreadable: (path) => `No env file at ${path}. Pass a path that exists, or drop --env-file to send no environment.`,
326168
+ envIdBlank: "--env-id was given nothing. Pass the id or alias of a QA Wolf environment, or drop the flag to send no environment.",
326169
+ envIdWithEnvFile: "--env-id and --env-file both give the run its environment, so only one may be passed. Use --env-id for a QA Wolf environment, which QA Wolf reads itself, or --env-file for a dotenv file on this machine.",
326153
326170
  fileNotCollected: (path) => `"${path}" is not one of the files that travel to a runner. It must be inside the current directory and end in .ts, .tsx, .js, .mjs, .cjs or .json.`,
326154
326171
  fileUnreadable: (path, reason) => `${path} is one of the files that travel to a runner, but it could not be read: ${reason}. Move it out of the current directory, or make it readable.`,
326155
326172
  filesUnreadable: (reason) => `One of the files that travel to a runner could not be read: ${reason}.`,
@@ -342183,7 +342200,7 @@ var makeCreateRunContract = (ids) => {
342183
342200
  aiTaskId: ids.aiTask.describe("The AI task whose conversation should receive run status updates.").optional(),
342184
342201
  environmentId: ids.environmentRef,
342185
342202
  environmentVariables: exports_external.record(exports_external.string(), exports_external.string()).optional(),
342186
- ignoreRules: exports_external.boolean().default(false),
342203
+ ignoreRules: exports_external.boolean().describe("Ignore this team's sequencing rules for this run. By default they are honoured: the flows your selection depends on are added to the run, and flows run in the order the rules require. Set this to run exactly what you selected, in any order.").default(false),
342187
342204
  pullRequestNumber: exports_external.number().int().positive().describe("The number of the pull request to associate with the run.").optional(),
342188
342205
  repository: exports_external.string().min(1).describe('The owner/name of the repository, e.g. "acme/web".').optional()
342189
342206
  }).and(makeFlowSelectionSchema(ids));
@@ -342272,8 +342289,8 @@ function refusalForRunEnvironmentVariableName(name) {
342272
342289
  return `${reservedRunEnvironmentVariableName} is reserved by QA Wolf and set for you.`;
342273
342290
  return;
342274
342291
  }
342275
- var maxRunEnvironmentVariables = 100;
342276
- var maxRunEnvironmentValueLength = 8 * 1024;
342292
+ var maxRunEnvironmentVariables = 200;
342293
+ var maxRunEnvironmentValueLength = 16 * 1024;
342277
342294
  var runEnvironmentSchema = exports_external.record(exports_external.string(), exports_external.string().max(maxRunEnvironmentValueLength)).superRefine((environment, refinementContext) => {
342278
342295
  for (const name of Object.keys(environment)) {
342279
342296
  const refusal = refusalForRunEnvironmentVariableName(name);
@@ -342366,18 +342383,167 @@ var makeInspectOnRunnerContract = () => {
342366
342383
  }),
342367
342384
  exports_external.object({
342368
342385
  errorMessage: exports_external.string().optional().describe("What the runner said, when it said anything."),
342369
- failureReason: exports_external.enum(["nothing-to-inspect", "runner-unreachable"]),
342386
+ failureReason: exports_external.enum([
342387
+ "nothing-to-inspect",
342388
+ "runner-is-not-a-browser",
342389
+ "runner-unreachable"
342390
+ ]),
342370
342391
  outcome: exports_external.literal("failure")
342371
342392
  })
342372
342393
  ]);
342373
342394
  return {
342374
- description: "Inspect one thing on an interactive runner: an element's HTML, the page's HTML simplified for a model, or a top-level variable's value as JSON. `nothing-to-inspect` means the runner had nothing to answer with: no live page, no element matching the selector, or no variable under that name. A runner cannot tell those apart, so it reports the one reason and puts whatever it did say in `errorMessage`. " + retryableRunnerUnreachableDescription,
342395
+ description: "Inspect one thing on an interactive runner: an element's HTML, the page's HTML simplified for a model, or a top-level variable's value as JSON. `nothing-to-inspect` means the runner had nothing to answer with: no live page, no element matching the selector, or no variable under that name. A runner cannot tell those apart, so it reports the one reason and puts whatever it did say in `errorMessage`. `runner-is-not-a-browser` on a mobile runner — call `runner.inspectMobile` for its equivalent surface instead. " + retryableRunnerUnreachableDescription,
342375
342396
  input,
342376
342397
  kind: "read",
342377
342398
  name: "runner.inspect",
342378
342399
  output
342379
342400
  };
342380
342401
  };
342402
+ // node_modules/@qawolf/api-contracts/dist/v1/runner/inspectMobileShapes.js
342403
+ var boundsSchema = exports_external.object({
342404
+ bottom: exports_external.number(),
342405
+ left: exports_external.number(),
342406
+ right: exports_external.number(),
342407
+ top: exports_external.number()
342408
+ });
342409
+ var pointSchema = exports_external.object({ x: exports_external.number(), y: exports_external.number() });
342410
+ var selectorSchema = exports_external.object({
342411
+ strategy: exports_external.union([
342412
+ exports_external.object({
342413
+ name: exports_external.literal("xpath"),
342414
+ type: exports_external.enum(["unique", "full-path", "sibling-of", "parent-of"])
342415
+ }),
342416
+ exports_external.object({ name: exports_external.literal("ios-predicate") }),
342417
+ exports_external.object({
342418
+ hostSelector: exports_external.string(),
342419
+ innerSelector: exports_external.string(),
342420
+ name: exports_external.literal("shadow")
342421
+ })
342422
+ ]),
342423
+ value: exports_external.string()
342424
+ });
342425
+ var elementSchema = exports_external.object({
342426
+ attributes: exports_external.record(exports_external.string(), exports_external.string()),
342427
+ bounds: boundsSchema.optional().describe("The element's on-screen rectangle, absent off-screen."),
342428
+ center: pointSchema.optional().describe("Derived from `bounds`; absent wherever `bounds` is."),
342429
+ selectors: selectorSchema.array().describe("Ways to address this element again, most reliable first."),
342430
+ tag: exports_external.string()
342431
+ });
342432
+ var basePageSourceNodeSchema = exports_external.object({
342433
+ attributes: exports_external.object({
342434
+ bounds: boundsSchema.optional(),
342435
+ rest: exports_external.record(exports_external.string(), exports_external.string()).optional()
342436
+ }).optional(),
342437
+ selectors: selectorSchema.array(),
342438
+ tag: exports_external.string()
342439
+ });
342440
+ var pageSourceNodeSchema = basePageSourceNodeSchema.extend({
342441
+ children: exports_external.lazy(() => exports_external.union([pageSourceNodeSchema, exports_external.string()]).array()).optional().describe("A string child is a text node; there is no element to select.")
342442
+ });
342443
+ var orientationSchema = exports_external.enum(["PORTRAIT", "LANDSCAPE"]);
342444
+
342445
+ // node_modules/@qawolf/api-contracts/dist/v1/runner/outcome.js
342446
+ function makeRunnerFailureSchema(reasons) {
342447
+ return exports_external.object({
342448
+ failureReason: exports_external.enum(reasons),
342449
+ outcome: exports_external.literal("failure")
342450
+ });
342451
+ }
342452
+
342453
+ // node_modules/@qawolf/api-contracts/dist/v1/runner/inspectMobile.js
342454
+ var maxElementTextLength = 500;
342455
+ var inspectMobileRequestSchema = exports_external.discriminatedUnion("what", [
342456
+ exports_external.object({
342457
+ what: exports_external.literal("session").describe("The status of the Appium session driving the device: ready and what it is, or why not.")
342458
+ }),
342459
+ exports_external.object({
342460
+ what: exports_external.literal("contexts").describe("The WebView contexts available on the device, and which one is current.")
342461
+ }),
342462
+ exports_external.object({
342463
+ context: exports_external.string().min(1).optional().describe("Read this context instead of whichever is current."),
342464
+ what: exports_external.literal("page").describe("The page source of the device's current context, as a tree.")
342465
+ }),
342466
+ exports_external.discriminatedUnion("by", [
342467
+ exports_external.object({
342468
+ by: exports_external.literal("point"),
342469
+ context: exports_external.string().min(1).optional(),
342470
+ what: exports_external.literal("elements"),
342471
+ x: exports_external.int().min(0).describe("Whole pixels on the device's own screen."),
342472
+ y: exports_external.int().min(0).describe("Whole pixels on the device's own screen.")
342473
+ }),
342474
+ exports_external.object({
342475
+ by: exports_external.literal("text"),
342476
+ context: exports_external.string().min(1).optional(),
342477
+ partial: exports_external.boolean().optional().describe("Match text containing this, rather than exactly this."),
342478
+ text: exports_external.string().min(1).max(maxElementTextLength),
342479
+ what: exports_external.literal("elements")
342480
+ })
342481
+ ]).describe("Elements at a screen point, or elements carrying some text — never both at once.")
342482
+ ]);
342483
+ var inspectMobileAnswerSchema = exports_external.discriminatedUnion("what", [
342484
+ exports_external.object({
342485
+ outcome: exports_external.literal("success"),
342486
+ session: exports_external.discriminatedUnion("type", [
342487
+ exports_external.object({
342488
+ deviceName: exports_external.string().optional(),
342489
+ platformName: exports_external.string(),
342490
+ sessionId: exports_external.string(),
342491
+ type: exports_external.literal("ready")
342492
+ }),
342493
+ exports_external.object({
342494
+ error: exports_external.string(),
342495
+ type: exports_external.literal("unreachable")
342496
+ }),
342497
+ exports_external.object({
342498
+ sessionCount: exports_external.number(),
342499
+ type: exports_external.literal("ambiguous")
342500
+ }),
342501
+ exports_external.object({ type: exports_external.literal("no-session") })
342502
+ ]),
342503
+ what: exports_external.literal("session")
342504
+ }),
342505
+ exports_external.object({
342506
+ contexts: exports_external.string().array(),
342507
+ current: exports_external.string(),
342508
+ outcome: exports_external.literal("success"),
342509
+ what: exports_external.literal("contexts")
342510
+ }),
342511
+ exports_external.object({
342512
+ context: exports_external.string(),
342513
+ orientation: orientationSchema,
342514
+ outcome: exports_external.literal("success"),
342515
+ pageSource: pageSourceNodeSchema,
342516
+ what: exports_external.literal("page")
342517
+ }),
342518
+ exports_external.object({
342519
+ matches: elementSchema.array(),
342520
+ outcome: exports_external.literal("success"),
342521
+ what: exports_external.literal("elements")
342522
+ })
342523
+ ]);
342524
+ var makeInspectMobileOnRunnerContract = () => {
342525
+ const input = exports_external.object({
342526
+ id: runnerIdSchema.describe("Id of the runner to inspect."),
342527
+ request: inspectMobileRequestSchema
342528
+ });
342529
+ const output = exports_external.discriminatedUnion("outcome", [
342530
+ inspectMobileAnswerSchema,
342531
+ makeRunnerFailureSchema([
342532
+ "runner-is-not-mobile",
342533
+ "screen-needs-a-run",
342534
+ "screen-not-ready",
342535
+ runnerUnreachableFailureReason
342536
+ ])
342537
+ ]);
342538
+ return {
342539
+ description: `Inspect one thing on a mobile interactive runner: the Appium session's status, the WebView contexts available, the current context's page source, or the elements at a point or carrying some text. Mobile only — a browser runner answers \`runner-is-not-mobile\`; call \`runner.inspect\` for a browser's equivalent surface instead. \`what: "session"\` always answers with the session's own status rather than \`screen-needs-a-run\`, since that is the question it exists to answer; the other three request kinds need a live session first and answer the same \`screen-needs-a-run\` or \`screen-not-ready\` outcomes \`runner.performAction\` and \`runner.takeScreenshot\` use, instead of reading anything when there is none. \`screen-needs-a-run\` means no Appium session has started on this runner yet — call \`runner.runFlow\` with a flow that opens one, then inspect again. \`screen-not-ready\` means the runner's Appium session exists but did not answer this instant, or more than one is somehow live — retry once; if it persists, relaunch the runner. \`runner-is-not-mobile\` means there is nothing here to inspect, and retrying will never help — launch an \`android\` or \`ios\` runner instead. ${retryableRunnerUnreachableDescription}`,
342540
+ input,
342541
+ kind: "read",
342542
+ name: "runner.inspectMobile",
342543
+ output
342544
+ };
342545
+ };
342546
+
342381
342547
  // node_modules/@qawolf/api-contracts/dist/v1/runner/journal.js
342382
342548
  var journalEntrySchema = exports_external.object({
342383
342549
  payload: exports_external.unknown(),
@@ -342434,14 +342600,6 @@ var runFilesSchema = exports_external.record(exports_external.string(), exports_
342434
342600
  }).refine((files) => runFilesByteLength(files) <= maxRunFilesByteLength, {
342435
342601
  error: `The files in one request may carry at most ${maxRunFilesByteLength} bytes in total.`
342436
342602
  });
342437
- // node_modules/@qawolf/api-contracts/dist/v1/runner/outcome.js
342438
- function makeRunnerFailureSchema(reasons) {
342439
- return exports_external.object({
342440
- failureReason: exports_external.enum(reasons),
342441
- outcome: exports_external.literal("failure")
342442
- });
342443
- }
342444
-
342445
342603
  // node_modules/@qawolf/api-contracts/dist/v1/runner/screen.js
342446
342604
  var screenFailureReasons = [
342447
342605
  "runner-has-no-screen",
@@ -342454,6 +342612,7 @@ var runnerHasNoScreenDescription = "`runner-has-no-screen` if this runner is not
342454
342612
 
342455
342613
  // node_modules/@qawolf/api-contracts/dist/v1/runner/performAction.js
342456
342614
  var unreachableDescription = "`runner-unreachable` if the runner could not be reached: it may still be starting, it may have terminated after inactivity, or it may have stopped answering mid-action. This does not mean the action was not performed — take a screenshot before repeating it.";
342615
+ var notSupportedOnMobileDescription = "`action-not-supported-on-mobile` if the runner is a mobile device and this action has no touchscreen equivalent: `double_click`, `scroll`, `move`, `keypress` and `navigate`, and a `click` whose `button` is not `left`, are all pointer-device concepts a touchscreen has nothing to offer for. `click` taps, `drag` swipes between its path's first and last point, and `type` types into whatever the last tap focused.";
342457
342616
  var maxActionErrorMessageLength = 1000;
342458
342617
  var makePerformActionOnRunnerContract = () => {
342459
342618
  const input = exports_external.object({
@@ -342468,6 +342627,7 @@ var makePerformActionOnRunnerContract = () => {
342468
342627
  }),
342469
342628
  makeRunnerFailureSchema([
342470
342629
  ...screenFailureReasons,
342630
+ "action-not-supported-on-mobile",
342471
342631
  runnerUnreachableFailureReason
342472
342632
  ])
342473
342633
  ]);
@@ -342476,7 +342636,7 @@ var makePerformActionOnRunnerContract = () => {
342476
342636
  failure
342477
342637
  ]);
342478
342638
  return {
342479
- description: `Perform one raw browser action on an interactive runner: click, double_click, move, drag, scroll, keypress, type, or navigate. Coordinates are whole pixels on the runner's virtual desktop, in the same space as \`runner.takeScreenshot\`. One action per request, and the runner serves one at a time. The action shapes follow the computer-use vocabulary, minus \`screenshot\` (use \`runner.takeScreenshot\`) and \`wait\` (delay on the caller's side). A success means the action took effect. \`action-failed\`, with a reason, if it reached the runner and did not take effect. On a runner image with a browser, the first action on a runner that has never run anything starts its browser and waits for it, so it can take up to a minute to answer — no \`runner.runFlow\` is needed before acting; if the browser is still starting when the wait runs out, the answer is \`screen-not-ready\` and retrying converges. \`screen-needs-a-run\` if the browser could not be started that way — usually a runner whose runs all finished without starting its desktop; call \`runner.runFlow\` with a flow that opens a browser. ${screenNotReadyDescription} A \`navigate\` does not go through the screen, so a screen that is not ready does not stop it. ${runnerHasNoScreenDescription} ${unreachableDescription}`,
342639
+ description: `Perform one raw browser action on an interactive runner: click, double_click, move, drag, scroll, keypress, type, or navigate. Coordinates are whole pixels on the runner's virtual desktop, in the same space as \`runner.takeScreenshot\`. One action per request, and the runner serves one at a time. The action shapes follow the computer-use vocabulary, minus \`screenshot\` (use \`runner.takeScreenshot\`) and \`wait\` (delay on the caller's side). A success means the action took effect. \`action-failed\`, with a reason, if it reached the runner and did not take effect. On a runner image with a browser, the first action on a runner that has never run anything starts its browser and waits for it, so it can take up to a minute to answer — no \`runner.runFlow\` is needed before acting; if the browser is still starting when the wait runs out, the answer is \`screen-not-ready\` and retrying converges. On a mobile runner, the same \`screen-needs-a-run\` and \`screen-not-ready\` outcomes mean no Appium session has started yet, or it did not answer this instant; \`runner.runFlow\` is what starts one, same as a browser. \`screen-needs-a-run\` if the browser could not be started that way — usually a runner whose runs all finished without starting its desktop; call \`runner.runFlow\` with a flow that opens a browser. ${screenNotReadyDescription} A \`navigate\` does not go through the screen, so a screen that is not ready does not stop it. ${runnerHasNoScreenDescription} ${notSupportedOnMobileDescription} ${unreachableDescription}`,
342480
342640
  input,
342481
342641
  kind: "write",
342482
342642
  name: "runner.performAction",
@@ -343082,6 +343242,43 @@ var makeEvaluateSnippetOnRunnerContract = () => {
343082
343242
  };
343083
343243
  };
343084
343244
 
343245
+ // node_modules/@qawolf/api-contracts/dist/v1/runner/highlightSelector.js
343246
+ var maxHighlightSelectorLength = 2000;
343247
+ var makeHighlightSelectorOnRunnerContract = () => {
343248
+ const input = exports_external.object({
343249
+ id: runnerIdSchema.describe("Id of the runner to highlight on."),
343250
+ selector: exports_external.string().max(maxHighlightSelectorLength).describe("The selector to highlight. An empty string clears whatever is highlighted.")
343251
+ });
343252
+ const output = exports_external.discriminatedUnion("outcome", [
343253
+ exports_external.object({
343254
+ matchCount: exports_external.number().int().gte(0).describe("How many elements the selector matched."),
343255
+ outcome: exports_external.literal("success"),
343256
+ selector: exports_external.string().describe("The selector the page matched on."),
343257
+ status: exports_external.literal(["valid", "invalid", "empty"]).describe("`valid` when the selector parsed and matched, `empty` when it parsed and matched nothing, `invalid` when the page could not read it as a selector."),
343258
+ targetPage: exports_external.string().optional().describe("Present when the match was on a page other than the default one.")
343259
+ }),
343260
+ exports_external.object({
343261
+ outcome: exports_external.literal("cleared")
343262
+ }),
343263
+ exports_external.object({
343264
+ errorMessage: exports_external.string().optional(),
343265
+ failureReason: exports_external.enum([
343266
+ "no-answer",
343267
+ "runner-cannot-highlight-selectors",
343268
+ "runner-unreachable"
343269
+ ]),
343270
+ outcome: exports_external.literal("failure")
343271
+ })
343272
+ ]);
343273
+ return {
343274
+ description: "Highlight the elements a selector matches on an interactive runner's live page, and answer how many it matched. The highlight stays until it is replaced or cleared, so it is visible in the next `runner.takeScreenshot` — which is the point, since a caller cannot see the runner's screen otherwise. Send an empty selector to clear, which answers `cleared`. `status` tells a selector that matched nothing (`empty`) from one the page could not parse (`invalid`), so a caller can tell a bad locator from a locator pointing at nothing. `no-answer` if the page did not answer in time: the highlight runs inside the page, so a page that is gone or mid-navigation does not answer slowly, it does not answer at all. `runner-cannot-highlight-selectors` on a runner with no browser to draw on. `runner-unreachable` if the runner could not be reached: it may still be starting, or it may have terminated after inactivity.",
343275
+ input,
343276
+ kind: "write",
343277
+ name: "runner.highlightSelector",
343278
+ output
343279
+ };
343280
+ };
343281
+
343085
343282
  // node_modules/@qawolf/api-contracts/dist/v1/runner/importPackage.js
343086
343283
  var maxPackageNameLength = 214;
343087
343284
  var maxPackageVersionLength = 256;
@@ -343145,6 +343342,34 @@ var makeTerminateRunnerContract = () => {
343145
343342
  };
343146
343343
  };
343147
343344
 
343345
+ // node_modules/@qawolf/api-contracts/dist/v1/runner/promoteSnapshot.js
343346
+ var maxSnapshotPathLength = 1024;
343347
+ var makePromoteSnapshotOnRunnerContract = () => {
343348
+ const input = exports_external.object({
343349
+ baselinePath: exports_external.string().min(1).max(maxSnapshotPathLength).describe("The baseline to replace, as named in the image diff."),
343350
+ id: runnerIdSchema.describe("Id of the runner holding the screenshot."),
343351
+ screenshotPath: exports_external.string().min(1).max(maxSnapshotPathLength).describe("The screenshot to promote, as named in the image diff.")
343352
+ });
343353
+ const output = exports_external.discriminatedUnion("outcome", [
343354
+ exports_external.object({ outcome: exports_external.literal("success") }),
343355
+ exports_external.object({
343356
+ failureReason: exports_external.enum([
343357
+ "runner-cannot-promote-snapshots",
343358
+ "runner-unreachable",
343359
+ "snapshot-not-found"
343360
+ ]),
343361
+ outcome: exports_external.literal("failure")
343362
+ })
343363
+ ]);
343364
+ return {
343365
+ description: "Accept a run's screenshot as the new baseline for an image diff, on the runner that produced it. The two paths are the ones the diff reported, which reach a caller as an `image-diff-artifact` entry on the runner's `run-events` journal stream. `snapshot-not-found` if the run wrote no screenshot at that path, which usually means the paths were not taken from a diff this run produced; nothing is changed, so correcting the path and repeating is safe. `runner-cannot-promote-snapshots` on a runner that stores no screenshots. `runner-unreachable` if the runner could not be reached: it may still be starting, or it may have terminated after inactivity. A promotion that lands is not undone by promoting again, so a retry after an unreachable answer is safe.",
343366
+ input,
343367
+ kind: "write",
343368
+ name: "runner.promoteSnapshot",
343369
+ output
343370
+ };
343371
+ };
343372
+
343148
343373
  // node_modules/@qawolf/api-contracts/dist/v1/runner/readJournal.js
343149
343374
  var makeReadRunnerJournalContract = () => {
343150
343375
  const input = readJournalRequestSchema.extend({
@@ -343176,14 +343401,18 @@ var unchangedFilesSchema = exports_external.record(exports_external.string(), ex
343176
343401
  });
343177
343402
  }
343178
343403
  });
343179
- var makeRunFlowOnRunnerContract = () => {
343404
+ var makeRunFlowOnRunnerContract = (ids) => {
343180
343405
  const input = exports_external.object({
343181
343406
  entryPointPath: runFilePathSchema.describe("Path of the flow file to run, as it appears in `files`."),
343182
343407
  env: runEnvironmentSchema.optional().describe("Environment variables to make available to the run."),
343408
+ environmentId: ids.environmentRef.optional().describe("A QA Wolf environment whose variables the run is given, by id or alias. QA Wolf reads and decrypts them itself, so they never travel in the request and the count and length limits on `env` do not apply to them. Send this or `env`, not both."),
343183
343409
  files: runFilesSchema.describe("Every file the run needs keyed by its path — the flow file, everything it imports, package.json and tsconfig.json. A runner holds no copy of your project, so what runs is exactly what is sent here."),
343184
343410
  id: runnerIdSchema.describe("Id of the runner to run the flow on."),
343185
343411
  selection: runSelectionSchema.optional().describe("Run only these lines, inside the page the runner is already on, instead of running the whole flow from a fresh browser. Omit to run the whole entry point."),
343186
343412
  unchangedFiles: unchangedFilesSchema.optional().describe("Files this runner already holds from an earlier run, keyed by path with a hex SHA-256 of the content it should hold. Send this to make `files` only what changed. The runner refuses with `needs-full-sync` if it holds none of a referenced path, so it never runs something other than what was asked for. Omit it to send every file.")
343413
+ }).refine((request) => request.env === undefined || request.environmentId === undefined, {
343414
+ error: "Send env or environmentId, not both. An environment's variables are taken as they are stored, so there is no order in which the two would combine.",
343415
+ path: ["environmentId"]
343187
343416
  }).refine((request) => Object.hasOwn(request.files, request.entryPointPath), {
343188
343417
  error: "The entry point must be one of the files.",
343189
343418
  path: ["files"]
@@ -343268,7 +343497,7 @@ var makeTakeScreenshotOnRunnerContract = () => {
343268
343497
  ])
343269
343498
  ]);
343270
343499
  return {
343271
- description: `Take one screenshot of an interactive runner's screen. The image is the runner's whole virtual desktop, browser window and all. ${screenNeedsARunDescription} ${screenNotReadyDescription} ${runnerHasNoScreenDescription} ${retryableRunnerUnreachableDescription}`,
343500
+ description: `Take one screenshot of an interactive runner's screen. On a runner with a browser the image is the whole virtual desktop, browser window and all. On a mobile runner it is the device's own screen, re-encoded to JPEG on the pod so this contract reads one image format regardless of runner family. ${screenNeedsARunDescription} ${screenNotReadyDescription} ${runnerHasNoScreenDescription} ${retryableRunnerUnreachableDescription}`,
343272
343501
  input,
343273
343502
  kind: "read",
343274
343503
  name: "runner.takeScreenshot",
@@ -343334,12 +343563,15 @@ var makeContractsV1 = (ids) => {
343334
343563
  },
343335
343564
  runner: {
343336
343565
  evaluateSnippet: makeEvaluateSnippetOnRunnerContract(),
343566
+ highlightSelector: makeHighlightSelectorOnRunnerContract(),
343337
343567
  importPackage: makeImportPackageOnRunnerContract(),
343338
343568
  inspect: makeInspectOnRunnerContract(),
343569
+ inspectMobile: makeInspectMobileOnRunnerContract(),
343339
343570
  launch: makeLaunchRunnerContract(),
343340
343571
  performAction: makePerformActionOnRunnerContract(),
343572
+ promoteSnapshot: makePromoteSnapshotOnRunnerContract(),
343341
343573
  readJournal: makeReadRunnerJournalContract(),
343342
- runFlow: makeRunFlowOnRunnerContract(),
343574
+ runFlow: makeRunFlowOnRunnerContract(resolvedIds),
343343
343575
  stopRun: makeStopRunOnRunnerContract(),
343344
343576
  takeScreenshot: makeTakeScreenshotOnRunnerContract(),
343345
343577
  terminate: makeTerminateRunnerContract()
@@ -345866,7 +346098,7 @@ function startUpdateCheck(deps) {
345866
346098
  // package.json
345867
346099
  var package_default = {
345868
346100
  name: "@qawolf/cli",
345869
- version: "1.15.0",
346101
+ version: "1.17.0",
345870
346102
  description: "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
345871
346103
  keywords: [
345872
346104
  "automation",
@@ -345926,7 +346158,7 @@ var package_default = {
345926
346158
  "@clack/prompts": "1.5.1",
345927
346159
  "@napi-rs/keyring": "1.3.0",
345928
346160
  "@oxc-node/core": "0.1.0",
345929
- "@qawolf/api-contracts": "0.30.0",
346161
+ "@qawolf/api-contracts": "0.34.0",
345930
346162
  "@qawolf/emails": "1.1.1",
345931
346163
  "@qawolf/flow-targets": "1.0.0",
345932
346164
  "@qawolf/flows": "0.1.4",
@@ -356360,10 +356592,13 @@ async function handlePublicApiCommand(ctx, spec, options) {
356360
356592
  var skippedContractNames = new Set([
356361
356593
  "flow.list",
356362
356594
  "runner.evaluateSnippet",
356595
+ "runner.highlightSelector",
356363
356596
  "runner.importPackage",
356364
356597
  "runner.inspect",
356598
+ "runner.inspectMobile",
356365
356599
  "runner.launch",
356366
356600
  "runner.performAction",
356601
+ "runner.promoteSnapshot",
356367
356602
  "runner.readJournal",
356368
356603
  "runner.runFlow",
356369
356604
  "runner.stopRun",
@@ -356422,47 +356657,8 @@ function registerPublicApiCommands(program2, signals, options = {}) {
356422
356657
  }
356423
356658
  }
356424
356659
 
356425
- // src/domains/interactiveRunner/importPackage.ts
356426
- import { join as join44 } from "node:path";
356427
-
356428
- // src/core/interactiveRunner/npmDependencies.ts
356429
- var sectionsInPrecedenceOrder = ["dependencies", "devDependencies"];
356430
- var isInstallable = (version2) => !version2.startsWith("workspace:");
356431
- function isRecord5(value) {
356432
- return typeof value === "object" && value !== null && !Array.isArray(value);
356433
- }
356434
- function readNpmDependencies(packageJsonContent) {
356435
- let parsed;
356436
- try {
356437
- parsed = JSON.parse(packageJsonContent);
356438
- } catch {
356439
- return { ok: false, reason: "it is not valid JSON" };
356440
- }
356441
- if (!isRecord5(parsed))
356442
- return { ok: false, reason: "it is not an object" };
356443
- const dependencies = {};
356444
- for (const section of sectionsInPrecedenceOrder) {
356445
- const declared = parsed[section];
356446
- if (declared === undefined)
356447
- continue;
356448
- if (!isRecord5(declared)) {
356449
- return { ok: false, reason: `${section} is not an object` };
356450
- }
356451
- for (const [name, version2] of Object.entries(declared)) {
356452
- if (typeof version2 !== "string") {
356453
- return { ok: false, reason: `${section}.${name} is not a string` };
356454
- }
356455
- if (Object.hasOwn(dependencies, name))
356456
- continue;
356457
- if (isInstallable(version2))
356458
- dependencies[name] = version2;
356459
- }
356460
- }
356461
- return { dependencies, ok: true };
356462
- }
356463
-
356464
356660
  // src/domains/interactiveRunner/runnerCallOptions.ts
356465
- var runnerCallOptions = { timeoutMs: 60000 };
356661
+ var runnerCallOptions = { timeoutMs: 90000 };
356466
356662
 
356467
356663
  // src/domains/interactiveRunner/runnerIds.ts
356468
356664
  function parseRunnerId(id) {
@@ -356575,71 +356771,73 @@ function announceRunner(ctx, resolved) {
356575
356771
  }
356576
356772
  }
356577
356773
 
356578
- // src/domains/interactiveRunner/importPackage.ts
356579
- var defaultPackageVersion = "latest";
356580
- async function handleRunnerImportPackage(ctx, options, deps) {
356581
- const content = await deps.readFile(join44(deps.cwd, runPackageJsonPath)).catch(() => {
356582
- return;
356583
- });
356584
- if (content === undefined) {
356585
- return {
356586
- error: interactiveRunnerMessages.missingPackageJsonForImport,
356587
- exitCode: exitCodes.config
356588
- };
356589
- }
356590
- const dependencies = readNpmDependencies(content);
356591
- if (!dependencies.ok) {
356592
- return {
356593
- error: interactiveRunnerMessages.packageJsonUnreadable(dependencies.reason),
356594
- exitCode: exitCodes.config
356595
- };
356596
- }
356774
+ // src/domains/interactiveRunner/highlightSelector.ts
356775
+ async function handleRunnerHighlightSelector(ctx, options, deps) {
356597
356776
  const resolved = await resolveRunner(ctx, {
356598
356777
  autoLaunch: false,
356599
- noRunnerIdMessage: interactiveRunnerMessages.noRunnerIdForImport,
356778
+ noRunnerIdMessage: interactiveRunnerMessages.noRunnerIdForHighlight,
356600
356779
  runner: options.runner
356601
356780
  }, deps);
356602
356781
  if (resolved.type === "failed") {
356603
356782
  return { ...failureFields(resolved), exitCode: resolved.exitCode };
356604
356783
  }
356605
- const packageVersion = options.version ?? defaultPackageVersion;
356606
- const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.importPackage, {
356607
- id: resolved.runnerId,
356608
- npmDependencies: dependencies.dependencies,
356609
- packageName: options.name,
356610
- packageVersion
356611
- }, runnerCallOptions);
356784
+ const selector = options.selector ?? "";
356785
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.highlightSelector, { id: resolved.runnerId, selector }, runnerCallOptions);
356612
356786
  if (!result.ok) {
356613
356787
  return { ...failureFields(result), exitCode: exitCodes.network };
356614
356788
  }
356615
- if (result.value.outcome === "failure") {
356616
- const failure = result.value;
356617
- const { failureReason } = failure;
356618
- switch (failureReason) {
356619
- case "install-failed":
356789
+ const { outcome } = result.value;
356790
+ switch (outcome) {
356791
+ case "cleared":
356792
+ ctx.ui.output(result.value, interactiveRunnerMessages.highlightCleared);
356793
+ return;
356794
+ case "success": {
356795
+ const answer = result.value;
356796
+ if (answer.status === "invalid") {
356620
356797
  return {
356621
- error: interactiveRunnerMessages.installFailed(options.name, packageVersion, failure.errorMessage),
356798
+ error: interactiveRunnerMessages.highlightSelectorInvalid(answer.selector),
356622
356799
  exitCode: exitCodes.invalidArgs
356623
356800
  };
356624
- case "runner-unreachable":
356625
- return {
356626
- error: interactiveRunnerMessages.runnerUnreachable,
356627
- exitCode: exitCodes.network
356628
- };
356629
- default: {
356630
- return {
356631
- error: interactiveRunnerMessages.importAnsweredUnknown(failureReason),
356632
- exitCode: exitCodes.network
356633
- };
356634
356801
  }
356802
+ ctx.ui.output(answer, answer.status === "empty" ? interactiveRunnerMessages.highlightMatchedNothing(answer.selector) : interactiveRunnerMessages.highlightMatched(answer.matchCount, answer.targetPage));
356803
+ return;
356635
356804
  }
356805
+ case "failure":
356806
+ return describeFailure(result.value.failureReason);
356807
+ default:
356808
+ return {
356809
+ error: interactiveRunnerMessages.highlightAnsweredUnknown(outcome),
356810
+ exitCode: exitCodes.network
356811
+ };
356812
+ }
356813
+ }
356814
+ function describeFailure(failureReason) {
356815
+ switch (failureReason) {
356816
+ case "no-answer":
356817
+ return {
356818
+ error: interactiveRunnerMessages.highlightNoAnswer,
356819
+ exitCode: exitCodes.network
356820
+ };
356821
+ case "runner-cannot-highlight-selectors":
356822
+ return {
356823
+ error: interactiveRunnerMessages.runnerCannotHighlightSelectors,
356824
+ exitCode: exitCodes.invalidArgs
356825
+ };
356826
+ case "runner-unreachable":
356827
+ return {
356828
+ error: interactiveRunnerMessages.runnerUnreachable,
356829
+ exitCode: exitCodes.network
356830
+ };
356831
+ default:
356832
+ return {
356833
+ error: interactiveRunnerMessages.highlightAnsweredUnknown(failureReason),
356834
+ exitCode: exitCodes.network
356835
+ };
356636
356836
  }
356637
- ctx.ui.output(result.value, interactiveRunnerMessages.packageInstalled(options.name, packageVersion));
356638
- return;
356639
356837
  }
356640
356838
 
356641
356839
  // src/shell/interactiveRunner/collectRunFiles.ts
356642
- import { join as join46 } from "node:path";
356840
+ import { join as join45 } from "node:path";
356643
356841
 
356644
356842
  // src/core/interactiveRunner/getImports.ts
356645
356843
  function getImports(options) {
@@ -356678,14 +356876,14 @@ function isPathAlias(importPath, paths) {
356678
356876
  }
356679
356877
 
356680
356878
  // src/core/interactiveRunner/resolveImportPath.ts
356681
- import { dirname as dirname15, join as join45, normalize as normalize3 } from "node:path/posix";
356879
+ import { dirname as dirname15, join as join44, normalize as normalize3 } from "node:path/posix";
356682
356880
 
356683
356881
  // src/core/interactiveRunner/tsconfigPaths.ts
356684
- function isRecord6(value) {
356882
+ function isRecord5(value) {
356685
356883
  return typeof value === "object" && value !== null;
356686
356884
  }
356687
356885
  function isTsconfigPaths(value) {
356688
- return isRecord6(value) && Object.values(value).every((targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string"));
356886
+ return isRecord5(value) && Object.values(value).every((targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string"));
356689
356887
  }
356690
356888
  function parseTsconfigPaths(tsconfigContent) {
356691
356889
  let parsed;
@@ -356694,10 +356892,10 @@ function parseTsconfigPaths(tsconfigContent) {
356694
356892
  } catch {
356695
356893
  return;
356696
356894
  }
356697
- if (!isRecord6(parsed))
356895
+ if (!isRecord5(parsed))
356698
356896
  return;
356699
356897
  const compilerOptions = parsed["compilerOptions"];
356700
- if (!isRecord6(compilerOptions))
356898
+ if (!isRecord5(compilerOptions))
356701
356899
  return;
356702
356900
  const paths = compilerOptions["paths"];
356703
356901
  return isTsconfigPaths(paths) ? paths : undefined;
@@ -356731,7 +356929,7 @@ function candidatesForExplicitExtension(options) {
356731
356929
  }
356732
356930
  function resolveImportPath(options) {
356733
356931
  const aliasTarget = resolvePathAlias(options.importPath, options.tsconfigPaths);
356734
- const resolvedPath = aliasTarget === undefined ? normalize3(join45(dirname15(options.importingFilePath), options.importPath)) : normalize3(aliasTarget);
356932
+ const resolvedPath = aliasTarget === undefined ? normalize3(join44(dirname15(options.importingFilePath), options.importPath)) : normalize3(aliasTarget);
356735
356933
  const hasExplicitExtension = supportedExtensions.some((extension) => options.importPath.endsWith(extension));
356736
356934
  const candidates = hasExplicitExtension ? candidatesForExplicitExtension({
356737
356935
  importPath: options.importPath,
@@ -356755,7 +356953,7 @@ function resolveImportPath(options) {
356755
356953
  var tsconfigPath = "tsconfig.json";
356756
356954
  async function collectRunFiles(options) {
356757
356955
  const held = await listShippablePaths(options);
356758
- const readFile2 = (path9) => options.fs.readFile(join46(options.cwd, path9));
356956
+ const readFile2 = (path9) => options.fs.readFile(join45(options.cwd, path9));
356759
356957
  const tsconfigContent = held.has(tsconfigPath) ? await readFile2(tsconfigPath).catch(() => {
356760
356958
  return;
356761
356959
  }) : undefined;
@@ -356838,7 +357036,7 @@ function makeRunnerId() {
356838
357036
  }
356839
357037
 
356840
357038
  // src/shell/interactiveRunner/runFilesManifest.ts
356841
- import { join as join47 } from "node:path";
357039
+ import { join as join46 } from "node:path";
356842
357040
  var manifestFileName = "runner-files.json";
356843
357041
  var manifestSchema2 = exports_external.object({
356844
357042
  files: exports_external.array(exports_external.object({ contentHash: exports_external.string(), path: exports_external.string() })),
@@ -356846,8 +357044,8 @@ var manifestSchema2 = exports_external.object({
356846
357044
  version: exports_external.literal(1)
356847
357045
  });
356848
357046
  function makeRunFilesManifestStore(options) {
356849
- const directory = join47(options.cwd, qawolfDir);
356850
- const path9 = join47(directory, manifestFileName);
357047
+ const directory = join46(options.cwd, qawolfDir);
357048
+ const path9 = join46(directory, manifestFileName);
356851
357049
  let pendingWrites = 0;
356852
357050
  return {
356853
357051
  async read() {
@@ -356877,7 +357075,7 @@ function parseJson(text3) {
356877
357075
  }
356878
357076
 
356879
357077
  // src/shell/interactiveRunner/runnerStore.ts
356880
- import { join as join48 } from "node:path";
357078
+ import { join as join47 } from "node:path";
356881
357079
  var storeFileName = "runner.json";
356882
357080
  var storeSchema = exports_external.object({ defaultRunnerId: exports_external.string().optional() });
356883
357081
  function parseJson2(text3) {
@@ -356888,8 +357086,8 @@ function parseJson2(text3) {
356888
357086
  }
356889
357087
  }
356890
357088
  function makeRunnerStore(options) {
356891
- const directory = join48(options.cwd, qawolfDir);
356892
- const path9 = join48(directory, storeFileName);
357089
+ const directory = join47(options.cwd, qawolfDir);
357090
+ const path9 = join47(directory, storeFileName);
356893
357091
  let pendingWrites = 0;
356894
357092
  const nextPendingPath = () => `${path9}.${process.pid}.${++pendingWrites}.tmp`;
356895
357093
  return {
@@ -356971,6 +357169,118 @@ function runnerDeps(ctx) {
356971
357169
  });
356972
357170
  }
356973
357171
 
357172
+ // src/commands/runner/highlightSelector.register.ts
357173
+ var highlightExamples = `
357174
+ Examples:
357175
+ $ qawolf runner highlight-selector "text=Sign in"
357176
+ $ qawolf runner highlight-selector "#checkout" && qawolf runner screenshot
357177
+ $ qawolf runner highlight-selector`;
357178
+ function registerRunnerHighlightSelectorCommand(runner, signals) {
357179
+ declareCommandKind(runner.command("highlight-selector [selector]"), "write").description("Highlight what a selector matches on a runner's live page, so the next screenshot shows it. Omit the selector to clear the highlight").option("--runner <id>", runnerFlagDescription).addHelpText("after", highlightExamples).action((selector, opts, command) => withAuthContext(signals, (ctx) => handleRunnerHighlightSelector(ctx, { runner: opts.runner, selector }, runnerDeps(ctx)))(opts, command));
357180
+ }
357181
+
357182
+ // src/domains/interactiveRunner/importPackage.ts
357183
+ import { join as join48 } from "node:path";
357184
+
357185
+ // src/core/interactiveRunner/npmDependencies.ts
357186
+ var sectionsInPrecedenceOrder = ["dependencies", "devDependencies"];
357187
+ var isInstallable = (version2) => !version2.startsWith("workspace:");
357188
+ function isRecord6(value) {
357189
+ return typeof value === "object" && value !== null && !Array.isArray(value);
357190
+ }
357191
+ function readNpmDependencies(packageJsonContent) {
357192
+ let parsed;
357193
+ try {
357194
+ parsed = JSON.parse(packageJsonContent);
357195
+ } catch {
357196
+ return { ok: false, reason: "it is not valid JSON" };
357197
+ }
357198
+ if (!isRecord6(parsed))
357199
+ return { ok: false, reason: "it is not an object" };
357200
+ const dependencies = {};
357201
+ for (const section of sectionsInPrecedenceOrder) {
357202
+ const declared = parsed[section];
357203
+ if (declared === undefined)
357204
+ continue;
357205
+ if (!isRecord6(declared)) {
357206
+ return { ok: false, reason: `${section} is not an object` };
357207
+ }
357208
+ for (const [name, version2] of Object.entries(declared)) {
357209
+ if (typeof version2 !== "string") {
357210
+ return { ok: false, reason: `${section}.${name} is not a string` };
357211
+ }
357212
+ if (Object.hasOwn(dependencies, name))
357213
+ continue;
357214
+ if (isInstallable(version2))
357215
+ dependencies[name] = version2;
357216
+ }
357217
+ }
357218
+ return { dependencies, ok: true };
357219
+ }
357220
+
357221
+ // src/domains/interactiveRunner/importPackage.ts
357222
+ var defaultPackageVersion = "latest";
357223
+ async function handleRunnerImportPackage(ctx, options, deps) {
357224
+ const content = await deps.readFile(join48(deps.cwd, runPackageJsonPath)).catch(() => {
357225
+ return;
357226
+ });
357227
+ if (content === undefined) {
357228
+ return {
357229
+ error: interactiveRunnerMessages.missingPackageJsonForImport,
357230
+ exitCode: exitCodes.config
357231
+ };
357232
+ }
357233
+ const dependencies = readNpmDependencies(content);
357234
+ if (!dependencies.ok) {
357235
+ return {
357236
+ error: interactiveRunnerMessages.packageJsonUnreadable(dependencies.reason),
357237
+ exitCode: exitCodes.config
357238
+ };
357239
+ }
357240
+ const resolved = await resolveRunner(ctx, {
357241
+ autoLaunch: false,
357242
+ noRunnerIdMessage: interactiveRunnerMessages.noRunnerIdForImport,
357243
+ runner: options.runner
357244
+ }, deps);
357245
+ if (resolved.type === "failed") {
357246
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
357247
+ }
357248
+ const packageVersion = options.version ?? defaultPackageVersion;
357249
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.importPackage, {
357250
+ id: resolved.runnerId,
357251
+ npmDependencies: dependencies.dependencies,
357252
+ packageName: options.name,
357253
+ packageVersion
357254
+ }, runnerCallOptions);
357255
+ if (!result.ok) {
357256
+ return { ...failureFields(result), exitCode: exitCodes.network };
357257
+ }
357258
+ if (result.value.outcome === "failure") {
357259
+ const failure = result.value;
357260
+ const { failureReason } = failure;
357261
+ switch (failureReason) {
357262
+ case "install-failed":
357263
+ return {
357264
+ error: interactiveRunnerMessages.installFailed(options.name, packageVersion, failure.errorMessage),
357265
+ exitCode: exitCodes.invalidArgs
357266
+ };
357267
+ case "runner-unreachable":
357268
+ return {
357269
+ error: interactiveRunnerMessages.runnerUnreachable,
357270
+ exitCode: exitCodes.network
357271
+ };
357272
+ default: {
357273
+ return {
357274
+ error: interactiveRunnerMessages.importAnsweredUnknown(failureReason),
357275
+ exitCode: exitCodes.network
357276
+ };
357277
+ }
357278
+ }
357279
+ }
357280
+ ctx.ui.output(result.value, interactiveRunnerMessages.packageInstalled(options.name, packageVersion));
357281
+ return;
357282
+ }
357283
+
356974
357284
  // src/commands/runner/importPackage.register.ts
356975
357285
  var importPackageExamples = `
356976
357286
  Examples:
@@ -357021,6 +357331,11 @@ async function handleRunnerInspect(ctx, options, deps) {
357021
357331
  error: interactiveRunnerMessages.nothingToInspect(failure.errorMessage),
357022
357332
  exitCode: exitCodes.invalidArgs
357023
357333
  };
357334
+ case "runner-is-not-a-browser":
357335
+ return {
357336
+ error: interactiveRunnerMessages.inspectNeedsABrowserRunner,
357337
+ exitCode: exitCodes.invalidArgs
357338
+ };
357024
357339
  case "runner-unreachable":
357025
357340
  return {
357026
357341
  error: interactiveRunnerMessages.runnerUnreachable,
@@ -357238,6 +357553,50 @@ function parseBrowserAction(candidate) {
357238
357553
  return { action: parsed.data, ok: true };
357239
357554
  }
357240
357555
 
357556
+ // src/domains/interactiveRunner/performActionFailure.ts
357557
+ function describePerformActionFailure(options) {
357558
+ const { actionType, failure } = options;
357559
+ const { failureReason } = failure;
357560
+ switch (failureReason) {
357561
+ case "action-failed":
357562
+ return {
357563
+ error: interactiveRunnerMessages.actionFailed(failure.errorMessage),
357564
+ exitCode: exitCodes.testFailure
357565
+ };
357566
+ case "action-not-supported-on-mobile":
357567
+ return {
357568
+ error: interactiveRunnerMessages.actionNotSupportedOnMobile(actionType),
357569
+ exitCode: exitCodes.invalidArgs
357570
+ };
357571
+ case "screen-needs-a-run":
357572
+ return {
357573
+ error: interactiveRunnerMessages.screenNeedsARun,
357574
+ exitCode: exitCodes.invalidArgs
357575
+ };
357576
+ case "screen-not-ready":
357577
+ return {
357578
+ error: interactiveRunnerMessages.screenNotReady,
357579
+ exitCode: exitCodes.network
357580
+ };
357581
+ case "runner-has-no-screen":
357582
+ return {
357583
+ error: interactiveRunnerMessages.runnerHasNoScreen,
357584
+ exitCode: exitCodes.invalidArgs
357585
+ };
357586
+ case "runner-unreachable":
357587
+ return {
357588
+ error: interactiveRunnerMessages.actionMayHaveHappened,
357589
+ exitCode: exitCodes.network
357590
+ };
357591
+ default: {
357592
+ return {
357593
+ error: interactiveRunnerMessages.actionAnsweredUnknown(failureReason),
357594
+ exitCode: exitCodes.network
357595
+ };
357596
+ }
357597
+ }
357598
+ }
357599
+
357241
357600
  // src/domains/interactiveRunner/performAction.ts
357242
357601
  var stdinArgument2 = "-";
357243
357602
  async function readAction(type, flags, deps) {
@@ -357280,41 +357639,10 @@ async function handleRunnerAct(ctx, options, deps) {
357280
357639
  ctx.ui.output({ action: built.action, outcome: "success" }, interactiveRunnerMessages.actionPerformed(built.action.type));
357281
357640
  return;
357282
357641
  }
357283
- const failure = result.value;
357284
- const { failureReason } = failure;
357285
- switch (failureReason) {
357286
- case "action-failed":
357287
- return {
357288
- error: interactiveRunnerMessages.actionFailed(failure.errorMessage),
357289
- exitCode: exitCodes.testFailure
357290
- };
357291
- case "screen-needs-a-run":
357292
- return {
357293
- error: interactiveRunnerMessages.screenNeedsARun,
357294
- exitCode: exitCodes.invalidArgs
357295
- };
357296
- case "screen-not-ready":
357297
- return {
357298
- error: interactiveRunnerMessages.screenNotReady,
357299
- exitCode: exitCodes.network
357300
- };
357301
- case "runner-has-no-screen":
357302
- return {
357303
- error: interactiveRunnerMessages.runnerHasNoScreen,
357304
- exitCode: exitCodes.invalidArgs
357305
- };
357306
- case "runner-unreachable":
357307
- return {
357308
- error: interactiveRunnerMessages.actionMayHaveHappened,
357309
- exitCode: exitCodes.network
357310
- };
357311
- default: {
357312
- return {
357313
- error: interactiveRunnerMessages.actionAnsweredUnknown(failureReason),
357314
- exitCode: exitCodes.network
357315
- };
357316
- }
357317
- }
357642
+ return describePerformActionFailure({
357643
+ actionType: built.action.type,
357644
+ failure: result.value
357645
+ });
357318
357646
  }
357319
357647
 
357320
357648
  // src/domains/interactiveRunner/takeScreenshot.ts
@@ -357528,6 +357856,67 @@ function registerRunnerLifecycleCommands(runner, signals) {
357528
357856
  declareCommandKind(runner.command("keepalive"), "read").description("Reset a runner's inactivity clock, for a caller that pauses between actions").option("--runner <id>", runnerFlagDescription).addHelpText("after", keepaliveExamples).action((opts, command) => withAuthContext(signals, (ctx) => handleRunnerKeepalive(ctx, { runner: opts.runner }, runnerDeps(ctx)))(opts, command));
357529
357857
  }
357530
357858
 
357859
+ // src/domains/interactiveRunner/promoteSnapshot.ts
357860
+ async function handleRunnerPromoteSnapshot(ctx, options, deps) {
357861
+ const resolved = await resolveRunner(ctx, {
357862
+ autoLaunch: false,
357863
+ noRunnerIdMessage: interactiveRunnerMessages.noRunnerIdForPromoteSnapshot,
357864
+ runner: options.runner
357865
+ }, deps);
357866
+ if (resolved.type === "failed") {
357867
+ return { ...failureFields(resolved), exitCode: resolved.exitCode };
357868
+ }
357869
+ const result = await ctx.platformClient.callPublicApi(publicContractsV1.runner.promoteSnapshot, {
357870
+ baselinePath: options.baselinePath,
357871
+ id: resolved.runnerId,
357872
+ screenshotPath: options.screenshotPath
357873
+ }, runnerCallOptions);
357874
+ if (!result.ok) {
357875
+ return { ...failureFields(result), exitCode: exitCodes.network };
357876
+ }
357877
+ if (result.value.outcome === "success") {
357878
+ ctx.ui.output(result.value, interactiveRunnerMessages.snapshotPromoted(options.screenshotPath, options.baselinePath));
357879
+ return;
357880
+ }
357881
+ const { failureReason } = result.value;
357882
+ switch (failureReason) {
357883
+ case "snapshot-not-found":
357884
+ return {
357885
+ error: interactiveRunnerMessages.snapshotNotFound(options.screenshotPath),
357886
+ exitCode: exitCodes.invalidArgs
357887
+ };
357888
+ case "runner-cannot-promote-snapshots":
357889
+ return {
357890
+ error: interactiveRunnerMessages.runnerCannotPromoteSnapshots,
357891
+ exitCode: exitCodes.invalidArgs
357892
+ };
357893
+ case "runner-unreachable":
357894
+ return {
357895
+ error: interactiveRunnerMessages.runnerUnreachable,
357896
+ exitCode: exitCodes.network
357897
+ };
357898
+ default: {
357899
+ return {
357900
+ error: interactiveRunnerMessages.promoteSnapshotAnsweredUnknown(failureReason),
357901
+ exitCode: exitCodes.network
357902
+ };
357903
+ }
357904
+ }
357905
+ }
357906
+
357907
+ // src/commands/runner/promoteSnapshot.register.ts
357908
+ var promoteSnapshotExamples = `
357909
+ Examples:
357910
+ $ qawolf runner promote-snapshot --screenshot checkout-1-actual.png --baseline checkout-1.png
357911
+ $ qawolf runner events run-events --tail 20 | jq 'select(.type == "image-diff-artifact")'`;
357912
+ function registerRunnerPromoteSnapshotCommand(runner, signals) {
357913
+ declareCommandKind(runner.command("promote-snapshot"), "write").description("Accept a run's screenshot as the new baseline for an image diff, on the runner that produced it").requiredOption("--screenshot <path>", "The screenshot to promote, as the image diff named it").requiredOption("--baseline <path>", "The baseline to replace, as the image diff named it").option("--runner <id>", runnerFlagDescription).addHelpText("after", promoteSnapshotExamples).action((opts, command) => withAuthContext(signals, (ctx) => handleRunnerPromoteSnapshot(ctx, {
357914
+ baselinePath: opts.baseline,
357915
+ runner: opts.runner,
357916
+ screenshotPath: opts.screenshot
357917
+ }, runnerDeps(ctx)))(opts, command));
357918
+ }
357919
+
357531
357920
  // src/core/interactiveRunner/followTimeout.ts
357532
357921
  var defaultFollowTimeoutSeconds = 3600;
357533
357922
  var followTimeoutSchema = exports_external.coerce.number().int().positive();
@@ -357916,6 +358305,13 @@ var refused = (error51, exitCode) => ({
357916
358305
  ok: false
357917
358306
  });
357918
358307
  async function prepareRun(options, deps) {
358308
+ const environmentId = options.envId?.trim();
358309
+ if (environmentId !== undefined && options.envFile !== undefined) {
358310
+ return refused(interactiveRunnerMessages.envIdWithEnvFile, exitCodes.invalidArgs);
358311
+ }
358312
+ if (environmentId === "") {
358313
+ return refused(interactiveRunnerMessages.envIdBlank, exitCodes.invalidArgs);
358314
+ }
357919
358315
  const linesFilePath = options.linesFile === undefined ? undefined : toCollectedPath(deps.cwd, options.linesFile);
357920
358316
  const roots = linesFilePath === undefined || linesFilePath === options.entryPointPath ? [options.entryPointPath] : [options.entryPointPath, linesFilePath];
357921
358317
  const collected = await collectRunFiles2(deps, roots);
@@ -357932,14 +358328,26 @@ async function prepareRun(options, deps) {
357932
358328
  }
357933
358329
  const given = environment3?.ok === true ? environment3.environment : undefined;
357934
358330
  if (options.lines === undefined) {
357935
- return options.linesFile === undefined ? { environment: given, files, ok: true, selection: undefined } : refused(interactiveRunnerMessages.linesFileWithoutLines, exitCodes.invalidArgs);
358331
+ return options.linesFile === undefined ? {
358332
+ environment: given,
358333
+ environmentId,
358334
+ files,
358335
+ ok: true,
358336
+ selection: undefined
358337
+ } : refused(interactiveRunnerMessages.linesFileWithoutLines, exitCodes.invalidArgs);
357936
358338
  }
357937
358339
  const path9 = linesFilePath ?? options.entryPointPath;
357938
358340
  if (!Object.hasOwn(files, path9)) {
357939
358341
  return refused(interactiveRunnerMessages.fileNotCollected(path9), exitCodes.invalidArgs);
357940
358342
  }
357941
358343
  const built = buildRunSelection({ lines: options.lines, path: path9 });
357942
- return built.ok ? { environment: given, files, ok: true, selection: built.selection } : refused(built.error, exitCodes.invalidArgs);
358344
+ return built.ok ? {
358345
+ environment: given,
358346
+ environmentId,
358347
+ files,
358348
+ ok: true,
358349
+ selection: built.selection
358350
+ } : refused(built.error, exitCodes.invalidArgs);
357943
358351
  }
357944
358352
  async function readEnvironment(envFile, deps) {
357945
358353
  if (envFile === undefined)
@@ -357969,6 +358377,9 @@ function buildRunFileDelta(options) {
357969
358377
  }
357970
358378
  const heldHashes = new Map(held.files.map((entry) => [entry.path, entry.contentHash]));
357971
358379
  const alwaysSent = new Set([options.entryPointPath, runPackageJsonPath]);
358380
+ if (options.selectionPath !== undefined) {
358381
+ alwaysSent.add(options.selectionPath);
358382
+ }
357972
358383
  const files = {};
357973
358384
  const unchangedFiles = {};
357974
358385
  for (const [path9, content] of Object.entries(options.files)) {
@@ -358017,6 +358428,7 @@ async function sendRunFlowRequest(ctx, options, delta) {
358017
358428
  files: delta.files,
358018
358429
  id: options.resolved.runnerId,
358019
358430
  ...options.environment === undefined ? {} : { env: options.environment },
358431
+ ...options.environmentId === undefined ? {} : { environmentId: options.environmentId },
358020
358432
  ...options.selection === undefined ? {} : { selection: options.selection },
358021
358433
  ...delta.unchangedFiles === undefined ? {} : { unchangedFiles: delta.unchangedFiles }
358022
358434
  }, runnerCallOptions);
@@ -358056,7 +358468,8 @@ async function submitRun(ctx, options, deps) {
358056
358468
  entryPointPath: options.entryPointPath,
358057
358469
  files: options.files,
358058
358470
  held,
358059
- runnerId: options.resolved.runnerId
358471
+ runnerId: options.resolved.runnerId,
358472
+ selectionPath: options.selection?.path
358060
358473
  });
358061
358474
  const first2 = await sendRunFlowRequest(ctx, options, delta);
358062
358475
  if (first2.type === "needs-full-sync") {
@@ -358102,6 +358515,7 @@ async function handleRunnerRun(ctx, options, deps) {
358102
358515
  const prepared = await prepareRun({
358103
358516
  entryPointPath,
358104
358517
  envFile: options.envFile,
358518
+ envId: options.envId,
358105
358519
  lines: options.lines,
358106
358520
  linesFile: options.linesFile
358107
358521
  }, deps);
@@ -358123,6 +358537,7 @@ async function handleRunnerRun(ctx, options, deps) {
358123
358537
  const submitted = await submitRun(ctx, {
358124
358538
  entryPointPath,
358125
358539
  environment: prepared.environment,
358540
+ environmentId: prepared.environmentId,
358126
358541
  files: prepared.files,
358127
358542
  resolved,
358128
358543
  selection: prepared.selection
@@ -358161,11 +358576,13 @@ Examples:
358161
358576
  $ qawolf runner run flows/checkout.flow.ts --follow
358162
358577
  $ qawolf runner run flows/checkout.flow.ts --follow --logs
358163
358578
  $ qawolf runner run flows/checkout.flow.ts --lines 12-40
358164
- $ qawolf runner run flows/checkout.flow.ts --lines 4-9 --lines-file pages/login.ts`;
358579
+ $ qawolf runner run flows/checkout.flow.ts --lines 4-9 --lines-file pages/login.ts
358580
+ $ qawolf runner run flows/checkout.flow.ts --env-id staging`;
358165
358581
  function registerRunCommand(runner, signals) {
358166
- declareCommandKind(runner.command("run <flowFile>"), "write").description("Run a flow on an interactive runner, shipping the flow and what it imports").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("--env-file <path>", "Dotenv file whose variables the run is given. Not --env, which on qawolf flows names a QA Wolf environment").option("--lines <start-end>", "Run only these 1-indexed lines against the browser as it stands, instead of the whole flow from a fresh one").option("--lines-file <path>", "File the --lines range lives in. Defaults to <flowFile>; pass it only when the lines are in another file, such as a page object").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((flowFile, opts, command) => withAuthContext(signals, (ctx) => handleRunnerRun(ctx, {
358582
+ declareCommandKind(runner.command("run <flowFile>"), "write").description("Run a flow on an interactive runner, shipping the flow and what it imports").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("--env-id <env>", "QA Wolf environment whose variables the run is given, by id or alias. QA Wolf reads and decrypts them itself, so they never leave the server and no size limit applies to them").option("--env-file <path>", "Dotenv file on this machine whose variables the run is given. Pass this or --env-id, not both").option("--lines <start-end>", "Run only these 1-indexed lines against the browser as it stands, instead of the whole flow from a fresh one").option("--lines-file <path>", "File the --lines range lives in. Defaults to <flowFile>; pass it only when the lines are in another file, such as a page object").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((flowFile, opts, command) => withAuthContext(signals, (ctx) => handleRunnerRun(ctx, {
358167
358583
  entryPoint: flowFile,
358168
358584
  envFile: opts.envFile,
358585
+ envId: opts.envId,
358169
358586
  follow: opts.follow,
358170
358587
  lines: opts.lines,
358171
358588
  linesFile: opts.linesFile,
@@ -358186,6 +358603,8 @@ function registerRunnerCommand(program2, signals) {
358186
358603
  registerRunnerInteractCommands(runner, signals);
358187
358604
  registerRunnerInspectCommands(runner, signals);
358188
358605
  registerRunnerImportPackageCommand(runner, signals);
358606
+ registerRunnerHighlightSelectorCommand(runner, signals);
358607
+ registerRunnerPromoteSnapshotCommand(runner, signals);
358189
358608
  }
358190
358609
 
358191
358610
  // src/commands/program.ts
@@ -358224,4 +358643,4 @@ createProgram({ signals }).parseAsync().catch(() => {
358224
358643
  process.exitCode = 1;
358225
358644
  }).finally(() => flushAndExit(typeof process.exitCode === "number" ? process.exitCode : 0));
358226
358645
 
358227
- //# debugId=DD39A64EAC42A5D564756E2164756E21
358646
+ //# debugId=408E9965E881500264756E2164756E21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qawolf/cli",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Run and manage QA Wolf flows from the terminal, CI, or an AI agent",
5
5
  "keywords": [
6
6
  "automation",
@@ -60,7 +60,7 @@
60
60
  "@clack/prompts": "1.5.1",
61
61
  "@napi-rs/keyring": "1.3.0",
62
62
  "@oxc-node/core": "0.1.0",
63
- "@qawolf/api-contracts": "0.30.0",
63
+ "@qawolf/api-contracts": "0.34.0",
64
64
  "@qawolf/emails": "1.1.1",
65
65
  "@qawolf/flow-targets": "1.0.0",
66
66
  "@qawolf/flows": "0.1.4",
@@ -145,12 +145,14 @@ current branch.
145
145
  | `qawolf runner act` | write | Perform one raw action on a runner's screen: click, double_click, scroll, move, drag, keypress, navigate or type. Use - to read a whole action as JSON from stdin |
146
146
  | `qawolf runner events` | read | Print a runner's journal, one entry per line. QA Wolf writes console, recorder, run-events, run-logs, run-status |
147
147
  | `qawolf runner exec` | write | Evaluate a snippet against a runner's live page. Use - to read the snippet from stdin |
148
+ | `qawolf runner highlight-selector` | write | Highlight what a selector matches on a runner's live page, so the next screenshot shows it. Omit the selector to clear the highlight |
148
149
  | `qawolf runner import-package` | write | Install a package into a runner's live run, so a snippet or a selection can import it |
149
150
  | `qawolf runner inspect element-html` | read | Print the HTML of the first element a selector matches |
150
151
  | `qawolf runner inspect page-html` | read | Print the page's HTML, simplified for a model to read |
151
152
  | `qawolf runner inspect variable` | read | Print a top-level variable's value from the running workflow |
152
153
  | `qawolf runner keepalive` | read | Reset a runner's inactivity clock, for a caller that pauses between actions |
153
154
  | `qawolf runner launch` | write | Launch an interactive runner and make it this directory's default |
155
+ | `qawolf runner promote-snapshot` | write | Accept a run's screenshot as the new baseline for an image diff, on the runner that produced it |
154
156
  | `qawolf runner run` | write | Run a flow on an interactive runner, shipping the flow and what it imports |
155
157
  | `qawolf runner screenshot` | read | Save a JPEG of an interactive runner's screen to a file |
156
158
  | `qawolf runner stop-run` | write | Stop what a runner is currently executing, leaving the runner up |
@@ -159,6 +159,53 @@ Use `inspect` before reaching for `exec`. Reading a value through a snippet
159
159
  means printing it and then fishing it back out of the `console` stream, which is
160
160
  two calls and a marker; `inspect variable` is one call and the value.
161
161
 
162
+ ## Checking a selector: `highlight-selector`
163
+
164
+ `inspect element-html` tells you what a selector matched. `highlight-selector`
165
+ shows you _where_ it is, by drawing on the page itself:
166
+
167
+ ```sh
168
+ qawolf runner highlight-selector "text=Sign in"
169
+ qawolf runner screenshot # the highlight is in this
170
+ qawolf runner highlight-selector # omit the selector to clear
171
+ ```
172
+
173
+ The highlight stays until it is replaced or cleared, which is the point: you
174
+ cannot see the runner's screen, so the only way to read the result is the next
175
+ screenshot.
176
+
177
+ Three answers are worth telling apart. A selector that matched prints how many
178
+ elements it hit and exits `0`. A selector the page read fine but that matched
179
+ nothing also exits `0`, because the call did what was asked and the count is the
180
+ answer; the message says the syntax was fine so you look at the page, not the
181
+ locator. A selector the page could not read at all exits `2`, because that one
182
+ is yours to correct and retrying will not change it.
183
+
184
+ `runner-cannot-highlight-selectors` exits `2` and means the runner has no
185
+ browser to draw on. `no-answer` exits `4`: a highlight runs inside the page, so
186
+ a page that is gone or mid-navigation does not answer at all rather than
187
+ answering slowly.
188
+
189
+ ## Accepting a new baseline: `promote-snapshot`
190
+
191
+ When a run's image diff fails and the new screenshot is the one you want, this
192
+ replaces the baseline on the runner that produced it:
193
+
194
+ ```sh
195
+ qawolf runner events run-events --tail 20 | jq 'select(.type == "image-diff-artifact")'
196
+ qawolf runner promote-snapshot --screenshot checkout-1-actual.png --baseline checkout-1.png
197
+ ```
198
+
199
+ Both paths are the ones the diff reported, and both are named rather than
200
+ positional, because two paths with one unlabelled is easy to get backwards and
201
+ swapping them promotes the wrong image. They are paths inside the run's own
202
+ screenshot storage, not files on your machine.
203
+
204
+ `snapshot-not-found` exits `2` and means the run wrote no screenshot at that
205
+ path, which nearly always means the paths did not come from a diff this run
206
+ produced. Nothing is changed, so correcting the path and repeating is safe.
207
+ Promoting twice is also safe, so an unreachable runner is worth retrying.
208
+
162
209
  ## Installing a package mid-session
163
210
 
164
211
  `qawolf runner import-package <name>` installs a package into the runner's live
@@ -255,14 +302,29 @@ runner is addressed, naming the path.
255
302
 
256
303
  ### Giving the run environment variables
257
304
 
305
+ There are two ways, and a run takes one of them. `--env-id` names a QA Wolf
306
+ environment by id or alias, the same reference `qawolf flows` takes as `--env`.
258
307
  `--env-file .env` gives the run the variables in a dotenv file, in the format
259
- `qawolf flows pull` writes. It is `--env-file` and not `--env`, which on
260
- `qawolf flows` means a QA Wolf environment by id or slug.
308
+ `qawolf flows pull` writes.
309
+
310
+ ```sh
311
+ qawolf runner run flows/checkout.flow.ts --env-id staging
312
+ qawolf runner run flows/checkout.flow.ts --env-file .env
313
+ ```
314
+
315
+ **Prefer `--env-id`.** QA Wolf reads and decrypts the environment itself, so the
316
+ values never leave the server, nothing has to be pulled to disk first, and no
317
+ size limit applies to them. It is the only way to run a flow whose environment
318
+ holds something large, such as a session cookie.
319
+
320
+ A run that sends its own variables with `--env-file` may carry at most 200 of
321
+ them, each value at most 16 KiB. Names follow what a shell accepts, and
322
+ `QAWOLF_TEAM_ID` is reserved because QA Wolf sets it from the key you
323
+ authenticated with. All of that is refused before a runner is addressed, naming
324
+ the variable at fault.
261
325
 
262
- A run may carry at most 100 variables, each value at most 8 KiB. Names follow
263
- what a shell accepts, and `QAWOLF_TEAM_ID` is reserved because QA Wolf sets it
264
- from the key you authenticated with. All of that is refused before a runner is
265
- addressed, naming the variable at fault.
326
+ Passing both flags is refused. They each give the run its whole environment, so
327
+ there is no order in which they would combine.
266
328
 
267
329
  If the runner had no browser, one is started before your lines run, and the
268
330
  command says so on stderr. Those lines then ran against a fresh page rather than