Skip to content
Open
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/shared-flow-runtime-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

Provide shared local flow runtime dependencies for email inboxes and environment variable persistence across web and Android flows.
9 changes: 6 additions & 3 deletions src/commands/flows/buildFlowsRunDeps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js"
import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js";
import type { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js";
import type { RunWebFlowDeps } from "~/domains/runner/runWebFlow.js";
import type { FlowRuntimeDeps } from "~/domains/runner/flowRuntimeDeps.js";
import type {
FlowsRunDeps,
FlowsRunFlags,
Expand All @@ -21,6 +22,7 @@ type BuildFlowsRunDepsArgs = {
resolvedDir: string;
android: ReturnType<typeof createAndroidDeps>;
runWebFlowDeps: RunWebFlowDeps;
flowRuntimeDeps: FlowRuntimeDeps;
flags: FlowsRunFlags;
};

Expand All @@ -31,7 +33,8 @@ type BuildFlowsRunDepsArgs = {
* the injection point for tests) and passed in already awaited.
*/
export function buildFlowsRunDeps(args: BuildFlowsRunDepsArgs): FlowsRunDeps {
const { ctx, resolvedDir, android, runWebFlowDeps, flags } = args;
const { ctx, resolvedDir, android, runWebFlowDeps, flowRuntimeDeps, flags } =
args;
return {
peekFlowMeta: makePeekFlowMeta(ctx.fs),
installBrowsers: (innerCtx, browsers) =>
Expand All @@ -41,9 +44,9 @@ export function buildFlowsRunDeps(args: BuildFlowsRunDepsArgs): FlowsRunDeps {
playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform),
}),
runWebFlow: defaultRunWebFlow,
runWebFlowDeps,
runWebFlowDeps: { ...runWebFlowDeps, flowRuntimeDeps },
runAndroidFlow: defaultRunAndroidFlow,
runAndroidFlowDeps: android.deps,
runAndroidFlowDeps: { ...android.deps, flowRuntimeDeps },
bootAndroid: android.boot,
shutdownAndroid: android.shutdown,
createPooledDispatch: makePooledDispatch(resolvedDir),
Expand Down
63 changes: 63 additions & 0 deletions src/commands/flows/envVarDeps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { join } from "node:path";

import { makeMemoryFs } from "~/shell/fs.testUtils.js";
import { makeEnvVarDeps } from "./envVarDeps.js";

type EnvVarRuntimeDeps = {
setEnvironmentVariable: (key: string, value: string) => Promise<void>;
fetchLatestEnvironmentVariables: () => Promise<void>;
};

function makeDeps(envDir: string): {
fs: ReturnType<typeof makeMemoryFs>;
deps: EnvVarRuntimeDeps;
} {
const fs = makeMemoryFs();
return { fs, deps: makeEnvVarDeps(envDir, fs) as EnvVarRuntimeDeps };
}

describe("makeEnvVarDeps", () => {
it("persists setEnvironmentVariable to the env .env and process.env", async () => {
const envDir = "/env";
const { fs, deps } = makeDeps(envDir);
await fs.mkdir(envDir, { recursive: true });

await deps.setEnvironmentVariable("TOKEN", "abc");

expect(await fs.readFile(join(envDir, ".env"))).toBe('TOKEN="abc"\n');
expect(process.env["TOKEN"]).toBe("abc");
delete process.env["TOKEN"];
});

it("persists keys that need quoting instead of silently dropping them", async () => {
const envDir = "/env";
const { fs, deps } = makeDeps(envDir);
await fs.mkdir(envDir, { recursive: true });

await deps.setEnvironmentVariable("DOTTED.KEY", "abc");

expect(await fs.readFile(join(envDir, ".env"))).toBe(
'"DOTTED.KEY"="abc"\n',
);
expect(process.env["DOTTED.KEY"]).toBe("abc");
delete process.env["DOTTED.KEY"];
});

it("does not swallow malformed .env content", async () => {
const envDir = "/env";
const { fs, deps } = makeDeps(envDir);
await fs.mkdir(envDir, { recursive: true });
await fs.writeFile(join(envDir, ".env"), "not valid\n");

let caught: unknown;
try {
await deps.fetchLatestEnvironmentVariables();
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/Cannot parse/i);
});
});
56 changes: 56 additions & 0 deletions src/commands/flows/envVarDeps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { join } from "node:path";

import { isNoEntError } from "~/core/errors.js";
import { parseDotenv, serializeDotenv } from "~/core/dotenv.js";
import type { FlowRuntimeDeps } from "~/domains/runner/flowRuntimeDeps.js";
import type { Fs } from "~/shell/fs.js";

function envFilePath(envDir: string): string {
return join(envDir, ".env");
}

async function readEnv(
envDir: string,
fs: Fs,
): Promise<Record<string, string>> {
try {
return parseDotenv(await fs.readFile(envFilePath(envDir)));
} catch (err) {
if (isNoEntError(err)) return {};
throw err;
}
}

async function writeEnv(
envDir: string,
fs: Fs,
vars: Record<string, string>,
): Promise<void> {
const target = envFilePath(envDir);
const tmp = `${target}.${process.pid}.tmp`;
await fs.writeFile(tmp, serializeDotenv(vars), { mode: 0o600 });
await fs.rename(tmp, target);
}

// Flows persist values (e.g. freshly minted auth tokens) across runs via
// `setEnvironmentVariable`, and reload them via `fetchLatestEnvironmentVariables`.
// Locally we back these by the project's `.env` so subsequent flows — and the
// rest of the current flow — observe the change.
export function makeEnvVarDeps(envDir: string, fs: Fs): FlowRuntimeDeps {
return {
setEnvironmentVariable: async (key: string, value: string) => {
const vars = await readEnv(envDir, fs);
vars[key] = value;
await writeEnv(envDir, fs, vars);
process.env[key] = value;
},
fetchLatestEnvironmentVariables: async () => {
const vars = await readEnv(envDir, fs);
// Explicit reload: override existing process.env values with the latest
// from disk (unlike initial load, which only fills missing keys).
for (const [key, value] of Object.entries(vars)) {
process.env[key] = value;
}
},
};
}
85 changes: 85 additions & 0 deletions src/commands/flows/flowRuntimeDeps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, mock } from "bun:test";
import type { EmailsClient } from "@qawolf/emails";

import { makeNoopLogger } from "~/shell/logger.testUtils.js";
import { makeMemoryFs } from "~/shell/fs.testUtils.js";
import { createFlowRuntimeDeps } from "./flowRuntimeDeps.js";

function makeCtx() {
return {
apiBaseUrl: "https://app.qawolf.com",
configDir: "/config",
fs: makeMemoryFs(),
log: () => makeNoopLogger(),
};
}

describe("createFlowRuntimeDeps", () => {
it("uses explicit EMAILER_URL lazily without resolving API credentials", async () => {
const getInbox = mock(
async () => undefined,
) as unknown as EmailsClient["getInbox"];
const client = { getInbox } as unknown as EmailsClient;
const resolveApiKeyFn = mock(async () => {
throw new Error("should not resolve api key");
});
const configureEmailsFn = mock(async () => client);

const deps = await createFlowRuntimeDeps({
envDir: "/env",
ctx: makeCtx(),
env: {
EMAILER_URL: "https://emailer.example",
CLOUD_AGENTS_INBOX_TEAM_ID: "team_123",
},
resolveApiKeyFn,
configureEmailsFn,
});

expect(resolveApiKeyFn).not.toHaveBeenCalled();
expect(configureEmailsFn).not.toHaveBeenCalled();

await (deps.getInbox as (...args: unknown[]) => Promise<unknown>)({
address: "test@example.com",
});

expect(resolveApiKeyFn).not.toHaveBeenCalled();
expect(configureEmailsFn).toHaveBeenCalledWith(
{ emailerUrl: "https://emailer.example", teamId: "team_123" },
"/env",
);
});

it("returns env-var deps immediately and delays missing-auth failure until getInbox", async () => {
const configureEmailsFn = mock(
async () =>
({ getInbox: async () => undefined }) as unknown as EmailsClient,
);

const deps = await createFlowRuntimeDeps({
envDir: "/env",
ctx: makeCtx(),
env: {},
resolveApiKeyFn: async () => undefined,
configureEmailsFn,
});

expect(configureEmailsFn).not.toHaveBeenCalled();
expect(deps.getInbox).toBeFunction();
expect(deps.setEnvironmentVariable).toBeFunction();
expect(deps.fetchLatestEnvironmentVariables).toBeFunction();

let caught: unknown;
try {
await (deps.getInbox as (...args: unknown[]) => Promise<unknown>)({
address: "test@example.com",
});
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/getInbox requires EMAILER_URL/);
expect(configureEmailsFn).not.toHaveBeenCalled();
});
});
130 changes: 130 additions & 0 deletions src/commands/flows/flowRuntimeDeps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { resolveApiKey } from "~/domains/auth/resolve.js";
import {
configureEmails,
registerLazyEmailsClient,
} from "~/domains/emails/configureEmails.js";
import type { FlowRuntimeDeps } from "~/domains/runner/flowRuntimeDeps.js";
import type { CommandContext } from "~/shell/commandContext.js";
import { createPlatformClient } from "~/shell/platform/createPlatformClient.js";
import type { PlatformClient } from "~/shell/platform/createPlatformClient.js";
import { makeEnvVarDeps } from "./envVarDeps.js";

type CreateFlowRuntimeDepsArgs = {
readonly envDir: string;
readonly ctx: Pick<CommandContext, "apiBaseUrl" | "configDir" | "fs"> &
Partial<Pick<CommandContext, "log">>;
readonly platform?: PlatformClient;
readonly env?: Record<string, string | undefined>;
readonly resolveApiKeyFn?: typeof resolveApiKey;
readonly configureEmailsFn?: typeof configureEmails;
readonly createPlatform?: typeof createPlatformClient;
};

type GetInbox = NonNullable<FlowRuntimeDeps["getInbox"]>;

type LazyGetInboxArgs = Omit<
Required<CreateFlowRuntimeDepsArgs>,
"platform"
> & {
readonly platform: PlatformClient | undefined;
};

async function maybeGetIdentityTeamId(
platform: PlatformClient,
): Promise<string | undefined> {
const identity = await platform.getIdentity();
return identity.ok ? identity.value.team.id : undefined;
}

function lazyGetInbox({
envDir,
ctx,
platform,
env,
resolveApiKeyFn,
configureEmailsFn,
createPlatform,
}: LazyGetInboxArgs): GetInbox {
let getInboxPromise: Promise<GetInbox> | undefined;

async function configure(): Promise<GetInbox> {
const explicitEmailerUrl = env["EMAILER_URL"];
let teamId = env["CLOUD_AGENTS_INBOX_TEAM_ID"];
const apiKeyResult =
explicitEmailerUrl === undefined
? await resolveApiKeyFn(ctx.configDir, ctx.fs)
: undefined;

if (teamId === undefined && platform !== undefined) {
teamId = await maybeGetIdentityTeamId(platform);
}

if (teamId === undefined && apiKeyResult !== undefined) {
const identityPlatform = createPlatform(apiKeyResult.key, {
baseUrl: ctx.apiBaseUrl,
fetch: globalThis.fetch,
...(ctx.log ? { logger: ctx.log("trpc") } : {}),
});
teamId = await maybeGetIdentityTeamId(identityPlatform);
}

const teamIdPart = teamId !== undefined ? { teamId } : {};
if (explicitEmailerUrl !== undefined) {
const client = await configureEmailsFn(
{ emailerUrl: explicitEmailerUrl, ...teamIdPart },
envDir,
);
return client.getInbox;
}

if (apiKeyResult === undefined) {
throw new Error(
"getInbox requires EMAILER_URL, QAWOLF_API_KEY, or stored QA Wolf credentials. Run 'qawolf auth login'.",
);
}

const client = await configureEmailsFn(
{ apiKey: apiKeyResult.key, url: `${ctx.apiBaseUrl}/api`, ...teamIdPart },
envDir,
);
return client.getInbox;
}

return async (...args) => {
getInboxPromise ??= configure();
const getInbox = await getInboxPromise;
return getInbox(...args);
};
}

export async function createFlowRuntimeDeps({
envDir,
ctx,
platform,
env = process.env,
resolveApiKeyFn = resolveApiKey,
configureEmailsFn = configureEmails,
createPlatform = createPlatformClient,
}: CreateFlowRuntimeDepsArgs): Promise<FlowRuntimeDeps> {
const getInbox = lazyGetInbox({
envDir,
ctx,
platform,
env,
resolveApiKeyFn,
configureEmailsFn,
createPlatform,
});
// Eagerly register a lazy client as the module-global so mail.inbox()-only
// flows route through the same lazy getInbox. Graceful: if @qawolf/emails
// can't be loaded, mail.inbox() flows surface their own clear error at use.
try {
await registerLazyEmailsClient(getInbox, envDir);
} catch {
// @qawolf/emails not resolvable in the env dir; leave the global unset.
}
return {
...makeEnvVarDeps(envDir, ctx.fs),
getInbox,
};
}
Loading