Skip to content
Draft
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
4 changes: 2 additions & 2 deletions src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
77 changes: 77 additions & 0 deletions src/commands/flows/configureEmailsForRun.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
92 changes: 92 additions & 0 deletions src/commands/flows/configureEmailsForRun.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigureEmailsOutcome> {
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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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<void> {
if (workers !== 1) return;
await configureEmailsForRun({
apiBaseUrl: ctx.apiBaseUrl,
configDir: ctx.configDir,
cwd,
fs: ctx.fs,
log: (message) => ctx.log("emails").debug(message),
});
}
2 changes: 2 additions & 0 deletions src/commands/flows/hybridRunDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/flows/runDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions src/commands/flows/runWorker.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const chunks: Uint8Array[] = [];
Expand All @@ -30,6 +34,13 @@ async function runWorker(signals: SignalRegistry): Promise<void> {
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,
Expand Down
46 changes: 18 additions & 28 deletions src/domains/emails/configureEmails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand All @@ -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<EmailsClient> => {
throw new Error("service unavailable");
Expand All @@ -52,31 +61,12 @@ describe("configureEmails", () => {

let caughtError: unknown;
try {
await configureEmails("https://example.com", "/test", deps);
await configureEmails(params, deps);
} catch (e) {
caughtError = e;
}

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");
});
});
22 changes: 16 additions & 6 deletions src/domains/emails/configureEmails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,27 @@ async function loadSdkDeps(cwd: string): Promise<EmailsModule> {
}
}

// Configure the emails client in platform-proxied mode: the client reaches the
// authenticated API at `${apiBaseUrl}/api/trpc/<proc>` 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<void> {
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);
}
26 changes: 26 additions & 0 deletions src/shell/resolveApiBaseUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading