From f5f2f2cc330db8e657655e7b555916e57f320fac Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:38:35 +0100 Subject: [PATCH 1/5] fix(cli): open log destination synchronously An async pino destination races process exit: a command that errors before the log fd opens (e.g. instant flag validation) crashes pino's exit-time flushSync with "sonic boom is not ready yet". A sync destination opens the fd at creation, eliminating the race. --- src/shell/logger.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/shell/logger.ts b/src/shell/logger.ts index 3979f5fc9..e9f84b228 100644 --- a/src/shell/logger.ts +++ b/src/shell/logger.ts @@ -42,7 +42,10 @@ export function createLoggingSystem(opts: { }): LoggingSystem { const { stderrLevel, logPath } = opts; - const dest = pino.destination({ dest: logPath, mkdir: true, sync: false }); + // sync so the fd opens at creation: with an async destination, a command + // that errors before the fd opens (e.g. instant flag validation) crashes + // pino's process-exit flush hook with "sonic boom is not ready yet". + const dest = pino.destination({ dest: logPath, mkdir: true, sync: true }); const fileLogger = pino({ level: "debug" }, dest); const levelNums = { @@ -96,8 +99,8 @@ export function createLoggingSystem(opts: { try { dest.flushSync(); } catch { - // SonicBoom throws if the fd hasn't opened yet (async destination). - // Nothing is buffered in that case, so this is safe to ignore. + // Defensive: SonicBoom throws if the stream is destroyed or its fd + // is somehow not open; nothing recoverable is buffered in that case. } }, }; From 77ba95ca2e3ac3647a9d1145e1a2991c04689df3 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:59:07 +0100 Subject: [PATCH 2/5] feat(flows): scope flows list --remote by environment via public API Repoint the remote listing from REST v0 to the public.flow.list contract from @qawolf/api-contracts 0.2.0, called through callPublicApi. --env is now required with --remote, --include-drafts opts into draft flows, and JSON output emits flowId in contract terminology. The dependency bump makes the generator skip-list load-bearing: the 0.2.0 tree contains flow.list and must not mint a duplicate command. bun.lock is intentionally unchanged until 0.2.0 is on npm; a lockfile commit follows the publish. --- .changeset/flows-list-remote-env-scope.md | 5 + package.json | 2 +- skills/qawolf-cli/SKILL.md | 2 +- src/commands/__snapshots__/help.test.ts.snap | 19 ++- src/commands/flows/index.test.ts | 69 ++++++++ src/commands/flows/index.ts | 38 ++++- src/core/messages/flows.ts | 5 + src/domains/flows/listRemote.test.ts | 148 +++++++++--------- src/domains/flows/listRemote.ts | 16 +- .../createPlatformClient.testUtils.ts | 1 - src/shell/platform/createPlatformClient.ts | 11 -- src/shell/platform/getRemoteFlows.test.ts | 132 ---------------- src/shell/platform/getRemoteFlows.ts | 57 ------- 13 files changed, 214 insertions(+), 291 deletions(-) create mode 100644 .changeset/flows-list-remote-env-scope.md create mode 100644 src/commands/flows/index.test.ts delete mode 100644 src/shell/platform/getRemoteFlows.test.ts delete mode 100644 src/shell/platform/getRemoteFlows.ts diff --git a/.changeset/flows-list-remote-env-scope.md b/.changeset/flows-list-remote-env-scope.md new file mode 100644 index 000000000..10170381a --- /dev/null +++ b/.changeset/flows-list-remote-env-scope.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +`flows list --remote` is now environment-scoped via the QA Wolf public API: pass `--env ` (now required with `--remote`) and optionally `--include-drafts` to include draft flows. JSON output now emits `flowId` instead of `id` for each flow. diff --git a/package.json b/package.json index 4594969a0..4917d220a 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", "@playwright/test": "1.60.0", - "@qawolf/api-contracts": "0.1.0", + "@qawolf/api-contracts": "0.2.0", "@qawolf/emails": "1.1.1", "@qawolf/flow-targets": "1.0.0", "@qawolf/flows": "0.1.1", diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index b391e5269..2b8688762 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -41,7 +41,7 @@ write on timeout: it may have reached the server the first time. | `qawolf auth logout` | local | Remove stored credentials | | `qawolf auth whoami` | read | Show authentication status | | `qawolf doctor` | local | Diagnose problems running flows locally | -| `qawolf flows list` | local (read with --remote) | List flows matching [pattern] from the local project, or from QA Wolf with --remote | +| `qawolf flows list` | local (read with --remote) | List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote | | `qawolf flows pull` | read | Download an environment's flows into the local .qawolf// cache | | `qawolf flows run` | local (read with --env) | Run flows matching [pattern], or every flow when omitted; with --env, pull missing flows from that QA Wolf environment | | `qawolf init` | local | Scaffold a QA Wolf project in the current directory | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index c1ffc673e..dd061ffea 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -136,7 +136,7 @@ Commands: omitted; with --env, pull missing flows from that QA Wolf environment list [options] [pattern] List flows matching [pattern] from the local - project, or from QA Wolf with --remote + project, or from a QA Wolf environment with --remote pull [options] Download an environment's flows into the local .qawolf// cache help [command] display help for command @@ -188,19 +188,22 @@ Examples: exports[`--help output qawolf flows list 1`] = ` "Usage: qawolf flows list [options] [pattern] -List flows matching [pattern] from the local project, or from QA Wolf with ---remote +List flows matching [pattern] from the local project, or from a QA Wolf +environment with --remote Options: - --remote List flows from the QA Wolf platform instead of the local project - (default: false) - -h, --help display help for command + --remote List flows from the QA Wolf platform instead of the local + project (default: false) + --env Environment to list flows from (required with --remote) + --include-drafts Include draft flows in the listing (requires --remote) + (default: false) + -h, --help display help for command Examples: $ qawolf flows list $ qawolf flows list "flows/checkout/**" - $ qawolf flows list --remote - $ qawolf flows list "**/checkout/**" --remote + $ qawolf flows list --remote --env staging + $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts " `; diff --git a/src/commands/flows/index.test.ts b/src/commands/flows/index.test.ts new file mode 100644 index 000000000..8f06ee558 --- /dev/null +++ b/src/commands/flows/index.test.ts @@ -0,0 +1,69 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import { Command } from "commander"; + +import { flowsMessages } from "~/core/messages/index.js"; +import { createSignalRegistry } from "~/shell/signals/createSignalRegistry.js"; + +import { registerFlowsCommand } from "./index.js"; + +// `process.exitCode` is global state and persists across tests; reset to 0 +// before/after each case so a single test setting it does not bleed into +// other test files (bun's runner reads exitCode at process exit). +beforeEach(() => { + process.exitCode = 0; +}); + +afterEach(() => { + process.exitCode = 0; + mock.restore(); +}); + +function makeProgram(): Command { + const program = new Command().name("qawolf").exitOverride(); + registerFlowsCommand(program, createSignalRegistry()); + return program; +} + +async function runList(args: string[]): Promise { + const writes: string[] = []; + const capture = (chunk: unknown): boolean => { + writes.push(String(chunk)); + return true; + }; + spyOn(process.stdout, "write").mockImplementation(capture); + spyOn(process.stderr, "write").mockImplementation(capture); + + await makeProgram().parseAsync(["flows", "list", ...args], { from: "user" }); + return writes.join(""); +} + +describe("flows list flag combinations", () => { + it("rejects --remote without --env", async () => { + const output = await runList(["--remote"]); + + expect(process.exitCode).toBe(1); + expect(output).toContain(flowsMessages.list.remoteRequiresEnv); + }); + + it("rejects --env without --remote", async () => { + const output = await runList(["--env", "staging"]); + + expect(process.exitCode).toBe(1); + expect(output).toContain(flowsMessages.list.flagsRequireRemote); + }); + + it("rejects --include-drafts without --remote", async () => { + const output = await runList(["--include-drafts"]); + + expect(process.exitCode).toBe(1); + expect(output).toContain(flowsMessages.list.flagsRequireRemote); + }); +}); diff --git a/src/commands/flows/index.ts b/src/commands/flows/index.ts index 46224b971..9e074b3ba 100644 --- a/src/commands/flows/index.ts +++ b/src/commands/flows/index.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { declareCommandKind } from "~/commands/commandKind.js"; import { withAuthContext, withContext } from "~/commands/context.js"; +import { flowsMessages } from "~/core/messages/index.js"; import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; import { handleFlowsList } from "~/domains/flows/list.js"; @@ -17,10 +18,14 @@ const listExamples = ` Examples: $ qawolf flows list $ qawolf flows list "flows/checkout/**" - $ qawolf flows list --remote - $ qawolf flows list "**/checkout/**" --remote`; + $ qawolf flows list --remote --env staging + $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts`; -type FlowsListOptions = { readonly remote: boolean }; +type FlowsListOptions = { + readonly remote: boolean; + readonly env: string | undefined; + readonly includeDrafts: boolean; +}; const pullExamples = ` Examples: @@ -43,13 +48,22 @@ export function registerFlowsCommand( kindNote: "read with --remote", }) .description( - "List flows matching [pattern] from the local project, or from QA Wolf with --remote", + "List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote", ) .option( "--remote", "List flows from the QA Wolf platform instead of the local project", false, ) + .option( + "--env ", + "Environment to list flows from (required with --remote)", + ) + .option( + "--include-drafts", + "Include draft flows in the listing (requires --remote)", + false, + ) .addHelpText("after", listExamples) .action( ( @@ -58,10 +72,24 @@ export function registerFlowsCommand( command: Command, ) => { if (opts.remote) { + const env = opts.env; + if (env === undefined) { + return withContext(signals, async () => ({ + error: flowsMessages.list.remoteRequiresEnv, + }))(opts, command); + } return withAuthContext(signals, (ctx) => - flowsListRemote(ctx, pattern), + flowsListRemote(ctx, pattern, { + env, + includeDrafts: opts.includeDrafts, + }), )(opts, command); } + if (opts.env !== undefined || opts.includeDrafts) { + return withContext(signals, async () => ({ + error: flowsMessages.list.flagsRequireRemote, + }))(opts, command); + } return withContext(signals, (ctx) => handleFlowsList(ctx, pattern))( opts, command, diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index 61d80e525..48eb4ebcb 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -14,6 +14,11 @@ export const flowsMessages = { title: "Flows", remoteTitle: "Remote Flows", flowCount: (count: number) => pluralize(count, "flow"), + list: { + remoteRequiresEnv: + "--remote requires --env to pick the environment to list", + flagsRequireRemote: "--env and --include-drafts require --remote", + }, pull: { downloadingBundle: "Downloading flows bundle", fetchingEnvVars: "Fetching environment variables", diff --git a/src/domains/flows/listRemote.test.ts b/src/domains/flows/listRemote.test.ts index a96ac63b6..083ad74f5 100644 --- a/src/domains/flows/listRemote.test.ts +++ b/src/domains/flows/listRemote.test.ts @@ -1,35 +1,31 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; +import { publicContractsV1 } from "@qawolf/api-contracts/v1"; import type { AuthCommandContext } from "~/shell/commandContext.js"; import { makeCtx as makeBaseCtx } from "~/shell/commandContext.testUtils.js"; import type { OutputMode } from "~/shell/ui/env.js"; import type { UI } from "~/shell/ui/index.js"; -import { makeMockPlatformClient } from "~/shell/platform/createPlatformClient.testUtils.js"; +import { + makeCallPublicApiMock, + makeMockPlatformClient, +} from "~/shell/platform/createPlatformClient.testUtils.js"; import type { PlatformClient } from "~/shell/platform/createPlatformClient.js"; import { callsOf, makeFakeUI } from "~/domains/runner/run.fixtures.js"; -import { flowsListRemote } from "./listRemote.js"; +import { flowsListRemote, type FlowsListRemoteOptions } from "./listRemote.js"; afterEach(() => { mock.restore(); }); -function makeCtx( - ui: UI, - outputMode: OutputMode, - platform: PlatformClient, -): AuthCommandContext { - return { - ...makeBaseCtx(outputMode), - ui: { ...ui, mode: outputMode }, - apiKeySource: "env", - platform, - }; -} +const defaultOptions: FlowsListRemoteOptions = { + env: "environment-id", + includeDrafts: false, +}; type SampleFlow = { executionTarget: string | Record; - id: string; + flowId: string; name: string; path: string; tags: string[]; @@ -38,14 +34,14 @@ type SampleFlow = { const sampleFlows: SampleFlow[] = [ { executionTarget: "Web - Chrome", - id: "id-1", + flowId: "flow-id-1", name: "Login", path: "src/flows/login.flow.ts", tags: [], }, { executionTarget: "Web - Firefox", - id: "id-2", + flowId: "flow-id-2", name: "Checkout", path: "src/flows/sub/checkout.flow.ts", tags: ["smoke"], @@ -54,23 +50,58 @@ const sampleFlows: SampleFlow[] = [ function platformWithFlows(flows: SampleFlow[]): PlatformClient { return makeMockPlatformClient({ - getRemoteFlows: mock().mockResolvedValue({ + callPublicApi: makeCallPublicApiMock().mockResolvedValue({ ok: true, value: { flows }, }), }); } +async function run(opts: { + mode: OutputMode; + flows?: SampleFlow[]; + pattern?: string; + options?: FlowsListRemoteOptions; + platform?: PlatformClient; +}): Promise<{ ui: UI; platform: PlatformClient; result: unknown }> { + const ui = makeFakeUI(); + const platform = + opts.platform ?? platformWithFlows(opts.flows ?? sampleFlows); + const ctx: AuthCommandContext = { + ...makeBaseCtx(opts.mode), + ui: { ...ui, mode: opts.mode }, + apiKeySource: "env", + platform, + }; + const result = await flowsListRemote( + ctx, + opts.pattern, + opts.options ?? defaultOptions, + ); + return { ui, platform, result }; +} + const stripAnsi = (s: string | undefined): string => // oxlint-disable-next-line no-control-regex (s ?? "").replace(/\x1b\[[\d;]*m/g, ""); +describe("flowsListRemote wire call", () => { + it("calls public.flow.list with the environment and drafts flag", async () => { + const { platform } = await run({ + mode: "json", + options: { env: "env-1", includeDrafts: true }, + }); + + expect(platform.callPublicApi).toHaveBeenCalledWith( + publicContractsV1.flow.list, + { environmentId: "env-1", includeDrafts: true }, + ); + }); +}); + describe("flowsListRemote success paths", () => { it("renders a bolded header + name|target|file rows in human mode", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "human", platformWithFlows(sampleFlows)); - - await flowsListRemote(ctx, undefined); + const { ui } = await run({ mode: "human" }); const output = callsOf(ui.write) .map((c) => String(c[0])) @@ -95,10 +126,7 @@ describe("flowsListRemote success paths", () => { }); it("emits an unbolded table in agent mode", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "agent", platformWithFlows(sampleFlows)); - - await flowsListRemote(ctx, undefined); + const { ui } = await run({ mode: "agent" }); const output = callsOf(ui.write) .map((c) => String(c[0])) @@ -110,21 +138,18 @@ describe("flowsListRemote success paths", () => { }); it("emits the items as JSON in json mode", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "json", platformWithFlows(sampleFlows)); - - await flowsListRemote(ctx, undefined); + const { ui } = await run({ mode: "json" }); expect(ui.json).toHaveBeenCalledWith([ { - id: "id-1", + flowId: "flow-id-1", file: "src/flows/login.flow.ts", name: "Login", tags: [], target: "Web - Chrome", }, { - id: "id-2", + flowId: "flow-id-2", file: "src/flows/sub/checkout.flow.ts", name: "Checkout", tags: ["smoke"], @@ -137,10 +162,7 @@ describe("flowsListRemote success paths", () => { describe("flowsListRemote empty list", () => { it("prints 'No flows matched.' via info in human mode", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "human", platformWithFlows([])); - - const result = await flowsListRemote(ctx, undefined); + const { ui, result } = await run({ mode: "human", flows: [] }); expect(result).toBeUndefined(); expect(ui.info).toHaveBeenCalledWith("No flows matched."); @@ -148,23 +170,18 @@ describe("flowsListRemote empty list", () => { }); it("emits an empty JSON array in json mode", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "json", platformWithFlows([])); - await flowsListRemote(ctx, undefined); + const { ui } = await run({ mode: "json", flows: [] }); expect(ui.json).toHaveBeenCalledWith([]); }); }); describe("flowsListRemote pattern filtering", () => { it("filters flows by glob pattern against the path field", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "json", platformWithFlows(sampleFlows)); - - await flowsListRemote(ctx, "**/sub/**"); + const { ui } = await run({ mode: "json", pattern: "**/sub/**" }); expect(ui.json).toHaveBeenCalledWith([ { - id: "id-2", + flowId: "flow-id-2", file: "src/flows/sub/checkout.flow.ts", name: "Checkout", tags: ["smoke"], @@ -174,10 +191,7 @@ describe("flowsListRemote pattern filtering", () => { }); it("falls through to the empty-list branch when pattern matches nothing", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx(ui, "human", platformWithFlows(sampleFlows)); - - await flowsListRemote(ctx, "no/matches/**"); + const { ui } = await run({ mode: "human", pattern: "no/matches/**" }); expect(ui.info).toHaveBeenCalledWith("No flows matched."); expect(ui.outro).not.toHaveBeenCalled(); @@ -186,26 +200,22 @@ describe("flowsListRemote pattern filtering", () => { describe("flowsListRemote executionTarget shapes", () => { it("stringifies ad-hoc target objects to JSON", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx( - ui, - "json", - platformWithFlows([ + const { ui } = await run({ + mode: "json", + flows: [ { executionTarget: { runner: "android", device: "Pixel 7" }, - id: "id-3", + flowId: "flow-id-3", name: "Custom Android", path: "src/flows/mobile/custom-android.flow.ts", tags: [], }, - ]), - ); - - await flowsListRemote(ctx, undefined); + ], + }); expect(ui.json).toHaveBeenCalledWith([ { - id: "id-3", + flowId: "flow-id-3", file: "src/flows/mobile/custom-android.flow.ts", name: "Custom Android", tags: [], @@ -217,24 +227,18 @@ describe("flowsListRemote executionTarget shapes", () => { describe("flowsListRemote platform error", () => { it("returns a CommandResult with the platform error message", async () => { - const ui = makeFakeUI(); - const ctx = makeCtx( - ui, - "human", - makeMockPlatformClient({ - getRemoteFlows: mock< - PlatformClient["getRemoteFlows"] - >().mockResolvedValue({ + const { ui, result } = await run({ + mode: "human", + platform: makeMockPlatformClient({ + callPublicApi: makeCallPublicApiMock().mockResolvedValue({ ok: false, - error: "QA Wolf API rejected the flows request (HTTP 401).", + error: "QA Wolf API rejected the flow.list request (HTTP 401).", }), }), - ); - - const result = await flowsListRemote(ctx, undefined); + }); expect(result).toEqual({ - error: "QA Wolf API rejected the flows request (HTTP 401).", + error: "QA Wolf API rejected the flow.list request (HTTP 401).", }); expect(ui.write).not.toHaveBeenCalled(); expect(ui.json).not.toHaveBeenCalled(); diff --git a/src/domains/flows/listRemote.ts b/src/domains/flows/listRemote.ts index d82203fb7..52a01bf87 100644 --- a/src/domains/flows/listRemote.ts +++ b/src/domains/flows/listRemote.ts @@ -1,4 +1,5 @@ import picomatch from "picomatch"; +import { publicContractsV1 } from "@qawolf/api-contracts/v1"; import type { AuthCommandContext, @@ -9,18 +10,27 @@ import { flowsMessages, runnerMessages } from "~/core/messages/index.js"; import { renderListTable } from "./renderListTable.js"; type RemoteListItem = { - id: string; + flowId: string; file: string; name: string; tags: readonly string[]; target: string; }; +export type FlowsListRemoteOptions = { + readonly env: string; + readonly includeDrafts: boolean; +}; + export async function flowsListRemote( ctx: AuthCommandContext, pattern: string | undefined, + options: FlowsListRemoteOptions, ): Promise { - const result = await ctx.platform.getRemoteFlows(); + const result = await ctx.platform.callPublicApi(publicContractsV1.flow.list, { + environmentId: options.env, + includeDrafts: options.includeDrafts, + }); if (!result.ok) { return { error: result.error }; } @@ -29,7 +39,7 @@ export async function flowsListRemote( const items: RemoteListItem[] = result.value.flows .filter((f) => !matches || matches(f.path)) .map((f) => ({ - id: f.id, + flowId: f.flowId, file: f.path, name: f.name, tags: f.tags, diff --git a/src/shell/platform/createPlatformClient.testUtils.ts b/src/shell/platform/createPlatformClient.testUtils.ts index ff8fe33ac..b9eb54962 100644 --- a/src/shell/platform/createPlatformClient.testUtils.ts +++ b/src/shell/platform/createPlatformClient.testUtils.ts @@ -23,7 +23,6 @@ export function makeMockPlatformClient( const client: PlatformClient = { callPublicApi: makeCallPublicApiMock(), getIdentity: mock(), - getRemoteFlows: mock(), getFlowsBundleUrl: mock(), getEnvVars: mock(), listTeamStorageFiles: mock(), diff --git a/src/shell/platform/createPlatformClient.ts b/src/shell/platform/createPlatformClient.ts index 61ca2f18b..82a306c09 100644 --- a/src/shell/platform/createPlatformClient.ts +++ b/src/shell/platform/createPlatformClient.ts @@ -11,7 +11,6 @@ import { } from "./describeErrors.js"; import { downloadBundle } from "./downloadBundle.js"; import { getIdentity, type IdentityResponse } from "./getIdentity.js"; -import { getRemoteFlows, type RemoteFlowsResponse } from "./getRemoteFlows.js"; import { type PlatformResult, requestWithRetry } from "./requestWithRetry.js"; import { listTeamStorageFiles } from "./teamStorage.js"; import { @@ -27,7 +26,6 @@ import { export type PlatformClient = { callPublicApi: CallPublicApiMethod; getIdentity: () => Promise>; - getRemoteFlows: () => Promise>; getFlowsBundleUrl: ( envId: string, ) => Promise>; @@ -89,15 +87,6 @@ export function createPlatformClient( }); }, - async getRemoteFlows() { - return requestWithRetry({ - call: () => getRemoteFlows(apiKey, deps), - backoffMs: requestBackoffMs, - describe: (err) => describeRequestError(err, deps.baseUrl, "flows"), - sleep: deps.sleep, - }); - }, - getFlowsBundleUrl: getFlowsBundleUrlImpl, callPublicApi: makeCallPublicApiMethod(trpc, deps, requestBackoffMs), diff --git a/src/shell/platform/getRemoteFlows.test.ts b/src/shell/platform/getRemoteFlows.test.ts deleted file mode 100644 index d587b8144..000000000 --- a/src/shell/platform/getRemoteFlows.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; - -import { getRemoteFlows } from "./getRemoteFlows.js"; - -function createFetchMock(resolvedValue: Response): typeof fetch { - return mock().mockResolvedValue( - resolvedValue, - ) as unknown as typeof fetch; -} - -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - ...init, - }); -} - -const sampleFlow = { - executionTarget: "Web - Chrome", - id: "h68oxv33hwdnhthx4918560zowor", - name: "Read Team Storage Files", - path: "src/flows/team-storage/read-team-storage-files.flow.ts", - tags: [], -}; - -describe("getRemoteFlows", () => { - it("sends the API key as a Bearer token to /api/v0/flows?includeDrafts=false", async () => { - const mockFetch = createFetchMock(jsonResponse({ flows: [sampleFlow] })); - - await getRemoteFlows("qawolf_mykey", { - fetch: mockFetch, - baseUrl: "https://test.qawolf.com", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://test.qawolf.com/api/v0/flows?includeDrafts=false", - expect.objectContaining({ - headers: { Authorization: "Bearer qawolf_mykey" }, - }), - ); - }); - - it("returns ok with parsed flows on success", async () => { - const result = await getRemoteFlows("qawolf_key", { - fetch: createFetchMock(jsonResponse({ flows: [sampleFlow] })), - baseUrl: "https://test.qawolf.com", - }); - - expect(result).toEqual({ ok: true, data: { flows: [sampleFlow] } }); - }); - - it("returns ok with empty array when no flows exist", async () => { - const result = await getRemoteFlows("qawolf_key", { - fetch: createFetchMock(jsonResponse({ flows: [] })), - baseUrl: "https://test.qawolf.com", - }); - - expect(result).toEqual({ ok: true, data: { flows: [] } }); - }); - - it("returns http WireError with status on auth failure", async () => { - const result = await getRemoteFlows("qawolf_badkey", { - fetch: createFetchMock( - new Response('{"error":"You are not authenticated."}', { - status: 401, - headers: { "content-type": "application/json" }, - }), - ), - baseUrl: "https://test.qawolf.com", - }); - - expect(result).toEqual({ - ok: false, - error: { - kind: "http", - status: 401, - body: '{"error":"You are not authenticated."}', - }, - }); - }); - - it("returns network WireError on fetch failure", async () => { - const cause = new Error("fetch failed"); - const mockFetch = mock().mockRejectedValue( - cause, - ) as unknown as typeof fetch; - - const result = await getRemoteFlows("qawolf_key", { - fetch: mockFetch, - baseUrl: "https://test.qawolf.com", - }); - - expect(result).toEqual({ ok: false, error: { kind: "network", cause } }); - }); - - it("returns parse WireError when response body does not match schema", async () => { - const result = await getRemoteFlows("qawolf_key", { - fetch: createFetchMock(jsonResponse({ unexpected: "shape" })), - baseUrl: "https://test.qawolf.com", - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.kind).toBe("parse"); - }); - - it("accepts ad-hoc object executionTarget in addition to preset strings", async () => { - const flow = { - executionTarget: { runner: "android", device: "Pixel 7" }, - id: "id-3", - name: "Custom Android", - path: "src/flows/mobile/custom.flow.ts", - tags: [], - }; - const result = await getRemoteFlows("qawolf_key", { - fetch: createFetchMock(jsonResponse({ flows: [flow] })), - baseUrl: "https://test.qawolf.com", - }); - - expect(result).toEqual({ ok: true, data: { flows: [flow] } }); - }); - - it("returns parse WireError when a flow entry is missing fields", async () => { - const result = await getRemoteFlows("qawolf_key", { - fetch: createFetchMock(jsonResponse({ flows: [{ id: "x" }] })), - baseUrl: "https://test.qawolf.com", - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.kind).toBe("parse"); - }); -}); diff --git a/src/shell/platform/getRemoteFlows.ts b/src/shell/platform/getRemoteFlows.ts deleted file mode 100644 index dad3e45d2..000000000 --- a/src/shell/platform/getRemoteFlows.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from "zod"; -import type { WireResult } from "./createTrpcClient.js"; -import { toError } from "./toError.js"; - -// `executionTarget` is either a preset string ("Web - Chrome") or an ad-hoc -// target object; accept both and let the display layer flatten it. -const remoteFlowSchema = z.object({ - executionTarget: z.union([z.string(), z.record(z.string(), z.unknown())]), - id: z.string(), - name: z.string(), - path: z.string(), - tags: z.array(z.string()), -}); - -const remoteFlowsResponseSchema = z.object({ - flows: z.array(remoteFlowSchema), -}); - -export type RemoteFlowsResponse = z.infer; - -type GetRemoteFlowsDeps = { - fetch: typeof globalThis.fetch; - baseUrl: string; -}; - -export async function getRemoteFlows( - apiKey: string, - deps: GetRemoteFlowsDeps, -): Promise> { - const url = `${deps.baseUrl}/api/v0/flows?includeDrafts=false`; - - let response: Response; - try { - response = await deps.fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(15_000), - }); - } catch (error: unknown) { - return { ok: false, error: { kind: "network", cause: toError(error) } }; - } - - if (!response.ok) { - const body = await response.text().catch(() => ""); - return { - ok: false, - error: { kind: "http", status: response.status, body }, - }; - } - - const json: unknown = await response.json().catch(() => undefined); - const parsed = remoteFlowsResponseSchema.safeParse(json); - if (!parsed.success) { - return { ok: false, error: { kind: "parse", cause: parsed.error } }; - } - - return { ok: true, data: parsed.data }; -} From bf956fd0234846550b19a2ea8ca122d3b72d7c4d Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:26:23 +0100 Subject: [PATCH 3/5] build(cli): lock @qawolf/api-contracts 0.2.0 The 0.2.0 publish landed on npm; the lockfile now resolves the dependency the feature commit already declares. --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 962ab70fe..e3072e156 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", "@playwright/test": "1.60.0", - "@qawolf/api-contracts": "0.1.0", + "@qawolf/api-contracts": "0.2.0", "@qawolf/emails": "1.1.1", "@qawolf/flow-targets": "1.0.0", "@qawolf/flows": "0.1.1", @@ -453,7 +453,7 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], - "@qawolf/api-contracts": ["@qawolf/api-contracts@0.1.0", "", { "dependencies": { "tslib": "^2.5.3" }, "peerDependencies": { "zod": "^4.1.11" } }, "sha512-a72Jak9680xlgriz+XB4pHi107wppvuq6ARKhzdzbsCTW6IelgCrecWkKmi8eK7Ub2GfuCo2iVJSWmfIeqYjxg=="], + "@qawolf/api-contracts": ["@qawolf/api-contracts@0.2.0", "", { "dependencies": { "tslib": "^2.5.3" }, "peerDependencies": { "zod": "^4.1.11" } }, "sha512-tyZHvLweEI7EDawK6xmLYetZR7DHsH3EMVxYi0kPMrJtoI7q2tgQ4Xst6ACUaSnsu/cdtUJnYAACHho3GT07Ag=="], "@qawolf/emailer-types": ["@qawolf/emailer-types@1.1.0", "", { "dependencies": { "email-addresses": "^5.0.0", "tslib": "^2.5.3", "zod": "^4.1.11", "zod-form-data": "^3.0.1" } }, "sha512-7cX+e31L9dpO8fntQ2cfSMt/1Lla/yUjaLUr/KLpXuscBSajy7CgNzjAGURa1KsCcxz7n+9PGQw6cM3RlC1bSg=="], From c07f0577041173211b13c8802a8e7c147f2ec681 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:17:30 +0100 Subject: [PATCH 4/5] fix(cli): open log fd eagerly while keeping writes async sync: true also made every log write a blocking writeSync; only the open needed to be synchronous. Open the fd at creation and hand it to pino, so the exit-time flush can never race the open while writes stay buffered and asynchronous. --- src/shell/logger.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/shell/logger.ts b/src/shell/logger.ts index e9f84b228..2e58053d9 100644 --- a/src/shell/logger.ts +++ b/src/shell/logger.ts @@ -1,4 +1,5 @@ -import { join } from "node:path"; +import { mkdirSync, openSync } from "node:fs"; +import { dirname, join } from "node:path"; import pino from "pino"; import envPaths from "env-paths"; @@ -42,10 +43,13 @@ export function createLoggingSystem(opts: { }): LoggingSystem { const { stderrLevel, logPath } = opts; - // sync so the fd opens at creation: with an async destination, a command - // that errors before the fd opens (e.g. instant flag validation) crashes - // pino's process-exit flush hook with "sonic boom is not ready yet". - const dest = pino.destination({ dest: logPath, mkdir: true, sync: true }); + // Open the fd ourselves so the destination is ready from construction: + // when SonicBoom opens a path asynchronously, a command that errors before + // the fd opens (e.g. instant flag validation) crashes pino's process-exit + // flush hook with "sonic boom is not ready yet". Passing an open fd keeps + // writes asynchronous without that startup race. + mkdirSync(dirname(logPath), { recursive: true }); + const dest = pino.destination({ fd: openSync(logPath, "a"), sync: false }); const fileLogger = pino({ level: "debug" }, dest); const levelNums = { From db51021a13456377b6c6ad3951f2e918de63dbfb Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:35:24 +0100 Subject: [PATCH 5/5] chore(cli): add changeset for log fd exit-race fix --- .changeset/log-fd-exit-race.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/log-fd-exit-race.md diff --git a/.changeset/log-fd-exit-race.md b/.changeset/log-fd-exit-race.md new file mode 100644 index 000000000..6293fe2e3 --- /dev/null +++ b/.changeset/log-fd-exit-race.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": patch +--- + +Fix a crash in the exit path when a command fails before the log file opens (a "sonic boom is not ready yet" stack trace printed after the error message). The log fd now opens eagerly at creation while writes stay asynchronous.