Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/runner-screenshot-stdout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

`qawolf runner screenshot --out -` writes the JPEG bytes to stdout instead of a file. Stdout carries the image alone; the confirmation, a JSON line under `--json`, goes to stderr.
2 changes: 1 addition & 1 deletion skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ that `url`; never guess a route and never send a repository link in its place.
| `qawolf runner list` | read | List the runners this directory holds that are still running |
| `qawolf runner promote-snapshot` | write | Accept a run's screenshot as the new baseline for an image diff, on the runner that produced it |
| `qawolf runner run` | write | Run a flow on an interactive runner, shipping the flow and what it imports |
| `qawolf runner screenshot` | read | Save a JPEG of an interactive runner's screen to a file |
| `qawolf runner screenshot` | read | Save a JPEG of an interactive runner's screen to a file, or write it to stdout with --out - |
| `qawolf runner stop-run` | write | Stop what a runner is currently executing, leaving the runner up |
| `qawolf runner terminate` | write | End an interactive runner, and the pod it runs on with it |
| `qawolf tag create` | write | Create a tag on the caller's team. Tags select flows in run.create. |
Expand Down
5 changes: 4 additions & 1 deletion skills/qawolf-cli/references/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ vision loop on this surface.

`qawolf runner screenshot --out page.jpg` writes a real JPEG to disk, decoded,
because every coding harness can open an image file. Read it with whatever
vision you have.
vision you have. `--out -` writes the JPEG bytes to stdout instead, on their own,
for a caller that is a process rather than an agent: the confirmation, and the
JSON line under `--json`, goes to stderr so nothing follows the image on stdout.
A terminal on stdout is refused: redirect or pipe it.

`qawolf runner act <action>` performs exactly one action per call, in the
computer-use tool vocabulary a vision model already emits: `click`,
Expand Down
11 changes: 8 additions & 3 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Commands:
keepalive [options] Reset a runner's inactivity clock, for a caller that pauses between actions
run [options] <flowFile> Run a flow on an interactive runner, shipping the flow and what it imports
events [options] <stream> Print a runner's journal, one entry per line. QA Wolf writes console, recorder, run-events, run-logs, run-status
screenshot [options] Save a JPEG of an interactive runner's screen to a file
screenshot [options] Save a JPEG of an interactive runner's screen to a file, or write it to stdout with --out -
act [options] <action> 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. On a mobile runner only click (button left), drag and type have a touchscreen equivalent; the rest answer action-not-supported-on-mobile
exec [options] <file> Evaluate a snippet against a runner's live page. Use - to read the snippet from stdin
inspect Read one thing off a runner's live page (browser) or Appium session (mobile)
Expand Down Expand Up @@ -438,17 +438,22 @@ Examples:
exports[`--help output qawolf runner screenshot 1`] = `
"Usage: qawolf runner screenshot [options]

Save a JPEG of an interactive runner's screen to a file
Save a JPEG of an interactive runner's screen to a file, or write it to stdout
with --out -

Options:
--out <path> File to write the image to (default: "screenshot.jpg")
--out <path> File to write the image to. - writes the JPEG bytes to stdout
on their own and moves the confirmation, JSON included, to
stderr (default: "screenshot.jpg")
--runner <id> Runner to target. Defaults to QAWOLF_RUNNER_ID, then this
directory's stored runner
-h, --help display help for command

Examples:
$ qawolf runner screenshot
$ qawolf runner screenshot --out screens/step-3.jpg
$ qawolf runner screenshot --out - > step-3.jpg
$ qawolf runner screenshot --out - | my-vision-tool
"
`;

