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/.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. 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=="], 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/logger.ts b/src/shell/logger.ts index 3979f5fc9..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,7 +43,13 @@ export function createLoggingSystem(opts: { }): LoggingSystem { const { stderrLevel, logPath } = opts; - const dest = pino.destination({ dest: logPath, mkdir: true, sync: false }); + // 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 = { @@ -96,8 +103,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. } }, }; 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 }; -}