diff --git a/.changeset/flows-list-ai-task-id.md b/.changeset/flows-list-ai-task-id.md new file mode 100644 index 000000000..0fe3a4016 --- /dev/null +++ b/.changeset/flows-list-ai-task-id.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +`qawolf flows list --remote` accepts `--ai-task-id`, listing the flows on that AI task's branch — drafts included — instead of the ones the environment holds at its latest reconciled commit. It defaults to `QAWOLF_AI_TASK_ID`, which an AI task runner already sets, so the flag is only needed to point at another task. diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 89dd9aa7c..7f4099952 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -234,16 +234,19 @@ 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) - --env Environment to list flows from: a QA Wolf environment with - --remote, otherwise a pulled one by slug or id - --include-drafts Include draft flows in the listing (requires --remote) - (default: false) - --tag Only list flows carrying this tag; repeat for several. - Without --remote, matches against tags cached by the last - pull (default: []) - -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: a QA Wolf environment + with --remote, otherwise a pulled one by slug or id + --include-drafts Include draft flows in the listing (requires + --remote) (default: false) + --tag Only list flows carrying this tag; repeat for + several. Without --remote, matches against tags + cached by the last pull (default: []) + --ai-task-id List the flows on this AI task's branch, including + drafts, instead of the ones in the environment + (requires --remote) (env: QAWOLF_AI_TASK_ID) + -h, --help display help for command Examples: $ qawolf flows list @@ -253,6 +256,7 @@ Examples: $ qawolf flows list --env staging --tag auth $ qawolf flows list --remote --env staging --tag auth --tag smoke $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts + $ qawolf flows list --remote --env staging --ai-task-id ait_123 " `; diff --git a/src/commands/flows/index.aiTaskId.test.ts b/src/commands/flows/index.aiTaskId.test.ts new file mode 100644 index 000000000..7faa7f509 --- /dev/null +++ b/src/commands/flows/index.aiTaskId.test.ts @@ -0,0 +1,98 @@ +import { afterEach, expect, it } from "bun:test"; +import { Command } from "commander"; +import { publicContractsV1 } from "@qawolf/api-contracts/v1"; + +import type { AuthCommandContext } from "~/shell/commandContext.js"; +import { makeCtx, makeFakeUI } from "~/shell/commandContext.testUtils.js"; +import { + makeCallPublicApiMock, + makeMockPlatformClient, +} from "~/shell/platform/createPlatformClient.testUtils.js"; +import { createSignalRegistry } from "~/shell/signals/createSignalRegistry.js"; + +import { registerFlowsCommand } from "./index.js"; +import type { withResolvedEnv } from "./withResolvedEnv.js"; + +const callPublicApi = makeCallPublicApiMock().mockResolvedValue({ + ok: true, + value: { flows: [] }, +}); + +// The remote listing runs behind withResolvedEnv, which resolves an API key +// and a real platform client. Standing in for it is the smallest seam that +// still exercises the command's own option-to-request wiring. +const fakeWithResolvedEnv: typeof withResolvedEnv = + (_signals, args, fn) => async (): Promise => { + const ctx: AuthCommandContext = { + ...makeCtx("json"), + ui: { ...makeFakeUI("json"), mode: "json" }, + apiKeySource: "env", + platformClient: makeMockPlatformClient({ callPublicApi }), + }; + await fn(ctx, args.explicit ?? "environment-id", { + slug: undefined, + name: undefined, + }); + }; + +const originalAiTaskId = process.env["QAWOLF_AI_TASK_ID"]; + +afterEach(() => { + process.exitCode = 0; + callPublicApi.mockClear(); + if (originalAiTaskId === undefined) { + delete process.env["QAWOLF_AI_TASK_ID"]; + } else { + process.env["QAWOLF_AI_TASK_ID"] = originalAiTaskId; + } +}); + +async function listRemote(extraArgs: string[]): Promise { + const program = new Command().name("qawolf").exitOverride(); + registerFlowsCommand(program, createSignalRegistry(), { + withResolvedEnv: fakeWithResolvedEnv, + }); + await program.parseAsync( + ["flows", "list", "--remote", "--env", "staging", ...extraArgs], + { from: "user" }, + ); +} + +function requestedAiTaskId(): unknown { + const call = callPublicApi.mock.calls[0]; + if (call === undefined) throw new Error("flow.list was never requested"); + expect(call[0]).toBe(publicContractsV1.flow.list); + return (call[1] as { aiTaskId: unknown }).aiTaskId; +} + +it("forwards --ai-task-id to flow.list", async () => { + delete process.env["QAWOLF_AI_TASK_ID"]; + + await listRemote(["--ai-task-id", "flag-task-id"]); + + expect(requestedAiTaskId()).toBe("flag-task-id"); +}); + +it("defaults --ai-task-id from QAWOLF_AI_TASK_ID", async () => { + process.env["QAWOLF_AI_TASK_ID"] = "environment-task-id"; + + await listRemote([]); + + expect(requestedAiTaskId()).toBe("environment-task-id"); +}); + +it("prefers an explicit --ai-task-id over QAWOLF_AI_TASK_ID", async () => { + process.env["QAWOLF_AI_TASK_ID"] = "environment-task-id"; + + await listRemote(["--ai-task-id", "flag-task-id"]); + + expect(requestedAiTaskId()).toBe("flag-task-id"); +}); + +it("omits aiTaskId when neither the flag nor the variable is set", async () => { + delete process.env["QAWOLF_AI_TASK_ID"]; + + await listRemote([]); + + expect(requestedAiTaskId()).toBeUndefined(); +}); diff --git a/src/commands/flows/index.test.ts b/src/commands/flows/index.test.ts index c5af307e5..848d91c21 100644 --- a/src/commands/flows/index.test.ts +++ b/src/commands/flows/index.test.ts @@ -87,4 +87,11 @@ describe("flows list --tag parsing", () => { expect(output).not.toContain("src/flows/**"); }); + + it("rejects --ai-task-id without --remote", async () => { + const output = await runList(["--ai-task-id", "task-1"]); + + expect(process.exitCode).toBe(1); + expect(output).toContain(flowsMessages.list.aiTaskIdRequiresRemote); + }); }); diff --git a/src/commands/flows/index.ts b/src/commands/flows/index.ts index be715de2e..b9d2ae2a3 100644 --- a/src/commands/flows/index.ts +++ b/src/commands/flows/index.ts @@ -1,4 +1,4 @@ -import type { Command } from "commander"; +import { Option, type Command } from "commander"; import { declareCommandKind } from "~/commands/commandKind.js"; import { withContext } from "~/commands/context.js"; @@ -21,18 +21,27 @@ Examples: $ qawolf flows list --tag auth $ qawolf flows list --env staging --tag auth $ qawolf flows list --remote --env staging --tag auth --tag smoke - $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts`; + $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts + $ qawolf flows list --remote --env staging --ai-task-id ait_123`; type FlowsListOptions = { readonly remote: boolean; readonly env: string | undefined; readonly includeDrafts: boolean; + readonly aiTaskId: string | undefined; readonly tag: string[]; }; +type Deps = { + // The remote listing resolves its environment (and its auth) through this. + // A test stands in its own to drive the command without a platform. + readonly withResolvedEnv: typeof withResolvedEnv; +}; + export function registerFlowsCommand( program: Command, signals: SignalRegistry, + deps: Deps = { withResolvedEnv }, ): void { const flows = program .command("flows") @@ -67,6 +76,12 @@ export function registerFlowsCommand( collectValue, [], ) + .addOption( + new Option( + "--ai-task-id ", + "List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote)", + ).env("QAWOLF_AI_TASK_ID"), + ) .addHelpText("after", listExamples) .action( ( @@ -76,7 +91,7 @@ export function registerFlowsCommand( ) => { const tags = opts.tag; if (opts.remote) { - return withResolvedEnv( + return deps.withResolvedEnv( signals, { explicit: opts.env, @@ -86,10 +101,19 @@ export function registerFlowsCommand( flowsListRemote(ctx, pattern, { env, includeDrafts: opts.includeDrafts, + aiTaskId: opts.aiTaskId, tags, }), )(opts, command); } + // Only an explicitly passed --ai-task-id is a usage error here: + // QAWOLF_AI_TASK_ID is ambient in AI task runners, and a local + // listing must not fail just because it is set. + if (command.getOptionValueSource("aiTaskId") === "cli") { + return withContext(signals, async () => ({ + error: flowsMessages.list.aiTaskIdRequiresRemote, + }))(opts, command); + } // --include-drafts is a platform concept; --env is not, so without // --remote it names a pulled environment and is answered from disk. if (opts.includeDrafts) { diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index b85c5383f..9f7ce0ed9 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -17,6 +17,7 @@ export const flowsMessages = { list: { remoteRequiresEnv: "--remote requires an environment. Pass --env or set QAWOLF_ENVIRONMENT.", + aiTaskIdRequiresRemote: "--ai-task-id requires --remote", draftsRequireRemote: "--include-drafts requires --remote", }, selectors: { diff --git a/src/domains/flows/listRemote.errors.test.ts b/src/domains/flows/listRemote.errors.test.ts index b68dc7ec4..4390a7c08 100644 --- a/src/domains/flows/listRemote.errors.test.ts +++ b/src/domains/flows/listRemote.errors.test.ts @@ -17,6 +17,7 @@ afterEach(() => { }); const options: FlowsListRemoteOptions = { + aiTaskId: undefined, env: "environment-id", includeDrafts: false, tags: [], diff --git a/src/domains/flows/listRemote.selectors.test.ts b/src/domains/flows/listRemote.selectors.test.ts index 14537cda8..afe4e55c3 100644 --- a/src/domains/flows/listRemote.selectors.test.ts +++ b/src/domains/flows/listRemote.selectors.test.ts @@ -18,6 +18,7 @@ afterEach(() => { }); const defaultOptions: FlowsListRemoteOptions = { + aiTaskId: undefined, env: "environment-id", includeDrafts: false, tags: [], diff --git a/src/domains/flows/listRemote.test.ts b/src/domains/flows/listRemote.test.ts index bae5aa867..8b4d0909c 100644 --- a/src/domains/flows/listRemote.test.ts +++ b/src/domains/flows/listRemote.test.ts @@ -19,6 +19,7 @@ afterEach(() => { }); const defaultOptions: FlowsListRemoteOptions = { + aiTaskId: undefined, env: "environment-id", includeDrafts: false, tags: [], @@ -98,7 +99,19 @@ describe("flowsListRemote wire call", () => { expect(platformClient.callPublicApi).toHaveBeenCalledWith( publicContractsV1.flow.list, - { environmentId: "env-1", includeDrafts: true }, + { aiTaskId: undefined, environmentId: "env-1", includeDrafts: true }, + ); + }); + + it("passes the AI task through to public.flow.list", async () => { + const { platformClient } = await run({ + mode: "json", + options: { ...defaultOptions, aiTaskId: "task-1", env: "env-1" }, + }); + + expect(platformClient.callPublicApi).toHaveBeenCalledWith( + publicContractsV1.flow.list, + { aiTaskId: "task-1", environmentId: "env-1", includeDrafts: false }, ); }); }); diff --git a/src/domains/flows/listRemote.ts b/src/domains/flows/listRemote.ts index 24a17b87f..a457c29fb 100644 --- a/src/domains/flows/listRemote.ts +++ b/src/domains/flows/listRemote.ts @@ -25,6 +25,7 @@ type RemoteListItem = { export type FlowsListRemoteOptions = { readonly env: string; readonly includeDrafts: boolean; + readonly aiTaskId: string | undefined; readonly tags: readonly string[]; }; @@ -36,6 +37,7 @@ export async function flowsListRemote( const result = await ctx.platformClient.callPublicApi( publicContractsV1.flow.list, { + aiTaskId: options.aiTaskId, environmentId: options.env, includeDrafts: options.includeDrafts, },