diff --git a/.changeset/runner-run-default-env.md b/.changeset/runner-run-default-env.md new file mode 100644 index 000000000..44b09447c --- /dev/null +++ b/.changeset/runner-run-default-env.md @@ -0,0 +1,7 @@ +--- +"@qawolf/cli": patch +--- + +`qawolf runner run` now falls back to `QAWOLF_ENVIRONMENT` when neither `--env-id` nor `--env-file` is passed, so the one export that already sets a default for `qawolf flows` covers a runner run too. `--env-id` still wins over it, and `--env-file` suppresses it so a run reading a dotenv file is not handed a second environment on top. + +The run reports on stderr which environment it picked up. A run that was given none before is now given one, and those variables reach the flow's code, so it should never happen silently. diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 47be8fd4e..3890b49d2 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -38,7 +38,9 @@ is set; otherwise select an id or alias from `qawolf --json environment find` and reuse it. Shell invocations may not share exported variables, so pass the selected environment explicitly instead of rediscovering it. Check the command's help for its environment flag: variable -commands use `--environment-id`; `qawolf flows run` uses `--env`. +commands use `--environment-id`; `qawolf flows run` uses `--env`; and +`qawolf runner run` uses `--env-id`, which falls back to `QAWOLF_ENVIRONMENT` +when neither it nor `--env-file` is passed. If multiple environments are returned and the task context does not identify the target, ask instead of guessing. Do not default to the newest environment. diff --git a/skills/qawolf-cli/references/runner.md b/skills/qawolf-cli/references/runner.md index 30c78b571..98af005e5 100644 --- a/skills/qawolf-cli/references/runner.md +++ b/skills/qawolf-cli/references/runner.md @@ -312,6 +312,18 @@ qawolf runner run flows/checkout.flow.ts --env-id staging qawolf runner run flows/checkout.flow.ts --env-file .env ``` +**A run with neither flag falls back to `QAWOLF_ENVIRONMENT`**, the same +variable `qawolf flows` reads, so one export covers both. The run says on +stderr which environment it picked up, because those variables reach your flow's +code and a run should never be given an environment silently. `--env-id` wins +over it, and `--env-file` suppresses it, so a run reading a dotenv file is not +handed a second environment on top. + +```sh +export QAWOLF_ENVIRONMENT=staging +qawolf runner run flows/checkout.flow.ts # runs against staging +``` + **Prefer `--env-id`.** QA Wolf reads and decrypts the environment itself, so the values never leave the server, nothing has to be pulled to disk first, and no size limit applies to them. It is the only way to run a flow whose environment diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 8f9f9bc3d..1426ae756 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -347,9 +347,9 @@ Options: submission: the recorder is runner-wide, not run-scoped. Implies --follow (default: false) --env-id QA Wolf environment whose variables the run is given, by - id or alias. QA Wolf reads and decrypts them itself, so - they never leave the server and no size limit applies to - them + id or alias. Defaults to QAWOLF_ENVIRONMENT. QA Wolf + reads and decrypts them itself, so they never leave the + server and no size limit applies to them --env-file Dotenv file on this machine whose variables the run is given. Pass this or --env-id, not both --lines Run only these 1-indexed lines against the browser as it diff --git a/src/commands/qawolfCliSkill.template.md b/src/commands/qawolfCliSkill.template.md index 0d3ac447a..7b65e04b1 100644 --- a/src/commands/qawolfCliSkill.template.md +++ b/src/commands/qawolfCliSkill.template.md @@ -38,7 +38,9 @@ is set; otherwise select an id or alias from `qawolf --json environment find` and reuse it. Shell invocations may not share exported variables, so pass the selected environment explicitly instead of rediscovering it. Check the command's help for its environment flag: variable -commands use `--environment-id`; `qawolf flows run` uses `--env`. +commands use `--environment-id`; `qawolf flows run` uses `--env`; and +`qawolf runner run` uses `--env-id`, which falls back to `QAWOLF_ENVIRONMENT` +when neither it nor `--env-file` is passed. If multiple environments are returned and the task context does not identify the target, ask instead of guessing. Do not default to the newest environment. diff --git a/src/commands/runner/run.register.ts b/src/commands/runner/run.register.ts index e52cd8903..3ed6a579b 100644 --- a/src/commands/runner/run.register.ts +++ b/src/commands/runner/run.register.ts @@ -62,7 +62,7 @@ export function registerRunCommand( ) .option( "--env-id ", - "QA Wolf environment whose variables the run is given, by id or alias. QA Wolf reads and decrypts them itself, so they never leave the server and no size limit applies to them", + "QA Wolf environment whose variables the run is given, by id or alias. Defaults to QAWOLF_ENVIRONMENT. QA Wolf reads and decrypts them itself, so they never leave the server and no size limit applies to them", ) .option( "--env-file ", diff --git a/src/domains/interactiveRunner/deps.testUtils.ts b/src/domains/interactiveRunner/deps.testUtils.ts index 986372de3..fdbba422c 100644 --- a/src/domains/interactiveRunner/deps.testUtils.ts +++ b/src/domains/interactiveRunner/deps.testUtils.ts @@ -25,6 +25,7 @@ export const testCwd = "/workspace"; export function makeAuthCtx(mode: OutputMode = "human"): { callPublicApi: ReturnType; ctx: AuthCommandContext; + infos: () => string[]; outputs: () => { data: unknown; humanMessage: string }[]; streamed: () => string[]; streamedData: () => unknown[]; @@ -39,6 +40,10 @@ export function makeAuthCtx(mode: OutputMode = "human"): { apiKeySource: "env", platformClient: makeMockPlatformClient({ callPublicApi }), }, + infos: () => + (base.ui.info as Mock<(message: string) => void>).mock.calls.map( + ([message]) => message, + ), outputs: () => ( base.ui.output as Mock<(data: unknown, humanMessage: string) => void> diff --git a/src/domains/interactiveRunner/prepareRun.ts b/src/domains/interactiveRunner/prepareRun.ts index c97c67a36..f50032578 100644 --- a/src/domains/interactiveRunner/prepareRun.ts +++ b/src/domains/interactiveRunner/prepareRun.ts @@ -49,17 +49,22 @@ export async function prepareRun( }, deps: InteractiveRunnerDeps, ): Promise { - const environmentId = options.envId?.trim(); - if (environmentId !== undefined && options.envFile !== undefined) { + const explicitEnvId = options.envId?.trim(); + if (explicitEnvId !== undefined && options.envFile !== undefined) { return refused( interactiveRunnerMessages.envIdWithEnvFile, exitCodes.invalidArgs, ); } - if (environmentId === "") { + if (explicitEnvId === "") { return refused(interactiveRunnerMessages.envIdBlank, exitCodes.invalidArgs); } + const fromEnvVar = deps.env["QAWOLF_ENVIRONMENT"]?.trim(); + const environmentId = + explicitEnvId ?? + (options.envFile === undefined && fromEnvVar ? fromEnvVar : undefined); + const linesFilePath = options.linesFile === undefined ? undefined diff --git a/src/domains/interactiveRunner/runFlow.environment.test.ts b/src/domains/interactiveRunner/runFlow.environment.test.ts index 963f5a0dd..25c65eb02 100644 --- a/src/domains/interactiveRunner/runFlow.environment.test.ts +++ b/src/domains/interactiveRunner/runFlow.environment.test.ts @@ -7,8 +7,9 @@ import { handleRunnerRun } from "./runFlow.js"; const submitted = { outcome: "success" as const, runId: "run-a" }; describe("handleRunnerRun with --env-file and --env-id", () => { - const envFileDeps = (content: string) => + const envFileDeps = (content: string, env: Record = {}) => makeTestDeps({ + env, collectRunFiles: async () => ({ files: { "flow.ts": "export default {};", @@ -24,14 +25,16 @@ describe("handleRunnerRun with --env-file and --env-id", () => { const run = async ({ content = 'A="1"\n', + env, envFile, envId, }: { content?: string; + env?: Record; envFile?: string; envId?: string; }) => { - const { callPublicApi, ctx } = makeAuthCtx(); + const { callPublicApi, ctx, infos } = makeAuthCtx(); callPublicApi.mockResolvedValue({ ok: true, value: submitted }); const result = await handleRunnerRun( ctx, @@ -48,9 +51,9 @@ describe("handleRunnerRun with --env-file and --env-id", () => { runner: "ci", timeout: undefined, }, - envFileDeps(content), + envFileDeps(content, env), ); - return { callPublicApi, result }; + return { callPublicApi, infos, result }; }; const sentRequest = (callPublicApi: { mock: { calls: unknown[][] } }) => @@ -106,6 +109,52 @@ describe("handleRunnerRun with --env-file and --env-id", () => { }); }); + it("falls back to QAWOLF_ENVIRONMENT when neither flag is passed", async () => { + const { callPublicApi, infos, result } = await run({ + env: { QAWOLF_ENVIRONMENT: "staging" }, + }); + + expect(result).toBeUndefined(); + expect(sentRequest(callPublicApi)).toMatchObject({ + environmentId: "staging", + }); + // Those variables reach the flow's code, so the run says where they came from. + expect(infos().join("\n")).toContain("QAWOLF_ENVIRONMENT"); + }); + + it("prefers an explicit --env-id over QAWOLF_ENVIRONMENT", async () => { + const { callPublicApi, infos } = await run({ + env: { QAWOLF_ENVIRONMENT: "staging" }, + envId: "production", + }); + + expect(sentRequest(callPublicApi)).toMatchObject({ + environmentId: "production", + }); + expect(infos().join("\n")).not.toContain("QAWOLF_ENVIRONMENT"); + }); + + // The file is the whole environment the caller asked for, so it is not given + // a second one on top. + it("ignores QAWOLF_ENVIRONMENT when --env-file is passed", async () => { + const { callPublicApi } = await run({ + env: { QAWOLF_ENVIRONMENT: "staging" }, + envFile: ".env", + }); + const request = sentRequest(callPublicApi); + + expect(request).toMatchObject({ env: { A: "1" } }); + expect(Object.hasOwn(request, "environmentId")).toBe(false); + }); + + it("treats a blank QAWOLF_ENVIRONMENT as unset", async () => { + const { callPublicApi } = await run({ env: { QAWOLF_ENVIRONMENT: " " } }); + + expect(Object.hasOwn(sentRequest(callPublicApi), "environmentId")).toBe( + false, + ); + }); + it("refuses an --env-id given nothing", async () => { const { callPublicApi, result } = await run({ envId: " " }); diff --git a/src/domains/interactiveRunner/runFlow.ts b/src/domains/interactiveRunner/runFlow.ts index 281596981..e2ba6ea3f 100644 --- a/src/domains/interactiveRunner/runFlow.ts +++ b/src/domains/interactiveRunner/runFlow.ts @@ -1,6 +1,9 @@ import { parseFollowTimeout } from "~/core/interactiveRunner/followTimeout.js"; import { toCollectedPath } from "~/core/interactiveRunner/runFiles.js"; -import { interactiveRunnerMessages } from "~/core/messages/index.js"; +import { + environmentsMessages, + interactiveRunnerMessages, +} from "~/core/messages/index.js"; import type { AuthCommandContext, CommandResult, @@ -55,6 +58,10 @@ export async function handleRunnerRun( return { error: prepared.error, exitCode: prepared.exitCode }; } + if (options.envId === undefined && prepared.environmentId !== undefined) { + ctx.ui.info(environmentsMessages.usingFromEnvVar(prepared.environmentId)); + } + const resolved = await resolveRunner( ctx, { autoLaunch: true, runner: options.runner },