From 6e6b2c618cdb21ee2637f298b7964b0ffa124632 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:26:32 -0400 Subject: [PATCH 01/14] feat(runner): add runtimeEnv managed-deps domain --- knip.config.ts | 2 +- src/commands/__snapshots__/help.test.ts.snap | 2 + src/commands/doctor/handler.ts | 18 ++- src/commands/flows/hybridRun.test.ts | 13 +- src/commands/flows/hybridRunDefaults.ts | 29 +++-- src/commands/flows/run.register.ts | 4 + src/commands/flows/runDefaults.handle.test.ts | 101 ++++++++++++--- .../flows/runDefaults.reporterWiring.test.ts | 6 +- src/commands/flows/runDefaults.ts | 59 +++++---- src/commands/install/all.test.ts | 80 +++++------- src/commands/install/all.ts | 23 +++- src/commands/install/android.ts | 40 ++++-- src/commands/install/browsers.ts | 23 +++- src/core/messages/flows.ts | 2 - src/core/messages/runner.ts | 2 + src/core/paths.ts | 9 ++ src/domains/flows/ensureDeps.test.ts | 44 +------ src/domains/flows/ensureDeps.ts | 119 ----------------- .../runner/createRunner.guards.test.ts | 1 + src/domains/runner/createRunner.test.ts | 1 + src/domains/runner/initFlowRuntime.test.ts | 95 +++++++++++++- src/domains/runner/initFlowRuntime.ts | 94 +++++++++----- src/domains/runner/loadFlowDefault.test.ts | 55 ++++++-- src/domains/runner/loadFlowDefault.ts | 18 ++- src/domains/runner/runAndroidFlow.test.ts | 2 + src/domains/runner/runAndroidFlow.ts | 5 +- src/domains/runner/runAndroidFlowDeps.ts | 2 +- src/domains/runner/runInternals.ts | 7 +- src/domains/runner/runWebFlow.fixtures.ts | 2 + src/domains/runner/runWebFlow.ts | 10 +- src/domains/runner/runWebFlowDeps.ts | 2 +- src/domains/runner/runnerDeps.test.ts | 9 +- src/domains/runner/runnerDeps.ts | 6 +- src/domains/runner/types.ts | 2 + .../runtimeEnv/ensureRuntimeEnv.test.ts | 121 ++++++++++++++++++ src/domains/runtimeEnv/ensureRuntimeEnv.ts | 66 ++++++++++ src/domains/runtimeEnv/index.ts | 6 + src/domains/runtimeEnv/installPinned.test.ts | 70 ++++++++++ src/domains/runtimeEnv/installPinned.ts | 73 +++++++++++ src/domains/runtimeEnv/managedEnvDir.test.ts | 64 +++++++++ src/domains/runtimeEnv/managedEnvDir.ts | 50 ++++++++ src/domains/runtimeEnv/pinnedPackages.ts | 25 ++++ .../resolveDepsRootIfPresent.test.ts | 83 ++++++++++++ .../runtimeEnv/resolveDepsRootIfPresent.ts | 25 ++++ src/domains/runtimeEnv/resolvePinned.test.ts | 101 +++++++++++++++ src/domains/runtimeEnv/resolvePinned.ts | 44 +++++++ .../{flows => runtimeEnv}/shimDeps.test.ts | 0 src/domains/{flows => runtimeEnv}/shimDeps.ts | 15 ++- 48 files changed, 1270 insertions(+), 360 deletions(-) create mode 100644 src/domains/runtimeEnv/ensureRuntimeEnv.test.ts create mode 100644 src/domains/runtimeEnv/ensureRuntimeEnv.ts create mode 100644 src/domains/runtimeEnv/index.ts create mode 100644 src/domains/runtimeEnv/installPinned.test.ts create mode 100644 src/domains/runtimeEnv/installPinned.ts create mode 100644 src/domains/runtimeEnv/managedEnvDir.test.ts create mode 100644 src/domains/runtimeEnv/managedEnvDir.ts create mode 100644 src/domains/runtimeEnv/pinnedPackages.ts create mode 100644 src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts create mode 100644 src/domains/runtimeEnv/resolveDepsRootIfPresent.ts create mode 100644 src/domains/runtimeEnv/resolvePinned.test.ts create mode 100644 src/domains/runtimeEnv/resolvePinned.ts rename src/domains/{flows => runtimeEnv}/shimDeps.test.ts (100%) rename src/domains/{flows => runtimeEnv}/shimDeps.ts (94%) diff --git a/knip.config.ts b/knip.config.ts index 3672dbae4..559d1b9f6 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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", diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 1726eb3a2..393d9024f 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -155,6 +155,8 @@ Options: false) --env Pull and run a flow from this environment (UUID or slug) if not cached locally + --deps Use this prepared dependency directory instead of + auto-installing the runtime -h, --help display help for command Examples: diff --git a/src/commands/doctor/handler.ts b/src/commands/doctor/handler.ts index 1edab0ca4..ad3e7bb07 100644 --- a/src/commands/doctor/handler.ts +++ b/src/commands/doctor/handler.ts @@ -8,6 +8,7 @@ import { renderResults } from "~/domains/doctor/render.js"; import type { CheckResult } from "~/domains/doctor/types.js"; import { resolveUniqueEnvDir } 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, @@ -39,17 +40,22 @@ 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; + // Playwright/Appium live in the resolved runtime dir (managed env or project), not cwd. + let projectDir: string | undefined; try { - envDir = resolveUniqueEnvDir([...flowFiles], fs); + projectDir = resolveUniqueEnvDir([...flowFiles], fs); } catch { - // multiple env dirs — fall back to cwd + projectDir = undefined; } + const envDir = resolveDepsRootIfPresent( + projectDir !== undefined ? { projectDir } : {}, + fs, + ); let playwrightCliPath: string | undefined; try { - playwrightCliPath = resolvePlaywrightCli(envDir ?? cwd, process.platform); + playwrightCliPath = envDir + ? resolvePlaywrightCli(envDir, process.platform) + : undefined; } catch { playwrightCliPath = undefined; } diff --git a/src/commands/flows/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index 2cc936cdf..b404a981b 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -15,7 +15,8 @@ afterEach(() => { const expandPatternsMock = mock(); const pullEnvMock = mock(); -const ensureFlowDepsMock = mock<(envDir: string) => Promise>(); +const ensureRuntimeEnvMock = + mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<() => Promise>(); @@ -23,7 +24,7 @@ const runWebFlowDepsMock = mock<() => Promise>(); const trackedMocks = [ expandPatternsMock, pullEnvMock, - ensureFlowDepsMock, + ensureRuntimeEnvMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -33,7 +34,11 @@ beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); pullEnvMock.mockResolvedValue(undefined); - ensureFlowDepsMock.mockResolvedValue(undefined); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({} as unknown); @@ -72,7 +77,7 @@ function makeDeps(): HandleHybridFlowsRunDeps { return { expandPatterns: expandPatternsMock, pullEnv: pullEnvMock, - ensureFlowDeps: ensureFlowDepsMock, + ensureRuntimeEnv: ensureRuntimeEnvMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index b17558cf8..ea804edc3 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -18,7 +18,7 @@ import { buildRunReporter } from "./buildRunReporter.js"; import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js"; import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { ensureFlowDeps as defaultEnsureFlowDeps } from "~/domains/flows/ensureDeps.js"; +import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; import type { Fs } from "~/shell/fs.js"; import type { Logger } from "~/shell/logger.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; @@ -37,7 +37,7 @@ export type HandleHybridFlowsRunDeps = { logger?: Logger, ) => Promise; pullEnv: (ctx: AuthCommandContext, envId: string) => Promise; - ensureFlowDeps: (envDir: string) => Promise; + ensureRuntimeEnv: typeof ensureRuntimeEnv; configureTestkit: (dir: string) => Promise; flowsRun: typeof defaultFlowsRun; runWebFlowDeps: typeof defaultRunWebFlowDeps; @@ -48,7 +48,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), + ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), configureTestkit: defaultConfigureTestkit, flowsRun: defaultFlowsRun, runWebFlowDeps: defaultRunWebFlowDeps, @@ -99,18 +99,27 @@ 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.ensureRuntimeEnv({ + projectDir: envDir, + ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), + }), }, ], () => runnerMessages.environmentReady, ); + if (runtimeEnv.source === "managed") { + const note = runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot); + ctx.ui.note(note, "Runtime"); + } await loadEnvFile(envDir); - await resolvedDeps.configureTestkit(envDir); - const android = createAndroidDeps(envDir, ctx.signals); + const resolvedDir = runtimeEnv.depsRoot; + await resolvedDeps.configureTestkit(resolvedDir); + const android = createAndroidDeps(resolvedDir, ctx.signals); return resolvedDeps.flowsRun(ctx, files, flags, { peekFlowMeta: makePeekFlowMeta(ctx.fs), @@ -118,15 +127,15 @@ export async function handleHybridFlowsRun( installBrowserList(innerCtx, browsers, { spawn: defaultSpawn, platform: process.platform, - playwrightCliPath: resolvePlaywrightCli(envDir, process.platform), + playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform), }), runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(envDir, ctx.signals), + runWebFlowDeps: await resolvedDeps.runWebFlowDeps(resolvedDir, ctx.signals), runAndroidFlow: defaultRunAndroidFlow, runAndroidFlowDeps: android.deps, bootAndroid: android.boot, shutdownAndroid: android.shutdown, - createPooledDispatch: makePooledDispatch(envDir), + createPooledDispatch: makePooledDispatch(resolvedDir), findFlowStamp: defaultFindFlowStamp, warn: (message) => ctx.ui.warn(message), logger: ctx.log("runner"), diff --git a/src/commands/flows/run.register.ts b/src/commands/flows/run.register.ts index 462c94510..70cb4462d 100644 --- a/src/commands/flows/run.register.ts +++ b/src/commands/flows/run.register.ts @@ -99,6 +99,10 @@ export function registerFlowsRunCommand( "--env ", "Pull and run a flow from this environment (UUID or slug) if not cached locally", ) + .option( + "--deps ", + "Use this prepared dependency directory instead of auto-installing the runtime", + ) .addHelpText("after", runExamples) .action( ( diff --git a/src/commands/flows/runDefaults.handle.test.ts b/src/commands/flows/runDefaults.handle.test.ts index 70a640ba6..8edfd4cd6 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -13,29 +13,31 @@ const noopSignals = makeNoopSignals(); const expandPatternsMock = mock(); const resolveUniqueEnvDirMock = mock<(files: string[]) => string | undefined>(); -const ensureFlowDepsMock = mock<(envDir: string) => Promise>(); +const ensureRuntimeEnvMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<(...args: unknown[]) => Promise>(); const uiInfoMock = mock<(message: string) => void>(); const uiIntroMock = mock<(title: string) => void>(); +const uiNoteMock = mock<(message: string, title?: string) => void>(); const trackedMocks = [ expandPatternsMock, resolveUniqueEnvDirMock, - ensureFlowDepsMock, + ensureRuntimeEnvMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, uiInfoMock, uiIntroMock, + uiNoteMock, ]; function makeDeps(): HandleFlowsRunDeps { return { expandPatterns: expandPatternsMock, resolveUniqueEnvDir: resolveUniqueEnvDirMock, - ensureFlowDeps: ensureFlowDepsMock, + ensureRuntimeEnv: ensureRuntimeEnvMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -66,7 +68,12 @@ function makeCtx(): CommandContext { isInteractive: false, signals: noopSignals, log: () => makeNoopLogger(), - ui: { ...makeFakeUI("human"), info: uiInfoMock, intro: uiIntroMock }, + ui: { + ...makeFakeUI("human"), + info: uiInfoMock, + intro: uiIntroMock, + note: uiNoteMock, + }, } as unknown as CommandContext; } @@ -74,18 +81,27 @@ beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); resolveUniqueEnvDirMock.mockReturnValue(undefined); - ensureFlowDepsMock.mockResolvedValue(undefined); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({}); }); describe("handleFlowsRun", () => { - it("returns error with exitCode 2 when resolveUniqueEnvDir throws", async () => { + it("proceeds with managed dir when resolveUniqueEnvDir throws", async () => { expandPatternsMock.mockResolvedValue(["/some/file.flow.ts"]); resolveUniqueEnvDirMock.mockImplementation(() => { throw new Error("files span multiple env dirs"); }); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/managed", + source: "managed", + installed: true, + }); const result = await handleFlowsRun( makeCtx(), @@ -94,12 +110,9 @@ describe("handleFlowsRun", () => { makeDeps(), ); - expect(result).toEqual({ - error: "files span multiple env dirs", - exitCode: 2, - }); - expect(ensureFlowDepsMock).not.toHaveBeenCalled(); - expect(flowsRunMock).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); + expect(flowsRunMock).toHaveBeenCalledTimes(1); }); it("returns early and skips all setup when no flows match", async () => { @@ -112,35 +125,89 @@ describe("handleFlowsRun", () => { expect(result).toBeUndefined(); expect(uiInfoMock).toHaveBeenCalledWith(runnerMessages.noFlowsMatched); - expect(ensureFlowDepsMock).not.toHaveBeenCalled(); + expect(ensureRuntimeEnvMock).not.toHaveBeenCalled(); expect(configureTestkitMock).not.toHaveBeenCalled(); expect(flowsRunMock).not.toHaveBeenCalled(); }); - it("skips ensureFlowDeps when flows found but envDir is undefined", async () => { + it("calls ensureRuntimeEnv with undefined projectDir when resolveUniqueEnvDir returns undefined", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); resolveUniqueEnvDirMock.mockReturnValue(undefined); await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(ensureFlowDepsMock).not.toHaveBeenCalled(); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); expect(configureTestkitMock).toHaveBeenCalledTimes(1); expect(flowsRunMock).toHaveBeenCalledTimes(1); expect(runWebFlowDepsMock).toHaveBeenCalledTimes(1); }); - it("calls ensureFlowDeps and configureTestkit with envDir when envDir is present", async () => { + it("calls ensureRuntimeEnv with resolved projectDir and configureTestkit with depsRoot", async () => { const envDir = "/mock/.qawolf/env1"; expandPatternsMock.mockResolvedValue([`${envDir}/login.flow.ts`]); resolveUniqueEnvDirMock.mockReturnValue(envDir); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: envDir, + source: "project", + installed: false, + }); await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(ensureFlowDepsMock).toHaveBeenCalledWith(envDir); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ projectDir: envDir }); expect(configureTestkitMock).toHaveBeenCalledWith(envDir); expect(flowsRunMock).toHaveBeenCalledTimes(1); }); + it("emits managed runtime note when ensureRuntimeEnv source is managed", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/home/.qawolf/runtime", + source: "managed", + installed: false, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(uiNoteMock).toHaveBeenCalledWith( + runnerMessages.managedRuntimeNote("/home/.qawolf/runtime"), + "Runtime", + ); + }); + + it("does not emit managed runtime note when source is project", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(uiNoteMock).not.toHaveBeenCalled(); + }); + + it("threads --deps flag to ensureRuntimeEnv as overrideDir", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + ensureRuntimeEnvMock.mockResolvedValue({ + depsRoot: "/custom/deps", + source: "override", + installed: false, + }); + + await handleFlowsRun( + makeCtx(), + undefined, + { ...defaultFlags(), deps: "/custom/deps" }, + makeDeps(), + ); + + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ + overrideDir: "/custom/deps", + }); + }); + it("opens the run with an intro once flows are resolved", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); resolveUniqueEnvDirMock.mockReturnValue(undefined); diff --git a/src/commands/flows/runDefaults.reporterWiring.test.ts b/src/commands/flows/runDefaults.reporterWiring.test.ts index e0149a063..4d04e1d7e 100644 --- a/src/commands/flows/runDefaults.reporterWiring.test.ts +++ b/src/commands/flows/runDefaults.reporterWiring.test.ts @@ -61,7 +61,11 @@ function makeDeps( return { expandPatterns: async () => ["/fake/flow.flow.ts"], resolveUniqueEnvDir: () => undefined, - ensureFlowDeps: async () => {}, + ensureRuntimeEnv: async () => ({ + depsRoot: "/env", + source: "project" as const, + installed: false, + }), configureTestkit: async () => {}, flowsRun: flowsRun as HandleFlowsRunDeps["flowsRun"], runWebFlowDeps: (async () => diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 7741304f5..c8e0354cf 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -15,10 +15,12 @@ import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js" import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { pluralize } from "~/core/pluralize.js"; +import { resolveUniqueEnvDir as defaultResolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; import { - ensureFlowDeps as defaultEnsureFlowDeps, - resolveUniqueEnvDir as defaultResolveUniqueEnvDir, -} from "~/domains/flows/ensureDeps.js"; + ensureRuntimeEnv, + type EnsureRuntimeEnvArgs, + type EnsureRuntimeEnvResult, +} from "~/domains/runtimeEnv/index.js"; import type { Fs } from "~/shell/fs.js"; import type { Logger } from "~/shell/logger.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; @@ -36,7 +38,9 @@ export type HandleFlowsRunDeps = { logger?: Logger, ) => Promise; resolveUniqueEnvDir: (files: string[]) => string | undefined; - ensureFlowDeps: (envDir: string) => Promise; + ensureRuntimeEnv: ( + args: EnsureRuntimeEnvArgs, + ) => Promise; configureTestkit: (dir: string) => Promise; runWebFlowDeps: typeof defaultRunWebFlowDeps; flowsRun: typeof defaultFlowsRun; @@ -47,7 +51,7 @@ function makeDefaultDeps(fs: Fs): HandleFlowsRunDeps { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, fs), - ensureFlowDeps: (envDir) => defaultEnsureFlowDeps(envDir, fs), + ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), configureTestkit: defaultConfigureTestkit, runWebFlowDeps: defaultRunWebFlowDeps, flowsRun: defaultFlowsRun, @@ -77,33 +81,42 @@ export async function handleFlowsRun( return; } - let envDir: string | undefined; + let projectDir: string | undefined; try { - envDir = resolvedDeps.resolveUniqueEnvDir(expandedFiles); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - return { error, exitCode: 2 }; + projectDir = resolvedDeps.resolveUniqueEnvDir(expandedFiles); + } catch { + // Flows span multiple packages — fall back to the managed runtime dir. + projectDir = undefined; } ctx.ui.gap(); ctx.ui.intro("flows run"); - if (envDir) { - const dir = envDir; - await ctx.ui.withProgress( - [ - { - message: runnerMessages.preparingEnvironment, - task: () => resolvedDeps.ensureFlowDeps(dir), - }, - ], - () => runnerMessages.environmentReady, + const [runtimeEnv] = await ctx.ui.withProgress( + [ + { + message: runnerMessages.preparingEnvironment, + task: () => + resolvedDeps.ensureRuntimeEnv({ + ...(projectDir !== undefined ? { projectDir } : {}), + ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), + }), + }, + ], + () => runnerMessages.environmentReady, + ); + + if (runtimeEnv.source === "managed") { + ctx.ui.note( + runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot), + "Runtime", ); - await loadEnvFile(dir); } - // Resolve playwright from the env dir; falls back to CWD for local flows. - const resolvedDir = envDir ?? cwd; + // Load the user's project .env from the project dir (NOT the deps dir). + await loadEnvFile(projectDir ?? cwd); + + const resolvedDir = runtimeEnv.depsRoot; await resolvedDeps.configureTestkit(resolvedDir); const android = createAndroidDeps(resolvedDir, ctx.signals); diff --git a/src/commands/install/all.test.ts b/src/commands/install/all.test.ts index 5957ffac4..c47a08e51 100644 --- a/src/commands/install/all.test.ts +++ b/src/commands/install/all.test.ts @@ -1,37 +1,32 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; + import { installAll, type InstallAllDeps } from "./all.js"; +type FlowMeta = { name: string | undefined; target: string | undefined }; + +type SubInstallerFn = ( + ctx: CommandContext, + pattern: string | undefined, + envDir: string, +) => Promise; + const expandPatternsMock = mock<(patterns: string[], cwd?: string) => Promise>(); -const peekFlowMetaMock = - mock< - ( - filePath: string, - ) => Promise<{ name: string | undefined; target: string | undefined }> - >(); +const peekFlowMetaMock = mock<(filePath: string) => Promise>(); const resolveUniqueEnvDirMock = mock<(files: string[]) => string | undefined>(); -const installBrowsersMock = - mock< - ( - ctx: CommandContext, - pattern: string | undefined, - envDir: string, - ) => Promise - >(); -const installAndroidMock = - mock< - ( - ctx: CommandContext, - pattern: string | undefined, - envDir: string, - ) => Promise - >(); +const ensureRuntimeEnvMock = + mock<(args: { projectDir?: string }) => Promise>(); +const installBrowsersMock = mock(); +const installAndroidMock = mock(); const trackedMocks = [ expandPatternsMock, peekFlowMetaMock, resolveUniqueEnvDirMock, + ensureRuntimeEnvMock, installBrowsersMock, installAndroidMock, ]; @@ -64,15 +59,21 @@ function makeDeps(): InstallAllDeps { expandPatterns: expandPatternsMock, peekFlowMeta: peekFlowMetaMock, resolveUniqueEnvDir: resolveUniqueEnvDirMock, + ensureRuntimeEnv: ensureRuntimeEnvMock, installBrowsers: installBrowsersMock, installAndroid: installAndroidMock, }; } +function mockEnvResult(depsRoot = "/env"): EnsureRuntimeEnvResult { + return { depsRoot, source: "project", installed: false }; +} + beforeEach(() => { expandPatternsMock.mockResolvedValue([]); peekFlowMetaMock.mockResolvedValue({ name: undefined, target: undefined }); resolveUniqueEnvDirMock.mockReturnValue(undefined); + ensureRuntimeEnvMock.mockResolvedValue(mockEnvResult()); installBrowsersMock.mockResolvedValue(undefined); installAndroidMock.mockResolvedValue(undefined); }); @@ -187,29 +188,23 @@ describe("installAll", () => { expect(result).toEqual({ error: "Could not find Playwright" }); }); - it("should forward pattern and resolved envDir to both sub-handlers", async () => { + it("should forward pattern and depsRoot from ensureRuntimeEnv to both sub-handlers", async () => { expandPatternsMock.mockResolvedValue(["web.flow.ts", "android.flow.ts"]); peekFlowMetaMock .mockResolvedValueOnce({ name: undefined, target: "Web - Chrome" }) .mockResolvedValueOnce({ name: undefined, target: "Android - Pixel 9" }); - resolveUniqueEnvDirMock.mockReturnValue("/project/.qawolf/staging"); + resolveUniqueEnvDirMock.mockReturnValue("/prj"); + ensureRuntimeEnvMock.mockResolvedValue(mockEnvResult("/renv")); const { ctx } = makeCtx(); await installAll(ctx, "src/**", makeDeps()); - expect(installBrowsersMock).toHaveBeenCalledWith( - ctx, - "src/**", - "/project/.qawolf/staging", - ); - expect(installAndroidMock).toHaveBeenCalledWith( - ctx, - "src/**", - "/project/.qawolf/staging", - ); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ projectDir: "/prj" }); + expect(installBrowsersMock).toHaveBeenCalledWith(ctx, "src/**", "/renv"); + expect(installAndroidMock).toHaveBeenCalledWith(ctx, "src/**", "/renv"); }); - it("should fall back to cwd when no envDir can be resolved from flow files", async () => { + it("should use managed env when no projectDir can be resolved from flow files", async () => { expandPatternsMock.mockResolvedValue(["web.flow.ts"]); peekFlowMetaMock.mockResolvedValue({ name: undefined, @@ -220,14 +215,11 @@ describe("installAll", () => { await installAll(ctx, undefined, makeDeps()); - expect(installBrowsersMock).toHaveBeenCalledWith( - ctx, - undefined, - "/project", - ); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); + expect(installBrowsersMock).toHaveBeenCalledWith(ctx, undefined, "/env"); }); - it("should return exitCode 2 when flow files span multiple packages", async () => { + it("should fall back to managed env when flow files span multiple packages", async () => { expandPatternsMock.mockResolvedValue([ ".qawolf/staging/a.flow.ts", ".qawolf/prod/b.flow.ts", @@ -239,11 +231,9 @@ describe("installAll", () => { const result = await installAll(ctx, undefined, makeDeps()); - expect(result).toEqual({ - error: "Pattern matches flows from 2 packages", - exitCode: 2, - }); + expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); expect(installBrowsersMock).not.toHaveBeenCalled(); expect(installAndroidMock).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); }); }); diff --git a/src/commands/install/all.ts b/src/commands/install/all.ts index 48834d8cb..21ee72b3c 100644 --- a/src/commands/install/all.ts +++ b/src/commands/install/all.ts @@ -3,6 +3,10 @@ import { makePeekFlowMeta, } from "~/domains/flows/expand.js"; import { resolveUniqueEnvDir as defaultResolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { + ensureRuntimeEnv, + type EnsureRuntimeEnvResult, +} from "~/domains/runtimeEnv/index.js"; import { classifyTarget, type PeekFlowMetaFn } from "~/core/flowMeta.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { errorMessage } from "~/core/errors.js"; @@ -21,6 +25,9 @@ export type InstallAllDeps = { ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; readonly resolveUniqueEnvDir: (files: string[]) => string | undefined; + readonly ensureRuntimeEnv: (args: { + projectDir?: string; + }) => Promise; readonly installBrowsers: ( ctx: CommandContext, pattern: string | undefined, @@ -41,12 +48,15 @@ export async function installAll( const patterns = buildPatternArgs(pattern); const files = await deps.expandPatterns(patterns, deps.cwd); - let envDir: string; + let projectDir: string | undefined; try { - envDir = deps.resolveUniqueEnvDir(files) ?? deps.cwd; - } catch (err: unknown) { - return { error: errorMessage(err), exitCode: 2 }; + projectDir = deps.resolveUniqueEnvDir(files); + } catch { + projectDir = undefined; } + const { depsRoot } = await deps.ensureRuntimeEnv( + projectDir !== undefined ? { projectDir } : {}, + ); let hasWeb = false; let hasAndroid = false; @@ -77,7 +87,7 @@ export async function installAll( if (hasWeb) { try { - const result = await deps.installBrowsers(ctx, pattern, envDir); + const result = await deps.installBrowsers(ctx, pattern, depsRoot); if (result) firstError = result; } catch (err: unknown) { if (!firstError) firstError = { error: errorMessage(err) }; @@ -86,7 +96,7 @@ export async function installAll( if (hasAndroid) { try { - const result = await deps.installAndroid(ctx, pattern, envDir); + const result = await deps.installAndroid(ctx, pattern, depsRoot); if (result && !firstError) firstError = result; } catch (err: unknown) { if (!firstError) firstError = { error: errorMessage(err) }; @@ -111,6 +121,7 @@ export async function handleInstall( defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, fs), + ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), installBrowsers: handleInstallBrowsers, installAndroid: handleInstallAndroid, }); diff --git a/src/commands/install/android.ts b/src/commands/install/android.ts index 26ff663a7..aa724b802 100644 --- a/src/commands/install/android.ts +++ b/src/commands/install/android.ts @@ -1,11 +1,14 @@ import { join } from "node:path"; + import { expandPatterns as defaultExpandPatterns, makePeekFlowMeta, } from "~/domains/flows/expand.js"; import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; -import { resolveAppiumBin } from "~/shell/appium/resolveAppiumBin.js"; +import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; +import { buildPatternArgs } from "~/core/patternArgs.js"; import { installMessages } from "~/core/messages/index.js"; +import { resolveAppiumBin } from "~/shell/appium/resolveAppiumBin.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { defaultSpawn } from "~/shell/spawn.js"; import { installAndroid } from "~/domains/install/android/index.js"; @@ -23,17 +26,28 @@ export async function handleInstallAndroid( const { fs } = ctx; - // When envDir is pre-resolved (composite `qawolf install` path), use it - // directly. Otherwise let installAndroid resolve from matched files. - const resolveEnvDir = envDir - ? () => envDir - : (files: string[]) => { - try { - return resolveUniqueEnvDir(files, fs); - } catch { - return undefined; - } - }; + let depsRoot: string; + if (envDir !== undefined) { + depsRoot = envDir; + } else { + const cwd = process.cwd(); + const files = await defaultExpandPatterns( + buildPatternArgs(pattern), + cwd, + undefined, + fs, + ); + let projectDir: string | undefined; + try { + projectDir = resolveUniqueEnvDir(files, fs); + } catch { + projectDir = undefined; + } + ({ depsRoot } = await ensureRuntimeEnv( + projectDir !== undefined ? { projectDir } : {}, + { fs }, + )); + } return installAndroid(ctx, pattern, { cwd: process.cwd(), @@ -58,7 +72,7 @@ export async function handleInstallAndroid( expandPatterns: (patterns, cwd) => defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - resolveEnvDir, + resolveEnvDir: () => depsRoot, resolveAppiumBin, }); } diff --git a/src/commands/install/browsers.ts b/src/commands/install/browsers.ts index 8af376eba..9ece0a378 100644 --- a/src/commands/install/browsers.ts +++ b/src/commands/install/browsers.ts @@ -3,8 +3,8 @@ import { makePeekFlowMeta, } from "~/domains/flows/expand.js"; import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; -import { errorMessage } from "~/core/errors.js"; import { defaultSpawn } from "~/shell/spawn.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { resolvePlaywrightCli } from "~/shell/playwright.js"; @@ -17,20 +17,29 @@ export async function handleInstallBrowsers( ): Promise { const cwd = process.cwd(); const { fs } = ctx; - let resolvedDir = envDir; - if (!resolvedDir) { + + let depsRoot: string; + if (envDir !== undefined) { + depsRoot = envDir; + } else { const files = await defaultExpandPatterns( buildPatternArgs(pattern), cwd, undefined, fs, ); + let projectDir: string | undefined; try { - resolvedDir = resolveUniqueEnvDir(files, fs) ?? cwd; - } catch (err: unknown) { - return { error: errorMessage(err), exitCode: 2 }; + projectDir = resolveUniqueEnvDir(files, fs); + } catch { + projectDir = undefined; } + ({ depsRoot } = await ensureRuntimeEnv( + projectDir !== undefined ? { projectDir } : {}, + { fs }, + )); } + return installBrowsers(ctx, pattern, { cwd, spawn: defaultSpawn, @@ -38,6 +47,6 @@ export async function handleInstallBrowsers( expandPatterns: (patterns, dir) => defaultExpandPatterns(patterns, dir ?? cwd, undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform), + playwrightCliPath: resolvePlaywrightCli(depsRoot, process.platform), }); } diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index b2271627e..61d80e525 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -76,8 +76,6 @@ export const flowsMessages = { ensureDeps: { multiPackagePattern: (count: number, listed: string) => `Pattern matches flows from ${count} packages — narrow it to a single package:\n${listed}\n\nHint: pass a pattern scoped to one package, e.g \`qawolf flows run '.qawolf//**'\`.`, - installFailed: (pm: string, envDir: string, stderr: string) => - `${pm} install failed in ${envDir}:\n${stderr}`, }, dotenv: { unparseableLine: (line: string) => diff --git a/src/core/messages/runner.ts b/src/core/messages/runner.ts index 875f3f00e..fdbc3d85d 100644 --- a/src/core/messages/runner.ts +++ b/src/core/messages/runner.ts @@ -29,4 +29,6 @@ export const runnerMessages = { retrying: (attempt: number, maxAttempts: number) => `Retrying (${attempt} of ${maxAttempts})...`, screenshot: (path: string) => `Screenshot: ${path}`, + managedRuntimeNote: (dir: string) => + `QA Wolf installed its runtime in a managed location (your project is untouched):\n ${dir}\nOverride with --deps .`, } as const; diff --git a/src/core/paths.ts b/src/core/paths.ts index 2eb0d697f..cf184bff3 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -19,3 +19,12 @@ export function getConfigDir(): string { _paths ??= envPaths("qawolf"); return _paths.config; } + +/** + * Persistent platform data directory where the CLI installs its managed + * runtime dependencies. Distinct from config (settings) and cache (ephemeral). + */ +export function getDataDir(): string { + _paths ??= envPaths("qawolf"); + return _paths.data; +} diff --git a/src/domains/flows/ensureDeps.test.ts b/src/domains/flows/ensureDeps.test.ts index a9870aa37..c7ef745af 100644 --- a/src/domains/flows/ensureDeps.test.ts +++ b/src/domains/flows/ensureDeps.test.ts @@ -7,11 +7,7 @@ import { join } from "node:path"; import { makeDefaultFs } from "~/shell/fs.js"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; -import { - detectPackageManager, - findEnvDir, - resolveUniqueEnvDir, -} from "./ensureDeps.js"; +import { findEnvDir, resolveUniqueEnvDir } from "./ensureDeps.js"; const defaultFs = makeDefaultFs(); @@ -59,44 +55,6 @@ describe("findEnvDir", () => { }); }); -describe("detectPackageManager", () => { - it("should detect bun from bun.lockb", async () => { - const dir = await makeTmpDir(); - await writeFile(join(dir, "bun.lockb"), ""); - expect(detectPackageManager(dir, defaultFs)).toBe("bun"); - }); - - it("should detect pnpm from pnpm-lock.yaml", async () => { - const dir = await makeTmpDir(); - await writeFile(join(dir, "pnpm-lock.yaml"), ""); - expect(detectPackageManager(dir, defaultFs)).toBe("pnpm"); - }); - - it("should detect yarn from yarn.lock", async () => { - const dir = await makeTmpDir(); - await writeFile(join(dir, "yarn.lock"), ""); - expect(detectPackageManager(dir, defaultFs)).toBe("yarn"); - }); - - it("should fall back to npm when no lockfile is present", async () => { - const dir = await makeTmpDir(); - expect(detectPackageManager(dir, defaultFs)).toBe("npm"); - }); - - it("should prefer bun over pnpm and yarn when multiple lockfiles present", async () => { - const dir = await makeTmpDir(); - await writeFile(join(dir, "bun.lockb"), ""); - await writeFile(join(dir, "pnpm-lock.yaml"), ""); - expect(detectPackageManager(dir, defaultFs)).toBe("bun"); - }); - - it("should detect bun from bun.lock (text format, bun ≥ 1.1)", async () => { - const dir = await makeTmpDir(); - await writeFile(join(dir, "bun.lock"), ""); - expect(detectPackageManager(dir, defaultFs)).toBe("bun"); - }); -}); - describe("findEnvDir with injected fs", () => { it("should find package.json via injected memFs", async () => { const memFs = makeMemoryFs(); diff --git a/src/domains/flows/ensureDeps.ts b/src/domains/flows/ensureDeps.ts index c784d073f..a7a57257a 100644 --- a/src/domains/flows/ensureDeps.ts +++ b/src/domains/flows/ensureDeps.ts @@ -1,18 +1,6 @@ -// oxlint-disable eslint/max-lines -- fs injection added ~10 lines; extracting spawnPm would be premature import type { Fs } from "~/shell/fs.js"; -import { spawn as nodeSpawn } from "~/shell/spawn.js"; import { dirname, join } from "node:path"; import { flowsMessages } from "~/core/messages/index.js"; -import { shimFlowsDeps } from "./shimDeps.js"; -import { - appiumUiautomator2DriverVersion, - appiumVersion, - appiumXcuitestDriverVersion, - emailsVersion, - flowsVersion, - playwrightVersion, - testkitVersion, -} from "~/generated/dependencyVersions.js"; // Walk up from a flow file to find its containing package root (the directory // with the package.json that declares its dependencies). @@ -26,42 +14,6 @@ export function findEnvDir(flowPath: string, fs: Fs): string | undefined { } } -type PackageManager = "npm" | "bun" | "pnpm" | "yarn"; - -export function detectPackageManager(dir: string, fs: Fs): PackageManager { - // bun.lockb = binary format (bun < 1.1); bun.lock = text format (bun ≥ 1.1, now default) - if ( - fs.existsSync(join(dir, "bun.lockb")) || - fs.existsSync(join(dir, "bun.lock")) - ) - return "bun"; - if (fs.existsSync(join(dir, "pnpm-lock.yaml"))) return "pnpm"; - if (fs.existsSync(join(dir, "yarn.lock"))) return "yarn"; - return "npm"; -} - -async function spawnPm( - pm: PackageManager, - args: string[], - cwd: string, -): Promise<{ exitCode: number; stderr: string }> { - // npm 7+ strict peer-dep resolution rejects peerOptional conflicts — revert to npm 6 behaviour. - const resolvedArgs = pm === "npm" ? [...args, "--legacy-peer-deps"] : args; - return new Promise((resolve) => { - const child = nodeSpawn(pm, resolvedArgs, { cwd }); - let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += String(chunk); - }); - child.on("error", () => resolve({ exitCode: -1, stderr })); - child.on("close", (code) => resolve({ exitCode: code ?? -1, stderr })); - }); -} - -function pkgDir(envDir: string, ...pkgParts: string[]): string { - return join(envDir, "node_modules", ...pkgParts); -} - // Returns the single envDir for all flow files, or undefined if none have a // package.json ancestor. Throws if files span multiple packages. export function resolveUniqueEnvDir( @@ -81,74 +33,3 @@ export function resolveUniqueEnvDir( } return dirs.size === 1 ? [...dirs][0] : undefined; } - -const pinnedPackages: [string, string][] = [ - ["@qawolf/flows", flowsVersion], - ["playwright", playwrightVersion], - ["@qawolf/emails", emailsVersion], - ["@qawolf/testkit", testkitVersion], - ["appium", appiumVersion], - ["appium-xcuitest-driver", appiumXcuitestDriverVersion], - ["appium-uiautomator2-driver", appiumUiautomator2DriverVersion], -]; - -// Install all deps in the env directory, then ensure the CLI's external -// packages are present at the versions baked in at build time (see -// dependencyVersions.ts). This guarantees the env matches the CLI binary -// regardless of what the env's own package.json declares. -export async function ensureFlowDeps(envDir: string, fs: Fs): Promise { - function readPkgJson( - ...parts: string[] - ): Record | undefined { - try { - return JSON.parse( - fs.readFileSync(join(pkgDir(envDir, ...parts), "package.json")), - ) as Record; - } catch { - return undefined; - } - } - - function getInstalledVersion(...parts: string[]): string | undefined { - const pkg = readPkgJson(...parts); - const v = pkg?.["version"]; - return typeof v === "string" ? v : undefined; - } - - const pm = detectPackageManager(envDir, fs); - - if (!fs.existsSync(pkgDir(envDir))) { - const install = await spawnPm(pm, ["install"], envDir); - if (install.exitCode !== 0) { - throw new Error( - flowsMessages.ensureDeps.installFailed( - pm, - envDir, - install.stderr.trim(), - ), - ); - } - } - - const needsInstall = pinnedPackages.some( - ([pkg, ver]) => getInstalledVersion(...pkg.split("/")) !== ver, - ); - if (!needsInstall) { - await shimFlowsDeps(envDir, fs); - return; - } - - // All pinned packages are installed in one command. npm replaces the entire - // @qawolf/ scope directory on each sequential install, so batching prevents - // a later @qawolf/* install from wiping an earlier one. - const pkgSpecs = pinnedPackages.map(([pkg, ver]) => `${pkg}@${ver}`); - const installCmd = - pm === "npm" ? ["install", "--no-save", ...pkgSpecs] : ["add", ...pkgSpecs]; - const r = await spawnPm(pm, installCmd, envDir); - if (r.exitCode !== 0) { - throw new Error( - flowsMessages.ensureDeps.installFailed(pm, envDir, r.stderr.trim()), - ); - } - await shimFlowsDeps(envDir, fs); -} diff --git a/src/domains/runner/createRunner.guards.test.ts b/src/domains/runner/createRunner.guards.test.ts index a46216e55..46a330390 100644 --- a/src/domains/runner/createRunner.guards.test.ts +++ b/src/domains/runner/createRunner.guards.test.ts @@ -16,6 +16,7 @@ function makeDeps(): RunnerDeps { kill: () => {}, }), signals: makeNoopSignals(), + depsRoot: "/tmp", createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/createRunner.test.ts b/src/domains/runner/createRunner.test.ts index adf233fe1..d993ef991 100644 --- a/src/domains/runner/createRunner.test.ts +++ b/src/domains/runner/createRunner.test.ts @@ -16,6 +16,7 @@ function makeDeps(): RunnerDeps { kill: () => {}, }), signals: makeNoopSignals(), + depsRoot: "/tmp", createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/initFlowRuntime.test.ts b/src/domains/runner/initFlowRuntime.test.ts index 74bb3b3e1..73a92454e 100644 --- a/src/domains/runner/initFlowRuntime.test.ts +++ b/src/domains/runner/initFlowRuntime.test.ts @@ -2,7 +2,11 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; -import { _resetInitCache, initFlowRuntime } from "./initFlowRuntime.js"; +import { + _resetInitCache, + initFlowRuntime, + runnerPathInDir, +} from "./initFlowRuntime.js"; afterEach(() => { _resetInitCache(); @@ -125,4 +129,93 @@ describe("initFlowRuntime", () => { await rm(tmp, { recursive: true }); } }); + + it("uses depsRoot to resolve _runner when provided, skipping walk-up", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-init-depsroot-")); + try { + // Build a minimal @qawolf/flows in depsRoot with a _runner export + const pkgDir = path.join(tmp, "node_modules", "@qawolf", "flows"); + await mkdir(pkgDir, { recursive: true }); + const runnerJs = path.join(pkgDir, "runner.js"); + await writeFile( + runnerJs, + `export async function configureFlowRuntime() {}\n`, + ); + await writeFile( + path.join(pkgDir, "package.json"), + JSON.stringify({ + exports: { "./_runner": { import: "./runner.js" } }, + }), + ); + + // Flow file is in an isolated tmp directory — no @qawolf/flows ancestor + const flowTmp = await mkdtemp( + path.join(tmpdir(), "qawolf-init-isolated-"), + ); + try { + await initFlowRuntime(path.join(flowTmp, "my.flow.ts"), { + timeout: 30_000, + depsRoot: tmp, + }); + // If we reach here, depsRoot resolution succeeded + } finally { + await rm(flowTmp, { recursive: true }); + } + } finally { + await rm(tmp, { recursive: true }); + } + }); + + it("throws when depsRoot is set but @qawolf/flows is not found there", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-init-test-")); + try { + let caught: unknown; + try { + await initFlowRuntime(path.join(thisDir, "fake.flow.ts"), { + timeout: 30_000, + depsRoot: tmp, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain( + "not found in node_modules of depsRoot", + ); + } finally { + await rm(tmp, { recursive: true }); + } + }); +}); + +describe("runnerPathInDir", () => { + it("returns undefined when @qawolf/flows is not present in the directory", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-runner-path-")); + try { + const { makeDefaultFs } = await import("~/shell/fs.js"); + const result = await runnerPathInDir(tmp, makeDefaultFs()); + expect(result).toBeUndefined(); + } finally { + await rm(tmp, { recursive: true }); + } + }); + + it("returns the resolved runner path when package.json has a valid _runner export", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-runner-path-")); + try { + const pkgDir = path.join(tmp, "node_modules", "@qawolf", "flows"); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + path.join(pkgDir, "package.json"), + JSON.stringify({ + exports: { "./_runner": { import: "./runner.js" } }, + }), + ); + const { makeDefaultFs } = await import("~/shell/fs.js"); + const result = await runnerPathInDir(tmp, makeDefaultFs()); + expect(result).toBe(path.join(pkgDir, "runner.js")); + } finally { + await rm(tmp, { recursive: true }); + } + }); }); diff --git a/src/domains/runner/initFlowRuntime.ts b/src/domains/runner/initFlowRuntime.ts index b5af90a06..46558ec0c 100644 --- a/src/domains/runner/initFlowRuntime.ts +++ b/src/domains/runner/initFlowRuntime.ts @@ -1,5 +1,4 @@ -import { makeDefaultFs } from "~/shell/fs.js"; -import type { Fs } from "~/shell/fs.js"; +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { isNoEntError } from "~/core/errors.js"; @@ -18,41 +17,56 @@ export type InitFlowRuntimeOptions = { * applied separately via `context.setDefaultTimeout` at launch. */ timeout: number; + // When set, resolve @qawolf/flows/_runner from this dir instead of walking up from the flow file. + depsRoot?: string; }; +/** + * Reads the @qawolf/flows/_runner export path from a single directory's + * node_modules. Returns undefined when the package is not present (ENOENT); + * re-throws any other error (e.g. malformed package.json, missing export). + */ +export async function runnerPathInDir( + dir: string, + fs: Fs, +): Promise { + const pkgPath = path.join( + dir, + "node_modules", + "@qawolf", + "flows", + "package.json", + ); + try { + const pkg = JSON.parse(await fs.readFile(pkgPath)) as { + exports?: Record; + }; + const entry = pkg.exports?.["./_runner"]; + const importPath = + typeof entry === "object" && entry !== null ? entry.import : undefined; + if (typeof importPath !== "string") { + throw new Error( + `@qawolf/flows at ${pkgPath} does not export "./_runner" with an "import" condition`, + ); + } + return path.resolve(path.dirname(pkgPath), importPath); + } catch (err) { + if (!isNoEntError(err)) throw err; + return undefined; + } +} + async function findFlowsRunnerPath(flowPath: string, fs: Fs): Promise { let dir = path.dirname(flowPath); while (true) { - const pkgPath = path.join( - dir, - "node_modules", - "@qawolf", - "flows", - "package.json", - ); - try { - const pkg = JSON.parse(await fs.readFile(pkgPath)) as { - exports?: Record; - }; - const entry = pkg.exports?.["./_runner"]; - const importPath = - typeof entry === "object" && entry !== null ? entry.import : undefined; - if (typeof importPath !== "string") { - throw new Error( - `@qawolf/flows at ${pkgPath} does not export "./_runner" with an "import" condition`, - ); - } - return path.resolve(path.dirname(pkgPath), importPath); - } catch (err) { - if (!isNoEntError(err)) throw err; - const parent = path.dirname(dir); - if (parent === dir) - throw new Error( - `@qawolf/flows not found in node_modules above: ${flowPath}`, - { cause: err }, - ); - dir = parent; - } + const result = await runnerPathInDir(dir, fs); + if (result !== undefined) return result; + const parent = path.dirname(dir); + if (parent === dir) + throw new Error( + `@qawolf/flows not found in node_modules above: ${flowPath}`, + ); + dir = parent; } } @@ -62,8 +76,20 @@ async function doInit( flowPath: string, timeout: number, fs: Fs, + depsRoot?: string, ): Promise { - const runnerPath = await findFlowsRunnerPath(flowPath, fs); + let runnerPath: string; + if (depsRoot !== undefined) { + const found = await runnerPathInDir(depsRoot, fs); + if (found === undefined) { + throw new Error( + `@qawolf/flows not found in node_modules of depsRoot: ${depsRoot}`, + ); + } + runnerPath = found; + } else { + runnerPath = await findFlowsRunnerPath(flowPath, fs); + } const mod = (await import(pathToFileURL(runnerPath).href)) as { configureFlowRuntime?: ConfigureFlowRuntime; }; @@ -93,7 +119,7 @@ export function initFlowRuntime( // single run-global flag, so every flow in a process shares one value. let p = initCache.get(startDir); if (!p) { - p = doInit(flowPath, options.timeout, fs); + p = doInit(flowPath, options.timeout, fs, options.depsRoot); initCache.set(startDir, p); } return p; diff --git a/src/domains/runner/loadFlowDefault.test.ts b/src/domains/runner/loadFlowDefault.test.ts index ff9f19cd7..c7959f7d2 100644 --- a/src/domains/runner/loadFlowDefault.test.ts +++ b/src/domains/runner/loadFlowDefault.test.ts @@ -73,9 +73,9 @@ describe("loadFlowDefault", () => { path.join(tmp, "flow.mjs"), "export default { name: 'test-flow' };\n", ); - const result = await loadFlowDefault<{ name: string }>( - path.join(tmp, "flow.mjs"), - ); + const result = await loadFlowDefault<{ name: string }>({ + flowPath: path.join(tmp, "flow.mjs"), + }); expect(result).toEqual({ name: "test-flow" }); } finally { await rm(tmp, { recursive: true }); @@ -88,7 +88,9 @@ describe("loadFlowDefault", () => { await writeFile(path.join(tmp, "flow.mjs"), "export const foo = 1;\n"); let caught: unknown; try { - await loadFlowDefault(path.join(tmp, "flow.mjs")); + await loadFlowDefault({ + flowPath: path.join(tmp, "flow.mjs"), + }); } catch (e) { caught = e; } @@ -137,7 +139,7 @@ describe("loadFlowDefault (compiled binary mode)", () => { flowPath, `import {} from '@qawolf/flows';\nexport default { ok: true };\n`, ); - const result = await loadFlowDefault<{ ok: boolean }>(flowPath); + const result = await loadFlowDefault<{ ok: boolean }>({ flowPath }); expect(result).toEqual({ ok: true }); } finally { await rm(tmp, { recursive: true }); @@ -153,7 +155,7 @@ describe("loadFlowDefault (compiled binary mode)", () => { flowPath, `import {} from '@qawolf/flows/helpers';\nexport default { sub: true };\n`, ); - const result = await loadFlowDefault<{ sub: boolean }>(flowPath); + const result = await loadFlowDefault<{ sub: boolean }>({ flowPath }); expect(result).toEqual({ sub: true }); } finally { await rm(tmp, { recursive: true }); @@ -169,7 +171,7 @@ describe("loadFlowDefault (compiled binary mode)", () => { flowPath, `import {} from '@qawolf/flows';\nexport default 42;\n`, ); - const result = await loadFlowDefault(flowPath); + const result = await loadFlowDefault({ flowPath }); expect(result).toBe(42); } finally { await rm(tmp, { recursive: true }); @@ -182,10 +184,47 @@ describe("loadFlowDefault (compiled binary mode)", () => { try { const flowPath = path.join(flowsDir2, "flow.mjs"); await writeFile(flowPath, `export default { plain: true };\n`); - const result = await loadFlowDefault<{ plain: boolean }>(flowPath); + const result = await loadFlowDefault<{ plain: boolean }>({ flowPath }); expect(result).toEqual({ plain: true }); } finally { await rm(tmp, { recursive: true }); } }); + + it("uses depsRoot to resolve @qawolf/flows when provided, even without @qawolf/flows in parent dirs", async () => { + process.env.QAWOLF_COMPILED = "true"; + // Create depsRoot with @qawolf/flows + const depsRoot = await mkdtemp(path.join(tmpdir(), "load-flow-depsroot-")); + // Create a separate tmpdir for the flow with no @qawolf/flows ancestor + const flowTmp = await mkdtemp(path.join(tmpdir(), "load-flow-isolated-")); + try { + const flowsDir = path.join(depsRoot, "node_modules", "@qawolf", "flows"); + await mkdir(flowsDir, { recursive: true }); + await writeFile( + path.join(flowsDir, "package.json"), + JSON.stringify({ + exports: { ".": "./index.js" }, + }), + ); + await writeFile( + path.join(flowsDir, "index.js"), + "export const flows = {};\n", + ); + + const flowPath = path.join(flowTmp, "flow.mjs"); + await writeFile( + flowPath, + `import {} from '@qawolf/flows';\nexport default { fromDepsRoot: true };\n`, + ); + + const result = await loadFlowDefault<{ fromDepsRoot: boolean }>({ + flowPath, + depsRoot, + }); + expect(result).toEqual({ fromDepsRoot: true }); + } finally { + await rm(depsRoot, { recursive: true }); + await rm(flowTmp, { recursive: true }); + } + }); }); diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 73cc58fe3..799c87807 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -50,10 +50,18 @@ export function rewriteFlowImports( ); } +type LoadFlowDefaultArgs = { + flowPath: string; + // When set, resolve @qawolf/flows from this dir instead of walking up from the flow file. + depsRoot?: string; + fs?: Fs; +}; + export async function loadFlowDefault( - flowPath: string, - fs: Fs = makeDefaultFs(), + args: LoadFlowDefaultArgs, ): Promise { + const { flowPath, depsRoot, fs = makeDefaultFs() } = args; + // process.env.QAWOLF_COMPILED is injected via --define at binary build time // (see build:binary in package.json). Undefined in bun run / bun test dev mode. const isCompiledBinary = process.env.QAWOLF_COMPILED === "true"; @@ -75,7 +83,7 @@ export async function loadFlowDefault( // package traversal bug. Transform @qawolf/flows/* imports to absolute file:// // paths so Bun loads them directly without any resolution step. const content = await fs.readFile(flowPath); - const envDir = findFlowsEnvDir(flowPath, fs); + const envDir = depsRoot ?? findFlowsEnvDir(flowPath, fs); const transformed = envDir ? rewriteFlowImports( @@ -96,7 +104,9 @@ export async function loadFlowDefault( return exported; } - const annotated = `${transformed}\n//# sourceURL=${pathToFileURL(flowPath).href}`; + const annotated = `${transformed}\n//# sourceURL=${ + pathToFileURL(flowPath).href + }`; const dataUri = `data:text/javascript,${encodeURIComponent(annotated)}`; const mod = (await import(dataUri)) as Record; const exported = mod["default"] as T | undefined; diff --git a/src/domains/runner/runAndroidFlow.test.ts b/src/domains/runner/runAndroidFlow.test.ts index 10c86fe2e..375c72328 100644 --- a/src/domains/runner/runAndroidFlow.test.ts +++ b/src/domains/runner/runAndroidFlow.test.ts @@ -24,6 +24,8 @@ function makeRunnerDeps() { }, spawn: () => ({ exitCode: Promise.resolve(0), kill: () => {} }), signals: makeNoopSignals(), + // Point to the CLI project root where @qawolf/flows is installed in tests. + depsRoot: join(import.meta.dirname, "../../.."), createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/runAndroidFlow.ts b/src/domains/runner/runAndroidFlow.ts index ae8d3fa5c..29401b20f 100644 --- a/src/domains/runner/runAndroidFlow.ts +++ b/src/domains/runner/runAndroidFlow.ts @@ -41,7 +41,10 @@ export async function runAndroidFlow({ options: RunAndroidFlowOptions; flowPath: string; }): Promise { - const exported = await loadFlowDefault(flowPath); + const exported = await loadFlowDefault({ + flowPath, + depsRoot: deps.depsRoot, + }); if (typeof exported === "function") { // (D2) Android legacy flows have no target; AVD derivation is impossible. throw new Error( diff --git a/src/domains/runner/runAndroidFlowDeps.ts b/src/domains/runner/runAndroidFlowDeps.ts index 0a6fda589..d942b4c6f 100644 --- a/src/domains/runner/runAndroidFlowDeps.ts +++ b/src/domains/runner/runAndroidFlowDeps.ts @@ -59,7 +59,7 @@ export function createAndroidDeps( let serverStarted = false; const deps: RunAndroidFlowDeps = { - ...createRunnerDeps(signals), + ...createRunnerDeps(signals, envDir), appiumServer: serverHandle, emulatorPool: pool, createSession, diff --git a/src/domains/runner/runInternals.ts b/src/domains/runner/runInternals.ts index 12143e500..dfa1b020a 100644 --- a/src/domains/runner/runInternals.ts +++ b/src/domains/runner/runInternals.ts @@ -39,6 +39,8 @@ export type FlowsRunFlags = { /** `--junit` writes a JUnit XML report. Bare flag (true) uses a default path * under outputDir; a string is an explicit output path. */ readonly junit?: string | boolean; + // --deps : use this prepared dependency directory instead of auto-resolving. + readonly deps?: string; }; export type FlowsRunDeps = { @@ -141,8 +143,7 @@ export async function dispatchFlow({ const durationMs = deps.now() - flowStart; const outcome = run.passed ? "pass" : "fail"; const attempts = run.attempts; - deps.logger?.info( - `${outcome}: ${flow.name} (${durationMs}ms, ${attempts} attempt${attempts === 1 ? "" : "s"})`, - ); + const attempt = `${attempts} attempt${attempts === 1 ? "" : "s"}`; + deps.logger?.info(`${outcome}: ${flow.name} (${durationMs}ms, ${attempt})`); return { run, durationMs }; } diff --git a/src/domains/runner/runWebFlow.fixtures.ts b/src/domains/runner/runWebFlow.fixtures.ts index a7468b358..39c0ef888 100644 --- a/src/domains/runner/runWebFlow.fixtures.ts +++ b/src/domains/runner/runWebFlow.fixtures.ts @@ -20,6 +20,8 @@ export function makeRunnerDeps(): RunnerDeps { }, spawn: () => ({ exitCode: Promise.resolve(0), kill: () => {} }), signals: makeNoopSignals(), + // Point to the CLI project root where @qawolf/flows is installed in tests. + depsRoot: join(import.meta.dirname, "../../.."), createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/runWebFlow.ts b/src/domains/runner/runWebFlow.ts index 6c851a06a..e553aa6d2 100644 --- a/src/domains/runner/runWebFlow.ts +++ b/src/domains/runner/runWebFlow.ts @@ -41,9 +41,15 @@ export async function runWebFlow({ options: RunWebFlowOptions; flowPath: string; }): Promise { - await initFlowRuntime(flowPath, { timeout: options.timeout }); + await initFlowRuntime(flowPath, { + timeout: options.timeout, + depsRoot: deps.depsRoot, + }); - const exported = await loadFlowDefault(flowPath); + const exported = await loadFlowDefault({ + flowPath, + depsRoot: deps.depsRoot, + }); const isLegacy = typeof exported === "function"; const flowName = isLegacy diff --git a/src/domains/runner/runWebFlowDeps.ts b/src/domains/runner/runWebFlowDeps.ts index 3d300a309..789ae5617 100644 --- a/src/domains/runner/runWebFlowDeps.ts +++ b/src/domains/runner/runWebFlowDeps.ts @@ -34,6 +34,6 @@ export async function defaultRunWebFlowDeps( chromium, firefox, webkit, - ...createRunnerDeps(signals), + ...createRunnerDeps(signals, cwd), }; } diff --git a/src/domains/runner/runnerDeps.test.ts b/src/domains/runner/runnerDeps.test.ts index aeffcd065..3c7577afe 100644 --- a/src/domains/runner/runnerDeps.test.ts +++ b/src/domains/runner/runnerDeps.test.ts @@ -4,17 +4,22 @@ import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.j describe("createRunnerDeps", () => { it("resolves exitCode to -1 when the binary is missing instead of crashing on an unhandled error event", async () => { - const deps = createRunnerDeps(makeNoopSignals()); + const deps = createRunnerDeps(makeNoopSignals(), "/tmp/deps"); const { exitCode } = deps.spawn("__qawolf_nonexistent_binary_xyzzy__", []); expect(await exitCode).toBe(-1); }); it("resolves exitCode with the child's exit code on a normal close", async () => { - const deps = createRunnerDeps(makeNoopSignals()); + const deps = createRunnerDeps(makeNoopSignals(), "/tmp/deps"); const { exitCode } = deps.spawn(process.execPath, [ "-e", "process.exit(7)", ]); expect(await exitCode).toBe(7); }); + + it("includes the provided depsRoot in the returned deps", () => { + const deps = createRunnerDeps(makeNoopSignals(), "/tmp/my-deps-root"); + expect(deps.depsRoot).toBe("/tmp/my-deps-root"); + }); }); diff --git a/src/domains/runner/runnerDeps.ts b/src/domains/runner/runnerDeps.ts index 59c6fd747..133021c86 100644 --- a/src/domains/runner/runnerDeps.ts +++ b/src/domains/runner/runnerDeps.ts @@ -4,7 +4,10 @@ import { spawn as nodeSpawn } from "~/shell/spawn.js"; import type { RunnerDeps } from "./types.js"; import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; -export function createRunnerDeps(signals: SignalRegistry): RunnerDeps { +export function createRunnerDeps( + signals: SignalRegistry, + depsRoot: string, +): RunnerDeps { return { fs: makeDefaultFs(), spawn: (cmd, args) => { @@ -23,6 +26,7 @@ export function createRunnerDeps(signals: SignalRegistry): RunnerDeps { }; }, signals, + depsRoot, createStorage: () => { // Stored as `unknown` internally; the getStore cast keeps the outer T // contract while sidestepping TS's inability to unify the outer T with diff --git a/src/domains/runner/types.ts b/src/domains/runner/types.ts index 76cb3c98e..ada708609 100644 --- a/src/domains/runner/types.ts +++ b/src/domains/runner/types.ts @@ -51,6 +51,8 @@ export type RunnerDeps = { spawn: RunnerSpawnFn; signals: SignalRegistry; createStorage: () => AsyncStorage; + // Directory the flow runtime resolves @qawolf/flows + playwright from. + depsRoot: string; logger?: Logger; }; diff --git a/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts new file mode 100644 index 000000000..4c04a1846 --- /dev/null +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { ensureRuntimeEnv } from "./ensureRuntimeEnv.js"; + +const managedDir = "/data/runtime/abc123"; + +function seedFullEnv(fs: ReturnType, dir: string): void { + for (const { name, version } of pinnedPackages) { + const pkgDir = join(dir, "node_modules", ...name.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + const binDir = join(dir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); +} + +function makeNoopInstall() { + let called = false; + const install = async (_targetDir: string): Promise => { + called = true; + }; + return { install, wasCalled: () => called }; +} + +describe("ensureRuntimeEnv", () => { + it("returns override source when overrideDir has all pinned deps", async () => { + const fs = makeMemoryFs(); + const overrideDir = "/override/env"; + seedFullEnv(fs, overrideDir); + const { install } = makeNoopInstall(); + + const result = await ensureRuntimeEnv( + { overrideDir }, + { fs, install, resolveManagedDir: () => managedDir }, + ); + + expect(result).toEqual({ + depsRoot: overrideDir, + source: "override", + installed: false, + }); + }); + + it("throws when overrideDir is missing pinned dependencies", async () => { + const fs = makeMemoryFs(); + const overrideDir = "/override/empty"; + const { install } = makeNoopInstall(); + + let caughtError: unknown; + try { + await ensureRuntimeEnv( + { overrideDir }, + { fs, install, resolveManagedDir: () => managedDir }, + ); + } catch (e) { + caughtError = e; + } + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toContain(overrideDir); + }); + + it("returns project source when projectDir has all pinned deps", async () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + const { install, wasCalled } = makeNoopInstall(); + + const result = await ensureRuntimeEnv( + { projectDir }, + { fs, install, resolveManagedDir: () => managedDir }, + ); + + expect(result).toEqual({ + depsRoot: projectDir, + source: "project", + installed: false, + }); + expect(wasCalled()).toBe(false); + }); + + it("installs managed env and returns installed:true when no resolved dir exists", async () => { + const fs = makeMemoryFs(); + const { install, wasCalled } = makeNoopInstall(); + + const result = await ensureRuntimeEnv( + {}, + { fs, install, resolveManagedDir: () => managedDir }, + ); + + expect(result).toEqual({ + depsRoot: managedDir, + source: "managed", + installed: true, + }); + expect(wasCalled()).toBe(true); + }); + + it("returns installed:false when managed env is already complete", async () => { + const fs = makeMemoryFs(); + seedFullEnv(fs, managedDir); + const { install, wasCalled } = makeNoopInstall(); + + const result = await ensureRuntimeEnv( + {}, + { fs, install, resolveManagedDir: () => managedDir }, + ); + + expect(result).toEqual({ + depsRoot: managedDir, + source: "managed", + installed: false, + }); + expect(wasCalled()).toBe(false); + }); +}); diff --git a/src/domains/runtimeEnv/ensureRuntimeEnv.ts b/src/domains/runtimeEnv/ensureRuntimeEnv.ts new file mode 100644 index 000000000..a9b51d6af --- /dev/null +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.ts @@ -0,0 +1,66 @@ +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; + +import { managedEnvDir as defaultManagedEnvDir } from "./managedEnvDir.js"; +import { installPinned, defaultSpawnInstall } from "./installPinned.js"; +import { allPinnedResolved } from "./resolvePinned.js"; + +type RuntimeEnvSource = "override" | "project" | "managed"; + +export type EnsureRuntimeEnvResult = { + depsRoot: string; + source: RuntimeEnvSource; + installed: boolean; +}; + +export type EnsureRuntimeEnvArgs = { + projectDir?: string; + overrideDir?: string; +}; + +type EnsureRuntimeEnvDeps = { + fs: Fs; + install: (targetDir: string) => Promise; + resolveManagedDir: () => string; +}; + +/** + * Resolves a single directory (`depsRoot`) that callers use to resolve all + * pinned runtime dependencies. Checks override → project → managed env in + * order, installing the managed env on first use. + */ +export async function ensureRuntimeEnv( + args: EnsureRuntimeEnvArgs, + deps: Partial = {}, +): Promise { + const fs = deps.fs ?? makeDefaultFs(); + const resolveManagedDir = deps.resolveManagedDir ?? defaultManagedEnvDir; + const install = + deps.install ?? + ((t) => installPinned(t, { fs, spawnInstall: defaultSpawnInstall })); + + if (args.overrideDir !== undefined) { + if (allPinnedResolved(args.overrideDir, fs)) { + return { + depsRoot: args.overrideDir, + source: "override", + installed: false, + }; + } + throw new Error( + `--deps directory ${args.overrideDir} is missing required pinned dependencies. ` + + `Run 'npm install' in that directory or point to a valid managed env directory.`, + ); + } + + if (args.projectDir !== undefined && allPinnedResolved(args.projectDir, fs)) { + return { depsRoot: args.projectDir, source: "project", installed: false }; + } + + const managed = resolveManagedDir(); + if (allPinnedResolved(managed, fs)) { + return { depsRoot: managed, source: "managed", installed: false }; + } + + await install(managed); + return { depsRoot: managed, source: "managed", installed: true }; +} diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts new file mode 100644 index 000000000..f8c36848e --- /dev/null +++ b/src/domains/runtimeEnv/index.ts @@ -0,0 +1,6 @@ +export { + ensureRuntimeEnv, + type EnsureRuntimeEnvArgs, + type EnsureRuntimeEnvResult, +} from "./ensureRuntimeEnv.js"; +export { resolveDepsRootIfPresent } from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/installPinned.test.ts b/src/domains/runtimeEnv/installPinned.test.ts new file mode 100644 index 000000000..91fd6565d --- /dev/null +++ b/src/domains/runtimeEnv/installPinned.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { installPinned } from "./installPinned.js"; + +const targetDir = "/runtime/env/abc123"; +const tempDir = `${targetDir}.installing.${process.pid}`; + +function makeSpawnInstall(exitCode: number, stderr = "") { + return async (_cwd: string) => ({ exitCode, stderr }); +} + +describe("installPinned", () => { + it("scaffolds temp dir, installs, and renames to target on success", async () => { + const fs = makeMemoryFs(); + + await installPinned(targetDir, { + fs, + spawnInstall: makeSpawnInstall(0), + }); + + // Target dir should exist with a package.json (scaffolded before install) + expect(fs.existsSync(targetDir)).toBe(true); + expect(fs.existsSync(join(targetDir, "package.json"))).toBe(true); + // Temp dir should be gone after successful rename + expect(fs.existsSync(tempDir)).toBe(false); + }); + + it("cleans up temp dir and throws when install fails", async () => { + const fs = makeMemoryFs(); + + let caughtError: unknown; + try { + await installPinned(targetDir, { + fs, + spawnInstall: makeSpawnInstall(1, "npm ERR! some failure"), + }); + } catch (e) { + caughtError = e; + } + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toContain(targetDir); + expect((caughtError as Error).message).toContain("npm ERR! some failure"); + // Both temp and target dirs should be absent + expect(fs.existsSync(tempDir)).toBe(false); + expect(fs.existsSync(targetDir)).toBe(false); + }); + + it("short-circuits without calling spawnInstall when .bin/playwright exists", async () => { + const fs = makeMemoryFs(); + // Simulate a previously completed install + const binDir = join(targetDir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); + + let spawnCalled = false; + await installPinned(targetDir, { + fs, + spawnInstall: async (_cwd) => { + spawnCalled = true; + return { exitCode: 0, stderr: "" }; + }, + }); + + expect(spawnCalled).toBe(false); + }); +}); diff --git a/src/domains/runtimeEnv/installPinned.ts b/src/domains/runtimeEnv/installPinned.ts new file mode 100644 index 000000000..91e7aa1e1 --- /dev/null +++ b/src/domains/runtimeEnv/installPinned.ts @@ -0,0 +1,73 @@ +import { join } from "node:path"; + +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; +import { spawn as nodeSpawn } from "~/shell/spawn.js"; + +import { scaffoldManagedEnv } from "./managedEnvDir.js"; +import { shimFlowsDeps } from "./shimDeps.js"; + +type SpawnInstallResult = { exitCode: number; stderr: string }; + +type SpawnInstallFn = (cwd: string) => Promise; + +export type InstallPinnedDeps = { fs: Fs; spawnInstall: SpawnInstallFn }; + +export function defaultSpawnInstall(cwd: string): Promise { + return new Promise((resolve) => { + // npm 7+ strict peer-dep resolution rejects peerOptional conflicts — revert to npm 6 behaviour. + const child = nodeSpawn("npm", ["install", "--legacy-peer-deps"], { cwd }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += String(chunk); + }); + child.on("error", () => resolve({ exitCode: -1, stderr })); + child.on("close", (code) => resolve({ exitCode: code ?? -1, stderr })); + }); +} + +export async function installPinned( + targetDir: string, + deps: InstallPinnedDeps = { + fs: makeDefaultFs(), + spawnInstall: defaultSpawnInstall, + }, +): Promise { + // Short-circuit: another process or a previous run already completed the install. + if ( + deps.fs.existsSync(join(targetDir, "node_modules", ".bin", "playwright")) + ) { + return; + } + + // Use a PID-scoped temp dir so parallel CI shards don't collide. + const tempDir = `${targetDir}.installing.${process.pid}`; + + // Clean any stale temp dir left by a previous crash. + await deps.fs.rm(tempDir, { recursive: true, force: true }); + await scaffoldManagedEnv(tempDir, deps.fs); + + const result = await deps.spawnInstall(tempDir); + if (result.exitCode !== 0) { + await deps.fs.rm(tempDir, { recursive: true, force: true }); + throw new Error( + `Failed to install managed runtime into ${targetDir}: ${result.stderr.trim()}`, + ); + } + + await shimFlowsDeps(tempDir, deps.fs); + + // Atomic publish: the first shard to rename wins; others detect the completed + // .bin/playwright shim and quietly remove their own temp dir. + try { + await deps.fs.rename(tempDir, targetDir); + } catch (err) { + const anotherShardWon = deps.fs.existsSync( + join(targetDir, "node_modules", ".bin", "playwright"), + ); + if (anotherShardWon) { + await deps.fs.rm(tempDir, { recursive: true, force: true }); + return; + } + throw err; + } +} diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts new file mode 100644 index 000000000..626d0410a --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { + managedEnvDir, + managedEnvHash, + scaffoldManagedEnv, +} from "./managedEnvDir.js"; + +describe("managedEnvHash", () => { + it("returns exactly 16 hex characters", () => { + const hash = managedEnvHash(); + expect(hash).toHaveLength(16); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + + it("is stable across multiple calls", () => { + expect(managedEnvHash()).toBe(managedEnvHash()); + }); +}); + +describe("managedEnvDir", () => { + it("ends with runtime/", () => { + const hash = managedEnvHash(); + const result = managedEnvDir(); + expect(result).toContain(join("runtime", hash)); + }); +}); + +describe("scaffoldManagedEnv", () => { + it("creates the directory and writes a package.json with all pinned deps", async () => { + const fs = makeMemoryFs(); + const dir = "/test/managed/env"; + + await scaffoldManagedEnv(dir, fs); + + const raw = fs.readFileSync(join(dir, "package.json")); + const pkg = JSON.parse(raw) as { + name: string; + private: boolean; + dependencies: Record; + }; + + expect(pkg.name).toBe("qawolf-runtime"); + expect(pkg.private).toBe(true); + expect(Object.keys(pkg.dependencies)).toHaveLength(pinnedPackages.length); + for (const { name, version } of pinnedPackages) { + expect(pkg.dependencies[name]).toBe(version); + } + }); + + it("writes an .npmrc pinning the @qawolf scope to public npm", async () => { + const fs = makeMemoryFs(); + const dir = "/test/managed/env"; + + await scaffoldManagedEnv(dir, fs); + + const npmrc = fs.readFileSync(join(dir, ".npmrc")); + expect(npmrc).toContain("@qawolf:registry=https://registry.npmjs.org/"); + }); +}); diff --git a/src/domains/runtimeEnv/managedEnvDir.ts b/src/domains/runtimeEnv/managedEnvDir.ts new file mode 100644 index 000000000..e3aa66917 --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +import type { Fs } from "~/shell/fs.js"; +import { getDataDir } from "~/core/paths.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; + +/** + * Deterministic 16-hex-char SHA-256 digest of the pinned package specs. A + * new hash is produced whenever any pinned version changes, so each release + * gets its own isolated install directory. + */ +export function managedEnvHash(): string { + const content = pinnedPackages + .map(({ name, version }) => `${name}@${version}`) + .join("\n"); + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +/** Absolute path to the versioned managed runtime directory. */ +export function managedEnvDir(): string { + return join(getDataDir(), "runtime", managedEnvHash()); +} + +/** + * Creates `dir` recursively and writes a private package.json listing all + * pinned dependencies so `npm install` can populate them. Also writes an + * `.npmrc` pinning the @qawolf scope to public npm — the managed dir has no + * project `.npmrc`, so without this a developer whose global config redirects + * @qawolf to a private registry (e.g. GitHub Packages) would fail to install. + */ +export async function scaffoldManagedEnv(dir: string, fs: Fs): Promise { + await fs.mkdir(dir, { recursive: true }); + const dependencies = Object.fromEntries( + pinnedPackages.map(({ name, version }) => [name, version]), + ); + await fs.writeFile( + join(dir, "package.json"), + JSON.stringify( + { name: "qawolf-runtime", private: true, dependencies }, + undefined, + 2, + ), + ); + await fs.writeFile( + join(dir, ".npmrc"), + "@qawolf:registry=https://registry.npmjs.org/\n", + ); +} diff --git a/src/domains/runtimeEnv/pinnedPackages.ts b/src/domains/runtimeEnv/pinnedPackages.ts new file mode 100644 index 000000000..a538e0200 --- /dev/null +++ b/src/domains/runtimeEnv/pinnedPackages.ts @@ -0,0 +1,25 @@ +import { + appiumUiautomator2DriverVersion, + appiumVersion, + appiumXcuitestDriverVersion, + emailsVersion, + flowsVersion, + playwrightVersion, + testkitVersion, +} from "~/generated/dependencyVersions.js"; + +export type PinnedPackage = { name: string; version: string }; + +/** Canonical list of pinned runtime packages the CLI installs and resolves from. */ +export const pinnedPackages: PinnedPackage[] = [ + { name: "@qawolf/flows", version: flowsVersion }, + { name: "playwright", version: playwrightVersion }, + { name: "@qawolf/emails", version: emailsVersion }, + { name: "@qawolf/testkit", version: testkitVersion }, + { name: "appium", version: appiumVersion }, + { name: "appium-xcuitest-driver", version: appiumXcuitestDriverVersion }, + { + name: "appium-uiautomator2-driver", + version: appiumUiautomator2DriverVersion, + }, +]; diff --git a/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts b/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts new file mode 100644 index 000000000..9c5e6da48 --- /dev/null +++ b/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { managedEnvDir } from "./managedEnvDir.js"; +import { pinnedPackages } from "./pinnedPackages.js"; +import { resolveDepsRootIfPresent } from "./resolveDepsRootIfPresent.js"; + +function seedFullEnv(fs: ReturnType, dir: string): void { + for (const { name, version } of pinnedPackages) { + const pkgDir = join(dir, "node_modules", ...name.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + const binDir = join(dir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); +} + +describe("resolveDepsRootIfPresent", () => { + it("returns overrideDir when override has all pinned deps", () => { + const fs = makeMemoryFs(); + const overrideDir = "/override/env"; + seedFullEnv(fs, overrideDir); + + const result = resolveDepsRootIfPresent({ overrideDir }, fs); + + expect(result).toBe(overrideDir); + }); + + it("skips override and returns projectDir when override is absent but project is present", () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + + const result = resolveDepsRootIfPresent( + { overrideDir: "/missing/override", projectDir }, + fs, + ); + + expect(result).toBe(projectDir); + }); + + it("returns projectDir when project has all pinned deps and no override is given", () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + + const result = resolveDepsRootIfPresent({ projectDir }, fs); + + expect(result).toBe(projectDir); + }); + + it("returns managed dir when only managed env is installed", () => { + const fs = makeMemoryFs(); + const managed = managedEnvDir(); + seedFullEnv(fs, managed); + + const result = resolveDepsRootIfPresent({}, fs); + + expect(result).toBe(managed); + }); + + it("returns undefined when no directory has all pinned deps installed", () => { + const fs = makeMemoryFs(); + + const result = resolveDepsRootIfPresent( + { projectDir: "/missing/project" }, + fs, + ); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when called with no args and managed env is absent", () => { + const fs = makeMemoryFs(); + + const result = resolveDepsRootIfPresent({}, fs); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts new file mode 100644 index 000000000..4e9d892a3 --- /dev/null +++ b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts @@ -0,0 +1,25 @@ +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; + +import { type EnsureRuntimeEnvArgs } from "./ensureRuntimeEnv.js"; +import { managedEnvDir } from "./managedEnvDir.js"; +import { allPinnedResolved } from "./resolvePinned.js"; + +export type ResolveDepsRootArgs = EnsureRuntimeEnvArgs; + +/** + * Returns the first directory whose pinned deps already resolve (override → + * project → managed), or undefined if none are installed. Never installs — + * use for read-only diagnostics like `doctor`. + */ +export function resolveDepsRootIfPresent( + args: ResolveDepsRootArgs, + fs: Fs = makeDefaultFs(), +): string | undefined { + if (args.overrideDir !== undefined && allPinnedResolved(args.overrideDir, fs)) + return args.overrideDir; + if (args.projectDir !== undefined && allPinnedResolved(args.projectDir, fs)) + return args.projectDir; + const managed = managedEnvDir(); + if (allPinnedResolved(managed, fs)) return managed; + return undefined; +} diff --git a/src/domains/runtimeEnv/resolvePinned.test.ts b/src/domains/runtimeEnv/resolvePinned.test.ts new file mode 100644 index 000000000..0c325a325 --- /dev/null +++ b/src/domains/runtimeEnv/resolvePinned.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { allPinnedResolved, readInstalledVersion } from "./resolvePinned.js"; + +const dir = "/project"; + +function seedPackage( + fs: ReturnType, + pkgName: string, + version: string, +): void { + const pkgDir = join(dir, "node_modules", ...pkgName.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); +} + +function seedAllPackages(fs: ReturnType): void { + for (const { name, version } of pinnedPackages) { + seedPackage(fs, name, version); + } + fs.mkdirSync(join(dir, "node_modules", ".bin"), { recursive: true }); + fs.writeFileSync( + join(dir, "node_modules", ".bin", "playwright"), + "#!/bin/sh", + ); +} + +describe("readInstalledVersion", () => { + it("returns the version when package.json is present", () => { + const fs = makeMemoryFs(); + seedPackage(fs, "@qawolf/flows", "1.2.3"); + + expect(readInstalledVersion(dir, "@qawolf/flows", fs)).toBe("1.2.3"); + }); + + it("returns undefined when the package directory is missing", () => { + const fs = makeMemoryFs(); + + expect(readInstalledVersion(dir, "@qawolf/flows", fs)).toBeUndefined(); + }); + + it("returns undefined when package.json has no version field", () => { + const fs = makeMemoryFs(); + seedPackage(fs, "playwright", ""); + // Overwrite with JSON that has no version field + const pkgDir = join(dir, "node_modules", "playwright"); + fs.writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: "playwright" }), + ); + + expect(readInstalledVersion(dir, "playwright", fs)).toBeUndefined(); + }); + + it("returns undefined when package.json contains malformed JSON", () => { + const fs = makeMemoryFs(); + fs.mkdirSync(join(dir, "node_modules", "playwright"), { recursive: true }); + fs.writeFileSync( + join(dir, "node_modules", "playwright", "package.json"), + "not-json", + ); + + expect(readInstalledVersion(dir, "playwright", fs)).toBeUndefined(); + }); +}); + +describe("allPinnedResolved", () => { + it("returns true when all packages match and .bin/playwright exists", () => { + const fs = makeMemoryFs(); + seedAllPackages(fs); + + expect(allPinnedResolved(dir, fs)).toBe(true); + }); + + it("returns false when one package version does not match", () => { + const fs = makeMemoryFs(); + seedAllPackages(fs); + // Overwrite one package with wrong version + const pkgDir = join(dir, "node_modules", "@qawolf", "flows"); + fs.writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ version: "0.0.0" }), + ); + + expect(allPinnedResolved(dir, fs)).toBe(false); + }); + + it("returns false when .bin/playwright shim is absent", () => { + const fs = makeMemoryFs(); + for (const { name, version } of pinnedPackages) { + seedPackage(fs, name, version); + } + // No .bin/playwright + + expect(allPinnedResolved(dir, fs)).toBe(false); + }); +}); diff --git a/src/domains/runtimeEnv/resolvePinned.ts b/src/domains/runtimeEnv/resolvePinned.ts new file mode 100644 index 000000000..939240de4 --- /dev/null +++ b/src/domains/runtimeEnv/resolvePinned.ts @@ -0,0 +1,44 @@ +import { join } from "node:path"; + +import type { Fs } from "~/shell/fs.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; + +/** + * Reads the installed version of a package from its package.json inside + * node_modules. Returns undefined on any error (missing, malformed JSON, no + * version field). + */ +export function readInstalledVersion( + dir: string, + pkgName: string, + fs: Fs, +): string | undefined { + try { + const pkgPath = join( + dir, + "node_modules", + ...pkgName.split("/"), + "package.json", + ); + const raw = JSON.parse(fs.readFileSync(pkgPath)) as { version?: unknown }; + const { version } = raw; + return typeof version === "string" ? version : undefined; + } catch { + return undefined; + } +} + +/** + * Returns true when every pinned package is installed at its exact pinned + * version AND the .bin/playwright shim exists (required by resolvePlaywrightCli + * and installBrowserList). + */ +export function allPinnedResolved(dir: string, fs: Fs): boolean { + if (!fs.existsSync(join(dir, "node_modules", ".bin", "playwright"))) { + return false; + } + return pinnedPackages.every( + ({ name, version }) => readInstalledVersion(dir, name, fs) === version, + ); +} diff --git a/src/domains/flows/shimDeps.test.ts b/src/domains/runtimeEnv/shimDeps.test.ts similarity index 100% rename from src/domains/flows/shimDeps.test.ts rename to src/domains/runtimeEnv/shimDeps.test.ts diff --git a/src/domains/flows/shimDeps.ts b/src/domains/runtimeEnv/shimDeps.ts similarity index 94% rename from src/domains/flows/shimDeps.ts rename to src/domains/runtimeEnv/shimDeps.ts index 245be2077..2da5dfd8b 100644 --- a/src/domains/flows/shimDeps.ts +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -28,9 +28,11 @@ import { resolveFromEnvDir } from "~/shell/resolveExport.js"; type ShimMarker = { _qawolf_version: string; _qawolf_format: string }; -// Uses a structural type instead of `typeof Bun.build` to avoid the -// no-restricted-globals lint rule. Injected in tests — globalThis.Bun is -// read-only in the Bun runtime and cannot be reassigned. +/** + * Uses a structural type instead of `typeof Bun.build` to avoid the + * no-restricted-globals lint rule. Injected in tests — globalThis.Bun is + * read-only in the Bun runtime and cannot be reassigned. + */ export type BuildFn = (config: { entrypoints: string[]; target?: string; @@ -41,8 +43,11 @@ export type BuildFn = (config: { logs: { message: string }[]; }>; -// Returns the shim marker if shimDir is a qawolf-managed shim, undefined otherwise. -// A directory without the marker is a real package — must not be touched. +/** + * Returns the shim marker if shimDir is a qawolf-managed shim, undefined + * otherwise. A directory without the marker is a real package — must not be + * touched. + */ function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { try { const pkg = JSON.parse( From 9b51d57fb2c3b3a1de3d8d28e8270ca75d946f3d Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:23 -0400 Subject: [PATCH 02/14] fix(runner): harden managed runtime readiness checks --- .../runtimeEnv/ensureRuntimeEnv.test.ts | 31 +++++++++++++++++-- src/domains/runtimeEnv/ensureRuntimeEnv.ts | 5 +++ src/domains/runtimeEnv/installPinned.test.ts | 22 ++++++++++--- src/domains/runtimeEnv/installPinned.ts | 11 ++----- src/domains/runtimeEnv/resolvePinned.test.ts | 14 +++++++++ src/domains/runtimeEnv/resolvePinned.ts | 8 ++++- 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts index 4c04a1846..4e9660ea3 100644 --- a/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts @@ -86,7 +86,12 @@ describe("ensureRuntimeEnv", () => { it("installs managed env and returns installed:true when no resolved dir exists", async () => { const fs = makeMemoryFs(); - const { install, wasCalled } = makeNoopInstall(); + let called = false; + // Fake install materializes the managed dir so the post-install check passes. + const install = async (targetDir: string): Promise => { + called = true; + seedFullEnv(fs, targetDir); + }; const result = await ensureRuntimeEnv( {}, @@ -98,7 +103,29 @@ describe("ensureRuntimeEnv", () => { source: "managed", installed: true, }); - expect(wasCalled()).toBe(true); + expect(called).toBe(true); + }); + + it("throws when install does not materialize the managed deps", async () => { + const fs = makeMemoryFs(); + // Install resolves but leaves the managed dir incomplete. + const { install } = makeNoopInstall(); + + let caughtError: unknown; + try { + await ensureRuntimeEnv( + {}, + { fs, install, resolveManagedDir: () => managedDir }, + ); + } catch (e) { + caughtError = e; + } + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toContain( + "incomplete after install", + ); + expect((caughtError as Error).message).toContain(managedDir); }); it("returns installed:false when managed env is already complete", async () => { diff --git a/src/domains/runtimeEnv/ensureRuntimeEnv.ts b/src/domains/runtimeEnv/ensureRuntimeEnv.ts index a9b51d6af..bbfba3815 100644 --- a/src/domains/runtimeEnv/ensureRuntimeEnv.ts +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.ts @@ -62,5 +62,10 @@ export async function ensureRuntimeEnv( } await install(managed); + if (!allPinnedResolved(managed, fs)) { + throw new Error( + `Managed runtime is incomplete after install at ${managed}.`, + ); + } return { depsRoot: managed, source: "managed", installed: true }; } diff --git a/src/domains/runtimeEnv/installPinned.test.ts b/src/domains/runtimeEnv/installPinned.test.ts index 91fd6565d..77c1e40ce 100644 --- a/src/domains/runtimeEnv/installPinned.test.ts +++ b/src/domains/runtimeEnv/installPinned.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { installPinned } from "./installPinned.js"; +import { pinnedPackages } from "./pinnedPackages.js"; const targetDir = "/runtime/env/abc123"; const tempDir = `${targetDir}.installing.${process.pid}`; @@ -12,6 +13,19 @@ function makeSpawnInstall(exitCode: number, stderr = "") { return async (_cwd: string) => ({ exitCode, stderr }); } +// Seeds a dir so allPinnedResolved returns true: every pinned package at its +// exact version plus the .bin/playwright shim. +function seedFullEnv(fs: ReturnType, dir: string): void { + for (const { name, version } of pinnedPackages) { + const pkgDir = join(dir, "node_modules", ...name.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + const binDir = join(dir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); +} + describe("installPinned", () => { it("scaffolds temp dir, installs, and renames to target on success", async () => { const fs = makeMemoryFs(); @@ -49,12 +63,10 @@ describe("installPinned", () => { expect(fs.existsSync(targetDir)).toBe(false); }); - it("short-circuits without calling spawnInstall when .bin/playwright exists", async () => { + it("short-circuits without calling spawnInstall when target is fully resolved", async () => { const fs = makeMemoryFs(); - // Simulate a previously completed install - const binDir = join(targetDir, "node_modules", ".bin"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); + // Simulate a previously completed install with all pinned versions present + seedFullEnv(fs, targetDir); let spawnCalled = false; await installPinned(targetDir, { diff --git a/src/domains/runtimeEnv/installPinned.ts b/src/domains/runtimeEnv/installPinned.ts index 91e7aa1e1..dec75fdb4 100644 --- a/src/domains/runtimeEnv/installPinned.ts +++ b/src/domains/runtimeEnv/installPinned.ts @@ -1,9 +1,8 @@ -import { join } from "node:path"; - import { type Fs, makeDefaultFs } from "~/shell/fs.js"; import { spawn as nodeSpawn } from "~/shell/spawn.js"; import { scaffoldManagedEnv } from "./managedEnvDir.js"; +import { allPinnedResolved } from "./resolvePinned.js"; import { shimFlowsDeps } from "./shimDeps.js"; type SpawnInstallResult = { exitCode: number; stderr: string }; @@ -33,9 +32,7 @@ export async function installPinned( }, ): Promise { // Short-circuit: another process or a previous run already completed the install. - if ( - deps.fs.existsSync(join(targetDir, "node_modules", ".bin", "playwright")) - ) { + if (allPinnedResolved(targetDir, deps.fs)) { return; } @@ -61,9 +58,7 @@ export async function installPinned( try { await deps.fs.rename(tempDir, targetDir); } catch (err) { - const anotherShardWon = deps.fs.existsSync( - join(targetDir, "node_modules", ".bin", "playwright"), - ); + const anotherShardWon = allPinnedResolved(targetDir, deps.fs); if (anotherShardWon) { await deps.fs.rm(tempDir, { recursive: true, force: true }); return; diff --git a/src/domains/runtimeEnv/resolvePinned.test.ts b/src/domains/runtimeEnv/resolvePinned.test.ts index 0c325a325..49cb91421 100644 --- a/src/domains/runtimeEnv/resolvePinned.test.ts +++ b/src/domains/runtimeEnv/resolvePinned.test.ts @@ -98,4 +98,18 @@ describe("allPinnedResolved", () => { expect(allPinnedResolved(dir, fs)).toBe(false); }); + + it("returns true when only the Windows .bin/playwright.cmd shim exists", () => { + const fs = makeMemoryFs(); + for (const { name, version } of pinnedPackages) { + seedPackage(fs, name, version); + } + fs.mkdirSync(join(dir, "node_modules", ".bin"), { recursive: true }); + fs.writeFileSync( + join(dir, "node_modules", ".bin", "playwright.cmd"), + "@echo off", + ); + + expect(allPinnedResolved(dir, fs)).toBe(true); + }); }); diff --git a/src/domains/runtimeEnv/resolvePinned.ts b/src/domains/runtimeEnv/resolvePinned.ts index 939240de4..5593480cb 100644 --- a/src/domains/runtimeEnv/resolvePinned.ts +++ b/src/domains/runtimeEnv/resolvePinned.ts @@ -35,7 +35,13 @@ export function readInstalledVersion( * and installBrowserList). */ export function allPinnedResolved(dir: string, fs: Fs): boolean { - if (!fs.existsSync(join(dir, "node_modules", ".bin", "playwright"))) { + // npm/bun create an extension-less POSIX shim and a .cmd wrapper on Windows; + // either one satisfies resolvePlaywrightCli, so accept both names. + const binDir = join(dir, "node_modules", ".bin"); + const hasPlaywrightShim = + fs.existsSync(join(binDir, "playwright")) || + fs.existsSync(join(binDir, "playwright.cmd")); + if (!hasPlaywrightShim) { return false; } return pinnedPackages.every( From 225c519eca6819bc8ef22a79d8b53432f0ef653e Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:19:00 -0400 Subject: [PATCH 03/14] refactor(runner): defer runtime install + share resolveDepsRoot helper --- src/commands/flows/hybridRun.test.ts | 9 +- src/commands/flows/hybridRunDefaults.ts | 30 +++---- src/commands/flows/runDefaults.handle.test.ts | 57 ++++++------- .../flows/runDefaults.reporterWiring.test.ts | 5 +- src/commands/flows/runDefaults.ts | 36 ++++---- src/commands/install/all.test.ts | 56 ++++++------ src/commands/install/all.ts | 29 ++----- src/commands/install/android.ts | 30 +------ src/commands/install/browsers.fixtures.ts | 2 +- src/commands/install/browsers.ts | 32 ++----- src/commands/resolveDepsRoot.test.ts | 85 +++++++++++++++++++ src/commands/resolveDepsRoot.ts | 39 +++++++++ src/domains/install/android/index.ts | 8 +- .../install/android/installAndroid.test.ts | 2 +- src/domains/install/browsers.ts | 15 +++- src/domains/runtimeEnv/index.ts | 1 - 16 files changed, 258 insertions(+), 178 deletions(-) create mode 100644 src/commands/resolveDepsRoot.test.ts create mode 100644 src/commands/resolveDepsRoot.ts diff --git a/src/commands/flows/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index b404a981b..eca6fa8e6 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -15,8 +15,7 @@ afterEach(() => { const expandPatternsMock = mock(); const pullEnvMock = mock(); -const ensureRuntimeEnvMock = - mock(); +const resolveDepsRootMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<() => Promise>(); @@ -24,7 +23,7 @@ const runWebFlowDepsMock = mock<() => Promise>(); const trackedMocks = [ expandPatternsMock, pullEnvMock, - ensureRuntimeEnvMock, + resolveDepsRootMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -34,7 +33,7 @@ beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); pullEnvMock.mockResolvedValue(undefined); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/env", source: "project", installed: false, @@ -77,7 +76,7 @@ function makeDeps(): HandleHybridFlowsRunDeps { return { expandPatterns: expandPatternsMock, pullEnv: pullEnvMock, - ensureRuntimeEnv: ensureRuntimeEnvMock, + resolveDepsRoot: resolveDepsRootMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index ea804edc3..71d1d000d 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -18,7 +18,11 @@ import { buildRunReporter } from "./buildRunReporter.js"; import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js"; import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; +import { type EnsureRuntimeEnvResult } 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"; @@ -37,7 +41,9 @@ export type HandleHybridFlowsRunDeps = { logger?: Logger, ) => Promise; pullEnv: (ctx: AuthCommandContext, envId: string) => Promise; - ensureRuntimeEnv: typeof ensureRuntimeEnv; + resolveDepsRoot: ( + args: Omit, + ) => Promise; configureTestkit: (dir: string) => Promise; flowsRun: typeof defaultFlowsRun; runWebFlowDeps: typeof defaultRunWebFlowDeps; @@ -48,7 +54,7 @@ function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), pullEnv: (ctx, envId) => handleFlowsPull(ctx, { env: envId, yes: true }), - ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), + resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), configureTestkit: defaultConfigureTestkit, flowsRun: defaultFlowsRun, runWebFlowDeps: defaultRunWebFlowDeps, @@ -69,22 +75,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: @@ -104,8 +104,8 @@ export async function handleHybridFlowsRun( { message: runnerMessages.preparingEnvironment, task: () => - resolvedDeps.ensureRuntimeEnv({ - projectDir: envDir, + resolvedDeps.resolveDepsRoot({ + files, ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), }), }, diff --git a/src/commands/flows/runDefaults.handle.test.ts b/src/commands/flows/runDefaults.handle.test.ts index 8edfd4cd6..696d9f735 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -5,6 +5,7 @@ import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; import { makeNoopLogger } from "~/shell/logger.testUtils.js"; import { runnerMessages } from "~/core/messages/index.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; const noopSignals = makeNoopSignals(); @@ -12,8 +13,7 @@ const noopSignals = makeNoopSignals(); // handleFlowsRun accepts injectable deps, so no mock.module() is needed. const expandPatternsMock = mock(); -const resolveUniqueEnvDirMock = mock<(files: string[]) => string | undefined>(); -const ensureRuntimeEnvMock = mock(); +const resolveDepsRootMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<(...args: unknown[]) => Promise>(); @@ -23,8 +23,7 @@ const uiNoteMock = mock<(message: string, title?: string) => void>(); const trackedMocks = [ expandPatternsMock, - resolveUniqueEnvDirMock, - ensureRuntimeEnvMock, + resolveDepsRootMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -36,8 +35,7 @@ const trackedMocks = [ function makeDeps(): HandleFlowsRunDeps { return { expandPatterns: expandPatternsMock, - resolveUniqueEnvDir: resolveUniqueEnvDirMock, - ensureRuntimeEnv: ensureRuntimeEnvMock, + resolveDepsRoot: resolveDepsRootMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -67,6 +65,7 @@ function makeCtx(): CommandContext { outputMode: "human", isInteractive: false, signals: noopSignals, + fs: makeMemoryFs(), log: () => makeNoopLogger(), ui: { ...makeFakeUI("human"), @@ -80,8 +79,7 @@ function makeCtx(): CommandContext { beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); - resolveUniqueEnvDirMock.mockReturnValue(undefined); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/env", source: "project", installed: false, @@ -92,12 +90,9 @@ beforeEach(() => { }); describe("handleFlowsRun", () => { - it("proceeds with managed dir when resolveUniqueEnvDir throws", async () => { + it("uses the managed dir resolved by resolveDepsRoot for multi-package patterns", async () => { expandPatternsMock.mockResolvedValue(["/some/file.flow.ts"]); - resolveUniqueEnvDirMock.mockImplementation(() => { - throw new Error("files span multiple env dirs"); - }); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/managed", source: "managed", installed: true, @@ -111,7 +106,9 @@ describe("handleFlowsRun", () => { ); expect(result).toBeUndefined(); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/file.flow.ts"], + }); expect(flowsRunMock).toHaveBeenCalledTimes(1); }); @@ -125,28 +122,28 @@ describe("handleFlowsRun", () => { expect(result).toBeUndefined(); expect(uiInfoMock).toHaveBeenCalledWith(runnerMessages.noFlowsMatched); - expect(ensureRuntimeEnvMock).not.toHaveBeenCalled(); + expect(resolveDepsRootMock).not.toHaveBeenCalled(); expect(configureTestkitMock).not.toHaveBeenCalled(); expect(flowsRunMock).not.toHaveBeenCalled(); }); - it("calls ensureRuntimeEnv with undefined projectDir when resolveUniqueEnvDir returns undefined", async () => { + it("calls resolveDepsRoot with the expanded files and no overrideDir by default", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); - resolveUniqueEnvDirMock.mockReturnValue(undefined); await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/flow.ts"], + }); expect(configureTestkitMock).toHaveBeenCalledTimes(1); expect(flowsRunMock).toHaveBeenCalledTimes(1); expect(runWebFlowDepsMock).toHaveBeenCalledTimes(1); }); - it("calls ensureRuntimeEnv with resolved projectDir and configureTestkit with depsRoot", async () => { + it("configures testkit with the depsRoot returned by resolveDepsRoot", async () => { const envDir = "/mock/.qawolf/env1"; expandPatternsMock.mockResolvedValue([`${envDir}/login.flow.ts`]); - resolveUniqueEnvDirMock.mockReturnValue(envDir); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: envDir, source: "project", installed: false, @@ -154,14 +151,16 @@ describe("handleFlowsRun", () => { await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ projectDir: envDir }); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: [`${envDir}/login.flow.ts`], + }); expect(configureTestkitMock).toHaveBeenCalledWith(envDir); expect(flowsRunMock).toHaveBeenCalledTimes(1); }); - it("emits managed runtime note when ensureRuntimeEnv source is managed", async () => { + it("emits managed runtime note when resolveDepsRoot source is managed", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/home/.qawolf/runtime", source: "managed", installed: false, @@ -177,7 +176,7 @@ describe("handleFlowsRun", () => { it("does not emit managed runtime note when source is project", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/env", source: "project", installed: false, @@ -188,9 +187,9 @@ describe("handleFlowsRun", () => { expect(uiNoteMock).not.toHaveBeenCalled(); }); - it("threads --deps flag to ensureRuntimeEnv as overrideDir", async () => { + it("threads --deps flag to resolveDepsRoot as overrideDir", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); - ensureRuntimeEnvMock.mockResolvedValue({ + resolveDepsRootMock.mockResolvedValue({ depsRoot: "/custom/deps", source: "override", installed: false, @@ -203,14 +202,14 @@ describe("handleFlowsRun", () => { makeDeps(), ); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/flow.ts"], overrideDir: "/custom/deps", }); }); it("opens the run with an intro once flows are resolved", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); - resolveUniqueEnvDirMock.mockReturnValue(undefined); await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); diff --git a/src/commands/flows/runDefaults.reporterWiring.test.ts b/src/commands/flows/runDefaults.reporterWiring.test.ts index 4d04e1d7e..ed0051713 100644 --- a/src/commands/flows/runDefaults.reporterWiring.test.ts +++ b/src/commands/flows/runDefaults.reporterWiring.test.ts @@ -7,6 +7,7 @@ import type { } from "~/domains/runner/runInternals.js"; import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; import { makeNoopLogger } from "~/shell/logger.testUtils.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; // Integration test: proves handleFlowsRun wires the reporter all the way from @@ -41,6 +42,7 @@ function makeCtx(): CommandContext { outputMode: "human", isInteractive: false, signals: noopSignals, + fs: makeMemoryFs(), log: () => makeNoopLogger(), ui: { ...makeFakeUI("human"), @@ -60,8 +62,7 @@ function makeDeps( ): HandleFlowsRunDeps { return { expandPatterns: async () => ["/fake/flow.flow.ts"], - resolveUniqueEnvDir: () => undefined, - ensureRuntimeEnv: async () => ({ + resolveDepsRoot: async () => ({ depsRoot: "/env", source: "project" as const, installed: false, diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index c8e0354cf..93b38d1d6 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -15,12 +15,12 @@ import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js" import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { pluralize } from "~/core/pluralize.js"; -import { resolveUniqueEnvDir as defaultResolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; import { - ensureRuntimeEnv, - type EnsureRuntimeEnvArgs, - type EnsureRuntimeEnvResult, -} from "~/domains/runtimeEnv/index.js"; + 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"; @@ -37,9 +37,8 @@ export type HandleFlowsRunDeps = { cwd: string, logger?: Logger, ) => Promise; - resolveUniqueEnvDir: (files: string[]) => string | undefined; - ensureRuntimeEnv: ( - args: EnsureRuntimeEnvArgs, + resolveDepsRoot: ( + args: Omit, ) => Promise; configureTestkit: (dir: string) => Promise; runWebFlowDeps: typeof defaultRunWebFlowDeps; @@ -50,8 +49,7 @@ function makeDefaultDeps(fs: Fs): HandleFlowsRunDeps { return { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), - resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, fs), - ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), + resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), configureTestkit: defaultConfigureTestkit, runWebFlowDeps: defaultRunWebFlowDeps, flowsRun: defaultFlowsRun, @@ -81,14 +79,6 @@ export async function handleFlowsRun( return; } - let projectDir: string | undefined; - try { - projectDir = resolvedDeps.resolveUniqueEnvDir(expandedFiles); - } catch { - // Flows span multiple packages — fall back to the managed runtime dir. - projectDir = undefined; - } - ctx.ui.gap(); ctx.ui.intro("flows run"); @@ -97,8 +87,8 @@ export async function handleFlowsRun( { message: runnerMessages.preparingEnvironment, task: () => - resolvedDeps.ensureRuntimeEnv({ - ...(projectDir !== undefined ? { projectDir } : {}), + resolvedDeps.resolveDepsRoot({ + files: expandedFiles, ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), }), }, @@ -114,6 +104,12 @@ export async function handleFlowsRun( } // Load the user's project .env from the project dir (NOT the deps dir). + let projectDir: string | undefined; + try { + projectDir = resolveUniqueEnvDir(expandedFiles, ctx.fs); + } catch { + projectDir = undefined; + } await loadEnvFile(projectDir ?? cwd); const resolvedDir = runtimeEnv.depsRoot; diff --git a/src/commands/install/all.test.ts b/src/commands/install/all.test.ts index c47a08e51..917b23a67 100644 --- a/src/commands/install/all.test.ts +++ b/src/commands/install/all.test.ts @@ -16,17 +16,15 @@ type SubInstallerFn = ( const expandPatternsMock = mock<(patterns: string[], cwd?: string) => Promise>(); const peekFlowMetaMock = mock<(filePath: string) => Promise>(); -const resolveUniqueEnvDirMock = mock<(files: string[]) => string | undefined>(); -const ensureRuntimeEnvMock = - mock<(args: { projectDir?: string }) => Promise>(); +const resolveDepsRootMock = + mock<(files: string[]) => Promise>(); const installBrowsersMock = mock(); const installAndroidMock = mock(); const trackedMocks = [ expandPatternsMock, peekFlowMetaMock, - resolveUniqueEnvDirMock, - ensureRuntimeEnvMock, + resolveDepsRootMock, installBrowsersMock, installAndroidMock, ]; @@ -58,8 +56,7 @@ function makeDeps(): InstallAllDeps { cwd: "/project", expandPatterns: expandPatternsMock, peekFlowMeta: peekFlowMetaMock, - resolveUniqueEnvDir: resolveUniqueEnvDirMock, - ensureRuntimeEnv: ensureRuntimeEnvMock, + resolveDepsRoot: resolveDepsRootMock, installBrowsers: installBrowsersMock, installAndroid: installAndroidMock, }; @@ -72,8 +69,7 @@ function mockEnvResult(depsRoot = "/env"): EnsureRuntimeEnvResult { beforeEach(() => { expandPatternsMock.mockResolvedValue([]); peekFlowMetaMock.mockResolvedValue({ name: undefined, target: undefined }); - resolveUniqueEnvDirMock.mockReturnValue(undefined); - ensureRuntimeEnvMock.mockResolvedValue(mockEnvResult()); + resolveDepsRootMock.mockResolvedValue(mockEnvResult()); installBrowsersMock.mockResolvedValue(undefined); installAndroidMock.mockResolvedValue(undefined); }); @@ -88,6 +84,7 @@ describe("installAll", () => { const result = await installAll(ctx, undefined, makeDeps()); + expect(resolveDepsRootMock).toHaveBeenCalledTimes(1); expect(installBrowsersMock).toHaveBeenCalledTimes(1); expect(installAndroidMock).toHaveBeenCalledTimes(1); expect(messages.some((m) => m.method === "success")).toBe(true); @@ -108,7 +105,7 @@ describe("installAll", () => { expect(installBrowsersMock).not.toHaveBeenCalled(); }); - it("should print skip message and not install when only iOS flows are present", async () => { + it("should not resolve deps and not install when only iOS flows are present", async () => { expandPatternsMock.mockResolvedValue(["ios.flow.ts"]); peekFlowMetaMock.mockResolvedValue({ name: undefined, @@ -118,6 +115,7 @@ describe("installAll", () => { const result = await installAll(ctx, undefined, makeDeps()); + expect(resolveDepsRootMock).not.toHaveBeenCalled(); expect(installBrowsersMock).not.toHaveBeenCalled(); expect(installAndroidMock).not.toHaveBeenCalled(); expect( @@ -126,12 +124,13 @@ describe("installAll", () => { expect(result).toBeUndefined(); }); - it("should print info and skip installs when no flows are found", async () => { + it("should not resolve deps and skip installs when no flows are found", async () => { expandPatternsMock.mockResolvedValue([]); const { ctx, messages } = makeCtx(); await installAll(ctx, undefined, makeDeps()); + expect(resolveDepsRootMock).not.toHaveBeenCalled(); expect(installBrowsersMock).not.toHaveBeenCalled(); expect(installAndroidMock).not.toHaveBeenCalled(); expect(messages.some((m) => m.method === "info")).toBe(true); @@ -188,52 +187,61 @@ describe("installAll", () => { expect(result).toEqual({ error: "Could not find Playwright" }); }); - it("should forward pattern and depsRoot from ensureRuntimeEnv to both sub-handlers", async () => { + it("should forward pattern and resolved depsRoot to both sub-handlers", async () => { expandPatternsMock.mockResolvedValue(["web.flow.ts", "android.flow.ts"]); peekFlowMetaMock .mockResolvedValueOnce({ name: undefined, target: "Web - Chrome" }) .mockResolvedValueOnce({ name: undefined, target: "Android - Pixel 9" }); - resolveUniqueEnvDirMock.mockReturnValue("/prj"); - ensureRuntimeEnvMock.mockResolvedValue(mockEnvResult("/renv")); + resolveDepsRootMock.mockResolvedValue(mockEnvResult("/renv")); const { ctx } = makeCtx(); await installAll(ctx, "src/**", makeDeps()); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({ projectDir: "/prj" }); + expect(resolveDepsRootMock).toHaveBeenCalledWith([ + "web.flow.ts", + "android.flow.ts", + ]); expect(installBrowsersMock).toHaveBeenCalledWith(ctx, "src/**", "/renv"); expect(installAndroidMock).toHaveBeenCalledWith(ctx, "src/**", "/renv"); }); - it("should use managed env when no projectDir can be resolved from flow files", async () => { + it("should pass the resolved depsRoot to sub-installers", async () => { expandPatternsMock.mockResolvedValue(["web.flow.ts"]); peekFlowMetaMock.mockResolvedValue({ name: undefined, target: "Web - Chrome", }); - resolveUniqueEnvDirMock.mockReturnValue(undefined); const { ctx } = makeCtx(); await installAll(ctx, undefined, makeDeps()); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); + expect(resolveDepsRootMock).toHaveBeenCalledWith(["web.flow.ts"]); expect(installBrowsersMock).toHaveBeenCalledWith(ctx, undefined, "/env"); }); - it("should fall back to managed env when flow files span multiple packages", async () => { + it("should install web flows spanning multiple packages using the resolved managed dir", async () => { expandPatternsMock.mockResolvedValue([ ".qawolf/staging/a.flow.ts", ".qawolf/prod/b.flow.ts", ]); - resolveUniqueEnvDirMock.mockImplementation(() => { - throw new Error("Pattern matches flows from 2 packages"); + peekFlowMetaMock.mockResolvedValue({ + name: undefined, + target: "Web - Chrome", + }); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/managed", + source: "managed", + installed: false, }); const { ctx } = makeCtx(); const result = await installAll(ctx, undefined, makeDeps()); - expect(ensureRuntimeEnvMock).toHaveBeenCalledWith({}); - expect(installBrowsersMock).not.toHaveBeenCalled(); - expect(installAndroidMock).not.toHaveBeenCalled(); + expect(installBrowsersMock).toHaveBeenCalledWith( + ctx, + undefined, + "/managed", + ); expect(result).toBeUndefined(); }); }); diff --git a/src/commands/install/all.ts b/src/commands/install/all.ts index 21ee72b3c..d53d329cf 100644 --- a/src/commands/install/all.ts +++ b/src/commands/install/all.ts @@ -2,11 +2,7 @@ import { expandPatterns as defaultExpandPatterns, makePeekFlowMeta, } from "~/domains/flows/expand.js"; -import { resolveUniqueEnvDir as defaultResolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; -import { - ensureRuntimeEnv, - type EnsureRuntimeEnvResult, -} from "~/domains/runtimeEnv/index.js"; +import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; import { classifyTarget, type PeekFlowMetaFn } from "~/core/flowMeta.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { errorMessage } from "~/core/errors.js"; @@ -14,6 +10,7 @@ import { installMessages } from "~/core/messages/index.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { batchMap, flowBatchSize } from "~/core/batchMap.js"; +import { resolveDepsRoot } from "~/commands/resolveDepsRoot.js"; import { handleInstallAndroid } from "./android.js"; import { handleInstallBrowsers } from "./browsers.js"; @@ -24,10 +21,9 @@ export type InstallAllDeps = { cwd?: string, ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; - readonly resolveUniqueEnvDir: (files: string[]) => string | undefined; - readonly ensureRuntimeEnv: (args: { - projectDir?: string; - }) => Promise; + readonly resolveDepsRoot: ( + files: string[], + ) => Promise; readonly installBrowsers: ( ctx: CommandContext, pattern: string | undefined, @@ -48,16 +44,6 @@ export async function installAll( const patterns = buildPatternArgs(pattern); const files = await deps.expandPatterns(patterns, deps.cwd); - let projectDir: string | undefined; - try { - projectDir = deps.resolveUniqueEnvDir(files); - } catch { - projectDir = undefined; - } - const { depsRoot } = await deps.ensureRuntimeEnv( - projectDir !== undefined ? { projectDir } : {}, - ); - let hasWeb = false; let hasAndroid = false; let hasIos = false; @@ -83,6 +69,8 @@ export async function installAll( return; } + const { depsRoot } = await deps.resolveDepsRoot(files); + let firstError: { error: string; exitCode?: number } | undefined; if (hasWeb) { @@ -120,8 +108,7 @@ export async function handleInstall( expandPatterns: (patterns, cwd) => defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, fs), - ensureRuntimeEnv: (args) => ensureRuntimeEnv(args, { fs }), + resolveDepsRoot: (files) => resolveDepsRoot({ files, fs }), installBrowsers: handleInstallBrowsers, installAndroid: handleInstallAndroid, }); diff --git a/src/commands/install/android.ts b/src/commands/install/android.ts index aa724b802..44e2d7536 100644 --- a/src/commands/install/android.ts +++ b/src/commands/install/android.ts @@ -4,9 +4,7 @@ import { expandPatterns as defaultExpandPatterns, makePeekFlowMeta, } from "~/domains/flows/expand.js"; -import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; -import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; -import { buildPatternArgs } from "~/core/patternArgs.js"; +import { resolveDepsRoot as resolveDepsRootHelper } from "~/commands/resolveDepsRoot.js"; import { installMessages } from "~/core/messages/index.js"; import { resolveAppiumBin } from "~/shell/appium/resolveAppiumBin.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; @@ -26,29 +24,6 @@ export async function handleInstallAndroid( const { fs } = ctx; - let depsRoot: string; - if (envDir !== undefined) { - depsRoot = envDir; - } else { - const cwd = process.cwd(); - const files = await defaultExpandPatterns( - buildPatternArgs(pattern), - cwd, - undefined, - fs, - ); - let projectDir: string | undefined; - try { - projectDir = resolveUniqueEnvDir(files, fs); - } catch { - projectDir = undefined; - } - ({ depsRoot } = await ensureRuntimeEnv( - projectDir !== undefined ? { projectDir } : {}, - { fs }, - )); - } - return installAndroid(ctx, pattern, { cwd: process.cwd(), spawn: defaultSpawn, @@ -72,7 +47,8 @@ export async function handleInstallAndroid( expandPatterns: (patterns, cwd) => defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - resolveEnvDir: () => depsRoot, + resolveDepsRoot: async (files) => + envDir ?? (await resolveDepsRootHelper({ files, fs })).depsRoot, resolveAppiumBin, }); } diff --git a/src/commands/install/browsers.fixtures.ts b/src/commands/install/browsers.fixtures.ts index 106adde17..30da5b348 100644 --- a/src/commands/install/browsers.fixtures.ts +++ b/src/commands/install/browsers.fixtures.ts @@ -45,7 +45,7 @@ export function makeDeps(overrides: DepsOverrides): InstallBrowsersDeps { target: metaByFile[file]?.target, }), ), - playwrightCliPath: fakeCli, + resolvePlaywrightCliPath: async () => fakeCli, }; } diff --git a/src/commands/install/browsers.ts b/src/commands/install/browsers.ts index 9ece0a378..5de02692b 100644 --- a/src/commands/install/browsers.ts +++ b/src/commands/install/browsers.ts @@ -2,9 +2,7 @@ import { expandPatterns as defaultExpandPatterns, makePeekFlowMeta, } from "~/domains/flows/expand.js"; -import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; -import { ensureRuntimeEnv } from "~/domains/runtimeEnv/index.js"; -import { buildPatternArgs } from "~/core/patternArgs.js"; +import { resolveDepsRoot } from "~/commands/resolveDepsRoot.js"; import { defaultSpawn } from "~/shell/spawn.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { resolvePlaywrightCli } from "~/shell/playwright.js"; @@ -18,28 +16,6 @@ export async function handleInstallBrowsers( const cwd = process.cwd(); const { fs } = ctx; - let depsRoot: string; - if (envDir !== undefined) { - depsRoot = envDir; - } else { - const files = await defaultExpandPatterns( - buildPatternArgs(pattern), - cwd, - undefined, - fs, - ); - let projectDir: string | undefined; - try { - projectDir = resolveUniqueEnvDir(files, fs); - } catch { - projectDir = undefined; - } - ({ depsRoot } = await ensureRuntimeEnv( - projectDir !== undefined ? { projectDir } : {}, - { fs }, - )); - } - return installBrowsers(ctx, pattern, { cwd, spawn: defaultSpawn, @@ -47,6 +23,10 @@ export async function handleInstallBrowsers( expandPatterns: (patterns, dir) => defaultExpandPatterns(patterns, dir ?? cwd, undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - playwrightCliPath: resolvePlaywrightCli(depsRoot, process.platform), + resolvePlaywrightCliPath: async (files) => { + const depsRoot = + envDir ?? (await resolveDepsRoot({ files, fs })).depsRoot; + return resolvePlaywrightCli(depsRoot, process.platform); + }, }); } diff --git a/src/commands/resolveDepsRoot.test.ts b/src/commands/resolveDepsRoot.test.ts new file mode 100644 index 000000000..310a59dc0 --- /dev/null +++ b/src/commands/resolveDepsRoot.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import { pinnedPackages } from "~/domains/runtimeEnv/pinnedPackages.js"; +import { managedEnvDir } from "~/domains/runtimeEnv/managedEnvDir.js"; + +import { resolveDepsRoot } from "./resolveDepsRoot.js"; + +type MemFs = ReturnType; + +// Materializes every pinned package at its exact version plus the .bin/playwright +// shim so allPinnedResolved(dir) returns true for `dir`. +function seedFullEnv(fs: MemFs, dir: string): void { + for (const { name, version } of pinnedPackages) { + const pkgDir = join(dir, "node_modules", ...name.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + const binDir = join(dir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); +} + +function seedPackageJson(fs: MemFs, dir: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "pkg" })); +} + +describe("resolveDepsRoot", () => { + it("returns the project dir when a single package resolves its pinned deps", async () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + seedPackageJson(fs, projectDir); + + const result = await resolveDepsRoot({ + files: [join(projectDir, "flows", "login.flow.ts")], + fs, + }); + + expect(result).toEqual({ + depsRoot: projectDir, + source: "project", + installed: false, + }); + }); + + it("falls back to the managed dir when flow files span multiple packages", async () => { + const fs = makeMemoryFs(); + seedPackageJson(fs, "/repo/a"); + seedPackageJson(fs, "/repo/b"); + const managed = managedEnvDir(); + seedFullEnv(fs, managed); + + const result = await resolveDepsRoot({ + files: ["/repo/a/x.flow.ts", "/repo/b/y.flow.ts"], + fs, + }); + + expect(result).toEqual({ + depsRoot: managed, + source: "managed", + installed: false, + }); + }); + + it("forwards overrideDir to ensureRuntimeEnv", async () => { + const fs = makeMemoryFs(); + const overrideDir = "/custom/deps"; + seedFullEnv(fs, overrideDir); + + const result = await resolveDepsRoot({ + files: ["/anywhere/x.flow.ts"], + overrideDir, + fs, + }); + + expect(result).toEqual({ + depsRoot: overrideDir, + source: "override", + installed: false, + }); + }); +}); diff --git a/src/commands/resolveDepsRoot.ts b/src/commands/resolveDepsRoot.ts new file mode 100644 index 000000000..046cb9777 --- /dev/null +++ b/src/commands/resolveDepsRoot.ts @@ -0,0 +1,39 @@ +import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { + ensureRuntimeEnv, + type EnsureRuntimeEnvResult, +} from "~/domains/runtimeEnv/index.js"; +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; + +export type ResolveDepsRootArgs = { + files: string[]; + overrideDir?: string; + fs?: Fs; +}; + +/** + * Resolves the dependency root for a set of flow files: finds the project + * package dir (best-effort — multi-package patterns fall back to the managed + * dir) and hands off to ensureRuntimeEnv. The single entry every command uses + * so override / project / managed resolution stays identical across them. + */ +export function resolveDepsRoot( + args: ResolveDepsRootArgs, +): Promise { + const fs = args.fs ?? makeDefaultFs(); + let projectDir: string | undefined; + try { + projectDir = resolveUniqueEnvDir(args.files, fs); + } catch { + projectDir = undefined; + } + return ensureRuntimeEnv( + { + ...(projectDir !== undefined ? { projectDir } : {}), + ...(args.overrideDir !== undefined + ? { overrideDir: args.overrideDir } + : {}), + }, + { fs }, + ); +} diff --git a/src/domains/install/android/index.ts b/src/domains/install/android/index.ts index e061b7a4f..57a0afe99 100644 --- a/src/domains/install/android/index.ts +++ b/src/domains/install/android/index.ts @@ -25,8 +25,8 @@ export type InstallAndroidDeps = { cwd?: string, ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; - /** Resolves the env dir (package.json ancestor) from expanded flow files. */ - readonly resolveEnvDir: (files: string[]) => string | undefined; + /** Resolves the dependency root (override / project / managed) from expanded flow files. */ + readonly resolveDepsRoot: (files: string[]) => Promise; /** Resolves the appium binary path from an env dir. */ readonly resolveAppiumBin: (envDir: string) => string; }; @@ -55,10 +55,10 @@ export async function installAndroid( checkExists: deps.checkExists, }); - const envDir = deps.resolveEnvDir(files) ?? deps.cwd; + const depsRoot = await deps.resolveDepsRoot(files); await installUiautomator2Driver(ctx, { spawn: deps.spawn, - appiumBinPath: deps.resolveAppiumBin(envDir), + appiumBinPath: deps.resolveAppiumBin(depsRoot), }); ctx.ui.success( diff --git a/src/domains/install/android/installAndroid.test.ts b/src/domains/install/android/installAndroid.test.ts index c3f790e3a..0b28b4aae 100644 --- a/src/domains/install/android/installAndroid.test.ts +++ b/src/domains/install/android/installAndroid.test.ts @@ -69,7 +69,7 @@ function makeDeps( name: "Flow", target: "Android - Pixel 9 (Android 15)", })), - resolveEnvDir: () => "/cwd" as string | undefined, + resolveDepsRoot: async () => "/cwd", resolveAppiumBin: () => appiumBinPath, }; } diff --git a/src/domains/install/browsers.ts b/src/domains/install/browsers.ts index d369cdf74..a3b8de79d 100644 --- a/src/domains/install/browsers.ts +++ b/src/domains/install/browsers.ts @@ -15,13 +15,19 @@ export type InstallBrowsersDeps = { cwd?: string, ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; + readonly resolvePlaywrightCliPath: (files: string[]) => Promise; +}; + +export type InstallBrowserListDeps = { + readonly spawn: SpawnFn; + readonly platform: NodeJS.Platform; readonly playwrightCliPath: string; }; export async function installBrowserList( ctx: CommandContext, browsers: BrowserName[], - deps: Pick, + deps: InstallBrowserListDeps, ): Promise { await ctx.ui.withProgress( browsers.map((browser) => ({ @@ -52,7 +58,12 @@ export async function installBrowsers( return; } - await installBrowserList(ctx, browsers, deps); + const playwrightCliPath = await deps.resolvePlaywrightCliPath(files); + await installBrowserList(ctx, browsers, { + spawn: deps.spawn, + platform: deps.platform, + playwrightCliPath, + }); } async function collectBrowsers( diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts index f8c36848e..65042a5d8 100644 --- a/src/domains/runtimeEnv/index.ts +++ b/src/domains/runtimeEnv/index.ts @@ -1,6 +1,5 @@ export { ensureRuntimeEnv, - type EnsureRuntimeEnvArgs, type EnsureRuntimeEnvResult, } from "./ensureRuntimeEnv.js"; export { resolveDepsRootIfPresent } from "./resolveDepsRootIfPresent.js"; From 445f49a91068392dff82938e826b3973d6544a5f Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:42:15 -0400 Subject: [PATCH 04/14] refactor(runner): dedupe runtime-deps helpers and extract flowsRun deps builder --- src/commands/doctor/handler.ts | 9 +-- src/commands/flows/buildFlowsRunDeps.ts | 61 +++++++++++++++++++ src/commands/flows/hybridRunDefaults.ts | 50 ++++----------- src/commands/flows/runDefaults.ts | 59 +++++------------- src/commands/resolveDepsRoot.ts | 9 +-- src/domains/flows/ensureDeps.ts | 13 ++++ src/domains/runner/loadFlowDefault.ts | 37 +++++------ src/domains/runner/runInternals.ts | 3 +- .../runtimeEnv/resolveDepsRootIfPresent.ts | 4 +- src/domains/runtimeEnv/shimDeps.ts | 25 +++++--- 10 files changed, 138 insertions(+), 132 deletions(-) create mode 100644 src/commands/flows/buildFlowsRunDeps.ts diff --git a/src/commands/doctor/handler.ts b/src/commands/doctor/handler.ts index ad3e7bb07..300263592 100644 --- a/src/commands/doctor/handler.ts +++ b/src/commands/doctor/handler.ts @@ -6,7 +6,7 @@ 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"; @@ -41,12 +41,7 @@ export async function handleDoctor( const flowFiles = await expandPatterns([], cwd, undefined, fs); // Playwright/Appium live in the resolved runtime dir (managed env or project), not cwd. - let projectDir: string | undefined; - try { - projectDir = resolveUniqueEnvDir([...flowFiles], fs); - } catch { - projectDir = undefined; - } + const projectDir = resolveProjectDirSafe([...flowFiles], fs); const envDir = resolveDepsRootIfPresent( projectDir !== undefined ? { projectDir } : {}, fs, diff --git a/src/commands/flows/buildFlowsRunDeps.ts b/src/commands/flows/buildFlowsRunDeps.ts new file mode 100644 index 000000000..197d46401 --- /dev/null +++ b/src/commands/flows/buildFlowsRunDeps.ts @@ -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; + 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(), + }; +} diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 71d1d000d..62416123f 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -6,17 +6,7 @@ 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 { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; import { @@ -26,12 +16,12 @@ import { 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 = { @@ -120,31 +110,15 @@ export async function handleHybridFlowsRun( const resolvedDir = runtimeEnv.depsRoot; await resolvedDeps.configureTestkit(resolvedDir); const android = createAndroidDeps(resolvedDir, ctx.signals); + const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( + resolvedDir, + 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(resolvedDir, process.platform), - }), - runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(resolvedDir, ctx.signals), - 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(), - }); + return resolvedDeps.flowsRun( + ctx, + files, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); } diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 93b38d1d6..32a752a36 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -1,21 +1,11 @@ -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 { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.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 { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { pluralize } from "~/core/pluralize.js"; -import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; import { resolveDepsRoot, @@ -24,11 +14,11 @@ import { 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 { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; import { loadEnvFile } from "./loadEnvFile.js"; export type HandleFlowsRunDeps = { @@ -104,42 +94,21 @@ export async function handleFlowsRun( } // Load the user's project .env from the project dir (NOT the deps dir). - let projectDir: string | undefined; - try { - projectDir = resolveUniqueEnvDir(expandedFiles, ctx.fs); - } catch { - projectDir = undefined; - } + const projectDir = resolveProjectDirSafe(expandedFiles, ctx.fs); await loadEnvFile(projectDir ?? cwd); const resolvedDir = runtimeEnv.depsRoot; await resolvedDeps.configureTestkit(resolvedDir); const android = createAndroidDeps(resolvedDir, ctx.signals); - return resolvedDeps.flowsRun(ctx, expandedFiles, flags, { - peekFlowMeta: makePeekFlowMeta(ctx.fs), - installBrowsers: (innerCtx, browsers) => - installBrowserList(innerCtx, browsers, { - spawn: defaultSpawn, - platform: process.platform, - playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform), - }), - runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(resolvedDir, ctx.signals), - 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(), - }); + const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( + resolvedDir, + ctx.signals, + ); + return resolvedDeps.flowsRun( + ctx, + expandedFiles, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); } diff --git a/src/commands/resolveDepsRoot.ts b/src/commands/resolveDepsRoot.ts index 046cb9777..42150d7ac 100644 --- a/src/commands/resolveDepsRoot.ts +++ b/src/commands/resolveDepsRoot.ts @@ -1,4 +1,4 @@ -import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { ensureRuntimeEnv, type EnsureRuntimeEnvResult, @@ -21,12 +21,7 @@ export function resolveDepsRoot( args: ResolveDepsRootArgs, ): Promise { const fs = args.fs ?? makeDefaultFs(); - let projectDir: string | undefined; - try { - projectDir = resolveUniqueEnvDir(args.files, fs); - } catch { - projectDir = undefined; - } + const projectDir = resolveProjectDirSafe(args.files, fs); return ensureRuntimeEnv( { ...(projectDir !== undefined ? { projectDir } : {}), diff --git a/src/domains/flows/ensureDeps.ts b/src/domains/flows/ensureDeps.ts index a7a57257a..5c22653cc 100644 --- a/src/domains/flows/ensureDeps.ts +++ b/src/domains/flows/ensureDeps.ts @@ -33,3 +33,16 @@ export function resolveUniqueEnvDir( } return dirs.size === 1 ? [...dirs][0] : undefined; } + +// resolveUniqueEnvDir, but swallows the multi-package error and returns +// undefined so callers fall back to the managed runtime dir instead of failing. +export function resolveProjectDirSafe( + files: string[], + fs: Fs, +): string | undefined { + try { + return resolveUniqueEnvDir(files, fs); + } catch { + return undefined; + } +} diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 799c87807..197eb9911 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -57,6 +57,19 @@ type LoadFlowDefaultArgs = { fs?: Fs; }; +// Imports a module specifier (file:// path or data: URI) and returns its +// default export, throwing the canonical no-default-export error when absent. +async function importDefaultExport( + moduleUrl: string, + flowPath: string, +): Promise { + const mod = (await import(moduleUrl)) as Record; + const exported = mod["default"] as T | undefined; + if (exported === undefined) + throw new Error(runnerMessages.noDefaultExport(flowPath)); + return exported; +} + export async function loadFlowDefault( args: LoadFlowDefaultArgs, ): Promise { @@ -68,14 +81,7 @@ export async function loadFlowDefault( // Non-compiled path: direct import, no file read needed. if (!isCompiledBinary) { - const mod = (await import(pathToFileURL(flowPath).href)) as Record< - string, - unknown - >; - const exported = mod["default"] as T | undefined; - if (exported === undefined) - throw new Error(runnerMessages.noDefaultExport(flowPath)); - return exported; + return importDefaultExport(pathToFileURL(flowPath).href, flowPath); } // In compiled Bun binaries, dynamically imported external files cannot resolve @@ -94,23 +100,12 @@ export async function loadFlowDefault( : content; if (transformed === content) { - const mod = (await import(pathToFileURL(flowPath).href)) as Record< - string, - unknown - >; - const exported = mod["default"] as T | undefined; - if (exported === undefined) - throw new Error(runnerMessages.noDefaultExport(flowPath)); - return exported; + return importDefaultExport(pathToFileURL(flowPath).href, flowPath); } const annotated = `${transformed}\n//# sourceURL=${ pathToFileURL(flowPath).href }`; const dataUri = `data:text/javascript,${encodeURIComponent(annotated)}`; - const mod = (await import(dataUri)) as Record; - const exported = mod["default"] as T | undefined; - if (exported === undefined) - throw new Error(runnerMessages.noDefaultExport(flowPath)); - return exported; + return importDefaultExport(dataUri, flowPath); } diff --git a/src/domains/runner/runInternals.ts b/src/domains/runner/runInternals.ts index dfa1b020a..8b04b0418 100644 --- a/src/domains/runner/runInternals.ts +++ b/src/domains/runner/runInternals.ts @@ -3,6 +3,7 @@ import type { findFlowStamp as defaultFindFlowStamp } from "~/shell/manifest/loo import type { Logger } from "~/shell/logger.js"; import type { Reporter } from "~/shell/reporter/types.js"; import { runnerMessages } from "~/core/messages/index.js"; +import { pluralize } from "~/core/pluralize.js"; import { FlowRunError } from "./errors.js"; import type { RunAndroidFlowDeps, @@ -143,7 +144,7 @@ export async function dispatchFlow({ const durationMs = deps.now() - flowStart; const outcome = run.passed ? "pass" : "fail"; const attempts = run.attempts; - const attempt = `${attempts} attempt${attempts === 1 ? "" : "s"}`; + const attempt = pluralize(attempts, "attempt"); deps.logger?.info(`${outcome}: ${flow.name} (${durationMs}ms, ${attempt})`); return { run, durationMs }; } diff --git a/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts index 4e9d892a3..5d7e2e2db 100644 --- a/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts +++ b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts @@ -4,15 +4,13 @@ import { type EnsureRuntimeEnvArgs } from "./ensureRuntimeEnv.js"; import { managedEnvDir } from "./managedEnvDir.js"; import { allPinnedResolved } from "./resolvePinned.js"; -export type ResolveDepsRootArgs = EnsureRuntimeEnvArgs; - /** * Returns the first directory whose pinned deps already resolve (override → * project → managed), or undefined if none are installed. Never installs — * use for read-only diagnostics like `doctor`. */ export function resolveDepsRootIfPresent( - args: ResolveDepsRootArgs, + args: EnsureRuntimeEnvArgs, fs: Fs = makeDefaultFs(), ): string | undefined { if (args.overrideDir !== undefined && allPinnedResolved(args.overrideDir, fs)) diff --git a/src/domains/runtimeEnv/shimDeps.ts b/src/domains/runtimeEnv/shimDeps.ts index 2da5dfd8b..fa00eee55 100644 --- a/src/domains/runtimeEnv/shimDeps.ts +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -65,6 +65,20 @@ function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { return undefined; } +/** + * Resolves the Bun.build function from the injected override or the runtime: + * an explicit BuildFn (tests), false for Node.js mode (no Bun), or + * auto-detection from globalThis.Bun. Reading Bun via globalThis works in both + * the compiled binary (Bun available) and the Node.js CLI build (Bun absent). + */ +function resolveBunBuild( + bunBuild: BuildFn | false | undefined, +): { build: BuildFn } | undefined { + if (bunBuild === undefined) + return (globalThis as { Bun?: { build: BuildFn } }).Bun; + return bunBuild === false ? undefined : { build: bunBuild }; +} + export async function shimFlowsDeps( envDir: string, fs: Fs = makeDefaultFs(), @@ -84,16 +98,7 @@ export async function shimFlowsDeps( return; } - // Access Bun.build via globalThis — works in both the compiled binary (Bun - // available) and the Node.js CLI build (Bun absent). Uses a structural type - // instead of `typeof Bun.build` to avoid the no-restricted-globals lint rule. - // bunBuild is injected in tests (globalThis.Bun is read-only in the runtime). - const bun = - bunBuild !== undefined - ? bunBuild !== false - ? { build: bunBuild } - : undefined - : (globalThis as { Bun?: { build: BuildFn } }).Bun; + const bun = resolveBunBuild(bunBuild); // Node.js resolves bare specifiers correctly; shimming is unnecessary and // a CJS require() fallback for ESM-only packages would break named imports. // But stale Bun-built CJS shims from a prior binary run must be removed — From e9b905f6935f66debbcd73cfb2849508d0fff5e0 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:30:32 -0400 Subject: [PATCH 05/14] feat(runner): support QAWOLF_RUNTIME_DIR to relocate managed runtime --- src/commands/__snapshots__/help.test.ts.snap | 3 +- src/commands/flows/hybridRunDefaults.ts | 3 +- src/commands/flows/run.register.ts | 2 +- src/commands/flows/runDefaults.handle.test.ts | 9 ++--- src/commands/flows/runDefaults.ts | 5 +-- src/core/messages/runner.ts | 2 +- src/domains/runtimeEnv/index.ts | 7 +--- src/domains/runtimeEnv/managedEnvDir.test.ts | 39 ++++++++++++++++--- src/domains/runtimeEnv/managedEnvDir.ts | 17 +++++++- 9 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 393d9024f..d5be2a179 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -156,7 +156,8 @@ Options: --env Pull and run a flow from this environment (UUID or slug) if not cached locally --deps Use this prepared dependency directory instead of - auto-installing the runtime + auto-installing the runtime; or set QAWOLF_RUNTIME_DIR + to relocate the managed runtime -h, --help display help for command Examples: diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 62416123f..9cfec7d63 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -103,8 +103,7 @@ export async function handleHybridFlowsRun( () => runnerMessages.environmentReady, ); if (runtimeEnv.source === "managed") { - const note = runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot); - ctx.ui.note(note, "Runtime"); + ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); } await loadEnvFile(envDir); const resolvedDir = runtimeEnv.depsRoot; diff --git a/src/commands/flows/run.register.ts b/src/commands/flows/run.register.ts index 70cb4462d..c0bd26253 100644 --- a/src/commands/flows/run.register.ts +++ b/src/commands/flows/run.register.ts @@ -101,7 +101,7 @@ export function registerFlowsRunCommand( ) .option( "--deps ", - "Use this prepared dependency directory instead of auto-installing the runtime", + "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( diff --git a/src/commands/flows/runDefaults.handle.test.ts b/src/commands/flows/runDefaults.handle.test.ts index 696d9f735..dbcaeb6bb 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -158,7 +158,7 @@ describe("handleFlowsRun", () => { expect(flowsRunMock).toHaveBeenCalledTimes(1); }); - it("emits managed runtime note when resolveDepsRoot source is managed", async () => { + it("emits managed runtime info when resolveDepsRoot source is managed", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); resolveDepsRootMock.mockResolvedValue({ depsRoot: "/home/.qawolf/runtime", @@ -168,13 +168,12 @@ describe("handleFlowsRun", () => { await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(uiNoteMock).toHaveBeenCalledWith( + expect(uiInfoMock).toHaveBeenCalledWith( runnerMessages.managedRuntimeNote("/home/.qawolf/runtime"), - "Runtime", ); }); - it("does not emit managed runtime note when source is project", async () => { + it("does not emit managed runtime info when source is project", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); resolveDepsRootMock.mockResolvedValue({ depsRoot: "/env", @@ -184,7 +183,7 @@ describe("handleFlowsRun", () => { await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(uiNoteMock).not.toHaveBeenCalled(); + expect(uiInfoMock).not.toHaveBeenCalled(); }); it("threads --deps flag to resolveDepsRoot as overrideDir", async () => { diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 32a752a36..151406e32 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -87,10 +87,7 @@ export async function handleFlowsRun( ); if (runtimeEnv.source === "managed") { - ctx.ui.note( - runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot), - "Runtime", - ); + ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); } // Load the user's project .env from the project dir (NOT the deps dir). diff --git a/src/core/messages/runner.ts b/src/core/messages/runner.ts index fdbc3d85d..66cea4b2c 100644 --- a/src/core/messages/runner.ts +++ b/src/core/messages/runner.ts @@ -30,5 +30,5 @@ export const runnerMessages = { `Retrying (${attempt} of ${maxAttempts})...`, screenshot: (path: string) => `Screenshot: ${path}`, managedRuntimeNote: (dir: string) => - `QA Wolf installed its runtime in a managed location (your project is untouched):\n ${dir}\nOverride with --deps .`, + `Using managed runtime — override with --deps or QAWOLF_RUNTIME_DIR:\n${dir}`, } as const; diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts index 65042a5d8..f73413c75 100644 --- a/src/domains/runtimeEnv/index.ts +++ b/src/domains/runtimeEnv/index.ts @@ -1,5 +1,2 @@ -export { - ensureRuntimeEnv, - type EnsureRuntimeEnvResult, -} from "./ensureRuntimeEnv.js"; -export { resolveDepsRootIfPresent } from "./resolveDepsRootIfPresent.js"; +export * from "./ensureRuntimeEnv.js"; +export * from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts index 626d0410a..8d09cec61 100644 --- a/src/domains/runtimeEnv/managedEnvDir.test.ts +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "bun:test"; -import { join } from "node:path"; +import { afterEach, describe, expect, it } from "bun:test"; +import { join, resolve } from "node:path"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; @@ -23,10 +23,39 @@ describe("managedEnvHash", () => { }); describe("managedEnvDir", () => { - it("ends with runtime/", () => { + const priorOverride = process.env["QAWOLF_RUNTIME_DIR"]; + + afterEach(() => { + if (priorOverride === undefined) { + delete process.env["QAWOLF_RUNTIME_DIR"]; + } else { + process.env["QAWOLF_RUNTIME_DIR"] = priorOverride; + } + }); + + it("ends with runtime/ when QAWOLF_RUNTIME_DIR is unset", () => { + delete process.env["QAWOLF_RUNTIME_DIR"]; + const hash = managedEnvHash(); + expect(managedEnvDir()).toContain(join("runtime", hash)); + }); + + it("uses QAWOLF_RUNTIME_DIR as the base, dropping the runtime/ segment", () => { + process.env["QAWOLF_RUNTIME_DIR"] = "/custom/cache"; + const hash = managedEnvHash(); + expect(managedEnvDir()).toBe(join("/custom/cache", hash)); + expect(managedEnvDir()).not.toContain(join("runtime", hash)); + }); + + it("resolves a relative QAWOLF_RUNTIME_DIR to an absolute path", () => { + process.env["QAWOLF_RUNTIME_DIR"] = "./rt-cache"; + const hash = managedEnvHash(); + expect(managedEnvDir()).toBe(join(resolve("./rt-cache"), hash)); + }); + + it("falls back to the default base when QAWOLF_RUNTIME_DIR is whitespace", () => { + process.env["QAWOLF_RUNTIME_DIR"] = " "; const hash = managedEnvHash(); - const result = managedEnvDir(); - expect(result).toContain(join("runtime", hash)); + expect(managedEnvDir()).toContain(join("runtime", hash)); }); }); diff --git a/src/domains/runtimeEnv/managedEnvDir.ts b/src/domains/runtimeEnv/managedEnvDir.ts index e3aa66917..efac1393c 100644 --- a/src/domains/runtimeEnv/managedEnvDir.ts +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Fs } from "~/shell/fs.js"; import { getDataDir } from "~/core/paths.js"; @@ -18,9 +18,22 @@ export function managedEnvHash(): string { return createHash("sha256").update(content).digest("hex").slice(0, 16); } +/** + * Base directory the versioned managed runtime installs under. `QAWOLF_RUNTIME_DIR` + * relocates it (resolved to an absolute path; empty/whitespace falls back) so CI, + * airgapped, and non-writable-$HOME setups can move the cache — the same affordance + * as PLAYWRIGHT_BROWSERS_PATH / CYPRESS_CACHE_FOLDER. The `--deps` flag is a separate, + * higher-priority validate-only override handled in ensureRuntimeEnv. + */ +function managedEnvBaseDir(): string { + const override = process.env["QAWOLF_RUNTIME_DIR"]?.trim(); + if (override) return resolve(override); + return join(getDataDir(), "runtime"); +} + /** Absolute path to the versioned managed runtime directory. */ export function managedEnvDir(): string { - return join(getDataDir(), "runtime", managedEnvHash()); + return join(managedEnvBaseDir(), managedEnvHash()); } /** From 6a5ba87159e5c5a25236d24a2113de2f0ac2d283 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:42:01 -0400 Subject: [PATCH 06/14] test(runner): assert resolve() in QAWOLF_RUNTIME_DIR absolute-path case --- src/domains/runtimeEnv/managedEnvDir.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts index 8d09cec61..719405258 100644 --- a/src/domains/runtimeEnv/managedEnvDir.test.ts +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -42,7 +42,7 @@ describe("managedEnvDir", () => { it("uses QAWOLF_RUNTIME_DIR as the base, dropping the runtime/ segment", () => { process.env["QAWOLF_RUNTIME_DIR"] = "/custom/cache"; const hash = managedEnvHash(); - expect(managedEnvDir()).toBe(join("/custom/cache", hash)); + expect(managedEnvDir()).toBe(join(resolve("/custom/cache"), hash)); expect(managedEnvDir()).not.toContain(join("runtime", hash)); }); From e4722c62961cc2e3a9af2333ef556faacac69ea7 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:57:05 -0400 Subject: [PATCH 07/14] feat(runner): add `install clear` to reset managed runtime cache The managed runtime lives at /runtime/ with no in-CLI way to clear it, forcing a hand-typed `rm -rf` that risks deleting the wrong path. Add `qawolf install clear`: a destructive, confirmed command that removes the whole runtime base dir (honoring QAWOLF_RUNTIME_DIR), with a `--yes` flag and structured json/agent output. --- skills/qawolf-cli/SKILL.md | 1 + src/commands/__snapshots__/help.test.ts.snap | 17 ++++++ src/commands/help.test.ts | 5 ++ src/commands/install/clear.ts | 44 +++++++++++++++ src/commands/install/index.ts | 19 +++++++ src/core/messages/install.ts | 9 +++ .../runtimeEnv/clearRuntimeEnv.test.ts | 55 +++++++++++++++++++ src/domains/runtimeEnv/clearRuntimeEnv.ts | 16 ++++++ src/domains/runtimeEnv/index.ts | 2 + src/domains/runtimeEnv/managedEnvDir.ts | 2 +- 10 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/commands/install/clear.ts create mode 100644 src/domains/runtimeEnv/clearRuntimeEnv.test.ts create mode 100644 src/domains/runtimeEnv/clearRuntimeEnv.ts diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index b8fb0beb1..b391e5269 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -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. | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index d5be2a179..c1ffc673e 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -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 @@ -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] diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index 6619fb24a..8a5bda4f9 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -54,6 +54,10 @@ describe("--help output", () => { expect(helpFor("install", "android")).toMatchSnapshot(); }); + it("qawolf install clear", () => { + expect(helpFor("install", "clear")).toMatchSnapshot(); + }); + it("qawolf doctor", () => { expect(helpFor("doctor")).toMatchSnapshot(); }); @@ -81,6 +85,7 @@ describe("--help output", () => { ["install"], ["install", "browsers"], ["install", "android"], + ["install", "clear"], ["doctor"], ["flows"], ["flows", "run"], diff --git a/src/commands/install/clear.ts b/src/commands/install/clear.ts new file mode 100644 index 000000000..8308b8000 --- /dev/null +++ b/src/commands/install/clear.ts @@ -0,0 +1,44 @@ +import { installMessages } from "~/core/messages/index.js"; +import { + clearRuntimeEnv, + managedEnvBaseDir, +} from "~/domains/runtimeEnv/index.js"; +import { + type CommandContext, + type CommandResult, +} from "~/shell/commandContext.js"; + +export type HandleInstallClearOpts = { readonly yes: boolean }; + +export async function handleInstallClear( + ctx: CommandContext, + opts: HandleInstallClearOpts, +): Promise { + const dir = managedEnvBaseDir(); + + if (ctx.ui.mode === "human" && !opts.yes) { + ctx.ui.gap(); + ctx.ui.intro(installMessages.clear.title); + + const result = await ctx.ui.confirm( + installMessages.clear.confirmPrompt(dir), + { destructive: true }, + ); + if (!result.ok || !result.value) { + ctx.ui.cancel(installMessages.clear.cancelled); + return; + } + } + + const { dir: removed, existed } = await clearRuntimeEnv(ctx.fs); + + const message = existed + ? installMessages.clear.cleared(removed) + : installMessages.clear.nothingToClear(removed); + + if (ctx.ui.mode === "human") { + ctx.ui.success(message); + } else { + ctx.ui.output({ cleared: existed, dir: removed }, message); + } +} diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index 4555d8d78..30ed1991a 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -7,6 +7,7 @@ import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; import { handleInstallAndroid } from "./android.js"; import { handleInstall } from "./all.js"; import { handleInstallBrowsers } from "./browsers.js"; +import { handleInstallClear } from "./clear.js"; export function registerInstallCommand( program: Command, @@ -65,4 +66,22 @@ Examples: command, ); }); + + declareCommandKind(install.command("clear"), "local") + .description( + "Remove the managed runtime cache (all installed runtime versions)", + ) + .option("--yes", "Skip the confirmation prompt", false) + .addHelpText( + "after", + ` +Examples: + $ qawolf install clear + $ qawolf install clear --yes`, + ) + .action((opts: { yes?: boolean }, command: Command) => { + return withContext(signals, (ctx) => + handleInstallClear(ctx, { yes: opts.yes ?? false }), + )(opts, command); + }); } diff --git a/src/core/messages/install.ts b/src/core/messages/install.ts index ae90719b4..5b1eb012e 100644 --- a/src/core/messages/install.ts +++ b/src/core/messages/install.ts @@ -15,6 +15,15 @@ export const installMessages = { `playwright install ${browser} failed: ${detail}`, playwrightInstallLaunchFailed: (browser: string) => `playwright install ${browser} failed: process failed to launch`, + clear: { + title: "Clear runtime cache", + confirmPrompt: (dir: string) => + `Remove the managed runtime cache at\n${dir}?`, + cancelled: "Clear cancelled.", + cleared: (dir: string) => `Removed managed runtime cache at ${dir}.`, + nothingToClear: (dir: string) => + `No managed runtime cache found at ${dir}.`, + }, android: { noFlowsFound: "No Android flows found. Nothing to install.", licensesAlreadyAccepted: "Android SDK licenses already accepted.", diff --git a/src/domains/runtimeEnv/clearRuntimeEnv.test.ts b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts new file mode 100644 index 000000000..4946ca471 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts @@ -0,0 +1,55 @@ +import { resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "bun:test"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { clearRuntimeEnv } from "./clearRuntimeEnv.js"; +import { managedEnvBaseDir } from "./managedEnvDir.js"; + +describe("clearRuntimeEnv", () => { + const originalRuntimeDir = process.env["QAWOLF_RUNTIME_DIR"]; + + afterEach(() => { + if (originalRuntimeDir === undefined) { + delete process.env["QAWOLF_RUNTIME_DIR"]; + } else { + process.env["QAWOLF_RUNTIME_DIR"] = originalRuntimeDir; + } + }); + + it("removes the base dir when present and returns existed: true", async () => { + const fs = makeMemoryFs(); + const baseDir = managedEnvBaseDir(); + const hashDir = `${baseDir}/abc123def456`; + await fs.mkdir(hashDir, { recursive: true }); + await fs.writeFile(`${hashDir}/package.json`, '{"name":"qawolf-runtime"}'); + + const result = await clearRuntimeEnv(fs); + + expect(result).toEqual({ dir: baseDir, existed: true }); + expect(await fs.pathExists(baseDir)).toBe(false); + }); + + it("returns existed: false and does not throw when base dir is absent", async () => { + const fs = makeMemoryFs(); + + const result = await clearRuntimeEnv(fs); + + expect(result.existed).toBe(false); + expect(result.dir).toBe(managedEnvBaseDir()); + }); + + it("honors QAWOLF_RUNTIME_DIR and returns its resolved path", async () => { + const override = "/tmp/qawolf-rt-test"; + process.env["QAWOLF_RUNTIME_DIR"] = override; + + const fs = makeMemoryFs(); + await fs.mkdir(override, { recursive: true }); + + const result = await clearRuntimeEnv(fs); + + expect(result.dir).toBe(resolve(override)); + expect(result.existed).toBe(true); + }); +}); diff --git a/src/domains/runtimeEnv/clearRuntimeEnv.ts b/src/domains/runtimeEnv/clearRuntimeEnv.ts new file mode 100644 index 000000000..c2313e487 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.ts @@ -0,0 +1,16 @@ +import { type Fs } from "~/shell/fs.js"; + +import { managedEnvBaseDir } from "./managedEnvDir.js"; + +export type ClearRuntimeEnvResult = { dir: string; existed: boolean }; + +/** + * Removes the entire managed runtime base directory (every versioned hash dir). + * Honors QAWOLF_RUNTIME_DIR. Returns the resolved path and whether anything existed. + */ +export async function clearRuntimeEnv(fs: Fs): Promise { + const dir = managedEnvBaseDir(); + const existed = await fs.pathExists(dir); + if (existed) await fs.rm(dir, { recursive: true, force: true }); + return { dir, existed }; +} diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts index f73413c75..3ccf77f3c 100644 --- a/src/domains/runtimeEnv/index.ts +++ b/src/domains/runtimeEnv/index.ts @@ -1,2 +1,4 @@ +export * from "./clearRuntimeEnv.js"; export * from "./ensureRuntimeEnv.js"; +export * from "./managedEnvDir.js"; export * from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/managedEnvDir.ts b/src/domains/runtimeEnv/managedEnvDir.ts index efac1393c..60b6e5688 100644 --- a/src/domains/runtimeEnv/managedEnvDir.ts +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -25,7 +25,7 @@ export function managedEnvHash(): string { * as PLAYWRIGHT_BROWSERS_PATH / CYPRESS_CACHE_FOLDER. The `--deps` flag is a separate, * higher-priority validate-only override handled in ensureRuntimeEnv. */ -function managedEnvBaseDir(): string { +export function managedEnvBaseDir(): string { const override = process.env["QAWOLF_RUNTIME_DIR"]?.trim(); if (override) return resolve(override); return join(getDataDir(), "runtime"); From a7ba8f44daf85b378621895c1883ce2986745775 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:06:19 -0400 Subject: [PATCH 08/14] fix(cli): keep install clear confirm on the Clack timeline The destructive confirm used Clack's selectKey, which renders the message inline on the prompt line. The embedded newline (path on its own line) broke the framed timeline and garbled the y/n keystroke prompt so it could not be answered. Move the path into a Clack note box and keep the confirm message to a single line, matching the destructive-confirm pattern in init. --- src/commands/install/clear.ts | 8 ++++---- src/core/messages/install.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/install/clear.ts b/src/commands/install/clear.ts index 8308b8000..6ab11f050 100644 --- a/src/commands/install/clear.ts +++ b/src/commands/install/clear.ts @@ -19,11 +19,11 @@ export async function handleInstallClear( if (ctx.ui.mode === "human" && !opts.yes) { ctx.ui.gap(); ctx.ui.intro(installMessages.clear.title); + ctx.ui.note(dir, installMessages.clear.locationLabel); - const result = await ctx.ui.confirm( - installMessages.clear.confirmPrompt(dir), - { destructive: true }, - ); + const result = await ctx.ui.confirm(installMessages.clear.confirmPrompt, { + destructive: true, + }); if (!result.ok || !result.value) { ctx.ui.cancel(installMessages.clear.cancelled); return; diff --git a/src/core/messages/install.ts b/src/core/messages/install.ts index 5b1eb012e..4841feb44 100644 --- a/src/core/messages/install.ts +++ b/src/core/messages/install.ts @@ -17,8 +17,8 @@ export const installMessages = { `playwright install ${browser} failed: process failed to launch`, clear: { title: "Clear runtime cache", - confirmPrompt: (dir: string) => - `Remove the managed runtime cache at\n${dir}?`, + locationLabel: "Location", + confirmPrompt: "Remove the managed runtime cache?", cancelled: "Clear cancelled.", cleared: (dir: string) => `Removed managed runtime cache at ${dir}.`, nothingToClear: (dir: string) => From 7d5bf2e4018214f58da2b6887ea6ea31f8ae98a1 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:13:27 -0400 Subject: [PATCH 09/14] fix(cli): use arrow-key confirm for destructive prompts The destructive confirm used Clack's selectKey, a single-keystroke hotkey prompt with no up/down navigation: arrows did nothing and Enter cancelled instead of submitting, so the prompt felt frozen. Replace it with Clack's standard arrow-navigable confirm, starting the cursor on No so a stray Enter stays safe. Removes the selectKey path entirely; also fixes the same prompt in init and flows pull. --- src/shell/ui/clack/styledClack.mock.ts | 1 - src/shell/ui/clack/styledClack.ts | 10 +++------- src/shell/ui/renderers/confirm.test.ts | 23 ++++++++--------------- src/shell/ui/renderers/confirm.ts | 18 ++++-------------- src/shell/ui/types.ts | 2 +- 5 files changed, 16 insertions(+), 38 deletions(-) diff --git a/src/shell/ui/clack/styledClack.mock.ts b/src/shell/ui/clack/styledClack.mock.ts index 9bacdcb30..5a661ba6a 100644 --- a/src/shell/ui/clack/styledClack.mock.ts +++ b/src/shell/ui/clack/styledClack.mock.ts @@ -32,7 +32,6 @@ export function makeClack() { outro: mock(), cancel: mock(), confirm: mock(), - selectKey: mock(), password: mock(), isCancel: isCancel as typeof isCancel & StyledClack["isCancel"], spinner: mock((): MockSpinner => { diff --git a/src/shell/ui/clack/styledClack.ts b/src/shell/ui/clack/styledClack.ts index f5c340a72..7a40f5256 100644 --- a/src/shell/ui/clack/styledClack.ts +++ b/src/shell/ui/clack/styledClack.ts @@ -7,7 +7,6 @@ import { note, outro, password, - selectKey, spinner, taskLog, } from "@clack/prompts"; @@ -26,12 +25,10 @@ export type StyledClack = { note(message?: string, title?: string): void; outro(message: string): void; cancel(message: string): void; - confirm(opts: { message: string }): Promise; - selectKey(opts: { + confirm(opts: { message: string; - caseSensitive?: boolean; - options: { value: Value; label?: string }[]; - }): Promise; + initialValue?: boolean; + }): Promise; password(opts: { message: string }): Promise; isCancel(value: unknown): value is symbol; spinner(): { @@ -54,7 +51,6 @@ export function createStyledClack(): StyledClack { outro, cancel, confirm, - selectKey, password, isCancel, spinner, diff --git a/src/shell/ui/renderers/confirm.test.ts b/src/shell/ui/renderers/confirm.test.ts index 0c144cda5..46151f72f 100644 --- a/src/shell/ui/renderers/confirm.test.ts +++ b/src/shell/ui/renderers/confirm.test.ts @@ -18,7 +18,6 @@ describe("createConfirm", () => { const result = await confirm("Are you sure?"); expect(clack.confirm).toHaveBeenCalledWith({ message: "Are you sure?" }); - expect(clack.selectKey).not.toHaveBeenCalled(); expect(result).toEqual({ ok: true, value: true }); }); @@ -45,30 +44,25 @@ describe("createConfirm", () => { }); }); - describe("destructive (typed y/n) in human mode", () => { - it("uses selectKey, not the arrow-key confirm", async () => { + describe("destructive (arrow-key, default No) in human mode", () => { + it("uses the arrow-key confirm with the cursor defaulted to No", async () => { const clack = makeClack(); - clack.selectKey.mockResolvedValue("y"); + clack.confirm.mockResolvedValue(true); clack.isCancel.mockReturnValue(false); const confirm = createConfirm({ mode: "human", clack }); const result = await confirm("Overwrite?", { destructive: true }); - expect(clack.selectKey).toHaveBeenCalledWith({ + expect(clack.confirm).toHaveBeenCalledWith({ message: "Overwrite?", - caseSensitive: false, - options: [ - { value: "y", label: "Yes" }, - { value: "n", label: "No" }, - ], + initialValue: false, }); - expect(clack.confirm).not.toHaveBeenCalled(); expect(result).toEqual({ ok: true, value: true }); }); - it("returns ok with false when the user picks 'n'", async () => { + it("returns ok with false when the user declines", async () => { const clack = makeClack(); - clack.selectKey.mockResolvedValue("n"); + clack.confirm.mockResolvedValue(false); clack.isCancel.mockReturnValue(false); const confirm = createConfirm({ mode: "human", clack }); @@ -79,7 +73,7 @@ describe("createConfirm", () => { it("returns not ok when the user cancels", async () => { const clack = makeClack(); - clack.selectKey.mockResolvedValue(Symbol("cancel")); + clack.confirm.mockResolvedValue(Symbol("cancel")); clack.isCancel.mockReturnValue(true); const confirm = createConfirm({ mode: "human", clack }); @@ -102,7 +96,6 @@ describe("createConfirm", () => { expect(result).toEqual({ ok: true, value: true }); expect(clack.confirm).not.toHaveBeenCalled(); - expect(clack.selectKey).not.toHaveBeenCalled(); } }); }); diff --git a/src/shell/ui/renderers/confirm.ts b/src/shell/ui/renderers/confirm.ts index 2011a3a36..5a355d02b 100644 --- a/src/shell/ui/renderers/confirm.ts +++ b/src/shell/ui/renderers/confirm.ts @@ -20,20 +20,10 @@ export function createConfirm({ mode, clack }: ConfirmDeps): ConfirmFn { if (opts?.yes) return { ok: true, value: true }; assertHumanMode(mode, "confirm"); - if (opts?.destructive) { - const key = await clack.selectKey({ - message, - caseSensitive: false, - options: [ - { value: "y", label: "Yes" }, - { value: "n", label: "No" }, - ], - }); - if (clack.isCancel(key)) return { ok: false }; - return { ok: true, value: key === "y" }; - } - - const value = await clack.confirm({ message }); + // Destructive prompts start the cursor on No so a stray Enter is safe. + const value = await clack.confirm( + opts?.destructive ? { message, initialValue: false } : { message }, + ); if (clack.isCancel(value)) return { ok: false }; return { ok: true, value }; }; diff --git a/src/shell/ui/types.ts b/src/shell/ui/types.ts index 0b25ff473..0ab7a3a18 100644 --- a/src/shell/ui/types.ts +++ b/src/shell/ui/types.ts @@ -17,7 +17,7 @@ export type UI = { opts?: { /** When true, skip prompting and resolve to `{ ok: true, value: true }`. */ yes?: boolean; - /** When true, prompt with a typed `y`/`n` keystroke instead of arrow-key. */ + /** When true, start the Yes/No cursor on No so a stray Enter is safe. */ destructive?: boolean; }, ): Promise>; From e69f87fbbba5f161c2c4af78a784f788c2ef241e Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:17:08 -0400 Subject: [PATCH 10/14] fix(cli): show a spinner while clearing the runtime cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the removal in withProgress so the user gets immediate feedback while the directory is deleted, matching auth logout. Drop the path from the final message — the confirm note already shows it (human) and the structured output carries it in `dir` (json/agent). --- src/commands/install/clear.ts | 28 +++++++++++++++++++--------- src/core/messages/install.ts | 6 +++--- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/commands/install/clear.ts b/src/commands/install/clear.ts index 6ab11f050..c0b982f1a 100644 --- a/src/commands/install/clear.ts +++ b/src/commands/install/clear.ts @@ -30,15 +30,25 @@ export async function handleInstallClear( } } - const { dir: removed, existed } = await clearRuntimeEnv(ctx.fs); + const [{ existed }] = await ctx.ui.withProgress( + [ + { + message: installMessages.clear.removing, + task: () => clearRuntimeEnv(ctx.fs), + }, + ], + ([result]) => + result.existed + ? installMessages.clear.cleared + : installMessages.clear.nothingToClear, + ); - const message = existed - ? installMessages.clear.cleared(removed) - : installMessages.clear.nothingToClear(removed); - - if (ctx.ui.mode === "human") { - ctx.ui.success(message); - } else { - ctx.ui.output({ cleared: existed, dir: removed }, message); + if (ctx.ui.mode !== "human") { + ctx.ui.output( + { cleared: existed, dir }, + existed + ? installMessages.clear.cleared + : installMessages.clear.nothingToClear, + ); } } diff --git a/src/core/messages/install.ts b/src/core/messages/install.ts index 4841feb44..e26be3bc0 100644 --- a/src/core/messages/install.ts +++ b/src/core/messages/install.ts @@ -20,9 +20,9 @@ export const installMessages = { locationLabel: "Location", confirmPrompt: "Remove the managed runtime cache?", cancelled: "Clear cancelled.", - cleared: (dir: string) => `Removed managed runtime cache at ${dir}.`, - nothingToClear: (dir: string) => - `No managed runtime cache found at ${dir}.`, + removing: "Removing managed runtime cache", + cleared: "Removed managed runtime cache.", + nothingToClear: "No managed runtime cache found.", }, android: { noFlowsFound: "No Android flows found. Nothing to install.", From 9a37746a48011d4197c2f5e9236a7823586ce59a Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:43:21 -0400 Subject: [PATCH 11/14] fix(runner): resolve flow imports via managed-deps symlink and binary pre-bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 1–3: Implement flow-resolution strategy for managed runtime. - loadFlowDefault: Replace @qawolf/flows bare-import rewriting with Bun.build pre-bundling (compiled binary path) or direct import (Node path). Externalize native browser drivers so they resolve via the bundle root's node_modules symlink instead of being inlined. Remove depsRoot arg (no longer used); injection replaces env search. Delete rewriteFlowImports, findFlowsEnvDir, data: URI/sourceURL. - runWebFlow, runAndroidFlow: Drop depsRoot arg to loadFlowDefault. - New linkManagedDeps: Idempotent symlink /node_modules -> /node_modules; exported from runtimeEnv/index. - New stageFlows: Stage raw in-place projects into .qawolf/.local/ (excluding node_modules/.git/.qawolf), remap flow paths. Symlink never pollutes user project; .qawolf bundles used in place. - New copyDir: copyDirExcluding for entry-by-entry copy so destination may live inside source (avoids cp EINVAL). - runDefaults, hybridRunDefaults: Wire stageFlows + linkManagedDeps (symlink only when runtimeEnv.source !== "project"). Pass staged files to flowsRun. hybridRun.test adds fs to ctx. --- src/commands/flows/hybridRun.test.ts | 2 + src/commands/flows/hybridRunDefaults.ts | 21 +- src/commands/flows/runDefaults.ts | 18 +- src/domains/flows/stageFlows.test.ts | 128 +++++++++++ src/domains/flows/stageFlows.ts | 72 ++++++ src/domains/runner/loadFlowDefault.test.ts | 208 +++--------------- src/domains/runner/loadFlowDefault.ts | 183 ++++++++------- src/domains/runner/runAndroidFlow.ts | 1 - src/domains/runner/runWebFlow.ts | 5 +- src/domains/runtimeEnv/index.ts | 1 + .../runtimeEnv/linkManagedDeps.test.ts | 99 +++++++++ src/domains/runtimeEnv/linkManagedDeps.ts | 42 ++++ src/shell/copyDir.ts | 28 +++ 13 files changed, 550 insertions(+), 258 deletions(-) create mode 100644 src/domains/flows/stageFlows.test.ts create mode 100644 src/domains/flows/stageFlows.ts create mode 100644 src/domains/runtimeEnv/linkManagedDeps.test.ts create mode 100644 src/domains/runtimeEnv/linkManagedDeps.ts create mode 100644 src/shell/copyDir.ts diff --git a/src/commands/flows/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index eca6fa8e6..1f8ad6e9a 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -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, @@ -51,6 +52,7 @@ function makeCtx(): AuthCommandContext { isInteractive: false, apiKeySource: "env", platform: {} as unknown, + fs: makeMemoryFs(), signals: makeNoopSignals(), ui: makeFakeUI("human"), log: () => makeNoopLogger(), diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 9cfec7d63..11a47482f 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -7,8 +7,13 @@ import type { CommandResult, } from "~/shell/commandContext.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 { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; +import { + type EnsureRuntimeEnvResult, + linkManagedDeps, +} from "~/domains/runtimeEnv/index.js"; import { resolveDepsRoot, type ResolveDepsRootArgs, @@ -106,6 +111,18 @@ export async function handleHybridFlowsRun( ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); } await loadEnvFile(envDir); + + 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); @@ -116,7 +133,7 @@ export async function handleHybridFlowsRun( return resolvedDeps.flowsRun( ctx, - files, + staged.files, flags, buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), ); diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 151406e32..1b2ab90b4 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -6,7 +6,11 @@ import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js" import { pluralize } from "~/core/pluralize.js"; import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; -import { type EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; +import { stageFlows } from "~/domains/flows/stageFlows.js"; +import { + type EnsureRuntimeEnvResult, + linkManagedDeps, +} from "~/domains/runtimeEnv/index.js"; import { resolveDepsRoot, type ResolveDepsRootArgs, @@ -94,6 +98,16 @@ export async function handleFlowsRun( const projectDir = resolveProjectDirSafe(expandedFiles, ctx.fs); await loadEnvFile(projectDir ?? cwd); + const staged = await stageFlows({ + files: expandedFiles, + projectDir, + 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); @@ -104,7 +118,7 @@ export async function handleFlowsRun( ); return resolvedDeps.flowsRun( ctx, - expandedFiles, + staged.files, flags, buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), ); diff --git a/src/domains/flows/stageFlows.test.ts b/src/domains/flows/stageFlows.test.ts new file mode 100644 index 000000000..869801ee3 --- /dev/null +++ b/src/domains/flows/stageFlows.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { pathExists } from "~/shell/fs.js"; + +import { stageFlows } from "./stageFlows.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tmpDirs.map((d) => rm(d, { recursive: true, force: true })), + ); + tmpDirs.length = 0; +}); + +async function makeTmpDir(): Promise { + const d = realpathSync(await mkdtemp(join(tmpdir(), "qawolf-stage-test-"))); + tmpDirs.push(d); + return d; +} + +describe("stageFlows", () => { + it("passes through unchanged when there is no project dir", async () => { + const files = ["/some/a.flow.ts", "/some/b.flow.ts"]; + + const result = await stageFlows({ + files, + projectDir: undefined, + cwd: "/cwd", + }); + + expect(result).toEqual({ files, bundleRoot: undefined }); + }); + + it("uses the project in place when it already lives under .qawolf", async () => { + const cwd = await makeTmpDir(); + const projectDir = join(cwd, ".qawolf", "my-env"); + const files = [join(projectDir, "login.flow.ts")]; + + const result = await stageFlows({ files, projectDir, cwd }); + + expect(result).toEqual({ files, bundleRoot: projectDir }); + expect(await pathExists(join(cwd, ".qawolf", ".local"))).toBe(false); + }); + + it("stages a raw in-place project and remaps flow paths", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await mkdir(join(projectDir, "flows"), { recursive: true }); + await writeFile(join(projectDir, "flows", "a.flow.ts"), "export {};"); + await mkdir(join(projectDir, "node_modules", "dep"), { recursive: true }); + await writeFile(join(projectDir, "node_modules", "dep", "i.js"), ""); + await mkdir(join(projectDir, ".git"), { recursive: true }); + await writeFile(join(projectDir, ".git", "HEAD"), "ref"); + await mkdir(join(projectDir, ".qawolf"), { recursive: true }); + await writeFile(join(projectDir, ".qawolf", "x"), ""); + + const files = [join(projectDir, "flows", "a.flow.ts")]; + const result = await stageFlows({ files, projectDir, cwd }); + + const stagedDir = result.bundleRoot; + expect(stagedDir).toBeDefined(); + expect(stagedDir?.startsWith(join(cwd, ".qawolf", ".local"))).toBe(true); + + // Copied source is present. + expect(await pathExists(join(stagedDir as string, "package.json"))).toBe( + true, + ); + expect( + await pathExists(join(stagedDir as string, "flows", "a.flow.ts")), + ).toBe(true); + + // Excluded dirs are absent. + expect(await pathExists(join(stagedDir as string, "node_modules"))).toBe( + false, + ); + expect(await pathExists(join(stagedDir as string, ".git"))).toBe(false); + expect(await pathExists(join(stagedDir as string, ".qawolf"))).toBe(false); + + // Flow paths are remapped onto the staged copy. + expect(result.files).toEqual([ + join(stagedDir as string, "flows", "a.flow.ts"), + ]); + }); + + it("stages when cwd is the project dir (staged dir nested under source)", async () => { + // The real flows-run case: you run from your project, so the staged dir + // (/.qawolf/.local/) lives INSIDE projectDir. A single recursive + // copy would reject with EINVAL here; entry-by-entry copy must succeed. + const projectDir = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await mkdir(join(projectDir, "src"), { recursive: true }); + await writeFile(join(projectDir, "src", "a.flow.ts"), "export {};"); + + const files = [join(projectDir, "src", "a.flow.ts")]; + const result = await stageFlows({ files, projectDir, cwd: projectDir }); + + const stagedDir = result.bundleRoot as string; + expect(stagedDir.startsWith(join(projectDir, ".qawolf", ".local"))).toBe( + true, + ); + expect(await pathExists(join(stagedDir, "src", "a.flow.ts"))).toBe(true); + // The staging dir itself must not be recursively copied into itself. + expect(await pathExists(join(stagedDir, ".qawolf"))).toBe(false); + expect(result.files).toEqual([join(stagedDir, "src", "a.flow.ts")]); + }); + + it("refreshes the staged dir on re-run so edits are picked up", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await writeFile(join(projectDir, "a.flow.ts"), "v1"); + const files = [join(projectDir, "a.flow.ts")]; + + const first = await stageFlows({ files, projectDir, cwd }); + await writeFile(join(projectDir, "a.flow.ts"), "v2"); + const second = await stageFlows({ files, projectDir, cwd }); + + expect(second.bundleRoot).toBe(first.bundleRoot as string); + const staged = await stat(join(second.bundleRoot as string, "a.flow.ts")); + expect(staged.isFile()).toBe(true); + }); +}); diff --git a/src/domains/flows/stageFlows.ts b/src/domains/flows/stageFlows.ts new file mode 100644 index 000000000..414da4f94 --- /dev/null +++ b/src/domains/flows/stageFlows.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { join, resolve, sep } from "node:path"; + +import { copyDirExcluding } from "~/shell/copyDir.js"; +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; + +type StageFlowsArgs = { + files: string[]; + projectDir: string | undefined; + cwd: string; + fs?: Fs; +}; + +type StageFlowsResult = { + files: string[]; + bundleRoot: string | undefined; +}; + +const excludedDirs = new Set(["node_modules", ".git", ".qawolf"]); + +/** + * Prepares a flow bundle root that a `node_modules` symlink can be written into + * without polluting the user's project. Flows already living under a CLI-managed + * `.qawolf` dir are used in place; a raw in-place project is copied into + * `/.qawolf/.local/` (excluding node_modules/.git/.qawolf) and its + * flow paths remapped onto the staged copy. Returns the staged files plus the + * bundle root the symlink should target, or `undefined` when there is no project + * dir (managed-only fallback). + */ +export async function stageFlows( + args: StageFlowsArgs, +): Promise { + const { files, projectDir, cwd } = args; + const fs = args.fs ?? makeDefaultFs(); + + if (projectDir === undefined) return { files, bundleRoot: undefined }; + + if (isInsideQawolfDir(projectDir)) { + return { files, bundleRoot: projectDir }; + } + + const stagedDir = join(cwd, ".qawolf", ".local", hashProjectDir(projectDir)); + await fs.rm(stagedDir, { recursive: true, force: true }); + await fs.mkdir(stagedDir, { recursive: true }); + await copyDirExcluding(projectDir, stagedDir, excludedDirs); + + const stagedFiles = files.map((f) => remapPath(f, projectDir, stagedDir)); + return { files: stagedFiles, bundleRoot: stagedDir }; +} + +function isInsideQawolfDir(dir: string): boolean { + return dir.split(sep).includes(".qawolf"); +} + +function hashProjectDir(projectDir: string): string { + return createHash("sha256") + .update(resolve(projectDir)) + .digest("hex") + .slice(0, 16); +} + +function remapPath( + file: string, + projectDir: string, + stagedDir: string, +): string { + if (file === projectDir) return stagedDir; + if (file.startsWith(projectDir + sep)) { + return join(stagedDir, file.slice(projectDir.length + 1)); + } + return file; +} diff --git a/src/domains/runner/loadFlowDefault.test.ts b/src/domains/runner/loadFlowDefault.test.ts index c7959f7d2..42639c660 100644 --- a/src/domains/runner/loadFlowDefault.test.ts +++ b/src/domains/runner/loadFlowDefault.test.ts @@ -1,71 +1,14 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { describe, expect, it } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { loadFlowDefault, rewriteFlowImports } from "./loadFlowDefault.js"; +import { pathExists } from "~/shell/fs.js"; +import { loadFlowDefault } from "./loadFlowDefault.js"; -// ── rewriteFlowImports ──────────────────────────────────────────────────────── +// ── Node path (direct import) ─────────────────────────────────────────────── -describe("rewriteFlowImports", () => { - const resolve = (s: string) => - `file:///resolved/${s.replace("@qawolf/", "")}`; - - it("rewrites static from import of root specifier", () => { - const out = rewriteFlowImports( - `import { foo } from '@qawolf/flows';`, - resolve, - ); - expect(out).toBe(`import { foo } from 'file:///resolved/flows';`); - }); - - it("rewrites static from import of subpath specifier", () => { - const out = rewriteFlowImports( - `import { bar } from '@qawolf/flows/helpers';`, - resolve, - ); - expect(out).toBe(`import { bar } from 'file:///resolved/flows/helpers';`); - }); - - it("rewrites re-export (export ... from) of subpath", () => { - const out = rewriteFlowImports( - `export { baz } from '@qawolf/flows/utils';`, - resolve, - ); - expect(out).toBe(`export { baz } from 'file:///resolved/flows/utils';`); - }); - - it("rewrites dynamic import() of root specifier", () => { - const out = rewriteFlowImports(`import('@qawolf/flows')`, resolve); - expect(out).toBe(`import('file:///resolved/flows')`); - }); - - it("rewrites dynamic import() of subpath specifier", () => { - const out = rewriteFlowImports(`import('@qawolf/flows/client')`, resolve); - expect(out).toBe(`import('file:///resolved/flows/client')`); - }); - - it("leaves specifier unchanged when resolve throws", () => { - const out = rewriteFlowImports(`import {} from '@qawolf/flows';`, () => { - throw new Error("not found"); - }); - expect(out).toBe(`import {} from '@qawolf/flows';`); - }); - - it("does not rewrite unrelated imports", () => { - const src = `import { x } from 'playwright';\nimport { y } from '@qawolf/testkit';`; - expect(rewriteFlowImports(src, resolve)).toBe(src); - }); - - it("rewrites double-quoted specifiers", () => { - const out = rewriteFlowImports(`import foo from "@qawolf/flows";`, resolve); - expect(out).toBe(`import foo from "file:///resolved/flows";`); - }); -}); - -// ── loadFlowDefault ─────────────────────────────────────────────────────────── - -describe("loadFlowDefault", () => { +describe("loadFlowDefault (Node path)", () => { it("returns the default export when present", async () => { const tmp = await mkdtemp(path.join(tmpdir(), "load-flow-test-")); try { @@ -75,6 +18,7 @@ describe("loadFlowDefault", () => { ); const result = await loadFlowDefault<{ name: string }>({ flowPath: path.join(tmp, "flow.mjs"), + bundleFlow: undefined, }); expect(result).toEqual({ name: "test-flow" }); } finally { @@ -90,6 +34,7 @@ describe("loadFlowDefault", () => { try { await loadFlowDefault({ flowPath: path.join(tmp, "flow.mjs"), + bundleFlow: undefined, }); } catch (e) { caught = e; @@ -102,129 +47,48 @@ describe("loadFlowDefault", () => { }); }); -describe("loadFlowDefault (compiled binary mode)", () => { - afterEach(() => { - delete process.env.QAWOLF_COMPILED; - }); +// ── Bundle path (pre-bundled, compiled-binary) ────────────────────────────── - async function makeEnv() { - const tmp = await mkdtemp(path.join(tmpdir(), "load-flow-compiled-")); - const flowsDir = path.join(tmp, "node_modules", "@qawolf", "flows"); - await mkdir(flowsDir, { recursive: true }); - await writeFile( - path.join(flowsDir, "package.json"), - JSON.stringify({ - exports: { ".": "./index.js", "./helpers": "./helpers.js" }, - }), +describe("loadFlowDefault (bundle path)", () => { + function tempBundlePath(flowPath: string): string { + return path.join( + path.dirname(flowPath), + `.${path.basename(flowPath)}.qawolf-bundle.mjs`, ); - await writeFile( - path.join(flowsDir, "index.js"), - "export const flows = {};\n", - ); - await writeFile( - path.join(flowsDir, "helpers.js"), - "export const help = true;\n", - ); - const flowsDir2 = path.join(tmp, "flows"); - await mkdir(flowsDir2, { recursive: true }); - return { tmp, flowsDir2 }; } - it("rewrites and imports a flow that uses root @qawolf/flows", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); - try { - const flowPath = path.join(flowsDir2, "flow.mjs"); - await writeFile( - flowPath, - `import {} from '@qawolf/flows';\nexport default { ok: true };\n`, - ); - const result = await loadFlowDefault<{ ok: boolean }>({ flowPath }); - expect(result).toEqual({ ok: true }); - } finally { - await rm(tmp, { recursive: true }); - } - }); - - it("rewrites and imports a flow that uses a @qawolf/flows subpath", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); - try { - const flowPath = path.join(flowsDir2, "flow.mjs"); - await writeFile( - flowPath, - `import {} from '@qawolf/flows/helpers';\nexport default { sub: true };\n`, - ); - const result = await loadFlowDefault<{ sub: boolean }>({ flowPath }); - expect(result).toEqual({ sub: true }); - } finally { - await rm(tmp, { recursive: true }); - } - }); - - it("sources the data: URI back to the original flow path", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); + it("returns the default export from the bundled source", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "load-flow-bundle-")); try { - const flowPath = path.join(flowsDir2, "flow.mjs"); - await writeFile( + const flowPath = path.join(tmp, "flow.ts"); + const bundleFlow = async () => `export default { name: "x" };\n`; + const result = await loadFlowDefault<{ name: string }>({ flowPath, - `import {} from '@qawolf/flows';\nexport default 42;\n`, - ); - const result = await loadFlowDefault({ flowPath }); - expect(result).toBe(42); + bundleFlow, + }); + expect(result).toEqual({ name: "x" }); + expect(await pathExists(tempBundlePath(flowPath))).toBe(false); } finally { await rm(tmp, { recursive: true }); } }); - it("falls back to direct file import when no @qawolf/flows imports present", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); + it("removes the temp file when the default export is absent", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "load-flow-bundle-")); try { - const flowPath = path.join(flowsDir2, "flow.mjs"); - await writeFile(flowPath, `export default { plain: true };\n`); - const result = await loadFlowDefault<{ plain: boolean }>({ flowPath }); - expect(result).toEqual({ plain: true }); + const flowPath = path.join(tmp, "flow.ts"); + const bundleFlow = async () => `export const foo = 1;\n`; + let caught: unknown; + try { + await loadFlowDefault({ flowPath, bundleFlow }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/No default export found in "/); + expect(await pathExists(tempBundlePath(flowPath))).toBe(false); } finally { await rm(tmp, { recursive: true }); } }); - - it("uses depsRoot to resolve @qawolf/flows when provided, even without @qawolf/flows in parent dirs", async () => { - process.env.QAWOLF_COMPILED = "true"; - // Create depsRoot with @qawolf/flows - const depsRoot = await mkdtemp(path.join(tmpdir(), "load-flow-depsroot-")); - // Create a separate tmpdir for the flow with no @qawolf/flows ancestor - const flowTmp = await mkdtemp(path.join(tmpdir(), "load-flow-isolated-")); - try { - const flowsDir = path.join(depsRoot, "node_modules", "@qawolf", "flows"); - await mkdir(flowsDir, { recursive: true }); - await writeFile( - path.join(flowsDir, "package.json"), - JSON.stringify({ - exports: { ".": "./index.js" }, - }), - ); - await writeFile( - path.join(flowsDir, "index.js"), - "export const flows = {};\n", - ); - - const flowPath = path.join(flowTmp, "flow.mjs"); - await writeFile( - flowPath, - `import {} from '@qawolf/flows';\nexport default { fromDepsRoot: true };\n`, - ); - - const result = await loadFlowDefault<{ fromDepsRoot: boolean }>({ - flowPath, - depsRoot, - }); - expect(result).toEqual({ fromDepsRoot: true }); - } finally { - await rm(depsRoot, { recursive: true }); - await rm(flowTmp, { recursive: true }); - } - }); }); diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 197eb9911..0ada6fd52 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -3,62 +3,94 @@ import { pathToFileURL } from "node:url"; import { runnerMessages } from "~/core/messages/index.js"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; -import { resolveFromEnvDir } from "~/shell/resolveExport.js"; -// Walk up from flowPath to find the directory that holds node_modules/@qawolf/flows. -function findFlowsEnvDir( - flowPath: string, - fs: Fs = makeDefaultFs(), -): string | undefined { - let dir = path.dirname(flowPath); - while (true) { - if (fs.existsSync(path.join(dir, "node_modules", "@qawolf", "flows"))) - return dir; - const parent = path.dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } +// Native browser drivers stay bare so they resolve via the node_modules symlink +// at the flow's bundle root instead of being inlined into the bundle. +const browserDrivers = [ + "playwright", + "playwright-core", + "patchright", + "patchright-core", +]; + +// Pre-bundles a flow's full import tree into a single ESM source string. +type FlowBundler = (flowPath: string) => Promise; + +type BunBuildResult = { + success: boolean; + outputs: { text(): Promise }[]; + logs: { message: string }[]; +}; + +// Structural type read from globalThis to avoid the no-restricted-globals lint +// rule; Bun.build exists in the compiled binary but not the Node.js build. +type BunBuild = (config: { + entrypoints: string[]; + target?: string; + format?: string; + external?: string[]; +}) => Promise; + +function getBunBuild(): BunBuild | undefined { + return (globalThis as { Bun?: { build?: BunBuild } }).Bun?.build; } -// Exported for testing. Replaces @qawolf/flows and @qawolf/flows/* specifiers -// with the URL returned by resolve(specifier). Leaves unresolvable specifiers -// unchanged (resolve is expected to throw on failure). -export function rewriteFlowImports( - content: string, - resolve: (specifier: string) => string, -): string { - return content - .replace( - /(from|import)\s+(['"])(@qawolf\/flows(?:\/[^'"]+)?)\2/g, - (match, keyword: string, quote: string, specifier: string) => { - try { - return `${keyword} ${quote}${resolve(specifier)}${quote}`; - } catch { - return match; - } - }, - ) - .replace( - /\bimport\s*\(\s*(['"])(@qawolf\/flows(?:\/[^'"]+)?)\1\s*\)/g, - (match, quote: string, specifier: string) => { - try { - return `import(${quote}${resolve(specifier)}${quote})`; - } catch { - return match; - } - }, - ); +// Compiled Bun binaries cannot resolve exports-map bare specifiers from external +// node_modules, but Bun.build (available inside the binary) can. Pre-bundle the +// flow so everything except the native browser drivers is inlined. +async function defaultFlowBundler(flowPath: string): Promise { + const build = getBunBuild(); + if (build === undefined) + throw new Error("Cannot bundle flow: Bun.build is unavailable"); + + // Bun.build throws an AggregateError (with per-error messages on `.errors`) on + // resolve/parse failures rather than returning success:false, so surface those + // messages — otherwise the flow fails with an opaque "Bundle failed". + let result: BunBuildResult; + try { + result = await build({ + entrypoints: [flowPath], + target: "bun", + format: "esm", + external: browserDrivers, + }); + } catch (err) { + const aggregate = err as { errors?: { message?: string }[] }; + const detail = Array.isArray(aggregate.errors) + ? aggregate.errors.map((e) => e.message ?? "unknown error").join("\n") + : err instanceof Error + ? err.message + : "unknown error"; + throw new Error(`Failed to bundle flow ${flowPath}:\n${detail}`, { + cause: err, + }); + } + const [output] = result.outputs; + if (!result.success || !output) { + const logs = result.logs.map((entry) => entry.message).join("\n"); + throw new Error(`Failed to bundle flow ${flowPath}:\n${logs}`); + } + return output.text(); } +// Only the compiled binary needs bundling — it alone cannot resolve exports-map +// bare specifiers from external node_modules. Node and `bun run`/`bun test` +// resolve them directly, so they take the direct-import path. QAWOLF_COMPILED is +// injected via --define at binary build time (see build:binary in package.json). +// Tests inject bundleFlow explicitly to exercise either path deterministically. +const defaultBundleFlow: FlowBundler | undefined = + process.env.QAWOLF_COMPILED === "true" ? defaultFlowBundler : undefined; + type LoadFlowDefaultArgs = { flowPath: string; - // When set, resolve @qawolf/flows from this dir instead of walking up from the flow file. - depsRoot?: string; fs?: Fs; + // Injectable for tests. When defined, the flow is pre-bundled (compiled-binary + // path); when undefined, the flow is imported directly (Node path). + bundleFlow?: FlowBundler | undefined; }; -// Imports a module specifier (file:// path or data: URI) and returns its -// default export, throwing the canonical no-default-export error when absent. +// Imports a module by URL and returns its default export, throwing the canonical +// no-default-export error when absent. async function importDefaultExport( moduleUrl: string, flowPath: string, @@ -70,42 +102,39 @@ async function importDefaultExport( return exported; } -export async function loadFlowDefault( - args: LoadFlowDefaultArgs, +// Imports the bundled flow from a temp sibling of flowPath so the externalized +// browser-driver bare imports resolve via the node_modules symlink at the flow's +// bundle root. The temp file is always removed afterward. +async function importBundledFlow( + flowPath: string, + code: string, + fs: Fs, ): Promise { - const { flowPath, depsRoot, fs = makeDefaultFs() } = args; - - // process.env.QAWOLF_COMPILED is injected via --define at binary build time - // (see build:binary in package.json). Undefined in bun run / bun test dev mode. - const isCompiledBinary = process.env.QAWOLF_COMPILED === "true"; - - // Non-compiled path: direct import, no file read needed. - if (!isCompiledBinary) { - return importDefaultExport(pathToFileURL(flowPath).href, flowPath); + const tempPath = path.join( + path.dirname(flowPath), + `.${path.basename(flowPath)}.qawolf-bundle.mjs`, + ); + await fs.writeFile(tempPath, code); + try { + return await importDefaultExport(pathToFileURL(tempPath).href, flowPath); + } finally { + await fs.rm(tempPath, { force: true }); } +} - // In compiled Bun binaries, dynamically imported external files cannot resolve - // bare specifiers — this is a Bun binary limitation separate from the scoped- - // package traversal bug. Transform @qawolf/flows/* imports to absolute file:// - // paths so Bun loads them directly without any resolution step. - const content = await fs.readFile(flowPath); - const envDir = depsRoot ?? findFlowsEnvDir(flowPath, fs); - - const transformed = envDir - ? rewriteFlowImports( - content, - (specifier) => - pathToFileURL(resolveFromEnvDir(envDir, specifier, "esm", fs)).href, - ) - : content; +export async function loadFlowDefault( + args: LoadFlowDefaultArgs, +): Promise { + const { + flowPath, + fs = makeDefaultFs(), + bundleFlow = defaultBundleFlow, + } = args; - if (transformed === content) { + if (bundleFlow === undefined) { return importDefaultExport(pathToFileURL(flowPath).href, flowPath); } - const annotated = `${transformed}\n//# sourceURL=${ - pathToFileURL(flowPath).href - }`; - const dataUri = `data:text/javascript,${encodeURIComponent(annotated)}`; - return importDefaultExport(dataUri, flowPath); + const code = await bundleFlow(flowPath); + return importBundledFlow(flowPath, code, fs); } diff --git a/src/domains/runner/runAndroidFlow.ts b/src/domains/runner/runAndroidFlow.ts index 29401b20f..e7fc1da59 100644 --- a/src/domains/runner/runAndroidFlow.ts +++ b/src/domains/runner/runAndroidFlow.ts @@ -43,7 +43,6 @@ export async function runAndroidFlow({ }): Promise { const exported = await loadFlowDefault({ flowPath, - depsRoot: deps.depsRoot, }); if (typeof exported === "function") { // (D2) Android legacy flows have no target; AVD derivation is impossible. diff --git a/src/domains/runner/runWebFlow.ts b/src/domains/runner/runWebFlow.ts index e553aa6d2..be0b70ad9 100644 --- a/src/domains/runner/runWebFlow.ts +++ b/src/domains/runner/runWebFlow.ts @@ -46,10 +46,7 @@ export async function runWebFlow({ depsRoot: deps.depsRoot, }); - const exported = await loadFlowDefault({ - flowPath, - depsRoot: deps.depsRoot, - }); + const exported = await loadFlowDefault({ flowPath }); const isLegacy = typeof exported === "function"; const flowName = isLegacy diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts index 3ccf77f3c..b84917735 100644 --- a/src/domains/runtimeEnv/index.ts +++ b/src/domains/runtimeEnv/index.ts @@ -1,4 +1,5 @@ export * from "./clearRuntimeEnv.js"; export * from "./ensureRuntimeEnv.js"; +export * from "./linkManagedDeps.js"; export * from "./managedEnvDir.js"; export * from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/linkManagedDeps.test.ts b/src/domains/runtimeEnv/linkManagedDeps.test.ts new file mode 100644 index 000000000..8b7b68418 --- /dev/null +++ b/src/domains/runtimeEnv/linkManagedDeps.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + readlink, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { linkManagedDeps } from "./linkManagedDeps.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tmpDirs.map((d) => rm(d, { recursive: true, force: true })), + ); + tmpDirs.length = 0; +}); + +async function makeTmpDir(): Promise { + const d = realpathSync( + await mkdtemp(join(tmpdir(), "qawolf-linkdeps-test-")), + ); + tmpDirs.push(d); + return d; +} + +type Roots = { bundleRoot: string; depsRoot: string; source: string }; + +async function makeRoots(): Promise { + const bundleRoot = await makeTmpDir(); + const depsRoot = await makeTmpDir(); + const source = join(depsRoot, "node_modules"); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "marker.txt"), "deps"); + return { bundleRoot, depsRoot, source }; +} + +describe("linkManagedDeps", () => { + it("creates a fresh symlink to the managed node_modules", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + + await linkManagedDeps(bundleRoot, depsRoot); + + const target = join(bundleRoot, "node_modules"); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await readlink(target)).toBe(source); + }); + + it("is idempotent when re-run with the same roots", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + + await linkManagedDeps(bundleRoot, depsRoot); + await linkManagedDeps(bundleRoot, depsRoot); + + const target = join(bundleRoot, "node_modules"); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await readlink(target)).toBe(source); + }); + + it("never clobbers a real node_modules directory", async () => { + const { bundleRoot, depsRoot } = await makeRoots(); + const target = join(bundleRoot, "node_modules"); + await mkdir(target, { recursive: true }); + await writeFile(join(target, "real.txt"), "user deps"); + + await linkManagedDeps(bundleRoot, depsRoot); + + expect((await lstat(target)).isSymbolicLink()).toBe(false); + expect((await lstat(join(target, "real.txt"))).isFile()).toBe(true); + }); + + it("refreshes a stale symlink to point at the managed deps", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + const stale = await makeTmpDir(); + const target = join(bundleRoot, "node_modules"); + await symlink(stale, target, "dir"); + + await linkManagedDeps(bundleRoot, depsRoot); + + expect(await readlink(target)).toBe(source); + }); + + it("no-ops when bundleRoot equals depsRoot", async () => { + const { depsRoot } = await makeRoots(); + const target = join(depsRoot, "node_modules"); + + await linkManagedDeps(depsRoot, depsRoot); + + // The pre-existing real node_modules is untouched (not turned into a symlink). + expect((await lstat(target)).isSymbolicLink()).toBe(false); + }); +}); diff --git a/src/domains/runtimeEnv/linkManagedDeps.ts b/src/domains/runtimeEnv/linkManagedDeps.ts new file mode 100644 index 000000000..b8672c997 --- /dev/null +++ b/src/domains/runtimeEnv/linkManagedDeps.ts @@ -0,0 +1,42 @@ +import { type Stats } from "node:fs"; +import { lstat, readlink, symlink } from "node:fs/promises"; +import { join } from "node:path"; + +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; + +/** + * Makes the managed runtime's `node_modules` resolvable from a flow bundle by + * symlinking `/node_modules -> /node_modules`. Idempotent + * and safe: never clobbers a real `node_modules`, refreshes a stale symlink, + * and no-ops when the bundle already resolves deps from its own dir + * (`bundleRoot === depsRoot`). + */ +export async function linkManagedDeps( + bundleRoot: string, + depsRoot: string, + fs: Fs = makeDefaultFs(), +): Promise { + if (bundleRoot === depsRoot) return; + + const target = join(bundleRoot, "node_modules"); + const source = join(depsRoot, "node_modules"); + + const existing = await lstatOrUndefined(target); + if (existing?.isSymbolicLink()) { + const current = await readlink(target); + if (current === source) return; + await fs.rm(target, { recursive: true, force: true }); + } else if (existing !== undefined) { + return; + } + + await symlink(source, target, "dir"); +} + +async function lstatOrUndefined(path: string): Promise { + try { + return await lstat(path); + } catch { + return undefined; + } +} diff --git a/src/shell/copyDir.ts b/src/shell/copyDir.ts new file mode 100644 index 000000000..275a922e4 --- /dev/null +++ b/src/shell/copyDir.ts @@ -0,0 +1,28 @@ +import { cp, readdir } from "node:fs/promises"; +import { basename, join } from "node:path"; + +/** + * Recursively copies `source` into `destination`, skipping any entry whose + * basename is in `excludedNames` (matched at every depth — a skipped directory + * is not descended into). `destination` must already exist. Each top-level entry + * is copied independently so `destination` may live inside `source` (e.g. a + * `.qawolf` staging dir) as long as that dir is excluded — a single recursive + * `cp` would reject with EINVAL when the destination is a subdirectory of source. + */ +export async function copyDirExcluding( + source: string, + destination: string, + excludedNames: ReadonlySet, +): Promise { + const entries = await readdir(source, { withFileTypes: true }); + await Promise.all( + entries + .filter((entry) => !excludedNames.has(entry.name)) + .map((entry) => + cp(join(source, entry.name), join(destination, entry.name), { + recursive: true, + filter: (path) => !excludedNames.has(basename(path)), + }), + ), + ); +} From 0c5813ec682a4becdc28c45a01c72b63cf0c7a8a Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:03:28 -0400 Subject: [PATCH 12/14] fix(runner): use Windows junction for managed-deps symlink and assert staged refresh content --- src/domains/flows/stageFlows.test.ts | 12 +++++++++++- src/domains/runtimeEnv/linkManagedDeps.ts | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/domains/flows/stageFlows.test.ts b/src/domains/flows/stageFlows.test.ts index 869801ee3..97862cdf3 100644 --- a/src/domains/flows/stageFlows.test.ts +++ b/src/domains/flows/stageFlows.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it } from "bun:test"; import { realpathSync } from "node:fs"; -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -124,5 +131,8 @@ describe("stageFlows", () => { expect(second.bundleRoot).toBe(first.bundleRoot as string); const staged = await stat(join(second.bundleRoot as string, "a.flow.ts")); expect(staged.isFile()).toBe(true); + expect( + await readFile(join(second.bundleRoot as string, "a.flow.ts"), "utf8"), + ).toBe("v2"); }); }); diff --git a/src/domains/runtimeEnv/linkManagedDeps.ts b/src/domains/runtimeEnv/linkManagedDeps.ts index b8672c997..3b933fc44 100644 --- a/src/domains/runtimeEnv/linkManagedDeps.ts +++ b/src/domains/runtimeEnv/linkManagedDeps.ts @@ -30,7 +30,11 @@ export async function linkManagedDeps( return; } - await symlink(source, target, "dir"); + // On Windows a "dir" symlink needs the "Create symbolic links" privilege + // (admin / Developer Mode); a junction links directories without elevation. + // The qawolf binary ships for Windows, so prefer junction there. + const linkType = process.platform === "win32" ? "junction" : "dir"; + await symlink(source, target, linkType); } async function lstatOrUndefined(path: string): Promise { From b22b08077679c4073a136eefcbc807ef86372ad3 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:55:47 -0400 Subject: [PATCH 13/14] fix(runner): isolate per-run staging and guard managed-runtime deletion --- src/commands/flows/hybridRunDefaults.ts | 20 ++++++--- src/commands/flows/runDefaults.ts | 20 ++++++--- src/domains/flows/stageFlows.test.ts | 21 +++++++++ src/domains/flows/stageFlows.ts | 19 +++++++- .../runtimeEnv/clearRuntimeEnv.test.ts | 12 +++++ src/domains/runtimeEnv/clearRuntimeEnv.ts | 44 ++++++++++++++++++- 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 11a47482f..6d9ab0728 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -131,10 +131,18 @@ export async function handleHybridFlowsRun( ctx.signals, ); - return resolvedDeps.flowsRun( - ctx, - staged.files, - flags, - buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), - ); + 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?.(); + } } diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 1b2ab90b4..ced4e4aff 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -116,10 +116,18 @@ export async function handleFlowsRun( resolvedDir, ctx.signals, ); - return resolvedDeps.flowsRun( - ctx, - staged.files, - flags, - buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), - ); + 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?.(); + } } diff --git a/src/domains/flows/stageFlows.test.ts b/src/domains/flows/stageFlows.test.ts index 97862cdf3..55bffdebb 100644 --- a/src/domains/flows/stageFlows.test.ts +++ b/src/domains/flows/stageFlows.test.ts @@ -135,4 +135,25 @@ describe("stageFlows", () => { await readFile(join(second.bundleRoot as string, "a.flow.ts"), "utf8"), ).toBe("v2"); }); + + it("returns a cleanup that removes the staged dir; passthrough has none", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + const files = [join(projectDir, "a.flow.ts")]; + + const result = await stageFlows({ files, projectDir, cwd }); + expect(await pathExists(result.bundleRoot as string)).toBe(true); + await result.cleanup?.(); + expect(await pathExists(result.bundleRoot as string)).toBe(false); + + // No staged copy was created for the in-place .qawolf case → no cleanup. + const qawolfProject = join(cwd, ".qawolf", "env"); + const passthrough = await stageFlows({ + files: [join(qawolfProject, "x.flow.ts")], + projectDir: qawolfProject, + cwd, + }); + expect(passthrough.cleanup).toBeUndefined(); + }); }); diff --git a/src/domains/flows/stageFlows.ts b/src/domains/flows/stageFlows.ts index 414da4f94..460e0fc52 100644 --- a/src/domains/flows/stageFlows.ts +++ b/src/domains/flows/stageFlows.ts @@ -14,6 +14,9 @@ type StageFlowsArgs = { type StageFlowsResult = { files: string[]; bundleRoot: string | undefined; + // Removes the staged copy. Present only when this call created one; callers + // run it after the flows finish (and register it for interrupt cleanup). + cleanup?: () => Promise; }; const excludedDirs = new Set(["node_modules", ".git", ".qawolf"]); @@ -39,13 +42,25 @@ export async function stageFlows( return { files, bundleRoot: projectDir }; } - const stagedDir = join(cwd, ".qawolf", ".local", hashProjectDir(projectDir)); + // The dir is per-run (pid-suffixed) so concurrent `flows run` on the same + // project never delete each other's active staging tree; the caller removes + // it when the run ends. + const stagedDir = join( + cwd, + ".qawolf", + ".local", + `${hashProjectDir(projectDir)}-${process.pid}`, + ); await fs.rm(stagedDir, { recursive: true, force: true }); await fs.mkdir(stagedDir, { recursive: true }); await copyDirExcluding(projectDir, stagedDir, excludedDirs); const stagedFiles = files.map((f) => remapPath(f, projectDir, stagedDir)); - return { files: stagedFiles, bundleRoot: stagedDir }; + return { + files: stagedFiles, + bundleRoot: stagedDir, + cleanup: () => fs.rm(stagedDir, { recursive: true, force: true }), + }; } function isInsideQawolfDir(dir: string): boolean { diff --git a/src/domains/runtimeEnv/clearRuntimeEnv.test.ts b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts index 4946ca471..1bda707c2 100644 --- a/src/domains/runtimeEnv/clearRuntimeEnv.test.ts +++ b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts @@ -40,6 +40,18 @@ describe("clearRuntimeEnv", () => { expect(result.dir).toBe(managedEnvBaseDir()); }); + it("refuses to delete a dir holding non-managed content (fail closed)", async () => { + const override = "/tmp/qawolf-rt-foreign"; + process.env["QAWOLF_RUNTIME_DIR"] = override; + const fs = makeMemoryFs(); + // Looks nothing like a managed runtime — e.g. a misconfigured repo root. + await fs.mkdir(`${override}/src`, { recursive: true }); + await fs.writeFile(`${override}/package.json`, '{"name":"my-app"}'); + + expect(clearRuntimeEnv(fs)).rejects.toThrow("Refusing to delete"); + expect(await fs.pathExists(override)).toBe(true); + }); + it("honors QAWOLF_RUNTIME_DIR and returns its resolved path", async () => { const override = "/tmp/qawolf-rt-test"; process.env["QAWOLF_RUNTIME_DIR"] = override; diff --git a/src/domains/runtimeEnv/clearRuntimeEnv.ts b/src/domains/runtimeEnv/clearRuntimeEnv.ts index c2313e487..e9383ea85 100644 --- a/src/domains/runtimeEnv/clearRuntimeEnv.ts +++ b/src/domains/runtimeEnv/clearRuntimeEnv.ts @@ -1,16 +1,58 @@ +import { join } from "node:path"; + import { type Fs } from "~/shell/fs.js"; import { managedEnvBaseDir } from "./managedEnvDir.js"; +// Marker written into every managed hash dir's package.json by scaffoldManagedEnv. +const runtimeSentinel = "qawolf-runtime"; + export type ClearRuntimeEnvResult = { dir: string; existed: boolean }; /** * Removes the entire managed runtime base directory (every versioned hash dir). * Honors QAWOLF_RUNTIME_DIR. Returns the resolved path and whether anything existed. + * Fails closed when the resolved dir is not a managed runtime — a misconfigured + * QAWOLF_RUNTIME_DIR (e.g. a repo root) must never be recursively deleted. */ export async function clearRuntimeEnv(fs: Fs): Promise { const dir = managedEnvBaseDir(); const existed = await fs.pathExists(dir); - if (existed) await fs.rm(dir, { recursive: true, force: true }); + if (!existed) return { dir, existed }; + + if (!(await isManagedRuntimeBase(dir, fs))) { + throw new Error( + `Refusing to delete ${dir}: it does not look like a QA Wolf managed ` + + `runtime directory (expected only ${runtimeSentinel} hash dirs). ` + + `Check your QAWOLF_RUNTIME_DIR override.`, + ); + } + + await fs.rm(dir, { recursive: true, force: true }); return { dir, existed }; } + +// A managed base contains only versioned hash dirs, each scaffolded with a +// package.json named "qawolf-runtime". Empty is fine (nothing of value to lose); +// any other entry means the path is not ours, so refuse to delete it. +async function isManagedRuntimeBase(dir: string, fs: Fs): Promise { + const entries = await fs.readdirWithTypes(dir); + for (const entry of entries) { + if (!entry.isDirectory()) return false; + if (!(await hasRuntimeSentinel(join(dir, entry.name), fs))) return false; + } + return true; +} + +async function hasRuntimeSentinel(hashDir: string, fs: Fs): Promise { + try { + const pkg = JSON.parse( + await fs.readFile(join(hashDir, "package.json")), + ) as { + name?: string; + }; + return pkg.name === runtimeSentinel; + } catch { + return false; + } +} From 80fe8d8b12e58960e14fa8cd97cfb087c0f40eb4 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:36:53 -0400 Subject: [PATCH 14/14] docs(runner): add target architecture design for runtime-deps redesign --- .../2026-06-23-runtime-deps-target-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/plans/2026-06-23-runtime-deps-target-design.md diff --git a/docs/plans/2026-06-23-runtime-deps-target-design.md b/docs/plans/2026-06-23-runtime-deps-target-design.md new file mode 100644 index 000000000..c831d4763 --- /dev/null +++ b/docs/plans/2026-06-23-runtime-deps-target-design.md @@ -0,0 +1,170 @@ +--- +title: Runtime-dependency resolution — target architecture (design) +date: 2026-06-23 +branch: wiz-10907-potential-incompatibility-with-other-monorepo-and-single +status: approved-pending-spike +supersedes-investigation: docs/plans/2026-06-23-runtime-deps-architecture-redesign.local.md +tags: [design, runner, runtime-deps, architecture] +--- + +# Runtime-dependency resolution — target architecture + +## Problem + +PR #1381 moved the pinned flow runtime deps out of the user's project into an isolated managed +dir to stop monorepo `node_modules` pollution. It fixed pollution but broke running real flows: +a full empirical `/dd-compat` pass returned NOT-YET with three ship-blockers. + +The current design merged two concerns that must be separated: + +- **(a) Executor / native-runtime resolution** — `@qawolf/flows`, `playwright`, + `@qawolf/testkit`, `@qawolf/emails`, the appium drivers. CLI-owned, pinned, and must never + touch the project's `package.json` or `node_modules`. +- **(b) The flow project's own declared deps** — `diff`, `@faker-js/faker`, `axios`, workspace + packages, etc. These must still resolve. + +The design resolves (a) into a managed dir, then via staging + `linkManagedDeps` _replaces_ the +project's `node_modules` with the managed one — dropping (b) entirely. The unifying requirement +is: **executor resolution is independent of, and never pollutes, the surrounding project — but +the flow's own declared dependencies must still resolve.** + +### The three ship-blockers (from the empirical verification) + +1. **Managed runtime omits the flow's own deps** (both channels; regression). The managed dir + installs only the 7 pinned pkgs; staging excludes `node_modules` and `linkManagedDeps` + symlinks only the managed tree. The removed `ensureFlowDeps` (on `main`) used to install the + full project tree. +2. **Shared runtime dir corrupts across channels.** The dir is keyed on package versions only, + not channel. The binary writes CJS `Bun.build` shims; the Node channel removes them; whoever + installs first wins and the other channel breaks. +3. **Binary never runs a web flow end-to-end.** `loadFlowDefault` pre-bundles the flow under the + compiled binary, inlining a _second_ copy of `@qawolf/flows` whose AsyncLocalStorage instance + differs from the runner's → `page` undefined. + +## Decisions + +1. **Flow-deps model → hybrid / layered.** The executor is always CLI-owned and isolated; the + flow's own deps resolve from the project when present, else are CLI-installed into the run dir. +2. **Binary loading → drop the pre-bundle + shim, gated by an early spike**, with a keep-but-fix + fallback. + +## Core mechanism — layered `node_modules` via filesystem walk-up (fixes Blocker 1) + +Node and Bun resolve a bare import by walking `node_modules` up the **importing file's real +directory chain**, first match wins. Compose two independent roots without merging or polluting: + +``` +//node_modules/{@qawolf/flows, playwright, …} ← EXECUTOR (CLI-owned, cached, shared) +//.runs//node_modules/{diff, faker, axios} ← FLOW'S OWN DEPS (per run) +//.runs// ← the flow files (copied) +``` + +`import playwright` from a staged flow → child `.runs//node_modules` (miss) → walk up to +`/node_modules` (hit, executor). `import diff` → child (hit, flow's own). Both +resolve; the executor is shared and cached; the project is never written. + +This replaces today's stage-to-cwd + `linkManagedDeps`-replace path (which dropped the flow's +deps). **Per-run staging relocates under the managed version dir** so the executor is a real +ancestor: `.runs/-/` (keeps the per-run pid isolation from commit b22b0807, +moved off the user's cwd — a side win: no `.qawolf/.local` litter in the project). + +### Child `node_modules` population (the hybrid rule) + +- **Project has an installed `node_modules`** (case 2, monorepo) → symlink the child + `node_modules` to the **nearest ancestor `node_modules`** of the flow. This captures hoisted, + workspace, and private deps; workspace symlinks resolve onward into the real monorepo. Never + writes into the project. +- **No installed `node_modules`** (cases 1 & 3) → read the flow's `package.json` and + `npm install` its deps into the child, **stripping the 7 executor packages** so the pinned + parent stays authoritative. This restores the deleted `ensureFlowDeps` behavior, relocated off + the project. Empty-dir / no `package.json` → empty child; the executor still resolves via the + parent (the minor "Cannot find @qawolf/flows" failure disappears). + +The layered core is **channel-agnostic and low-risk**: the flow's own deps are plain packages +resolved by ordinary walk-up, and the flow file is **not** inside an `@scope/` dir, so its +first-hop bare imports are not subject to WIZ-10612. + +## Binary executor resolution (Blockers 2 & 3) — spike-gated + +WIZ-10612 (documented in `src/domains/runtimeEnv/shimDeps.ts:7-27`, current as of pinned +**Bun 1.3.13**) makes the **compiled binary** mis-resolve from inside an `@scope/` package: +`@qawolf/flows` (exports map `.`, `./web`, `./_runner`, …; deps `@qawolf/flow-targets`→`zod`, +`expect`, `pngjs`, …) cannot reach its own bare deps in the outer `node_modules`. That is _why_ +the pre-bundle and CJS shims exist — and the pre-bundle inlining a second `@qawolf/flows` copy +is the root of Blocker 3. + +**Blocker 3 fix is independent of the spike.** Whatever the binary does, **externalize the +executor packages** (`@qawolf/flows`, `@qawolf/testkit`, `@qawolf/emails`, browser drivers) from +any flow bundle so the flow imports the **same** `@qawolf/flows` instance the runner's +`initFlowRuntime` configured. Today `loadFlowDefault` externalizes only `browserDrivers`. + +**Spike (gates the binary branch).** Build the binary on pinned Bun and test whether the +compiled binary resolves, from the layered tree, (i) `@qawolf/flows/web` subpath exports from an +external `node_modules`, and (ii) a scoped package's transitive bare imports. + +- **PASS (WIZ-10612 effectively gone)** → drop the pre-bundle **and** the shim entirely. The + binary path becomes the Node path (`import()` + walk-up). Channel-keying the hash is + unnecessary. Blockers 2 & 3 vanish with the machinery. +- **FAIL (bug still bites)** → keep-but-fix: + - Pre-bundle the flow with **`@qawolf/flows`/testkit/emails/browser-drivers externalized** + (Bun.build inlines the flow's _own_ deps, resolved from the layered child) → fixes Blocker 3. + - **Channel-key `managedEnvHash()`** (`node` vs `binary`) so the binary's shims live in a + separate runtime dir and never corrupt the Node channel → fixes Blocker 2. + - Keep the executor-dep shim only inside the binary-keyed dir. + +Either branch fixes all three blockers; the spike only decides how much binary machinery remains. + +## Per-case × channel resolution matrix + +| Case | Channel | Executor (a) | Flow's own deps (b) | Project writes | +| -------------- | ------- | ---------------------------------- | ------------------------------------ | -------------- | +| 1 managed-only | Node | parent walk-up ✓ | child = npm install flow pkg.json ✓ | none | +| 1 managed-only | Binary | spike: parent walk-up, else shim ✓ | inlined by Bun.build OR child ✓ | none | +| 2 monorepo | Node | parent walk-up ✓ | child → symlink nearest project nm ✓ | none | +| 2 monorepo | Binary | spike: parent / shim ✓ | inlined / child symlink ✓ | none | +| 3 empty-dir | Node | parent walk-up ✓ | child = npm install flow pkg.json ✓ | none | +| 3 empty-dir | Binary | spike: parent / shim ✓ | inlined OR child ✓ | none | + +**Known limitation:** a monorepo that puts deps in a _leaf-local_ `node_modules` under the flow +(not hoisted) layers only the nearest one. Hoisted / workspace layouts (the norm) are fully +covered. + +## Observability (table stakes) + +The console reporter already prints the cause chain (`createConsoleReporter.formatErrorWithCause`, +lines 57-66, 100-108). Close the real gaps: + +- Surface **load-time** failures (`loadFlowDefault` / `initFlowRuntime`, which throw before + `runner.run` wraps a `FlowRunError`) with their structured cause. +- Add the cause chain to the **JUnit** reporter (currently `err.message` only). +- Audit the **json / markdown** renderers. + +## Implementation order (Slice 3) + +1. **Spike (gating):** build binary on pinned Bun; test layered-tree resolution of + `@qawolf/flows/web` exports + scoped transitive imports. Records PASS/FAIL. +2. **Layered resolution core** (channel-agnostic, regardless of spike): relocate staging into + `//.runs//`; add the child-`node_modules` step + (symlink-nearest-project-nm vs install-flow-pkg-deps with executor pkgs stripped); rewire + `runDefaults.ts` / `hybridRunDefaults.ts`. Keep `ensureRuntimeEnv` for the executor root only. +3. **Blocker 3 fix:** externalize `@qawolf/flows` / testkit / emails from `loadFlowDefault`'s + bundler. +4. **Binary branch (spike-driven):** PASS → delete the pre-bundle path, `shimDeps.ts`, and the + `QAWOLF_COMPILED` bundling fork; FAIL → channel-key `managedEnvHash()` and keep the shim in + the keyed dir. +5. **Observability:** load-time cause surfacing + JUnit cause + renderer audit. +6. Unit tests for the new resolution model; `lint:fix`, `format`, `typecheck`, `knip` clean. + +Likely-touched modules: +`src/domains/runtimeEnv/{ensureRuntimeEnv,managedEnvDir,linkManagedDeps,shimDeps,installPinned}.ts`, +`src/domains/flows/{stageFlows,ensureDeps}.ts`, +`src/domains/runner/{loadFlowDefault,initFlowRuntime}.ts`, +`src/commands/flows/{runDefaults,hybridRunDefaults}.ts`, +`src/shell/reporter/createJUnitReporter.ts`. + +## Verification (Slice 4) + +Re-run the `/dd-compat` harness: a real flow on BOTH channels in all 3 cases, zero project +pollution, no cross-channel corruption, flow-failure causes visible. Real env to pull: +`ckzese4wg5893850qb6x1r01pd`. Managed dir on this machine: +`~/Library/Application Support/qawolf-nodejs/runtime/`.