Expand Down
14 changes: 11 additions & 3 deletions src/commands/runner/interact.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ const defaultScreenshotPath = "screenshot.jpg";
const screenshotExamples = `
Examples:
$ qawolf runner screenshot
$ qawolf runner screenshot --out screens/step-3.jpg`;
$ qawolf runner screenshot --out screens/step-3.jpg
$ qawolf runner screenshot --out - > step-3.jpg
$ qawolf runner screenshot --out - | my-vision-tool`;

const actExamples = `
Examples:
Expand Down Expand Up @@ -51,8 +53,14 @@ export function registerRunnerInteractCommands(
signals: SignalRegistry,
): void {
declareCommandKind(runner.command("screenshot"), "read")
.description("Save a JPEG of an interactive runner's screen to a file")
.option("--out <path>", "File to write the image to", defaultScreenshotPath)
.description(
"Save a JPEG of an interactive runner's screen to a file, or write it to stdout with --out -",
)
.option(
"--out <path>",
"File to write the image to. - writes the JPEG bytes to stdout on their own and moves the confirmation, JSON included, to stderr",
defaultScreenshotPath,
)
.option("--runner <id>", runnerFlagDescription)
.addHelpText("after", screenshotExamples)
.action((opts: { out: string; runner?: string }, command: Command) =>
Expand Down
6 changes: 6 additions & 0 deletions src/core/messages/interactiveRunner/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,15 @@ export const interactMessages = {
"The runner has a screen and cannot serve this yet. Its virtual desktop restarts when a run changes the display size, and it serves one request at a time, so something already in flight is the usual reason. Retry in a second or two.",
screenshotNotAnImage:
"The screen was captured but did not arrive as a JPEG, so nothing was written. Nothing about the command needs changing: try it again, and report it if it keeps happening.",
screenshotStdoutIsATerminal:
'Stdout is a terminal, so the JPEG bytes would have nowhere to go. Redirect stdout to a file or pipe it into a reader, or give --out a file path instead of "-".',
screenshotStdoutUnwritable: (detail: string) =>
`The screen was captured but could not be written to stdout: ${detail}. Keep the pipe reading stdout open, or give --out a file path instead of "-".`,
screenshotUnwritable: (path: string, detail: string) =>
`The screen was captured but could not be written to "${path}": ${detail}. Give --out a path this process can write to.`,
screenshotWritten: (path: string) => `Wrote the runner's screen to ${path}.`,
screenshotWrittenToStdout:
"Wrote the runner's screen to stdout as a JPEG. Stdout holds the image bytes alone; this line, and the JSON with --json, is on stderr.",
snippetEmpty: (path: string) => `"${path}" holds no code to evaluate.`,
snippetErrored: (errorMessage: string | undefined) =>
errorMessage === undefined
Expand Down
31 changes: 28 additions & 3 deletions src/domains/interactiveRunner/deps.testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { sep } from "node:path";
import { Writable } from "node:stream";
import type { Mock } from "bun:test";
import type { RunFiles } from "@qawolf/api-contracts/v1";

import { makeCtx } from "~/shell/commandContext.testUtils.js";
import type { Fs } from "~/shell/fs.js";
import { makeMemoryFs } from "~/shell/fs.testUtils.js";
import { writeScreenshot } from "~/shell/interactiveRunner/writeScreenshot.js";
import {
type ScreenshotStdout,
writeScreenshot,
} from "~/shell/interactiveRunner/writeScreenshot.js";
import {
makeCallPublicApiMock,
makeMockPlatformClient,
Expand All @@ -29,6 +33,7 @@ export function makeAuthCtx(mode: OutputMode = "human"): {
outputs: () => { data: unknown; humanMessage: string }[];
streamed: () => string[];
streamedData: () => unknown[];
successes: () => string[];
warnings: () => string[];
} {
const callPublicApi = makeCallPublicApiMock();
Expand All @@ -50,6 +55,10 @@ export function makeAuthCtx(mode: OutputMode = "human"): {
).mock.calls.map(([data, humanMessage]) => ({ data, humanMessage })),
streamed: () => streamCalls(base).map(([, line]) => line),
streamedData: () => streamCalls(base).map(([data]) => data),
successes: () =>
(base.ui.success as Mock<(message: string) => void>).mock.calls.map(
([message]) => message,
),
warnings: () =>
(base.ui.warn as Mock<(message: string) => void>).mock.calls.map(
([message]) => message,
Expand All @@ -67,7 +76,11 @@ export type WrittenScreenshot = { bytes: Uint8Array; path: string };

export function makeTestDeps(
overrides: Partial<InteractiveRunnerDeps> = {},
): InteractiveRunnerDeps & { written: WrittenScreenshot[] } {
): InteractiveRunnerDeps & {
/** Every chunk handed to stdout, in order. */
stdoutWrites: Uint8Array[];
written: WrittenScreenshot[];
} {
const files: RunFiles = {
"flow.ts": "export default {};",
"package.json": "{}",
Expand All @@ -85,6 +98,13 @@ export function makeTestDeps(
written.push({ bytes: Uint8Array.from(data as Uint8Array), path });
},
};
const stdoutWrites: Uint8Array[] = [];
const recordingStdout: ScreenshotStdout = new Writable({
write(chunk: Uint8Array, _encoding, callback) {
stdoutWrites.push(Uint8Array.from(chunk));
callback();
},
});
return {
collectRunFiles: async () => ({ files, unresolvedImports: [] }),
cwd: testCwd,
Expand All @@ -105,8 +125,13 @@ export function makeTestDeps(
}),
sleep: async () => {},
store: makeRunnerStore({ cwd: testCwd, fs: makeMemoryFs() }),
stdoutWrites,
writeScreenshot: (screenshot) =>
writeScreenshot({ ...screenshot, fs: recordingFs }),
writeScreenshot({
...screenshot,
fs: recordingFs,
stdout: recordingStdout,
}),
written,
...overrides,
};
Expand Down
119 changes: 119 additions & 0 deletions src/domains/interactiveRunner/takeScreenshot.stdout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "bun:test";

import { handleRunnerScreenshot } from "./takeScreenshot.js";
import { makeAuthCtx, makeTestDeps } from "./deps.testUtils.js";

const jpegBytes = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
const imageJpegBase64 = Buffer.from(jpegBytes).toString("base64");

describe("handleRunnerScreenshot --out -", () => {
// The Tester session reads the bytes off the pipe instead of reserving a
// file, handing it to the sandboxed user, reading it back and deleting it.
it("writes the decoded image bytes to stdout and no file", async () => {
const { callPublicApi, ctx } = makeAuthCtx("json");
callPublicApi.mockResolvedValue({
ok: true,
value: { imageJpegBase64, outcome: "success" },
});
const deps = makeTestDeps();

const result = await handleRunnerScreenshot(
ctx,
{ out: "-", runner: "ci" },
deps,
);

expect(result).toBeUndefined();
expect(deps.stdoutWrites).toEqual([jpegBytes]);
expect(deps.written).toEqual([]);
});

// Stdout is the image, so nothing else may land there: in json mode the
// answer line would otherwise follow the JPEG bytes into the reader's file.
// These two are the modes a piped stdout lands in.
for (const mode of ["json", "agent"] as const) {
it(`keeps the confirmation off stdout in ${mode} mode`, async () => {
const { callPublicApi, ctx, outputs, streamed, successes } =
makeAuthCtx(mode);
callPublicApi.mockResolvedValue({
ok: true,
value: { imageJpegBase64, outcome: "success" },
});

await handleRunnerScreenshot(
ctx,
{ out: "-", runner: "ci" },
makeTestDeps(),
);

expect(outputs()).toEqual([]);
expect(streamed()).toEqual([]);
expect(successes()).toHaveLength(1);
expect(successes()[0]).toContain("stdout");
expect(successes()[0]).toContain("stderr");
});
}

// Human mode means a terminal on stdout, where nothing can read the bytes and
// the confirmation would land among them rather than on stderr.
it("refuses a terminal on stdout before asking the runner", async () => {
const { callPublicApi, ctx } = makeAuthCtx("human");
const deps = makeTestDeps();

const result = await handleRunnerScreenshot(
ctx,
{ out: "-", runner: "ci" },
deps,
);

expect(result?.error).toContain("terminal");
expect(result?.error).toContain("--out a file path");
expect(result?.exitCode).toBe(2);
expect(callPublicApi).not.toHaveBeenCalled();
expect(deps.stdoutWrites).toEqual([]);
});

it("reports a pipe that closed, naming stdout rather than a file", async () => {
const { callPublicApi, ctx } = makeAuthCtx("json");
callPublicApi.mockResolvedValue({
ok: true,
value: { imageJpegBase64, outcome: "success" },
});

const result = await handleRunnerScreenshot(
ctx,
{ out: "-", runner: "ci" },
makeTestDeps({
writeScreenshot: async () => ({
detail: "EPIPE: broken pipe",
ok: false,
reason: "unwritable",
}),
}),
);

expect(result?.error).toContain("stdout");
expect(result?.error).toContain("EPIPE");
expect(result?.error).not.toContain('to "-"');
expect(result?.exitCode).toBe(2);
});

it("writes nothing to stdout when the answer was not an image", async () => {
const { callPublicApi, ctx, successes } = makeAuthCtx("json");
callPublicApi.mockResolvedValue({
ok: true,
value: { imageJpegBase64: "", outcome: "success" },
});
const deps = makeTestDeps();

const result = await handleRunnerScreenshot(
ctx,
{ out: "-", runner: "ci" },
deps,
);

expect(result?.error).toContain("did not arrive as a JPEG");
expect(deps.stdoutWrites).toEqual([]);
expect(successes()).toEqual([]);
});
});
1 change: 1 addition & 0 deletions src/domains/interactiveRunner/takeScreenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("handleRunnerScreenshot", () => {
runnerCallOptions,
);
expect(deps.written).toEqual([{ bytes: jpegBytes, path: "shot.jpg" }]);
expect(deps.stdoutWrites).toEqual([]);
});

it("says where it wrote the image", async () => {
Expand Down
Loading
Loading