diff --git a/src/commands/context.ts b/src/commands/context.ts index 7247ffab4..1b1bf75f1 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -5,6 +5,7 @@ import { authMessages } from "~/core/messages/index.js"; import { getConfigDir } from "~/core/paths.js"; import { exitCodes } from "~/shell/exit.js"; import { makeDefaultFs } from "~/shell/fs.js"; +import { resolveApiBaseUrl } from "~/shell/resolveApiBaseUrl.js"; import { requireApiKey } from "~/domains/auth/index.js"; import { createLoggingSystem, @@ -59,8 +60,7 @@ export function buildBaseContext( logPath: defaultLogPath(), ...(verboseWrite ? { verboseWrite } : {}), }); - const apiBaseUrl = - env["QAWOLF_API_URL"]?.replace(/\/+$/, "") || "https://app.qawolf.com"; + const apiBaseUrl = resolveApiBaseUrl(env); return { ctx: { ui: createUI(outputMode, { diff --git a/src/commands/flows/configureEmailsForRun.test.ts b/src/commands/flows/configureEmailsForRun.test.ts new file mode 100644 index 000000000..345680046 --- /dev/null +++ b/src/commands/flows/configureEmailsForRun.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import type { Fs } from "~/shell/fs.js"; +import { configureEmailsForRun } from "./configureEmailsForRun.js"; + +const fakeFs = {} as Fs; + +function baseParams() { + return { + apiBaseUrl: "https://app.qawolf.com", + configDir: "/cfg", + cwd: "/env", + fs: fakeFs, + log: undefined, + }; +} + +const okResolveApiKey = async () => ({ key: "k", source: "env" as const }); +const okGetIdentity = async () => ({ + ok: true as const, + data: { team: { id: "team-1", name: "T", createdAt: "2026-01-01" } }, +}); + +describe("configureEmailsForRun", () => { + it("configures emails on the happy path", async () => { + let captured: unknown; + const outcome = await configureEmailsForRun(baseParams(), { + resolveApiKey: okResolveApiKey, + getIdentity: okGetIdentity, + configureEmails: async (p: unknown) => { + captured = p; + }, + }); + expect(outcome).toBe("configured"); + expect(captured).toEqual({ + apiBaseUrl: "https://app.qawolf.com", + apiKey: "k", + teamId: "team-1", + cwd: "/env", + }); + }); + + it("skips when not authenticated and does not call configureEmails", async () => { + let called = false; + const outcome = await configureEmailsForRun(baseParams(), { + resolveApiKey: async () => undefined, + getIdentity: okGetIdentity, + configureEmails: async () => { + called = true; + }, + }); + expect(outcome).toBe("skipped-not-authenticated"); + expect(called).toBe(false); + }); + + it("skips when identity cannot be resolved", async () => { + const outcome = await configureEmailsForRun(baseParams(), { + resolveApiKey: okResolveApiKey, + getIdentity: async () => ({ + ok: false as const, + error: { kind: "network" as const, cause: new Error("offline") }, + }), + configureEmails: async () => {}, + }); + expect(outcome).toBe("skipped-identity-unavailable"); + }); + + it("skips when the emails client cannot be built", async () => { + const outcome = await configureEmailsForRun(baseParams(), { + resolveApiKey: okResolveApiKey, + getIdentity: okGetIdentity, + configureEmails: async () => { + throw new Error("module missing"); + }, + }); + expect(outcome).toBe("skipped-emails-unavailable"); + }); +}); diff --git a/src/commands/flows/configureEmailsForRun.ts b/src/commands/flows/configureEmailsForRun.ts new file mode 100644 index 000000000..c4bf77cb7 --- /dev/null +++ b/src/commands/flows/configureEmailsForRun.ts @@ -0,0 +1,92 @@ +import { resolveApiKey } from "~/domains/auth/index.js"; +import { configureEmails as defaultConfigureEmails } from "~/domains/emails/configureEmails.js"; +import type { Fs } from "~/shell/fs.js"; +import { getIdentity } from "~/shell/platform/getIdentity.js"; +import type { CommandContext } from "~/shell/commandContext.js"; + +export type ConfigureEmailsOutcome = + | "configured" + | "skipped-not-authenticated" + | "skipped-identity-unavailable" + | "skipped-emails-unavailable"; + +type ConfigureEmailsForRunDeps = { + resolveApiKey: typeof resolveApiKey; + getIdentity: typeof getIdentity; + configureEmails: typeof defaultConfigureEmails; +}; + +function makeDefaultDeps(): ConfigureEmailsForRunDeps { + return { + resolveApiKey, + getIdentity, + configureEmails: defaultConfigureEmails, + }; +} + +// Resolve credentials and register the emails client for the current process. +// Total: any failure degrades gracefully to a "skipped-*" outcome so a run is +// never broken by email setup. Email-dependent flows that need a client and did +// not get one surface @qawolf/emails' own clear error at mail.inbox() time. +export async function configureEmailsForRun( + params: { + apiBaseUrl: string; + configDir: string; + cwd: string; + fs: Fs; + log: ((message: string) => void) | undefined; + }, + deps?: ConfigureEmailsForRunDeps, +): Promise { + const resolvedDeps = deps ?? makeDefaultDeps(); + const log = params.log ?? ((): void => undefined); + + const apiKey = await resolvedDeps.resolveApiKey(params.configDir, params.fs); + if (apiKey === undefined) { + log("emails: skipped — not authenticated"); + return "skipped-not-authenticated"; + } + + const identity = await resolvedDeps.getIdentity(apiKey.key, { + fetch: globalThis.fetch, + baseUrl: params.apiBaseUrl, + }); + if (!identity.ok) { + log(`emails: skipped — team identity unavailable (${identity.error.kind})`); + return "skipped-identity-unavailable"; + } + + try { + await resolvedDeps.configureEmails({ + apiBaseUrl: params.apiBaseUrl, + apiKey: apiKey.key, + teamId: identity.data.team.id, + cwd: params.cwd, + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log(`emails: skipped — client unavailable (${detail})`); + return "skipped-emails-unavailable"; + } + + log("emails: configured"); + return "configured"; +} + +// In-process (`--workers 1`) convenience over configureEmailsForRun: pulls +// credentials from the command context. No-op for pooled runs — the worker +// entry configures those processes itself. +export async function configureEmailsForInProcessRun( + ctx: CommandContext, + cwd: string, + workers: number, +): Promise { + if (workers !== 1) return; + await configureEmailsForRun({ + apiBaseUrl: ctx.apiBaseUrl, + configDir: ctx.configDir, + cwd, + fs: ctx.fs, + log: (message) => ctx.log("emails").debug(message), + }); +} diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index b17558cf8..e9f47fa9b 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -29,6 +29,7 @@ import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.js"; import { loadEnvFile } from "./loadEnvFile.js"; +import { configureEmailsForInProcessRun } from "./configureEmailsForRun.js"; export type HandleHybridFlowsRunDeps = { expandPatterns: ( @@ -110,6 +111,7 @@ export async function handleHybridFlowsRun( ); await loadEnvFile(envDir); await resolvedDeps.configureTestkit(envDir); + await configureEmailsForInProcessRun(ctx, envDir, flags.workers); const android = createAndroidDeps(envDir, ctx.signals); return resolvedDeps.flowsRun(ctx, files, flags, { diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 7741304f5..90c4693c9 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -28,6 +28,7 @@ import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { loadEnvFile } from "./loadEnvFile.js"; +import { configureEmailsForInProcessRun } from "./configureEmailsForRun.js"; export type HandleFlowsRunDeps = { expandPatterns: ( @@ -106,6 +107,7 @@ export async function handleFlowsRun( const resolvedDir = envDir ?? cwd; await resolvedDeps.configureTestkit(resolvedDir); + await configureEmailsForInProcessRun(ctx, resolvedDir, flags.workers); const android = createAndroidDeps(resolvedDir, ctx.signals); return resolvedDeps.flowsRun(ctx, expandedFiles, flags, { peekFlowMeta: makePeekFlowMeta(ctx.fs), diff --git a/src/commands/flows/runWorker.register.ts b/src/commands/flows/runWorker.register.ts index 1945b2149..cf38adc5a 100644 --- a/src/commands/flows/runWorker.register.ts +++ b/src/commands/flows/runWorker.register.ts @@ -11,6 +11,10 @@ import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js" import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; import type { FlowsRunDeps } from "~/domains/runner/runInternals.js"; import { parseWorkerInput } from "~/domains/runner/workerProtocol.js"; +import { getConfigDir } from "~/core/paths.js"; +import { makeDefaultFs } from "~/shell/fs.js"; +import { resolveApiBaseUrl } from "~/shell/resolveApiBaseUrl.js"; +import { configureEmailsForRun } from "./configureEmailsForRun.js"; async function readStdin(): Promise { const chunks: Uint8Array[] = []; @@ -30,6 +34,13 @@ async function runWorker(signals: SignalRegistry): Promise { throw new Error("worker subprocess currently supports web flows only"); await configureTestkit(input.resolvedDir); + await configureEmailsForRun({ + apiBaseUrl: resolveApiBaseUrl(process.env), + configDir: getConfigDir(), + cwd: input.resolvedDir, + fs: makeDefaultFs(), + log: (message) => process.stderr.write(`${message}\n`), + }); const runWebFlowDeps = await defaultRunWebFlowDeps( input.resolvedDir, signals, diff --git a/src/domains/emails/configureEmails.test.ts b/src/domains/emails/configureEmails.test.ts index c32b036fa..259cc2503 100644 --- a/src/domains/emails/configureEmails.test.ts +++ b/src/domains/emails/configureEmails.test.ts @@ -8,8 +8,15 @@ const fakeClient: EmailsClient = { }, }; +const params = { + apiBaseUrl: "https://app.qawolf.com", + apiKey: "test-key", + teamId: "team-1", + cwd: "/test", +}; + describe("configureEmails", () => { - it("should call createEmailsClient with the correct options", async () => { + it("calls createEmailsClient with platform-proxied options", async () => { let capturedOpts: unknown; const deps = { createEmailsClient: async (opts: unknown) => { @@ -19,16 +26,18 @@ describe("configureEmails", () => { configureEmailsClient: () => {}, }; - await configureEmails("https://app.qawolf.com", "/test", deps); + await configureEmails(params, deps); expect(capturedOpts).toEqual({ - emailerUrl: "https://app.qawolf.com", - pollForEmailsDefaultTimeoutMs: 60_000, - waitForMessagesDefaultDelayMs: 1_000, + url: "https://app.qawolf.com/api", + apiKey: "test-key", + teamId: "team-1", + pollForEmailsDefaultTimeoutMs: 300_000, + waitForMessagesDefaultDelayMs: 15_000, }); }); - it("should register the client returned by createEmailsClient", async () => { + it("registers the client returned by createEmailsClient", async () => { let registeredClient: EmailsClient | undefined; const deps = { createEmailsClient: async () => fakeClient, @@ -37,12 +46,12 @@ describe("configureEmails", () => { }, }; - await configureEmails("https://example.com", "/test", deps); + await configureEmails(params, deps); expect(registeredClient).toBe(fakeClient); }); - it("should propagate errors thrown by createEmailsClient", async () => { + it("propagates errors thrown by createEmailsClient", async () => { const deps = { createEmailsClient: async (): Promise => { throw new Error("service unavailable"); @@ -52,7 +61,7 @@ describe("configureEmails", () => { let caughtError: unknown; try { - await configureEmails("https://example.com", "/test", deps); + await configureEmails(params, deps); } catch (e) { caughtError = e; } @@ -60,23 +69,4 @@ describe("configureEmails", () => { expect(caughtError).toBeInstanceOf(Error); expect((caughtError as Error).message).toBe("service unavailable"); }); - - it("should propagate errors thrown by configureEmailsClient", async () => { - const deps = { - createEmailsClient: async () => fakeClient, - configureEmailsClient: (): void => { - throw new Error("registration failed"); - }, - }; - - let caughtError: unknown; - try { - await configureEmails("https://example.com", "/test", deps); - } catch (e) { - caughtError = e; - } - - expect(caughtError).toBeInstanceOf(Error); - expect((caughtError as Error).message).toBe("registration failed"); - }); }); diff --git a/src/domains/emails/configureEmails.ts b/src/domains/emails/configureEmails.ts index 940d97a22..50c221b75 100644 --- a/src/domains/emails/configureEmails.ts +++ b/src/domains/emails/configureEmails.ts @@ -21,17 +21,27 @@ async function loadSdkDeps(cwd: string): Promise { } } +// Configure the emails client in platform-proxied mode: the client reaches the +// authenticated API at `${apiBaseUrl}/api/trpc/` using the CLI's API key +// and team id. createEmailsClient does no network I/O — it builds closures that +// fetch lazily when a flow reads mail. export async function configureEmails( - apiBaseUrl: string, - cwd: string, + params: { + apiBaseUrl: string; + apiKey: string; + teamId: string; + cwd: string; + }, deps?: EmailsModule, ): Promise { const { createEmailsClient, configureEmailsClient } = - deps ?? (await loadSdkDeps(cwd)); + deps ?? (await loadSdkDeps(params.cwd)); const client = await createEmailsClient({ - emailerUrl: apiBaseUrl, - pollForEmailsDefaultTimeoutMs: 60_000, - waitForMessagesDefaultDelayMs: 1_000, + url: `${params.apiBaseUrl}/api`, + apiKey: params.apiKey, + teamId: params.teamId, + pollForEmailsDefaultTimeoutMs: 300_000, + waitForMessagesDefaultDelayMs: 15_000, }); configureEmailsClient(client); } diff --git a/src/shell/resolveApiBaseUrl.test.ts b/src/shell/resolveApiBaseUrl.test.ts new file mode 100644 index 000000000..a3354c2fa --- /dev/null +++ b/src/shell/resolveApiBaseUrl.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { resolveApiBaseUrl } from "./resolveApiBaseUrl.js"; + +describe("resolveApiBaseUrl", () => { + it("returns the production default when QAWOLF_API_URL is unset", () => { + expect(resolveApiBaseUrl({})).toBe("https://app.qawolf.com"); + }); + + it("uses QAWOLF_API_URL when set", () => { + expect( + resolveApiBaseUrl({ QAWOLF_API_URL: "https://staging.example.com" }), + ).toBe("https://staging.example.com"); + }); + + it("trims trailing slashes", () => { + expect( + resolveApiBaseUrl({ QAWOLF_API_URL: "https://x.example.com///" }), + ).toBe("https://x.example.com"); + }); + + it("falls back to the default for an empty string", () => { + expect(resolveApiBaseUrl({ QAWOLF_API_URL: "" })).toBe( + "https://app.qawolf.com", + ); + }); +}); diff --git a/src/shell/resolveApiBaseUrl.ts b/src/shell/resolveApiBaseUrl.ts new file mode 100644 index 000000000..70ae48aef --- /dev/null +++ b/src/shell/resolveApiBaseUrl.ts @@ -0,0 +1,10 @@ +const defaultApiBaseUrl = "https://app.qawolf.com"; + +/** Resolve the apex API base URL from the environment, trailing slashes + * trimmed. Shared by the command context and the worker subprocess so both + * compute the same value. */ +export function resolveApiBaseUrl( + env: Record, +): string { + return env["QAWOLF_API_URL"]?.replace(/\/+$/, "") || defaultApiBaseUrl; +}