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
2 changes: 1 addition & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const config: KnipConfig = {
ignoreDependencies: [
// TODO WIZ-10341 follow-up: consumed once the web-flow runner imports it.
"@playwright/test",
// Installed into the flow env dir at runtime by ensureFlowDeps; not imported by the CLI.
// Installed into the managed runtime dir at runtime by ensureRuntimeEnv; not imported by the CLI.
"appium",
"appium-xcuitest-driver",
"appium-uiautomator2-driver",
Expand Down
1 change: 1 addition & 0 deletions skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ write on timeout: it may have reached the server the first time.
| `qawolf install` | local | Install every runtime dependency the project's flows need |
| `qawolf install android` | local | Install Android system images, AVDs, and the Appium driver used by the project's Android flows |
| `qawolf install browsers` | local | Install Playwright browsers used by the project's web flows |
| `qawolf install clear` | local | Remove the managed runtime cache (all installed runtime versions) |
| `qawolf run create` | write | Create a run for selected flows in an environment. |

<!-- commands-table:end -->
Expand Down
20 changes: 20 additions & 0 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Commands:
flows
android [pattern] Install Android system images, AVDs, and the Appium driver
used by the project's Android flows
clear [options] Remove the managed runtime cache (all installed runtime
versions)

Examples:
$ qawolf install
Expand Down Expand Up @@ -90,6 +92,21 @@ Examples:
"
`;

exports[`--help output qawolf install clear 1`] = `
"Usage: qawolf install clear [options]

Remove the managed runtime cache (all installed runtime versions)

Options:
--yes Skip the confirmation prompt (default: false)
-h, --help display help for command

Examples:
$ qawolf install clear
$ qawolf install clear --yes
"
`;

exports[`--help output qawolf doctor 1`] = `
"Usage: qawolf doctor [options]

Expand Down Expand Up @@ -155,6 +172,9 @@ Options:
false)
--env <env> Pull and run a flow from this environment (UUID or slug)
if not cached locally
--deps <dir> Use this prepared dependency directory instead of
auto-installing the runtime; or set QAWOLF_RUNTIME_DIR
to relocate the managed runtime
-h, --help display help for command

Examples:
Expand Down
21 changes: 11 additions & 10 deletions src/commands/doctor/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { resolveApiKey } from "~/domains/auth/resolve.js";
import { runChecks } from "~/domains/doctor/checks/index.js";
import { renderResults } from "~/domains/doctor/render.js";
import type { CheckResult } from "~/domains/doctor/types.js";
import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js";
import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js";
import { expandPatterns, makePeekFlowMeta } from "~/domains/flows/expand.js";
import { resolveDepsRootIfPresent } from "~/domains/runtimeEnv/index.js";
import { resolveAppiumBin } from "~/shell/appium/resolveAppiumBin.js";
import {
type CommandContext,
Expand Down Expand Up @@ -39,17 +40,17 @@ export async function handleDoctor(
const cwd = process.cwd();
const flowFiles = await expandPatterns([], cwd, undefined, fs);

// Playwright lives in the env dir (installed by ensureFlowDeps), not in cwd.
// Silently fall back to cwd when no env dir is found or flows span multiple packages.
let envDir: string | undefined;
try {
envDir = resolveUniqueEnvDir([...flowFiles], fs);
} catch {
// multiple env dirs — fall back to cwd
}
// Playwright/Appium live in the resolved runtime dir (managed env or project), not cwd.
const projectDir = resolveProjectDirSafe([...flowFiles], fs);
const envDir = resolveDepsRootIfPresent(
projectDir !== undefined ? { projectDir } : {},
fs,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
let playwrightCliPath: string | undefined;
try {
playwrightCliPath = resolvePlaywrightCli(envDir ?? cwd, process.platform);
playwrightCliPath = envDir
? resolvePlaywrightCli(envDir, process.platform)
: undefined;
} catch {
playwrightCliPath = undefined;
}
Expand Down
61 changes: 61 additions & 0 deletions src/commands/flows/buildFlowsRunDeps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { makePeekFlowMeta } from "~/domains/flows/expand.js";
import { findFlowStamp as defaultFindFlowStamp } from "~/shell/manifest/lookup.js";
import { installBrowserList } from "~/domains/install/browsers.js";
import { defaultSpawn } from "~/shell/spawn.js";
import { resolvePlaywrightCli } from "~/shell/playwright.js";
import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js";
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 {
FlowsRunDeps,
FlowsRunFlags,
} from "~/domains/runner/runInternals.js";
import type { CommandContext } from "~/shell/commandContext.js";

import { buildRunReporter } from "./buildRunReporter.js";

type BuildFlowsRunDepsArgs = {
ctx: CommandContext;
resolvedDir: string;
android: ReturnType<typeof createAndroidDeps>;
runWebFlowDeps: RunWebFlowDeps;
flags: FlowsRunFlags;
};

/**
* Assembles the runner dependency bundle for `flowsRun`. Identical across the
* local (`flows run`) and hybrid (`--env`) entry points, so both share this
* single builder. `runWebFlowDeps` is resolved by the caller (it is async and
* the injection point for tests) and passed in already awaited.
*/
export function buildFlowsRunDeps(args: BuildFlowsRunDepsArgs): FlowsRunDeps {
const { ctx, resolvedDir, android, runWebFlowDeps, flags } = args;
return {
peekFlowMeta: makePeekFlowMeta(ctx.fs),
installBrowsers: (innerCtx, browsers) =>
installBrowserList(innerCtx, browsers, {
spawn: defaultSpawn,
platform: process.platform,
playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform),
}),
runWebFlow: defaultRunWebFlow,
runWebFlowDeps,
runAndroidFlow: defaultRunAndroidFlow,
runAndroidFlowDeps: android.deps,
bootAndroid: android.boot,
shutdownAndroid: android.shutdown,
createPooledDispatch: makePooledDispatch(resolvedDir),
findFlowStamp: defaultFindFlowStamp,
warn: (message) => ctx.ui.warn(message),
logger: ctx.log("runner"),
// Route reporter output through ctx.ui so streamed test logs stay inside the run's timeline.
reporter: buildRunReporter(flags, {
fs: ctx.fs,
stdout: { write: (text: string) => ctx.ui.write(text) },
stderr: { write: (text: string) => ctx.ui.write(text) },
}),
now: () => Date.now(),
};
}
14 changes: 10 additions & 4 deletions src/commands/flows/hybridRun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { makeFakeUI } from "~/shell/commandContext.testUtils.js";
import type { FlowsRunFlags } from "~/domains/runner/runInternals.js";
import { makeNoopLogger } from "~/shell/logger.testUtils.js";
import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js";
import { makeMemoryFs } from "~/shell/fs.testUtils.js";
import {
handleHybridFlowsRun,
type HandleHybridFlowsRunDeps,
Expand All @@ -15,15 +16,15 @@ afterEach(() => {

const expandPatternsMock = mock<HandleHybridFlowsRunDeps["expandPatterns"]>();
const pullEnvMock = mock<HandleHybridFlowsRunDeps["pullEnv"]>();
const ensureFlowDepsMock = mock<(envDir: string) => Promise<void>>();
const resolveDepsRootMock = mock<HandleHybridFlowsRunDeps["resolveDepsRoot"]>();
const configureTestkitMock = mock<(dir: string) => Promise<void>>();
const flowsRunMock = mock<HandleHybridFlowsRunDeps["flowsRun"]>();
const runWebFlowDepsMock = mock<() => Promise<unknown>>();

const trackedMocks = [
expandPatternsMock,
pullEnvMock,
ensureFlowDepsMock,
resolveDepsRootMock,
configureTestkitMock,
flowsRunMock,
runWebFlowDepsMock,
Expand All @@ -33,7 +34,11 @@ beforeEach(() => {
for (const m of trackedMocks) m.mockClear();
expandPatternsMock.mockResolvedValue([]);
pullEnvMock.mockResolvedValue(undefined);
ensureFlowDepsMock.mockResolvedValue(undefined);
resolveDepsRootMock.mockResolvedValue({
depsRoot: "/env",
source: "project",
installed: false,
});
configureTestkitMock.mockResolvedValue(undefined);
flowsRunMock.mockResolvedValue(undefined);
runWebFlowDepsMock.mockResolvedValue({} as unknown);
Expand All @@ -47,6 +52,7 @@ function makeCtx(): AuthCommandContext {
isInteractive: false,
apiKeySource: "env",
platform: {} as unknown,
fs: makeMemoryFs(),
signals: makeNoopSignals(),
ui: makeFakeUI("human"),
log: () => makeNoopLogger(),
Expand All @@ -72,7 +78,7 @@ function makeDeps(): HandleHybridFlowsRunDeps {
return {
expandPatterns: expandPatternsMock,
pullEnv: pullEnvMock,
ensureFlowDeps: ensureFlowDepsMock,
resolveDepsRoot: resolveDepsRootMock,
configureTestkit: configureTestkitMock,
flowsRun: flowsRunMock,
runWebFlowDeps:
Expand Down
115 changes: 61 additions & 54 deletions src/commands/flows/hybridRunDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,27 @@ import type {
AuthCommandContext,
CommandResult,
} from "~/shell/commandContext.js";
import {
expandPatterns as defaultExpandPatterns,
makePeekFlowMeta,
} from "~/domains/flows/expand.js";
import { findFlowStamp as defaultFindFlowStamp } from "~/shell/manifest/lookup.js";
import { installBrowserList } from "~/domains/install/browsers.js";
import { defaultSpawn } from "~/shell/spawn.js";
import { resolvePlaywrightCli } from "~/shell/playwright.js";
import { buildRunReporter } from "./buildRunReporter.js";
import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js";
import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js";
import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js";
import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js";
import { stageFlows } from "~/domains/flows/stageFlows.js";
import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js";
import { ensureFlowDeps as defaultEnsureFlowDeps } from "~/domains/flows/ensureDeps.js";
import {
type EnsureRuntimeEnvResult,
linkManagedDeps,
} from "~/domains/runtimeEnv/index.js";
import {
resolveDepsRoot,
type ResolveDepsRootArgs,
} from "~/commands/resolveDepsRoot.js";
import type { Fs } from "~/shell/fs.js";
import type { Logger } from "~/shell/logger.js";
import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js";
import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js";
import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js";
import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js";
import type { FlowsRunFlags } from "~/domains/runner/runInternals.js";
import { buildPatternArgs } from "~/core/patternArgs.js";
import { runnerMessages } from "~/core/messages/index.js";
import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js";
import { loadEnvFile } from "./loadEnvFile.js";

export type HandleHybridFlowsRunDeps = {
Expand All @@ -37,7 +36,9 @@ export type HandleHybridFlowsRunDeps = {
logger?: Logger,
) => Promise<string[]>;
pullEnv: (ctx: AuthCommandContext, envId: string) => Promise<CommandResult>;
ensureFlowDeps: (envDir: string) => Promise<void>;
resolveDepsRoot: (
args: Omit<ResolveDepsRootArgs, "fs">,
) => Promise<EnsureRuntimeEnvResult>;
configureTestkit: (dir: string) => Promise<void>;
flowsRun: typeof defaultFlowsRun;
runWebFlowDeps: typeof defaultRunWebFlowDeps;
Expand All @@ -48,7 +49,7 @@ function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps {
expandPatterns: (patterns, cwd, logger) =>
defaultExpandPatterns(patterns, cwd, logger, fs),
pullEnv: (ctx, envId) => handleFlowsPull(ctx, { env: envId, yes: true }),
ensureFlowDeps: (envDir) => defaultEnsureFlowDeps(envDir, fs),
resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }),
configureTestkit: defaultConfigureTestkit,
flowsRun: defaultFlowsRun,
runWebFlowDeps: defaultRunWebFlowDeps,
Expand All @@ -69,22 +70,16 @@ export async function handleHybridFlowsRun(

const envDir = resolve(join(".qawolf", flags.env));
const patternArgs = buildPatternArgs(pattern);
const globFlows = () =>
resolvedDeps.expandPatterns(patternArgs, envDir, ctx.log("flows"));

let files = await resolvedDeps.expandPatterns(
patternArgs,
envDir,
ctx.log("flows"),
);
let files = await globFlows();

if (files.length === 0) {
const pullResult = await resolvedDeps.pullEnv(ctx, flags.env);
if (pullResult !== undefined) return pullResult;

files = await resolvedDeps.expandPatterns(
patternArgs,
envDir,
ctx.log("flows"),
);
files = await globFlows();
if (files.length === 0) {
return {
error:
Expand All @@ -99,43 +94,55 @@ export async function handleHybridFlowsRun(
ctx.ui.gap();
ctx.ui.intro("flows run");

await ctx.ui.withProgress(
const [runtimeEnv] = await ctx.ui.withProgress(
[
{
message: runnerMessages.preparingEnvironment,
task: () => resolvedDeps.ensureFlowDeps(envDir),
task: () =>
resolvedDeps.resolveDepsRoot({
files,
...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}),
}),
},
],
() => runnerMessages.environmentReady,
);
if (runtimeEnv.source === "managed") {
ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot));
}
await loadEnvFile(envDir);
await resolvedDeps.configureTestkit(envDir);
const android = createAndroidDeps(envDir, ctx.signals);

return resolvedDeps.flowsRun(ctx, files, flags, {
peekFlowMeta: makePeekFlowMeta(ctx.fs),
installBrowsers: (innerCtx, browsers) =>
installBrowserList(innerCtx, browsers, {
spawn: defaultSpawn,
platform: process.platform,
playwrightCliPath: resolvePlaywrightCli(envDir, process.platform),
}),
runWebFlow: defaultRunWebFlow,
runWebFlowDeps: await resolvedDeps.runWebFlowDeps(envDir, ctx.signals),
runAndroidFlow: defaultRunAndroidFlow,
runAndroidFlowDeps: android.deps,
bootAndroid: android.boot,
shutdownAndroid: android.shutdown,
createPooledDispatch: makePooledDispatch(envDir),
findFlowStamp: defaultFindFlowStamp,
warn: (message) => ctx.ui.warn(message),
logger: ctx.log("runner"),
// Route reporter output through ctx.ui so streamed test logs stay inside the run's timeline.
reporter: buildRunReporter(flags, {
fs: ctx.fs,
stdout: { write: (text: string) => ctx.ui.write(text) },
stderr: { write: (text: string) => ctx.ui.write(text) },
}),
now: () => Date.now(),
const projectDir = resolveProjectDirSafe(files, ctx.fs);
const staged = await stageFlows({
files,
projectDir,
cwd: process.cwd(),
fs: ctx.fs,
});
if (runtimeEnv.source !== "project" && staged.bundleRoot !== undefined) {
await linkManagedDeps(staged.bundleRoot, runtimeEnv.depsRoot, ctx.fs);
}

const resolvedDir = runtimeEnv.depsRoot;
await resolvedDeps.configureTestkit(resolvedDir);
const android = createAndroidDeps(resolvedDir, ctx.signals);
const runWebFlowDeps = await resolvedDeps.runWebFlowDeps(
resolvedDir,
ctx.signals,
);

const unregisterCleanup = staged.cleanup
? ctx.signals.register(staged.cleanup)
: undefined;
try {
return await resolvedDeps.flowsRun(
ctx,
staged.files,
flags,
buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }),
);
} finally {
unregisterCleanup?.();
await staged.cleanup?.();
}
}
4 changes: 4 additions & 0 deletions src/commands/flows/run.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export function registerFlowsRunCommand(
"--env <env>",
"Pull and run a flow from this environment (UUID or slug) if not cached locally",
)
.option(
"--deps <dir>",
"Use this prepared dependency directory instead of auto-installing the runtime; or set QAWOLF_RUNTIME_DIR to relocate the managed runtime",
)
.addHelpText("after", runExamples)
.action(
(
Expand Down
Loading