Skip to content
Merged
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/flows-list-ai-task-id.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 14 additions & 10 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <name> 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 <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 <name> Only list flows carrying this tag; repeat for
several. Without --remote, matches against tags
cached by the last pull (default: [])
--ai-task-id <aiTaskId> 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
Expand All @@ -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
"
`;

Expand Down
98 changes: 98 additions & 0 deletions src/commands/flows/index.aiTaskId.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> {
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();
Comment thread
felipe-augusto marked this conversation as resolved.
});
7 changes: 7 additions & 0 deletions src/commands/flows/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
30 changes: 27 additions & 3 deletions src/commands/flows/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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")
Expand Down Expand Up @@ -67,6 +76,12 @@ export function registerFlowsCommand(
collectValue,
[],
)
.addOption(
new Option(
"--ai-task-id <aiTaskId>",
"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(
(
Expand All @@ -76,7 +91,7 @@ export function registerFlowsCommand(
) => {
const tags = opts.tag;
if (opts.remote) {
return withResolvedEnv(
return deps.withResolvedEnv(
signals,
{
explicit: opts.env,
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/core/messages/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const flowsMessages = {
list: {
remoteRequiresEnv:
"--remote requires an environment. Pass --env <env> or set QAWOLF_ENVIRONMENT.",
aiTaskIdRequiresRemote: "--ai-task-id requires --remote",
draftsRequireRemote: "--include-drafts requires --remote",
},
selectors: {
Expand Down
1 change: 1 addition & 0 deletions src/domains/flows/listRemote.errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ afterEach(() => {
});

const options: FlowsListRemoteOptions = {
aiTaskId: undefined,
env: "environment-id",
includeDrafts: false,
tags: [],
Expand Down
1 change: 1 addition & 0 deletions src/domains/flows/listRemote.selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ afterEach(() => {
});

const defaultOptions: FlowsListRemoteOptions = {
aiTaskId: undefined,
env: "environment-id",
includeDrafts: false,
tags: [],
Expand Down
15 changes: 14 additions & 1 deletion src/domains/flows/listRemote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ afterEach(() => {
});

const defaultOptions: FlowsListRemoteOptions = {
aiTaskId: undefined,
env: "environment-id",
includeDrafts: false,
tags: [],
Expand Down Expand Up @@ -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 },
);
});
});
Expand Down
2 changes: 2 additions & 0 deletions src/domains/flows/listRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type RemoteListItem = {
export type FlowsListRemoteOptions = {
readonly env: string;
readonly includeDrafts: boolean;
readonly aiTaskId: string | undefined;
readonly tags: readonly string[];
};

Expand All @@ -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,
},
Expand Down
Loading