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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 de77600005a323a1f41e025cbb0a1e22db3ffcd0 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:46:26 -0400 Subject: [PATCH 14/32] feat(runner): resolve flow deps via layered run-dir with prefer-pinned executor Replace stage-to-cwd + node_modules-replace with a per-run layered tree: the CLI-owned executor resolves from an inner exec/node_modules hop (always winning over any project copy) and the flow's own deps from an outer hop (symlinked from the project or installed with executors stripped). Fixes the regression where the managed runtime dropped the flow's own declared dependencies. Channel-key the managed runtime hash so the binary's CJS shims never corrupt the Node channel. --- src/commands/flows/hybridRun.test.ts | 104 ++++++++ src/commands/flows/hybridRunDefaults.ts | 58 ++-- src/commands/flows/runDefaults.handle.test.ts | 60 +++++ .../flows/runDefaults.reporterWiring.test.ts | 5 + src/commands/flows/runDefaults.ts | 52 ++-- src/domains/flows/stageFlows.test.ts | 159 ----------- src/domains/flows/stageFlows.ts | 87 ------ .../runtimeEnv/clearRuntimeEnv.test.ts | 9 +- src/domains/runtimeEnv/index.ts | 1 - .../runtimeEnv/linkManagedDeps.test.ts | 99 ------- src/domains/runtimeEnv/linkManagedDeps.ts | 46 ---- src/domains/runtimeEnv/managedEnvDir.test.ts | 75 +++++- src/domains/runtimeEnv/managedEnvDir.ts | 26 +- src/domains/runtimeEnv/outerHop.ts | 133 ++++++++++ src/domains/runtimeEnv/prepareRunDir.test.ts | 250 ++++++++++++++++++ src/domains/runtimeEnv/prepareRunDir.ts | 106 ++++++++ src/domains/runtimeEnv/symlinkDir.ts | 14 + 17 files changed, 831 insertions(+), 453 deletions(-) delete mode 100644 src/domains/flows/stageFlows.test.ts delete mode 100644 src/domains/flows/stageFlows.ts delete mode 100644 src/domains/runtimeEnv/linkManagedDeps.test.ts delete mode 100644 src/domains/runtimeEnv/linkManagedDeps.ts create mode 100644 src/domains/runtimeEnv/outerHop.ts create mode 100644 src/domains/runtimeEnv/prepareRunDir.test.ts create mode 100644 src/domains/runtimeEnv/prepareRunDir.ts create mode 100644 src/domains/runtimeEnv/symlinkDir.ts diff --git a/src/commands/flows/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index 1f8ad6e9a..1dd6bab21 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable eslint/max-lines -- adding the project-staging coverage pushed this past 250; splitting the handleHybridFlowsRun suite would fragment its coverage story import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import type { AuthCommandContext } from "~/shell/commandContext.js"; import { makeFakeUI } from "~/shell/commandContext.testUtils.js"; @@ -17,6 +18,7 @@ afterEach(() => { const expandPatternsMock = mock(); const pullEnvMock = mock(); const resolveDepsRootMock = mock(); +const prepareRunDirMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<() => Promise>(); @@ -25,6 +27,7 @@ const trackedMocks = [ expandPatternsMock, pullEnvMock, resolveDepsRootMock, + prepareRunDirMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -39,6 +42,11 @@ beforeEach(() => { source: "project", installed: false, }); + prepareRunDirMock.mockResolvedValue({ + files: [], + runDir: "/mock/run", + cleanup: async () => {}, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({} as unknown); @@ -79,6 +87,7 @@ function makeDeps(): HandleHybridFlowsRunDeps { expandPatterns: expandPatternsMock, pullEnv: pullEnvMock, resolveDepsRoot: resolveDepsRootMock, + prepareRunDir: prepareRunDirMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -108,6 +117,11 @@ describe("handleHybridFlowsRun", () => { expandPatternsMock.mockResolvedValue([ "/mock/.qawolf/my-env/login.flow.ts", ]); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/.qawolf/my-env/login.flow.ts"], + runDir: "/mock/run", + cleanup: async () => {}, + }); await handleHybridFlowsRun( ctx, @@ -132,6 +146,83 @@ describe("handleHybridFlowsRun", () => { expect(flowsRunDeps?.logger).toBeDefined(); }); + it("calls prepareRunDir with expanded files, depsRoot, and a .runs runRoot", async () => { + const ctx = makeCtx(); + const deps = makeDeps(); + const envDir = "/mock/.qawolf/my-env"; + expandPatternsMock.mockResolvedValue([`${envDir}/login.flow.ts`]); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/managed", + source: "managed", + installed: true, + }); + + await handleHybridFlowsRun( + ctx, + "**/login.flow.ts", + { ...defaultFlags(), env: "my-env" }, + deps, + ); + + expect(prepareRunDirMock).toHaveBeenCalledWith({ + files: [`${envDir}/login.flow.ts`], + projectDir: undefined, + depsRoot: "/managed", + runRoot: expect.stringContaining(".runs") as unknown, + }); + }); + + it("passes staged files from prepareRunDir to flowsRun", async () => { + const ctx = makeCtx(); + const deps = makeDeps(); + expandPatternsMock.mockResolvedValue([ + "/mock/.qawolf/my-env/login.flow.ts", + ]); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/run/exec/login.flow.ts"], + runDir: "/mock/run", + cleanup: async () => {}, + }); + + await handleHybridFlowsRun( + ctx, + "**/login.flow.ts", + { ...defaultFlags(), env: "my-env" }, + deps, + ); + + expect(flowsRunMock).toHaveBeenCalledWith( + expect.anything(), + ["/mock/run/exec/login.flow.ts"], + expect.anything(), + expect.anything(), + ); + }); + + it("calls staged cleanup after flowsRun completes", async () => { + const ctx = makeCtx(); + const deps = makeDeps(); + expandPatternsMock.mockResolvedValue([ + "/mock/.qawolf/my-env/login.flow.ts", + ]); + const cleanup = mock<() => Promise>(); + cleanup.mockResolvedValue(undefined); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/.qawolf/my-env/login.flow.ts"], + runDir: "/mock/run", + cleanup, + }); + + await handleHybridFlowsRun( + ctx, + "**/login.flow.ts", + { ...defaultFlags(), env: "my-env" }, + deps, + ); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it("pulls env on cache miss and runs matched files", async () => { const ctx = makeCtx(); const deps = makeDeps(); @@ -139,6 +230,11 @@ describe("handleHybridFlowsRun", () => { .mockResolvedValueOnce([]) .mockResolvedValueOnce(["/mock/.qawolf/my-env/login.flow.ts"]); pullEnvMock.mockResolvedValue(undefined); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/.qawolf/my-env/login.flow.ts"], + runDir: "/mock/run", + cleanup: async () => {}, + }); await handleHybridFlowsRun( ctx, @@ -202,6 +298,14 @@ describe("handleHybridFlowsRun", () => { "/mock/.qawolf/my-env/a.flow.ts", "/mock/.qawolf/my-env/b.flow.ts", ]); + prepareRunDirMock.mockResolvedValue({ + files: [ + "/mock/.qawolf/my-env/a.flow.ts", + "/mock/.qawolf/my-env/b.flow.ts", + ], + runDir: "/mock/run", + cleanup: async () => {}, + }); await handleHybridFlowsRun( ctx, diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 6d9ab0728..44c94e1ef 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -1,31 +1,34 @@ import { join, resolve } from "node:path"; -import { validateEnvId } from "~/domains/flows/pull/pull.js"; +import { buildPatternArgs } from "~/core/patternArgs.js"; +import { runnerMessages } from "~/core/messages/index.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; import { handleFlowsPull } from "~/domains/flows/pull/handler.js"; +import { validateEnvId } from "~/domains/flows/pull/pull.js"; +import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; +import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; +import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; +import type { EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; +import { managedEnvBaseDir } from "~/domains/runtimeEnv/managedEnvDir.js"; +import { + prepareRunDir as defaultPrepareRunDir, + type PrepareRunDirArgs, + type PrepareRunDirResult, +} from "~/domains/runtimeEnv/prepareRunDir.js"; import type { AuthCommandContext, 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 type { Fs } from "~/shell/fs.js"; +import type { Logger } from "~/shell/logger.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { - type EnsureRuntimeEnvResult, - linkManagedDeps, -} from "~/domains/runtimeEnv/index.js"; import { resolveDepsRoot, type ResolveDepsRootArgs, } from "~/commands/resolveDepsRoot.js"; -import type { Fs } from "~/shell/fs.js"; -import type { Logger } from "~/shell/logger.js"; -import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { 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"; @@ -39,6 +42,9 @@ export type HandleHybridFlowsRunDeps = { resolveDepsRoot: ( args: Omit, ) => Promise; + prepareRunDir: ( + args: Omit, + ) => Promise; configureTestkit: (dir: string) => Promise; flowsRun: typeof defaultFlowsRun; runWebFlowDeps: typeof defaultRunWebFlowDeps; @@ -50,6 +56,7 @@ function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { defaultExpandPatterns(patterns, cwd, logger, fs), pullEnv: (ctx, envId) => handleFlowsPull(ctx, { env: envId, yes: true }), resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), + prepareRunDir: (args) => defaultPrepareRunDir({ ...args, fs }), configureTestkit: defaultConfigureTestkit, flowsRun: defaultFlowsRun, runWebFlowDeps: defaultRunWebFlowDeps, @@ -70,7 +77,7 @@ export async function handleHybridFlowsRun( const envDir = resolve(join(".qawolf", flags.env)); const patternArgs = buildPatternArgs(pattern); - const globFlows = () => + const globFlows = (): Promise => resolvedDeps.expandPatterns(patternArgs, envDir, ctx.log("flows")); let files = await globFlows(); @@ -113,15 +120,12 @@ export async function handleHybridFlowsRun( await loadEnvFile(envDir); const projectDir = resolveProjectDirSafe(files, ctx.fs); - const staged = await stageFlows({ + const staged = await resolvedDeps.prepareRunDir({ files, projectDir, - cwd: process.cwd(), - fs: ctx.fs, + depsRoot: runtimeEnv.depsRoot, + runRoot: join(managedEnvBaseDir(), ".runs"), }); - if (runtimeEnv.source !== "project" && staged.bundleRoot !== undefined) { - await linkManagedDeps(staged.bundleRoot, runtimeEnv.depsRoot, ctx.fs); - } const resolvedDir = runtimeEnv.depsRoot; await resolvedDeps.configureTestkit(resolvedDir); @@ -131,9 +135,7 @@ export async function handleHybridFlowsRun( ctx.signals, ); - const unregisterCleanup = staged.cleanup - ? ctx.signals.register(staged.cleanup) - : undefined; + const unregisterCleanup = ctx.signals.register(staged.cleanup); try { return await resolvedDeps.flowsRun( ctx, @@ -142,7 +144,7 @@ export async function handleHybridFlowsRun( buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), ); } finally { - unregisterCleanup?.(); - await staged.cleanup?.(); + unregisterCleanup(); + await staged.cleanup(); } } diff --git a/src/commands/flows/runDefaults.handle.test.ts b/src/commands/flows/runDefaults.handle.test.ts index dbcaeb6bb..044a35d24 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable eslint/max-lines -- prepareRunDir wiring tests added past 250; splitting the handleFlowsRun suite would fragment coverage import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { CommandContext } from "~/shell/commandContext.js"; import { makeFakeUI } from "~/shell/commandContext.testUtils.js"; @@ -14,6 +15,7 @@ const noopSignals = makeNoopSignals(); const expandPatternsMock = mock(); const resolveDepsRootMock = mock(); +const prepareRunDirMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<(...args: unknown[]) => Promise>(); @@ -24,6 +26,7 @@ const uiNoteMock = mock<(message: string, title?: string) => void>(); const trackedMocks = [ expandPatternsMock, resolveDepsRootMock, + prepareRunDirMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -36,6 +39,7 @@ function makeDeps(): HandleFlowsRunDeps { return { expandPatterns: expandPatternsMock, resolveDepsRoot: resolveDepsRootMock, + prepareRunDir: prepareRunDirMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -84,6 +88,11 @@ beforeEach(() => { source: "project", installed: false, }); + prepareRunDirMock.mockResolvedValue({ + files: [], + runDir: "/mock/run", + cleanup: async () => {}, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({}); @@ -222,4 +231,55 @@ describe("handleFlowsRun", () => { expect(uiIntroMock).not.toHaveBeenCalled(); }); + + it("calls prepareRunDir with expanded files, depsRoot, and a .runs runRoot", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/managed", + source: "managed", + installed: true, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(prepareRunDirMock).toHaveBeenCalledWith({ + files: ["/some/flow.ts"], + projectDir: undefined, + depsRoot: "/managed", + runRoot: expect.stringContaining(".runs") as unknown, + }); + }); + + it("passes staged files from prepareRunDir to flowsRun", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/run/exec/flow.ts"], + runDir: "/mock/run", + cleanup: async () => {}, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(flowsRunMock).toHaveBeenCalledWith( + expect.anything(), + ["/mock/run/exec/flow.ts"], + expect.anything(), + expect.anything(), + ); + }); + + it("calls staged cleanup after flowsRun completes", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + const cleanup = mock<() => Promise>(); + cleanup.mockResolvedValue(undefined); + prepareRunDirMock.mockResolvedValue({ + files: ["/some/flow.ts"], + runDir: "/mock/run", + cleanup, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/commands/flows/runDefaults.reporterWiring.test.ts b/src/commands/flows/runDefaults.reporterWiring.test.ts index ed0051713..182b5d0a0 100644 --- a/src/commands/flows/runDefaults.reporterWiring.test.ts +++ b/src/commands/flows/runDefaults.reporterWiring.test.ts @@ -67,6 +67,11 @@ function makeDeps( source: "project" as const, installed: false, }), + prepareRunDir: async (args) => ({ + files: args.files, + runDir: "/mock/run", + cleanup: async () => {}, + }), configureTestkit: async () => {}, flowsRun: flowsRun as HandleFlowsRunDeps["flowsRun"], runWebFlowDeps: (async () => diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index ced4e4aff..df86919a7 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -1,26 +1,29 @@ -import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; -import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { join } from "node:path"; + import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.js"; -import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; - import { pluralize } from "~/core/pluralize.js"; import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; -import { stageFlows } from "~/domains/flows/stageFlows.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; +import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; +import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; +import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; +import type { EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; +import { managedEnvBaseDir } from "~/domains/runtimeEnv/managedEnvDir.js"; import { - type EnsureRuntimeEnvResult, - linkManagedDeps, -} from "~/domains/runtimeEnv/index.js"; + prepareRunDir as defaultPrepareRunDir, + type PrepareRunDirArgs, + type PrepareRunDirResult, +} from "~/domains/runtimeEnv/prepareRunDir.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import type { Fs } from "~/shell/fs.js"; +import type { Logger } from "~/shell/logger.js"; +import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { resolveDepsRoot, type ResolveDepsRootArgs, } from "~/commands/resolveDepsRoot.js"; -import type { Fs } from "~/shell/fs.js"; -import type { Logger } from "~/shell/logger.js"; -import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { 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"; @@ -34,6 +37,9 @@ export type HandleFlowsRunDeps = { resolveDepsRoot: ( args: Omit, ) => Promise; + prepareRunDir: ( + args: Omit, + ) => Promise; configureTestkit: (dir: string) => Promise; runWebFlowDeps: typeof defaultRunWebFlowDeps; flowsRun: typeof defaultFlowsRun; @@ -44,6 +50,7 @@ function makeDefaultDeps(fs: Fs): HandleFlowsRunDeps { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), + prepareRunDir: (args) => defaultPrepareRunDir({ ...args, fs }), configureTestkit: defaultConfigureTestkit, runWebFlowDeps: defaultRunWebFlowDeps, flowsRun: defaultFlowsRun, @@ -98,15 +105,12 @@ export async function handleFlowsRun( const projectDir = resolveProjectDirSafe(expandedFiles, ctx.fs); await loadEnvFile(projectDir ?? cwd); - const staged = await stageFlows({ + const staged = await resolvedDeps.prepareRunDir({ files: expandedFiles, projectDir, - cwd, - fs: ctx.fs, + depsRoot: runtimeEnv.depsRoot, + runRoot: join(managedEnvBaseDir(), ".runs"), }); - if (runtimeEnv.source !== "project" && staged.bundleRoot !== undefined) { - await linkManagedDeps(staged.bundleRoot, runtimeEnv.depsRoot, ctx.fs); - } const resolvedDir = runtimeEnv.depsRoot; @@ -116,9 +120,7 @@ export async function handleFlowsRun( resolvedDir, ctx.signals, ); - const unregisterCleanup = staged.cleanup - ? ctx.signals.register(staged.cleanup) - : undefined; + const unregisterCleanup = ctx.signals.register(staged.cleanup); try { return await resolvedDeps.flowsRun( ctx, @@ -127,7 +129,7 @@ export async function handleFlowsRun( buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), ); } finally { - unregisterCleanup?.(); - await staged.cleanup?.(); + unregisterCleanup(); + await staged.cleanup(); } } diff --git a/src/domains/flows/stageFlows.test.ts b/src/domains/flows/stageFlows.test.ts deleted file mode 100644 index 55bffdebb..000000000 --- a/src/domains/flows/stageFlows.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { realpathSync } from "node:fs"; -import { - mkdir, - mkdtemp, - readFile, - 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); - expect( - 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 deleted file mode 100644 index 460e0fc52..000000000 --- a/src/domains/flows/stageFlows.ts +++ /dev/null @@ -1,87 +0,0 @@ -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; - // 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"]); - -/** - * 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 }; - } - - // 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, - cleanup: () => fs.rm(stagedDir, { recursive: true, force: true }), - }; -} - -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/runtimeEnv/clearRuntimeEnv.test.ts b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts index 1bda707c2..26e718f08 100644 --- a/src/domains/runtimeEnv/clearRuntimeEnv.test.ts +++ b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts @@ -48,7 +48,14 @@ describe("clearRuntimeEnv", () => { await fs.mkdir(`${override}/src`, { recursive: true }); await fs.writeFile(`${override}/package.json`, '{"name":"my-app"}'); - expect(clearRuntimeEnv(fs)).rejects.toThrow("Refusing to delete"); + let caughtError: unknown; + try { + await clearRuntimeEnv(fs); + } catch (e) { + caughtError = e; + } + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toContain("Refusing to delete"); expect(await fs.pathExists(override)).toBe(true); }); diff --git a/src/domains/runtimeEnv/index.ts b/src/domains/runtimeEnv/index.ts index b84917735..3ccf77f3c 100644 --- a/src/domains/runtimeEnv/index.ts +++ b/src/domains/runtimeEnv/index.ts @@ -1,5 +1,4 @@ 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 deleted file mode 100644 index 8b7b68418..000000000 --- a/src/domains/runtimeEnv/linkManagedDeps.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 3b933fc44..000000000 --- a/src/domains/runtimeEnv/linkManagedDeps.ts +++ /dev/null @@ -1,46 +0,0 @@ -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; - } - - // 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 { - try { - return await lstat(path); - } catch { - return undefined; - } -} diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts index 719405258..184b12012 100644 --- a/src/domains/runtimeEnv/managedEnvDir.test.ts +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -7,19 +7,92 @@ import { pinnedPackages } from "./pinnedPackages.js"; import { managedEnvDir, managedEnvHash, + runtimeChannel, scaffoldManagedEnv, } from "./managedEnvDir.js"; +describe("runtimeChannel", () => { + const priorCompiled = process.env["QAWOLF_COMPILED"]; + + afterEach(() => { + if (priorCompiled === undefined) { + delete process.env["QAWOLF_COMPILED"]; + } else { + process.env["QAWOLF_COMPILED"] = priorCompiled; + } + }); + + it('returns "binary" when QAWOLF_COMPILED is "true"', () => { + process.env["QAWOLF_COMPILED"] = "true"; + expect(runtimeChannel()).toBe("binary"); + }); + + it('returns "node" when QAWOLF_COMPILED is unset', () => { + delete process.env["QAWOLF_COMPILED"]; + expect(runtimeChannel()).toBe("node"); + }); + + it('returns "node" when QAWOLF_COMPILED is any other value', () => { + process.env["QAWOLF_COMPILED"] = "false"; + expect(runtimeChannel()).toBe("node"); + }); +}); + describe("managedEnvHash", () => { + const priorCompiled = process.env["QAWOLF_COMPILED"]; + + afterEach(() => { + if (priorCompiled === undefined) { + delete process.env["QAWOLF_COMPILED"]; + } else { + process.env["QAWOLF_COMPILED"] = priorCompiled; + } + }); + 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", () => { + it("is stable within the node channel", () => { + delete process.env["QAWOLF_COMPILED"]; + expect(managedEnvHash()).toBe(managedEnvHash()); + }); + + it("is stable within the binary channel", () => { + process.env["QAWOLF_COMPILED"] = "true"; expect(managedEnvHash()).toBe(managedEnvHash()); }); + + it("differs between node and binary channels", () => { + delete process.env["QAWOLF_COMPILED"]; + const nodeHash = managedEnvHash(); + + process.env["QAWOLF_COMPILED"] = "true"; + const binaryHash = managedEnvHash(); + + expect(nodeHash).not.toBe(binaryHash); + }); + + it("changes when a pinned package version changes", () => { + delete process.env["QAWOLF_COMPILED"]; + const baseline = managedEnvHash(); + + // Temporarily mutate the first pinned package version to simulate a version bump. + const first = pinnedPackages[0]; + if (!first) + throw new Error( + "pinnedPackages is empty — cannot test version sensitivity", + ); + const originalVersion = first.version; + try { + (first as { version: string }).version = originalVersion + "-modified"; + expect(managedEnvHash()).not.toBe(baseline); + } finally { + (first as { version: string }).version = originalVersion; + } + }); }); describe("managedEnvDir", () => { diff --git a/src/domains/runtimeEnv/managedEnvDir.ts b/src/domains/runtimeEnv/managedEnvDir.ts index 60b6e5688..e942695cf 100644 --- a/src/domains/runtimeEnv/managedEnvDir.ts +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -7,14 +7,28 @@ 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. + * Identifies the runtime channel: "binary" when running inside the compiled + * Bun binary (QAWOLF_COMPILED injected via --define), "node" otherwise. + * The compiled binary writes CJS shims that break Node.js named imports, so + * each channel must have its own isolated managed runtime directory. + */ +export function runtimeChannel(): "node" | "binary" { + return process.env.QAWOLF_COMPILED === "true" ? "binary" : "node"; +} + +/** + * Deterministic 16-hex-char SHA-256 digest of the pinned package specs plus + * the runtime channel. A new hash is produced whenever any pinned version + * changes or when switching between the Node and compiled-binary channels, + * so each combination gets its own isolated install directory. Channel + * isolation prevents CJS shims written by the binary from corrupting the + * Node.js runtime and vice versa. */ export function managedEnvHash(): string { - const content = pinnedPackages - .map(({ name, version }) => `${name}@${version}`) - .join("\n"); + const content = [ + ...pinnedPackages.map(({ name, version }) => `${name}@${version}`), + `channel:${runtimeChannel()}`, + ].join("\n"); return createHash("sha256").update(content).digest("hex").slice(0, 16); } diff --git a/src/domains/runtimeEnv/outerHop.ts b/src/domains/runtimeEnv/outerHop.ts new file mode 100644 index 000000000..235027253 --- /dev/null +++ b/src/domains/runtimeEnv/outerHop.ts @@ -0,0 +1,133 @@ +import { type Stats } from "node:fs"; +import { lstat } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; + +import { type Fs } from "~/shell/fs.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { createDirSymlink } from "./symlinkDir.js"; + +const executorPackageNames = new Set(pinnedPackages.map((p) => p.name)); + +export type PopulateOuterHopArgs = { + projectDir: string | undefined; + runDir: string; + fs: Fs; +}; + +/** + * Populates the outer hop (`runDir/node_modules`) with the project's own + * dependencies so flow files can resolve non-executor packages. Executor + * packages are stripped to ensure the inner hop always wins for those. + * No-ops when `projectDir` is undefined or no installable deps are found. + */ +export async function populateOuterHop( + args: PopulateOuterHopArgs, +): Promise { + const { projectDir, runDir, fs } = args; + if (projectDir === undefined) return; + + const nearestNm = await findNearestNodeModulesDir(projectDir); + if (nearestNm !== undefined) { + await createDirSymlink(nearestNm, join(runDir, "node_modules")); + return; + } + + const installableDeps = await readInstallableDeps(projectDir, fs); + if (Object.keys(installableDeps).length > 0) { + await installOuterDeps(runDir, installableDeps, fs); + } +} + +async function findNearestNodeModulesDir( + startDir: string, +): Promise { + let dir = startDir; + while (true) { + const candidate = join(dir, "node_modules"); + const stats = await lstatSafe(candidate); + if (stats !== undefined && stats.isDirectory() && !stats.isSymbolicLink()) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +async function lstatSafe(path: string): Promise { + try { + return await lstat(path); + } catch { + return undefined; + } +} + +async function readInstallableDeps( + projectDir: string, + fs: Fs, +): Promise> { + let content: string; + try { + content = await fs.readFile(join(projectDir, "package.json")); + } catch { + return {}; + } + + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== "object" || parsed === null) return {}; + + const rawDeps = (parsed as Record)["dependencies"]; + if (typeof rawDeps !== "object" || rawDeps === null) return {}; + + return Object.fromEntries( + Object.entries(rawDeps as Record) + .filter(([name]) => !executorPackageNames.has(name)) + .filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +async function installOuterDeps( + runDir: string, + deps: Record, + fs: Fs, +): Promise { + await fs.writeFile( + join(runDir, "package.json"), + JSON.stringify( + { name: "qawolf-run", private: true, dependencies: deps }, + undefined, + 2, + ), + ); + await fs.writeFile( + join(runDir, ".npmrc"), + "@qawolf:registry=https://registry.npmjs.org/\n", + ); + + const result = await spawnNpmInstall(runDir); + if (result.exitCode !== 0) { + throw new Error( + `Failed to install project dependencies into ${runDir}: ${result.stderr.trim()}`, + ); + } +} + +type SpawnInstallResult = { exitCode: number; stderr: string }; + +function spawnNpmInstall(cwd: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn("npm", ["install", "--legacy-peer-deps"], { cwd }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += String(chunk); + }); + child.on("error", () => resolvePromise({ exitCode: -1, stderr })); + child.on("close", (code) => + resolvePromise({ exitCode: code ?? -1, stderr }), + ); + }); +} diff --git a/src/domains/runtimeEnv/prepareRunDir.test.ts b/src/domains/runtimeEnv/prepareRunDir.test.ts new file mode 100644 index 000000000..13e062a82 --- /dev/null +++ b/src/domains/runtimeEnv/prepareRunDir.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { prepareRunDir } from "./prepareRunDir.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-rundir-test-"))); + tmpDirs.push(d); + return d; +} + +async function makeDepsRoot(): Promise { + const depsRoot = await makeTmpDir(); + await mkdir(join(depsRoot, "node_modules"), { recursive: true }); + await writeFile(join(depsRoot, "node_modules", "executor.txt"), "executor"); + return depsRoot; +} + +describe("prepareRunDir", () => { + describe("inner-hop symlink", () => { + it("symlinks exec/node_modules to depsRoot/node_modules", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const srcDir = await makeTmpDir(); + const flowFile = join(srcDir, "flow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir: undefined, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + const innerHop = join(result.runDir, "exec", "node_modules"); + expect((await lstat(innerHop)).isSymbolicLink()).toBe(true); + expect(await readlink(innerHop)).toBe(join(depsRoot, "node_modules")); + }); + }); + + describe("outer-hop population", () => { + it("symlinks runDir/node_modules to the nearest ancestor node_modules when projectDir is given", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + + const projectDir = await makeTmpDir(); + const projectNm = join(projectDir, "node_modules"); + await mkdir(projectNm, { recursive: true }); + await writeFile(join(projectNm, "project-dep.txt"), "project dep"); + + const flowFile = join(projectDir, "flow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + const outerHop = join(result.runDir, "node_modules"); + expect((await lstat(outerHop)).isSymbolicLink()).toBe(true); + expect(await readlink(outerHop)).toBe(projectNm); + }); + + it("leaves outer hop absent when no projectDir or no installable deps", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + + // Case 1: no projectDir + const srcDir = await makeTmpDir(); + const flowFile1 = join(srcDir, "flow.ts"); + await writeFile(flowFile1, "// flow"); + + const result1 = await prepareRunDir({ + files: [flowFile1], + projectDir: undefined, + depsRoot, + runRoot, + }); + tmpDirs.push(result1.runDir); + expect(lstat(join(result1.runDir, "node_modules"))).rejects.toThrow(); + + // Case 2: projectDir with no node_modules and no package.json deps + const projectDir = await makeTmpDir(); + const flowFile2 = join(projectDir, "flow.ts"); + await writeFile(flowFile2, "// flow"); + + const result2 = await prepareRunDir({ + files: [flowFile2], + projectDir, + depsRoot, + runRoot, + }); + tmpDirs.push(result2.runDir); + expect(lstat(join(result2.runDir, "node_modules"))).rejects.toThrow(); + }); + }); + + describe("exec staging", () => { + it("copies individual flow files into exec/ when no projectDir", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const srcDir = await makeTmpDir(); + const flowFile = join(srcDir, "myFlow.ts"); + await writeFile(flowFile, "// my flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir: undefined, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + const stagedFlow = join(result.runDir, "exec", "myFlow.ts"); + expect((await lstat(stagedFlow)).isFile()).toBe(true); + expect(result.files).toEqual([stagedFlow]); + }); + + it("copies entire projectDir into exec/ and remaps flow paths", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const projectDir = await makeTmpDir(); + await writeFile(join(projectDir, "helper.ts"), "// helper"); + const flowFile = join(projectDir, "myFlow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + const execDir = join(result.runDir, "exec"); + expect((await lstat(join(execDir, "helper.ts"))).isFile()).toBe(true); + expect((await lstat(join(execDir, "myFlow.ts"))).isFile()).toBe(true); + expect(result.files).toEqual([join(execDir, "myFlow.ts")]); + }); + + it("excludes node_modules from the projectDir copy", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const projectDir = await makeTmpDir(); + const projectNm = join(projectDir, "node_modules"); + await mkdir(projectNm, { recursive: true }); + await writeFile(join(projectNm, "pkg.txt"), "pkg"); + const flowFile = join(projectDir, "flow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + // exec/node_modules should be the inner-hop symlink, not a copy of project nm + const execNm = join(result.runDir, "exec", "node_modules"); + expect((await lstat(execNm)).isSymbolicLink()).toBe(true); + expect(await readlink(execNm)).toBe(join(depsRoot, "node_modules")); + }); + }); + + describe("cleanup", () => { + it("removes the runDir on cleanup()", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const srcDir = await makeTmpDir(); + const flowFile = join(srcDir, "flow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir: undefined, + depsRoot, + runRoot, + }); + + await result.cleanup(); + + expect(lstat(result.runDir)).rejects.toThrow(); + }); + }); + + describe("prefer-pinned invariant", () => { + it("executor (inner hop) wins over project copy (outer hop) by path walk-up order", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeTmpDir(); + + // Inner hop: depsRoot/node_modules has the executor copy + const innerNm = join(depsRoot, "node_modules"); + await mkdir(join(innerNm, "@qawolf"), { recursive: true }); + await writeFile(join(innerNm, "@qawolf", "flows.txt"), "executor-copy"); + + // Outer hop: projectDir/node_modules has a project copy + const projectDir = await makeTmpDir(); + const projectNm = join(projectDir, "node_modules"); + await mkdir(join(projectNm, "@qawolf"), { recursive: true }); + await writeFile(join(projectNm, "@qawolf", "flows.txt"), "project-copy"); + + const flowFile = join(projectDir, "flow.ts"); + await writeFile(flowFile, "// flow"); + + const result = await prepareRunDir({ + files: [flowFile], + projectDir, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + const innerHop = join(result.runDir, "exec", "node_modules"); + expect(await readlink(innerHop)).toBe(innerNm); + + const outerHop = join(result.runDir, "node_modules"); + expect(await readlink(outerHop)).toBe(projectNm); + + // A flow file under exec/ resolves @qawolf/flows from the inner hop, + // which is closer in the walk-up than the outer hop. + const execFlowsFile = join(innerHop, "@qawolf", "flows.txt"); + expect(await readFile(execFlowsFile, "utf-8")).toBe("executor-copy"); + }); + }); +}); diff --git a/src/domains/runtimeEnv/prepareRunDir.ts b/src/domains/runtimeEnv/prepareRunDir.ts new file mode 100644 index 000000000..56656db27 --- /dev/null +++ b/src/domains/runtimeEnv/prepareRunDir.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; +import { basename, join, resolve, sep } from "node:path"; + +import { copyDirExcluding } from "~/shell/copyDir.js"; +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; + +import { populateOuterHop } from "./outerHop.js"; +import { createDirSymlink } from "./symlinkDir.js"; + +const excludedDirs = new Set(["node_modules", ".git", ".qawolf"]); + +export type PrepareRunDirArgs = { + files: string[]; + projectDir: string | undefined; + depsRoot: string; + runRoot: string; + // Pass a custom Fs for testing file operations; defaults to the real fs. + fs?: Fs; +}; + +export type PrepareRunDirResult = { + files: string[]; + runDir: string; + cleanup: () => Promise; +}; + +/** + * Builds a per-run layered node_modules directory so a flow resolves the + * CLI-owned executor from a pinned inner hop (`exec/node_modules`) and the + * flow's own project dependencies from an outer hop (`runDir/node_modules`), + * with the executor always winning over any project copy (prefer-pinned). + */ +export async function prepareRunDir( + args: PrepareRunDirArgs, +): Promise { + const { files, projectDir, depsRoot, runRoot } = args; + const fs = args.fs ?? makeDefaultFs(); + + const runId = buildRunId(projectDir, files); + const runDir = join(runRoot, runId); + + await fs.rm(runDir, { recursive: true, force: true }); + await fs.mkdir(runDir, { recursive: true }); + + const execDir = join(runDir, "exec"); + await fs.mkdir(execDir, { recursive: true }); + + // Inner hop: executor deps (playwright, @qawolf/flows, etc.) resolve from here. + await createDirSymlink( + join(depsRoot, "node_modules"), + join(execDir, "node_modules"), + ); + + const stagedFiles = await stageFlowFiles({ files, projectDir, execDir, fs }); + + await populateOuterHop({ projectDir, runDir, fs }); + + return { + files: stagedFiles, + runDir, + cleanup: () => fs.rm(runDir, { recursive: true, force: true }), + }; +} + +function buildRunId(projectDir: string | undefined, files: string[]): string { + const seedPath = projectDir ?? files[0]; + if (seedPath === undefined) { + throw new Error( + "prepareRunDir: files must be non-empty when projectDir is undefined", + ); + } + const hash = createHash("sha256") + .update(resolve(seedPath)) + .digest("hex") + .slice(0, 16); + return `${hash}-${process.pid}`; +} + +type StageFlowFilesArgs = { + files: string[]; + projectDir: string | undefined; + execDir: string; + fs: Fs; +}; + +async function stageFlowFiles(args: StageFlowFilesArgs): Promise { + const { files, projectDir, execDir, fs } = args; + + if (projectDir !== undefined) { + await copyDirExcluding(projectDir, execDir, excludedDirs); + return files.map((f) => remapPath(f, projectDir, execDir)); + } + + await Promise.all( + files.map((f) => fs.copyFile(f, join(execDir, basename(f)))), + ); + return files.map((f) => join(execDir, basename(f))); +} + +function remapPath(file: string, projectDir: string, execDir: string): string { + if (file === projectDir) return execDir; + if (file.startsWith(projectDir + sep)) { + return join(execDir, file.slice(projectDir.length + 1)); + } + return file; +} diff --git a/src/domains/runtimeEnv/symlinkDir.ts b/src/domains/runtimeEnv/symlinkDir.ts new file mode 100644 index 000000000..fe726b58f --- /dev/null +++ b/src/domains/runtimeEnv/symlinkDir.ts @@ -0,0 +1,14 @@ +import { symlink } from "node:fs/promises"; + +/** + * Creates a directory symlink (or Windows junction) at `target` pointing to + * `source`. Prefers junction on win32 to avoid requiring elevated privileges + * or Developer Mode. + */ +export async function createDirSymlink( + source: string, + target: string, +): Promise { + const linkType = process.platform === "win32" ? "junction" : "dir"; + await symlink(source, target, linkType); +} From 2ad2ff67e4c0a6c76457f8113f5db026d1e6d949 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:46:45 -0400 Subject: [PATCH 15/32] fix(runner): share executor instance in binary bundle and surface flow-failure causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Externalize @qawolf/flows/testkit/emails from the compiled-binary flow bundle to their absolute installed paths (via an onLoad+onResolve plugin) so the flow and the runner share one AsyncLocalStorage instance — fixes 'page undefined' on the binary channel. Thread depsRoot into loadFlowDefault for the resolution. Surface load-time failures (runtime init / flow load) as flow results carrying the cause, and emit the full cause chain in the JUnit reporter via a shared formatErrorWithCause helper. --- src/domains/runner/bundleFlow.ts | 89 +++++++++++++++++++ src/domains/runner/executorPlugin.ts | 82 +++++++++++++++++ src/domains/runner/loadFlowDefault.test.ts | 77 +++++++++++++++- src/domains/runner/loadFlowDefault.ts | 76 ++-------------- .../runAndroidFlow.noDefault.fixture.ts | 1 + src/domains/runner/runAndroidFlow.test.ts | 14 +++ src/domains/runner/runAndroidFlow.ts | 32 +++++-- src/domains/runner/runWebFlow.test.ts | 25 +++--- src/domains/runner/runWebFlow.ts | 27 ++++-- src/shell/reporter/createConsoleReporter.ts | 63 ++++--------- .../reporter/createJUnitReporter.test.ts | 27 ++++++ src/shell/reporter/createJUnitReporter.ts | 3 +- src/shell/reporter/formatErrorWithCause.ts | 42 +++++++++ 13 files changed, 411 insertions(+), 147 deletions(-) create mode 100644 src/domains/runner/bundleFlow.ts create mode 100644 src/domains/runner/executorPlugin.ts create mode 100644 src/domains/runner/runAndroidFlow.noDefault.fixture.ts create mode 100644 src/shell/reporter/formatErrorWithCause.ts diff --git a/src/domains/runner/bundleFlow.ts b/src/domains/runner/bundleFlow.ts new file mode 100644 index 000000000..5242d6724 --- /dev/null +++ b/src/domains/runner/bundleFlow.ts @@ -0,0 +1,89 @@ +import { makeDefaultFs } from "~/shell/fs.js"; +import { + type BunPlugin, + createExternalizeExecutorPlugin, +} from "./executorPlugin.js"; + +// 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. +// depsRoot is the executor root whose node_modules holds @qawolf/flows etc. +export type FlowBundler = ( + flowPath: string, + depsRoot: 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[]; + plugins?: BunPlugin[]; +}) => Promise; + +function getBunBuild(): BunBuild | undefined { + return (globalThis as { Bun?: { build?: BunBuild } }).Bun?.build; +} + +/** + * Compiled Bun binaries cannot resolve exports-map bare specifiers from + * external node_modules, but Bun.build (available inside the binary) can. + * Pre-bundles the flow so everything except the native browser drivers and + * executor packages is inlined. Executor packages are externalized to absolute + * on-disk paths so the runner and the flow share one AsyncLocalStorage instance. + */ +export async function defaultFlowBundler( + flowPath: string, + depsRoot: string, +): Promise { + const build = getBunBuild(); + if (build === undefined) + throw new Error("Cannot bundle flow: Bun.build is unavailable"); + + const fs = makeDefaultFs(); + + // 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, + plugins: [createExternalizeExecutorPlugin(depsRoot, fs)], + }); + } 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(); +} diff --git a/src/domains/runner/executorPlugin.ts b/src/domains/runner/executorPlugin.ts new file mode 100644 index 000000000..0137d8dfd --- /dev/null +++ b/src/domains/runner/executorPlugin.ts @@ -0,0 +1,82 @@ +import path from "node:path"; + +import { type Fs } from "~/shell/fs.js"; +import { resolveFromEnvDir } from "~/shell/resolveExport.js"; + +type BunOnResolveResult = { path: string; external?: boolean } | undefined; + +type BunOnLoadResult = + | { contents: string; loader: "js" | "ts" | "jsx" | "tsx" } + | undefined; + +// Minimal typing for the build context passed to a plugin's setup() function. +type BunPluginBuildCtx = { + onResolve( + options: { filter: RegExp }, + callback: (args: { path: string }) => BunOnResolveResult, + ): void; + onLoad( + options: { filter: RegExp }, + callback: (args: { + path: string; + }) => Promise | BunOnLoadResult, + ): void; +}; + +export type BunPlugin = { + name: string; + setup(build: BunPluginBuildCtx): void; +}; + +// Quick detection: does this source file contain any executor package imports? +const executorImportDetect = /from ['"]@qawolf\/(?:flows|testkit|emails)/; + +/** + * Builds a Bun.build plugin that rewrites @qawolf/flows (and subpaths), + * @qawolf/testkit, and @qawolf/emails imports to their absolute on-disk paths + * under depsRoot, then marks those absolute paths external so the compiled + * binary can import them at runtime without inlining their content. + * + * The two-step mechanism is required because Bun 1.3.x does not propagate a + * custom path through onResolve({ external: true }) — it keeps the original + * bare specifier, which the binary cannot resolve. Instead: (1) onLoad rewrites + * the import string to an absolute path; (2) onResolve intercepts the resulting + * absolute-path resolution and marks it external so Bun emits + * `import X from "/abs/path"` rather than inlining the content. + */ +export function createExternalizeExecutorPlugin( + depsRoot: string, + fs: Fs, +): BunPlugin { + // Populated by onLoad as imports are rewritten; consumed by onResolve. + const resolvedAbsPaths = new Set(); + + return { + name: "externalize-executor-packages", + setup(build) { + // Intercept every TS/JS source load, rewrite executor imports to absolute. + build.onLoad({ filter: /\.(ts|tsx|js|mjs|cjs)$/ }, async (args) => { + const source = await fs.readFile(args.path); + if (!executorImportDetect.test(source)) return undefined; + + const rewritten = source.replace( + /from\s+['"](@qawolf\/(?:flows|testkit|emails)[^'"]*)['"]/g, + (_, spec: string) => { + const absPath = resolveFromEnvDir(depsRoot, spec); + resolvedAbsPaths.add(absPath); + return `from ${JSON.stringify(absPath)}`; + }, + ); + const ext = path.extname(args.path); + const loader = ext === ".ts" || ext === ".tsx" ? "ts" : "js"; + return { contents: rewritten, loader }; + }); + + // Mark absolute executor paths external after onLoad has resolved them. + build.onResolve({ filter: /^\/.*\.(js|mjs|cjs|ts|tsx)$/ }, (args) => { + if (!resolvedAbsPaths.has(args.path)) return undefined; + return { path: args.path, external: true }; + }); + }, + }; +} diff --git a/src/domains/runner/loadFlowDefault.test.ts b/src/domains/runner/loadFlowDefault.test.ts index 42639c660..9a89dd81d 100644 --- a/src/domains/runner/loadFlowDefault.test.ts +++ b/src/domains/runner/loadFlowDefault.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathExists } from "~/shell/fs.js"; +import { defaultFlowBundler } from "./bundleFlow.js"; import { loadFlowDefault } from "./loadFlowDefault.js"; // ── Node path (direct import) ─────────────────────────────────────────────── @@ -92,3 +93,77 @@ describe("loadFlowDefault (bundle path)", () => { } }); }); + +// ── defaultFlowBundler (executor-package externalization) ─────────────────── + +describe("defaultFlowBundler", () => { + it("externalizes @qawolf/flows to an absolute path and inlines non-executor deps", async () => { + const depsRoot = await mkdtemp(path.join(tmpdir(), "flow-bundler-test-")); + try { + // Fake @qawolf/flows package with a ./web export + await mkdir( + path.join(depsRoot, "node_modules", "@qawolf", "flows", "dist"), + { recursive: true }, + ); + await writeFile( + path.join(depsRoot, "node_modules", "@qawolf", "flows", "package.json"), + JSON.stringify({ + name: "@qawolf/flows", + exports: { "./web": "./dist/web.js" }, + }), + ); + await writeFile( + path.join( + depsRoot, + "node_modules", + "@qawolf", + "flows", + "dist", + "web.js", + ), + `export const page = "FLOWS_WEB_MARKER";\n`, + ); + + // Non-executor dep that should remain inlined + await mkdir(path.join(depsRoot, "node_modules", "inline-dep"), { + recursive: true, + }); + await writeFile( + path.join(depsRoot, "node_modules", "inline-dep", "package.json"), + JSON.stringify({ name: "inline-dep", main: "./index.js" }), + ); + await writeFile( + path.join(depsRoot, "node_modules", "inline-dep", "index.js"), + `export const helper = "INLINE_DEP_MARKER";\n`, + ); + + // Flow that imports from both + const flowPath = path.join(depsRoot, "flow.ts"); + await writeFile( + flowPath, + `import { page } from "@qawolf/flows/web"; +import { helper } from "inline-dep"; +export default { page, helper }; +`, + ); + + const bundle = await defaultFlowBundler(flowPath, depsRoot); + + const expectedFlowsPath = path.join( + depsRoot, + "node_modules", + "@qawolf", + "flows", + "dist", + "web.js", + ); + // @qawolf/flows content is kept external at the absolute on-disk path + expect(bundle).toContain(`from "${expectedFlowsPath}"`); + expect(bundle).not.toContain("FLOWS_WEB_MARKER"); + // Non-executor dep is inlined into the bundle + expect(bundle).toContain("INLINE_DEP_MARKER"); + } finally { + await rm(depsRoot, { recursive: true }); + } + }); +}); diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 0ada6fd52..cc587bc29 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -3,75 +3,7 @@ import { pathToFileURL } from "node:url"; import { runnerMessages } from "~/core/messages/index.js"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; - -// 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; -} - -// 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(); -} +import { type FlowBundler, defaultFlowBundler } from "./bundleFlow.js"; // Only the compiled binary needs bundling — it alone cannot resolve exports-map // bare specifiers from external node_modules. Node and `bun run`/`bun test` @@ -87,6 +19,9 @@ type LoadFlowDefaultArgs = { // 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; + // Executor root whose node_modules holds @qawolf/flows etc. Required when + // bundleFlow is defined; unused on the direct-import path. + depsRoot?: string; }; // Imports a module by URL and returns its default export, throwing the canonical @@ -129,12 +64,13 @@ export async function loadFlowDefault( flowPath, fs = makeDefaultFs(), bundleFlow = defaultBundleFlow, + depsRoot = "", } = args; if (bundleFlow === undefined) { return importDefaultExport(pathToFileURL(flowPath).href, flowPath); } - const code = await bundleFlow(flowPath); + const code = await bundleFlow(flowPath, depsRoot); return importBundledFlow(flowPath, code, fs); } diff --git a/src/domains/runner/runAndroidFlow.noDefault.fixture.ts b/src/domains/runner/runAndroidFlow.noDefault.fixture.ts new file mode 100644 index 000000000..d20efe531 --- /dev/null +++ b/src/domains/runner/runAndroidFlow.noDefault.fixture.ts @@ -0,0 +1 @@ +export const notAFlow = "oops"; diff --git a/src/domains/runner/runAndroidFlow.test.ts b/src/domains/runner/runAndroidFlow.test.ts index 375c72328..ef1dfa3bb 100644 --- a/src/domains/runner/runAndroidFlow.test.ts +++ b/src/domains/runner/runAndroidFlow.test.ts @@ -125,4 +125,18 @@ describe("runAndroidFlow", () => { expect(result.passed).toBe(false); expect(result.attempts).toBe(1); }); + + it("should return a failed result with the load error as cause when the flow file has no default export", async () => { + const result = await runAndroidFlow({ + deps: makeAndroidDeps(), + options: baseOptions, + flowPath: fixturePath("noDefault"), + }); + + expect(result.passed).toBe(false); + expect(result.attempts).toBe(1); + const cause = (result.error as Error & { cause?: unknown })?.cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain("No default export found"); + }); }); diff --git a/src/domains/runner/runAndroidFlow.ts b/src/domains/runner/runAndroidFlow.ts index e7fc1da59..c156f566f 100644 --- a/src/domains/runner/runAndroidFlow.ts +++ b/src/domains/runner/runAndroidFlow.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import type { AndroidFlowApiReturnValue, AndroidFlowDefinition, @@ -17,7 +19,7 @@ import type { RunnerDeps, RunnerOptions, } from "./types.js"; -import { FailWithoutRetryError } from "./errors.js"; +import { FailWithoutRetryError, FlowRunError } from "./errors.js"; import { notSupported } from "./runWebFlowUtils.js"; import { resolveAvdName, @@ -41,14 +43,26 @@ export async function runAndroidFlow({ options: RunAndroidFlowOptions; flowPath: string; }): Promise { - const exported = await loadFlowDefault({ - flowPath, - }); - if (typeof exported === "function") { - // (D2) Android legacy flows have no target; AVD derivation is impossible. - throw new Error( - "runAndroidFlow: legacy flow functions are not supported; use flow() from @qawolf/flows/android", - ); + let exported: AndroidFlowApiReturnValue; + try { + 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( + "runAndroidFlow: legacy flow functions are not supported; use flow() from @qawolf/flows/android", + ); + } + } catch (err) { + const flowName = path.basename(flowPath, path.extname(flowPath)); + return { + passed: false, + testCounts: { passed: 0, total: 0 }, + attempts: 1, + error: new FlowRunError(flowName, 1, err), + }; } const { diff --git a/src/domains/runner/runWebFlow.test.ts b/src/domains/runner/runWebFlow.test.ts index fe56de1c4..b6ce960a6 100644 --- a/src/domains/runner/runWebFlow.test.ts +++ b/src/domains/runner/runWebFlow.test.ts @@ -145,19 +145,18 @@ describe("runWebFlow", () => { expect(result.attempts).toBe(1); }); - it("should throw when the flow file has no default export", async () => { - let caughtError: unknown; - try { - await runWebFlow({ - deps: makeWebDeps(), - options: baseOptions, - flowPath: fixturePath("noDefault"), - }); - } catch (e) { - caughtError = e; - } - expect(caughtError).toBeInstanceOf(Error); - expect((caughtError as Error).message).toContain("No default export found"); + it("should return a failed result with the load error as cause when the flow file has no default export", async () => { + const result = await runWebFlow({ + deps: makeWebDeps(), + options: baseOptions, + flowPath: fixturePath("noDefault"), + }); + + expect(result.passed).toBe(false); + expect(result.attempts).toBe(1); + const cause = (result.error as Error & { cause?: unknown })?.cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain("No default export found"); }); it("should close all open contexts and browsers when the flow throws", async () => { diff --git a/src/domains/runner/runWebFlow.ts b/src/domains/runner/runWebFlow.ts index be0b70ad9..d8a2be47f 100644 --- a/src/domains/runner/runWebFlow.ts +++ b/src/domains/runner/runWebFlow.ts @@ -13,7 +13,7 @@ import type { RunnerOptions, } from "./types.js"; import type { WebLaunchDeps, WebLaunchOptions } from "./web/types.js"; -import { FailWithoutRetryError } from "./errors.js"; +import { FailWithoutRetryError, FlowRunError } from "./errors.js"; import { initFlowRuntime } from "./initFlowRuntime.js"; import { createLaunch, @@ -41,12 +41,25 @@ export async function runWebFlow({ options: RunWebFlowOptions; flowPath: string; }): Promise { - await initFlowRuntime(flowPath, { - timeout: options.timeout, - depsRoot: deps.depsRoot, - }); - - const exported = await loadFlowDefault({ flowPath }); + let exported: WebFlowApiReturnValue; + try { + await initFlowRuntime(flowPath, { + timeout: options.timeout, + depsRoot: deps.depsRoot, + }); + exported = await loadFlowDefault({ + flowPath, + depsRoot: deps.depsRoot, + }); + } catch (err) { + const flowName = path.basename(flowPath, path.extname(flowPath)); + return { + passed: false, + testCounts: { passed: 0, total: 0 }, + attempts: 1, + error: new FlowRunError(flowName, 1, err), + }; + } const isLegacy = typeof exported === "function"; const flowName = isLegacy diff --git a/src/shell/reporter/createConsoleReporter.ts b/src/shell/reporter/createConsoleReporter.ts index 4e5b813ab..d92fb09ab 100644 --- a/src/shell/reporter/createConsoleReporter.ts +++ b/src/shell/reporter/createConsoleReporter.ts @@ -1,5 +1,6 @@ import { styleText } from "node:util"; import { runnerMessages } from "~/core/messages/index.js"; +import { formatErrorWithCause } from "./formatErrorWithCause.js"; import type { Reporter } from "./types.js"; type WriteSink = { write: (str: string) => void }; @@ -23,53 +24,14 @@ function fmtStampLine(manifest: { return ` ${styleText("dim", `env=${manifest.envId} hash=${shortHash}`)}\n`; } -function filterStack(stack: string): string { - const cwd = process.cwd(); - return stack - .split("\n") - .filter((line) => { - if (!/^\s+at /.test(line)) return true; - return !line.includes("node_modules") && !line.includes("dist/cli.js"); - }) - .map((line) => { - if (!/^\s+at /.test(line)) return line; - return line.replace(`file://${cwd}/`, "").replace(`${cwd}/`, ""); - }) - .join("\n"); -} - -function renderCause(cause: unknown): string { - if (cause instanceof Error) return filterStack(cause.stack ?? cause.message); - if (typeof cause === "object" && cause !== null) { - const obj = cause as Record; - // Duck-type: if it has a string message, treat it as error-like - if (typeof obj["message"] === "string") return obj["message"]; - try { - return JSON.stringify(cause); - } catch { - // oxlint-disable-next-line @typescript-eslint/no-base-to-string - return String(cause); - } - } - return String(cause); -} - -function formatErrorWithCause(err: Error): string { - const parts: string[] = [String(err)]; - let cause: unknown = err.cause; - while (cause !== undefined && cause !== null) { - parts.push(`Caused by: ${renderCause(cause)}`); - if (!(cause instanceof Error)) break; - cause = cause.cause; - } - return parts.join("\n"); -} - export function createConsoleReporter(deps: ConsoleDeps): Reporter { return { onFlowStart({ name, path }) { deps.stdout.write( - `${styleText("magenta", "•")} ${styleText(["bold", "magenta"], name)} ${styleText("dim", path)}\n`, + `${styleText("magenta", "•")} ${styleText( + ["bold", "magenta"], + name, + )} ${styleText("dim", path)}\n`, ); }, @@ -92,7 +54,10 @@ export function createConsoleReporter(deps: ConsoleDeps): Reporter { const counts = tests.total > 0 ? `${tests.passed}/${tests.total} tests ` : ""; deps.stdout.write( - ` ${styleText("green", "✓")} ${counts}passed ${styleText("dim", fmtDuration(durationMs))}\n`, + ` ${styleText("green", "✓")} ${counts}passed ${styleText( + "dim", + fmtDuration(durationMs), + )}\n`, ); if (manifest) deps.stdout.write(fmtStampLine(manifest)); }, @@ -110,7 +75,10 @@ export function createConsoleReporter(deps: ConsoleDeps): Reporter { const counts = tests.total > 0 ? `${tests.passed}/${tests.total} tests ` : ""; deps.stdout.write( - ` ${styleText("red", "✗")} ${counts}passed ${styleText("dim", fmtDuration(durationMs))}\n`, + ` ${styleText("red", "✗")} ${counts}passed ${styleText( + "dim", + fmtDuration(durationMs), + )}\n`, ); if (manifest) deps.stdout.write(fmtStampLine(manifest)); @@ -140,7 +108,10 @@ export function createConsoleReporter(deps: ConsoleDeps): Reporter { const width = deps.columns ?? process.stdout.columns ?? 72; deps.stdout.write(`\n${styleText("dim", "─".repeat(width))}\n`); deps.stdout.write( - ` ${icon} ${flowCount}${testCount} ${styleText("dim", fmtDuration(summary.durationMs))}\n`, + ` ${icon} ${flowCount}${testCount} ${styleText( + "dim", + fmtDuration(summary.durationMs), + )}\n`, ); }, }; diff --git a/src/shell/reporter/createJUnitReporter.test.ts b/src/shell/reporter/createJUnitReporter.test.ts index 0516670a4..2cab43d24 100644 --- a/src/shell/reporter/createJUnitReporter.test.ts +++ b/src/shell/reporter/createJUnitReporter.test.ts @@ -90,6 +90,33 @@ describe("createJUnitReporter", () => { expect(xml).toContain("Timed out waiting for #submit"); }); + it("includes the full cause chain in the failure text", () => { + const { writes, deps } = makeDeps(); + const reporter = createJUnitReporter(deps); + + const cause = new Error("module not found: @qawolf/flows"); + const flowRunErr = new Error('Flow "checkout" failed on attempt 1', { + cause, + }); + + reporter.onFlowFail?.({ + name: "Checkout", + path: "flows/checkout.ts", + err: flowRunErr, + tests: { passed: 0, total: 0 }, + durationMs: 100, + attempt: 1, + maxAttempts: 1, + }); + reporter.onRunComplete?.({ + summary: makeSummary({ flowsFailed: 1, durationMs: 100 }), + }); + + const xml = writes[0]?.content ?? ""; + expect(xml).toContain("Flow "checkout" failed on attempt 1"); + expect(xml).toContain("module not found: @qawolf/flows"); + }); + it("does not write before the run completes", () => { const { writes, deps } = makeDeps(); const reporter = createJUnitReporter(deps); diff --git a/src/shell/reporter/createJUnitReporter.ts b/src/shell/reporter/createJUnitReporter.ts index 2423542cb..ba1837361 100644 --- a/src/shell/reporter/createJUnitReporter.ts +++ b/src/shell/reporter/createJUnitReporter.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; +import { formatErrorWithCause } from "./formatErrorWithCause.js"; import { generateJUnit, type JUnitFlowRecord } from "./junitXml.js"; import type { Reporter } from "./types.js"; @@ -40,7 +41,7 @@ export function createJUnitReporter(deps: JUnitReporterDeps): Reporter { path, status: "fail", durationMs, - error: err.message, + error: formatErrorWithCause(err), }); }, diff --git a/src/shell/reporter/formatErrorWithCause.ts b/src/shell/reporter/formatErrorWithCause.ts new file mode 100644 index 000000000..b02f088b7 --- /dev/null +++ b/src/shell/reporter/formatErrorWithCause.ts @@ -0,0 +1,42 @@ +function filterStack(stack: string): string { + const cwd = process.cwd(); + return stack + .split("\n") + .filter((line) => { + if (!/^\s+at /.test(line)) return true; + return !line.includes("node_modules") && !line.includes("dist/cli.js"); + }) + .map((line) => { + if (!/^\s+at /.test(line)) return line; + return line.replace(`file://${cwd}/`, "").replace(`${cwd}/`, ""); + }) + .join("\n"); +} + +function renderCause(cause: unknown): string { + if (cause instanceof Error) return filterStack(cause.stack ?? cause.message); + if (typeof cause === "object" && cause !== null) { + const obj = cause as Record; + // Duck-type: if it has a string message, treat it as error-like + if (typeof obj["message"] === "string") return obj["message"]; + try { + return JSON.stringify(cause); + } catch { + // oxlint-disable-next-line @typescript-eslint/no-base-to-string + return String(cause); + } + } + return String(cause); +} + +/** Formats an error and its full cause chain as a single string. */ +export function formatErrorWithCause(err: Error): string { + const parts: string[] = [String(err)]; + let cause: unknown = err.cause; + while (cause !== undefined && cause !== null) { + parts.push(`Caused by: ${renderCause(cause)}`); + if (!(cause instanceof Error)) break; + cause = cause.cause; + } + return parts.join("\n"); +} From 390a4ab4c2f15ae92a78444fc106bdf502bbb3ec Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:12:35 -0400 Subject: [PATCH 16/32] fix(runner): prevent staged-flow basename collisions and harden run-dir helpers Address review: stage same-basename flows under a per-dir hash subdir when there is no project root (was silently overwriting); match Windows drive-absolute paths in the executor-externalize onResolve filter; capture the npm spawn error message. --- src/domains/runner/executorPlugin.test.ts | 50 +++++++++++++++ src/domains/runner/executorPlugin.ts | 12 ++-- src/domains/runtimeEnv/outerHop.ts | 4 +- .../prepareRunDir.stageCollision.test.ts | 62 +++++++++++++++++++ src/domains/runtimeEnv/prepareRunDir.ts | 24 ++++++- 5 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 src/domains/runner/executorPlugin.test.ts create mode 100644 src/domains/runtimeEnv/prepareRunDir.stageCollision.test.ts diff --git a/src/domains/runner/executorPlugin.test.ts b/src/domains/runner/executorPlugin.test.ts new file mode 100644 index 000000000..ace11cc39 --- /dev/null +++ b/src/domains/runner/executorPlugin.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; + +import { createExternalizeExecutorPlugin } from "./executorPlugin.js"; + +// The onResolve filter must match both Unix and Windows absolute paths so that +// externalization works when the CLI binary is built on Windows. +const onResolveFilter = /^([A-Za-z]:[\\/]|\/).*\.(js|mjs|cjs|ts|tsx)$/; + +describe("onResolve filter regex", () => { + it("matches Unix absolute paths", () => { + expect(onResolveFilter.test("/abs/path/to/file.js")).toBe(true); + expect(onResolveFilter.test("/abs/path/to/file.ts")).toBe(true); + expect(onResolveFilter.test("/abs/path/to/file.mjs")).toBe(true); + }); + + it("matches Windows drive-absolute paths with backslash", () => { + expect(onResolveFilter.test("C:\\abs\\path\\to\\file.js")).toBe(true); + expect(onResolveFilter.test("C:\\abs\\path\\to\\file.ts")).toBe(true); + expect(onResolveFilter.test("c:\\abs\\path\\to\\file.mjs")).toBe(true); + }); + + it("matches Windows drive-absolute paths with forward slash", () => { + expect(onResolveFilter.test("C:/abs/path/to/file.js")).toBe(true); + expect(onResolveFilter.test("D:/abs/path/to/file.tsx")).toBe(true); + }); + + it("does not match bare module specifiers", () => { + expect(onResolveFilter.test("@qawolf/flows")).toBe(false); + expect(onResolveFilter.test("node:path")).toBe(false); + expect(onResolveFilter.test("relative/path.js")).toBe(false); + }); + + it("does not match paths without a recognized extension", () => { + expect(onResolveFilter.test("/abs/path/to/file.py")).toBe(false); + expect(onResolveFilter.test("C:\\abs\\path\\to\\file.json")).toBe(false); + }); +}); + +describe("createExternalizeExecutorPlugin", () => { + it("returns a plugin with the correct name", () => { + const fakeFs = { + readFile: async () => "", + }; + const plugin = createExternalizeExecutorPlugin( + "/fake/deps", + fakeFs as never, + ); + expect(plugin.name).toBe("externalize-executor-packages"); + }); +}); diff --git a/src/domains/runner/executorPlugin.ts b/src/domains/runner/executorPlugin.ts index 0137d8dfd..cd2dd376c 100644 --- a/src/domains/runner/executorPlugin.ts +++ b/src/domains/runner/executorPlugin.ts @@ -73,10 +73,14 @@ export function createExternalizeExecutorPlugin( }); // Mark absolute executor paths external after onLoad has resolved them. - build.onResolve({ filter: /^\/.*\.(js|mjs|cjs|ts|tsx)$/ }, (args) => { - if (!resolvedAbsPaths.has(args.path)) return undefined; - return { path: args.path, external: true }; - }); + // The filter matches both Unix (/abs/path) and Windows (C:\abs\path) absolute paths. + build.onResolve( + { filter: /^([A-Za-z]:[\\/]|\/).*\.(js|mjs|cjs|ts|tsx)$/ }, + (args) => { + if (!resolvedAbsPaths.has(args.path)) return undefined; + return { path: args.path, external: true }; + }, + ); }, }; } diff --git a/src/domains/runtimeEnv/outerHop.ts b/src/domains/runtimeEnv/outerHop.ts index 235027253..acff55e1d 100644 --- a/src/domains/runtimeEnv/outerHop.ts +++ b/src/domains/runtimeEnv/outerHop.ts @@ -125,7 +125,9 @@ function spawnNpmInstall(cwd: string): Promise { child.stderr?.on("data", (chunk: Buffer) => { stderr += String(chunk); }); - child.on("error", () => resolvePromise({ exitCode: -1, stderr })); + child.on("error", (err: Error) => + resolvePromise({ exitCode: -1, stderr: stderr || err.message }), + ); child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stderr }), ); diff --git a/src/domains/runtimeEnv/prepareRunDir.stageCollision.test.ts b/src/domains/runtimeEnv/prepareRunDir.stageCollision.test.ts new file mode 100644 index 000000000..dc947dbd2 --- /dev/null +++ b/src/domains/runtimeEnv/prepareRunDir.stageCollision.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { prepareRunDir } from "./prepareRunDir.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; +} + +async function makeDepsRoot(): Promise { + const depsRoot = await makeTmpDir(); + await mkdir(join(depsRoot, "node_modules"), { recursive: true }); + return depsRoot; +} + +describe("stageFlowFiles — basename collision", () => { + it("stages files with the same basename from different dirs into distinct subdir paths", async () => { + const [runRoot, depsRoot, dirA, dirB] = await Promise.all([ + makeTmpDir(), + makeDepsRoot(), + makeTmpDir(), + makeTmpDir(), + ]); + const flowA = join(dirA, "flow.ts"); + const flowB = join(dirB, "flow.ts"); + await Promise.all([ + writeFile(flowA, "// flow A"), + writeFile(flowB, "// flow B"), + ]); + + const result = await prepareRunDir({ + files: [flowA, flowB], + projectDir: undefined, + depsRoot, + runRoot, + }); + tmpDirs.push(result.runDir); + + expect(result.files).toHaveLength(2); + const [pathA, pathB] = result.files; + if (pathA === undefined || pathB === undefined) { + throw new Error("expected 2 staged file paths"); + } + expect(pathA).not.toBe(pathB); + expect(await readFile(pathA, "utf-8")).toBe("// flow A"); + expect(await readFile(pathB, "utf-8")).toBe("// flow B"); + }); +}); diff --git a/src/domains/runtimeEnv/prepareRunDir.ts b/src/domains/runtimeEnv/prepareRunDir.ts index 56656db27..7500b36c9 100644 --- a/src/domains/runtimeEnv/prepareRunDir.ts +++ b/src/domains/runtimeEnv/prepareRunDir.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { basename, join, resolve, sep } from "node:path"; +import { basename, dirname, join, resolve, sep } from "node:path"; import { copyDirExcluding } from "~/shell/copyDir.js"; import { type Fs, makeDefaultFs } from "~/shell/fs.js"; @@ -73,6 +73,8 @@ function buildRunId(projectDir: string | undefined, files: string[]): string { .update(resolve(seedPath)) .digest("hex") .slice(0, 16); + // The pid suffix scopes the runDir to this process invocation; each command + // calls prepareRunDir once, so same-seedPath reuse within a process is intentional. return `${hash}-${process.pid}`; } @@ -91,6 +93,26 @@ async function stageFlowFiles(args: StageFlowFilesArgs): Promise { return files.map((f) => remapPath(f, projectDir, execDir)); } + if (files.length > 1) { + // Multiple files: place each under a subdir keyed by a hash of its source + // dirname so files with identical basenames from different directories do + // not overwrite each other. + return Promise.all( + files.map(async (f) => { + const dirHash = createHash("sha256") + .update(dirname(f)) + .digest("hex") + .slice(0, 8); + const subDir = join(execDir, dirHash); + await fs.mkdir(subDir, { recursive: true }); + const dest = join(subDir, basename(f)); + await fs.copyFile(f, dest); + return dest; + }), + ); + } + + // Single file (or empty — validated upstream by buildRunId): flat staging. await Promise.all( files.map((f) => fs.copyFile(f, join(execDir, basename(f)))), ); From 99e06f308a74fd757933e0d1ee5d1c67c60d1468 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:36:29 -0400 Subject: [PATCH 17/32] refactor(cli): extract shared runStagedFlows orchestrator --- src/commands/flows/hybridRunDefaults.ts | 87 ++------------------ src/commands/flows/runDefaults.ts | 85 ++----------------- src/commands/flows/runStagedFlows.ts | 103 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 158 deletions(-) create mode 100644 src/commands/flows/runStagedFlows.ts diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index 44c94e1ef..e9f2cf01c 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -1,53 +1,26 @@ import { join, resolve } from "node:path"; import { buildPatternArgs } from "~/core/patternArgs.js"; -import { runnerMessages } from "~/core/messages/index.js"; -import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; import { handleFlowsPull } from "~/domains/flows/pull/handler.js"; import { validateEnvId } from "~/domains/flows/pull/pull.js"; -import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import type { EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; -import { managedEnvBaseDir } from "~/domains/runtimeEnv/managedEnvDir.js"; -import { - prepareRunDir as defaultPrepareRunDir, - type PrepareRunDirArgs, - type PrepareRunDirResult, -} from "~/domains/runtimeEnv/prepareRunDir.js"; +import { prepareRunDir as defaultPrepareRunDir } from "~/domains/runtimeEnv/prepareRunDir.js"; import type { AuthCommandContext, CommandResult, } from "~/shell/commandContext.js"; import type { Fs } from "~/shell/fs.js"; -import type { Logger } from "~/shell/logger.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { - resolveDepsRoot, - type ResolveDepsRootArgs, -} from "~/commands/resolveDepsRoot.js"; +import { resolveDepsRoot } from "~/commands/resolveDepsRoot.js"; -import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; -import { loadEnvFile } from "./loadEnvFile.js"; +import { type HandleFlowsRunDeps } from "./runDefaults.js"; +import { runStagedFlows } from "./runStagedFlows.js"; -export type HandleHybridFlowsRunDeps = { - expandPatterns: ( - patterns: string[], - cwd: string, - logger?: Logger, - ) => Promise; +export type HandleHybridFlowsRunDeps = HandleFlowsRunDeps & { pullEnv: (ctx: AuthCommandContext, envId: string) => Promise; - resolveDepsRoot: ( - args: Omit, - ) => Promise; - prepareRunDir: ( - args: Omit, - ) => Promise; - configureTestkit: (dir: string) => Promise; - flowsRun: typeof defaultFlowsRun; - runWebFlowDeps: typeof defaultRunWebFlowDeps; }; function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { @@ -98,53 +71,5 @@ export async function handleHybridFlowsRun( } } - ctx.ui.gap(); - ctx.ui.intro("flows run"); - - const [runtimeEnv] = await ctx.ui.withProgress( - [ - { - message: runnerMessages.preparingEnvironment, - task: () => - resolvedDeps.resolveDepsRoot({ - files, - ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), - }), - }, - ], - () => runnerMessages.environmentReady, - ); - if (runtimeEnv.source === "managed") { - ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); - } - await loadEnvFile(envDir); - - const projectDir = resolveProjectDirSafe(files, ctx.fs); - const staged = await resolvedDeps.prepareRunDir({ - files, - projectDir, - depsRoot: runtimeEnv.depsRoot, - runRoot: join(managedEnvBaseDir(), ".runs"), - }); - - const resolvedDir = runtimeEnv.depsRoot; - await resolvedDeps.configureTestkit(resolvedDir); - const android = createAndroidDeps(resolvedDir, ctx.signals); - const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( - resolvedDir, - ctx.signals, - ); - - const unregisterCleanup = ctx.signals.register(staged.cleanup); - try { - return await resolvedDeps.flowsRun( - ctx, - staged.files, - flags, - buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), - ); - } finally { - unregisterCleanup(); - await staged.cleanup(); - } + return runStagedFlows({ ctx, files, flags, envDir, deps: resolvedDeps }); } diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index df86919a7..c4ef07ca9 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -1,48 +1,25 @@ -import { join } from "node:path"; - import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.js"; import { pluralize } from "~/core/pluralize.js"; -import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; -import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import type { EnsureRuntimeEnvResult } from "~/domains/runtimeEnv/index.js"; -import { managedEnvBaseDir } from "~/domains/runtimeEnv/managedEnvDir.js"; -import { - prepareRunDir as defaultPrepareRunDir, - type PrepareRunDirArgs, - type PrepareRunDirResult, -} from "~/domains/runtimeEnv/prepareRunDir.js"; +import { prepareRunDir as defaultPrepareRunDir } from "~/domains/runtimeEnv/prepareRunDir.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import type { Fs } from "~/shell/fs.js"; import type { Logger } from "~/shell/logger.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { - resolveDepsRoot, - type ResolveDepsRootArgs, -} from "~/commands/resolveDepsRoot.js"; +import { resolveDepsRoot } from "~/commands/resolveDepsRoot.js"; -import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; -import { loadEnvFile } from "./loadEnvFile.js"; +import { type StagedRunDeps, runStagedFlows } from "./runStagedFlows.js"; -export type HandleFlowsRunDeps = { +export type HandleFlowsRunDeps = StagedRunDeps & { expandPatterns: ( patterns: string[], cwd: string, logger?: Logger, ) => Promise; - resolveDepsRoot: ( - args: Omit, - ) => Promise; - prepareRunDir: ( - args: Omit, - ) => Promise; - configureTestkit: (dir: string) => Promise; - runWebFlowDeps: typeof defaultRunWebFlowDeps; - flowsRun: typeof defaultFlowsRun; }; function makeDefaultDeps(fs: Fs): HandleFlowsRunDeps { @@ -80,56 +57,10 @@ export async function handleFlowsRun( return; } - ctx.ui.gap(); - ctx.ui.intro("flows run"); - - const [runtimeEnv] = await ctx.ui.withProgress( - [ - { - message: runnerMessages.preparingEnvironment, - task: () => - resolvedDeps.resolveDepsRoot({ - files: expandedFiles, - ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), - }), - }, - ], - () => runnerMessages.environmentReady, - ); - - if (runtimeEnv.source === "managed") { - ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); - } - - // Load the user's project .env from the project dir (NOT the deps dir). - const projectDir = resolveProjectDirSafe(expandedFiles, ctx.fs); - await loadEnvFile(projectDir ?? cwd); - - const staged = await resolvedDeps.prepareRunDir({ + return runStagedFlows({ + ctx, files: expandedFiles, - projectDir, - depsRoot: runtimeEnv.depsRoot, - runRoot: join(managedEnvBaseDir(), ".runs"), + flags, + deps: resolvedDeps, }); - - const resolvedDir = runtimeEnv.depsRoot; - - await resolvedDeps.configureTestkit(resolvedDir); - const android = createAndroidDeps(resolvedDir, ctx.signals); - const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( - resolvedDir, - ctx.signals, - ); - const unregisterCleanup = ctx.signals.register(staged.cleanup); - 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/runStagedFlows.ts b/src/commands/flows/runStagedFlows.ts new file mode 100644 index 000000000..ac988c290 --- /dev/null +++ b/src/commands/flows/runStagedFlows.ts @@ -0,0 +1,103 @@ +import { join } from "node:path"; + +import { runnerMessages } from "~/core/messages/index.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; +import type { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; +import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import type { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; +import { + type EnsureRuntimeEnvResult, + managedEnvBaseDir, +} from "~/domains/runtimeEnv/index.js"; +import type { + PrepareRunDirArgs, + PrepareRunDirResult, +} from "~/domains/runtimeEnv/prepareRunDir.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import type { ResolveDepsRootArgs } from "~/commands/resolveDepsRoot.js"; + +import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; +import { loadEnvFile } from "./loadEnvFile.js"; + +/** Dependency bundle for the shared post-discovery run-setup phase. */ +export type StagedRunDeps = { + resolveDepsRoot: ( + args: Omit, + ) => Promise; + prepareRunDir: ( + args: Omit, + ) => Promise; + configureTestkit: (dir: string) => Promise; + runWebFlowDeps: typeof defaultRunWebFlowDeps; + flowsRun: typeof defaultFlowsRun; +}; + +export type RunStagedFlowsArgs = { + ctx: CommandContext; + files: string[]; + flags: FlowsRunFlags; + // .env load dir override; defaults to the resolved project dir or cwd + envDir?: string; + deps: StagedRunDeps; +}; + +/** + * Shared run-setup for both local and hybrid flow runs. Handles environment + * resolution, staging, and execution after flow discovery is complete. + */ +export async function runStagedFlows( + args: RunStagedFlowsArgs, +): Promise { + const { ctx, files, flags, envDir, deps } = args; + + ctx.ui.gap(); + ctx.ui.intro("flows run"); + + const [runtimeEnv] = await ctx.ui.withProgress( + [ + { + message: runnerMessages.preparingEnvironment, + task: () => + deps.resolveDepsRoot({ + files, + ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), + }), + }, + ], + () => runnerMessages.environmentReady, + ); + + if (runtimeEnv.source === "managed") { + ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); + } + + // Load the user's project .env from the project dir (NOT the deps dir). + const projectDir = resolveProjectDirSafe(files, ctx.fs); + await loadEnvFile(envDir ?? projectDir ?? process.cwd()); + + const staged = await deps.prepareRunDir({ + files, + projectDir, + depsRoot: runtimeEnv.depsRoot, + runRoot: join(managedEnvBaseDir(), ".runs"), + }); + + const resolvedDir = runtimeEnv.depsRoot; + await deps.configureTestkit(resolvedDir); + const android = createAndroidDeps(resolvedDir, ctx.signals); + const runWebFlowDeps = await deps.runWebFlowDeps(resolvedDir, ctx.signals); + + const unregisterCleanup = ctx.signals.register(staged.cleanup); + try { + return await deps.flowsRun( + ctx, + staged.files, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); + } finally { + unregisterCleanup(); + await staged.cleanup(); + } +} From 6a0e593171cc34d2a7ecf83d7dbaae9ab124addd Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:37:02 -0400 Subject: [PATCH 18/32] refactor(runner): dedupe npm-install spawn into canonical helper --- src/domains/runtimeEnv/installPinned.ts | 17 ++--------------- src/domains/runtimeEnv/npmInstall.ts | 23 +++++++++++++++++++++++ src/domains/runtimeEnv/outerHop.ts | 20 +------------------- 3 files changed, 26 insertions(+), 34 deletions(-) create mode 100644 src/domains/runtimeEnv/npmInstall.ts diff --git a/src/domains/runtimeEnv/installPinned.ts b/src/domains/runtimeEnv/installPinned.ts index dec75fdb4..bbcd64deb 100644 --- a/src/domains/runtimeEnv/installPinned.ts +++ b/src/domains/runtimeEnv/installPinned.ts @@ -1,28 +1,15 @@ import { type Fs, makeDefaultFs } from "~/shell/fs.js"; -import { spawn as nodeSpawn } from "~/shell/spawn.js"; +import { spawnNpmInstall, type SpawnInstallResult } from "./npmInstall.js"; import { scaffoldManagedEnv } from "./managedEnvDir.js"; import { allPinnedResolved } from "./resolvePinned.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 const defaultSpawnInstall = spawnNpmInstall; export async function installPinned( targetDir: string, diff --git a/src/domains/runtimeEnv/npmInstall.ts b/src/domains/runtimeEnv/npmInstall.ts new file mode 100644 index 000000000..01120c21d --- /dev/null +++ b/src/domains/runtimeEnv/npmInstall.ts @@ -0,0 +1,23 @@ +import { spawn as nodeSpawn } from "~/shell/spawn.js"; + +export type SpawnInstallResult = { exitCode: number; stderr: string }; + +/** + * Runs `npm install --legacy-peer-deps` in the given directory and returns the + * exit code and stderr. Uses the richer `stderr || err.message` fallback on + * spawn error so callers always have diagnostic context. + */ +export function spawnNpmInstall(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", (err: Error) => + resolve({ exitCode: -1, stderr: stderr || err.message }), + ); + child.on("close", (code) => resolve({ exitCode: code ?? -1, stderr })); + }); +} diff --git a/src/domains/runtimeEnv/outerHop.ts b/src/domains/runtimeEnv/outerHop.ts index acff55e1d..8ccf92107 100644 --- a/src/domains/runtimeEnv/outerHop.ts +++ b/src/domains/runtimeEnv/outerHop.ts @@ -1,10 +1,10 @@ import { type Stats } from "node:fs"; import { lstat } from "node:fs/promises"; -import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; import { type Fs } from "~/shell/fs.js"; +import { spawnNpmInstall } from "./npmInstall.js"; import { pinnedPackages } from "./pinnedPackages.js"; import { createDirSymlink } from "./symlinkDir.js"; @@ -115,21 +115,3 @@ async function installOuterDeps( ); } } - -type SpawnInstallResult = { exitCode: number; stderr: string }; - -function spawnNpmInstall(cwd: string): Promise { - return new Promise((resolvePromise) => { - const child = spawn("npm", ["install", "--legacy-peer-deps"], { cwd }); - let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += String(chunk); - }); - child.on("error", (err: Error) => - resolvePromise({ exitCode: -1, stderr: stderr || err.message }), - ); - child.on("close", (code) => - resolvePromise({ exitCode: code ?? -1, stderr }), - ); - }); -} From 714316abfb2f52868fc9cbaa5611ac0c05246688 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:37:28 -0400 Subject: [PATCH 19/32] refactor(runner): decompose shimFlowsDeps into orchestrator + helpers --- src/domains/runtimeEnv/shimDeps.ts | 185 +++++++++++++++++------------ 1 file changed, 107 insertions(+), 78 deletions(-) diff --git a/src/domains/runtimeEnv/shimDeps.ts b/src/domains/runtimeEnv/shimDeps.ts index fa00eee55..3d7bd429a 100644 --- a/src/domains/runtimeEnv/shimDeps.ts +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -1,4 +1,4 @@ -// oxlint-disable eslint/max-lines -- readShimMarker helper is colocated with shimFlowsDeps; extracting it would split tightly coupled logic +// oxlint-disable eslint/max-lines -- shim helpers are colocated due to shared shim-format coupling import { join } from "node:path"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; @@ -79,6 +79,110 @@ function resolveBunBuild( return bunBuild === false ? undefined : { build: bunBuild }; } +/** + * Removes Bun-built CJS shims left over from a prior binary run. Node.js + * resolves bare specifiers correctly so shimming is unnecessary, but stale CJS + * bundles shadow real packages — Node.js finds them first and cannot extract + * named exports from CJS bundles of ESM-only packages (e.g. @qawolf/flow-targets + * → getWebBrowserInfo fails). A CJS require() shim for an ESM-only package + * would also break named imports, so shimming is skipped entirely in Node mode + * and only cleanup runs. + */ +async function removeStaleShims( + flowsDir: string, + flowsDeps: string[], + fs: Fs, +): Promise { + const shimsDir = join(flowsDir, "node_modules"); + if (!fs.existsSync(shimsDir)) return; + for (const dep of flowsDeps) { + const shimDepDir = join(shimsDir, ...dep.split("/")); + if (readShimMarker(shimDepDir, fs)) { + await fs.rm(shimDepDir, { recursive: true, force: true }); + } + } +} + +type BuildDepShimArgs = { + envDir: string; + flowsDir: string; + dep: string; + fs: Fs; + build: BuildFn; +}; + +/** + * Builds and writes a Bun.build() CJS shim for a single @qawolf/flows + * dependency. Skips if the dep is absent, already up-to-date, is a real + * (unmanaged) package directory, or cannot be resolved. See the WIZ-10612 + * rationale comment above for why fully-inlined CJS bundles are required. + */ +async function buildDepShim(args: BuildDepShimArgs): Promise { + const { envDir, flowsDir, dep, fs, build } = args; + const depParts = dep.split("/"); // ["pkg"] or ["@scope", "pkg"] + const depDir = join(envDir, "node_modules", ...depParts); + if (!fs.existsSync(depDir)) return; + + let depVersion: string; + try { + const pkg = JSON.parse(fs.readFileSync(join(depDir, "package.json"))) as { + version?: string; + }; + depVersion = pkg.version ?? "unknown"; + } catch { + depVersion = "unknown"; + } + + const shimDir = join(flowsDir, "node_modules", ...depParts); + + if (fs.existsSync(shimDir)) { + const marker = readShimMarker(shimDir, fs); + // No marker = real package directory (e.g. pnpm nested install) — never overwrite or remove it. + if (!marker) return; + if ( + marker._qawolf_version === depVersion && + marker._qawolf_format === "bun-build-v1" + ) + return; + // Stale managed shim — remove and rebuild below. + await fs.rm(shimDir, { recursive: true, force: true }); + } + + let entry: string; + try { + entry = resolveFromEnvDir(envDir, dep, "cjs", fs); + } catch { + return; + } + + const result = await build({ + entrypoints: [entry], + target: "bun", + format: "cjs", + }); + const [output] = result.outputs; + if (!result.success || !output) { + const logs = result.logs.map((l) => l.message).join("; "); + // No logger is threaded into this shimming routine; write the build + // diagnostic to stderr directly (same channel as the worker error path). + // oxlint-disable-next-line no-restricted-properties + process.stderr.write(`[qawolf] bun.build failed for ${dep}: ${logs}\n`); + return; + } + const shimCode = await output.text(); + + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile( + join(shimDir, "package.json"), + JSON.stringify({ + name: dep, + _qawolf_version: depVersion, + _qawolf_format: "bun-build-v1", + }), + ); + await fs.writeFile(join(shimDir, "index.js"), shimCode); +} + export async function shimFlowsDeps( envDir: string, fs: Fs = makeDefaultFs(), @@ -99,87 +203,12 @@ export async function shimFlowsDeps( } 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 — - // Node.js finds them first and cannot extract named exports from CJS bundles - // of ESM-only packages (e.g. @qawolf/flow-targets → getWebBrowserInfo fails). if (!bun) { - const shimsDir = join(flowsDir, "node_modules"); - if (fs.existsSync(shimsDir)) { - for (const dep of flowsDeps) { - const shimDepDir = join(shimsDir, ...dep.split("/")); - if (readShimMarker(shimDepDir, fs)) { - await fs.rm(shimDepDir, { recursive: true, force: true }); - } - } - } + await removeStaleShims(flowsDir, flowsDeps, fs); return; } for (const dep of flowsDeps) { - const depParts = dep.split("/"); // ["pkg"] or ["@scope", "pkg"] - const depDir = join(envDir, "node_modules", ...depParts); - if (!fs.existsSync(depDir)) continue; - - let depVersion: string; - try { - const pkg = JSON.parse(fs.readFileSync(join(depDir, "package.json"))) as { - version?: string; - }; - depVersion = pkg.version ?? "unknown"; - } catch { - depVersion = "unknown"; - } - - const shimDir = join(flowsDir, "node_modules", ...depParts); - - if (fs.existsSync(shimDir)) { - const marker = readShimMarker(shimDir, fs); - // No marker means this is a real package directory (e.g. pnpm nested - // install) — never overwrite or remove it. - if (!marker) continue; - if ( - marker._qawolf_version === depVersion && - marker._qawolf_format === "bun-build-v1" - ) - continue; - // Stale managed shim — remove and rebuild below. - await fs.rm(shimDir, { recursive: true, force: true }); - } - - let entry: string; - try { - entry = resolveFromEnvDir(envDir, dep, "cjs", fs); - } catch { - continue; - } - - const result = await bun.build({ - entrypoints: [entry], - target: "bun", - format: "cjs", - }); - const [output] = result.outputs; - if (!result.success || !output) { - const logs = result.logs.map((l) => l.message).join("; "); - // No logger is threaded into this shimming routine; write the build - // diagnostic to stderr directly (same channel as the worker error path). - // oxlint-disable-next-line no-restricted-properties - process.stderr.write(`[qawolf] bun.build failed for ${dep}: ${logs}\n`); - continue; - } - const shimCode = await output.text(); - - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile( - join(shimDir, "package.json"), - JSON.stringify({ - name: dep, - _qawolf_version: depVersion, - _qawolf_format: "bun-build-v1", - }), - ); - await fs.writeFile(join(shimDir, "index.js"), shimCode); + await buildDepShim({ envDir, flowsDir, dep, fs, build: bun.build }); } } From 0a99329421a8298287301a68f62920b602a32efe Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:22:47 -0400 Subject: [PATCH 20/32] fix(runner): scope inner hop to pinned packages to stop transitive shadowing --- src/domains/runtimeEnv/innerHop.test.ts | 179 +++++++++++++++++++ src/domains/runtimeEnv/innerHop.ts | 39 ++++ src/domains/runtimeEnv/prepareRunDir.test.ts | 78 ++++---- src/domains/runtimeEnv/prepareRunDir.ts | 9 +- 4 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 src/domains/runtimeEnv/innerHop.test.ts create mode 100644 src/domains/runtimeEnv/innerHop.ts diff --git a/src/domains/runtimeEnv/innerHop.test.ts b/src/domains/runtimeEnv/innerHop.test.ts new file mode 100644 index 000000000..bee02416f --- /dev/null +++ b/src/domains/runtimeEnv/innerHop.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + readlink, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeDefaultFs } from "~/shell/fs.js"; + +import { populateInnerHop } from "./innerHop.js"; +import { pinnedPackages } from "./pinnedPackages.js"; +import { createDirSymlink } from "./symlinkDir.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-innerhop-test-")), + ); + tmpDirs.push(d); + return d; +} + +async function scaffoldManagedRuntime(depsRoot: string): Promise { + const nm = join(depsRoot, "node_modules"); + for (const { name } of pinnedPackages) { + const pkgDir = join(nm, ...name.split("/")); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify({ name, version: "0.0.0" }), + ); + } +} + +describe("populateInnerHop", () => { + describe("happy path — directory and symlinks", () => { + it("creates execDir/node_modules as a real directory, not a symlink", async () => { + const depsRoot = await makeTmpDir(); + const execDir = await makeTmpDir(); + await scaffoldManagedRuntime(depsRoot); + + await populateInnerHop({ depsRoot, execDir, fs: makeDefaultFs() }); + + const innerModules = join(execDir, "node_modules"); + const stats = await lstat(innerModules); + expect(stats.isDirectory()).toBe(true); + expect(stats.isSymbolicLink()).toBe(false); + }); + + it("creates one symlink per pinned package pointing into the managed tree", async () => { + const depsRoot = await makeTmpDir(); + const execDir = await makeTmpDir(); + await scaffoldManagedRuntime(depsRoot); + + await populateInnerHop({ depsRoot, execDir, fs: makeDefaultFs() }); + + const innerModules = join(execDir, "node_modules"); + const managedModules = join(depsRoot, "node_modules"); + for (const { name } of pinnedPackages) { + const segments = name.split("/"); + const linkPath = join(innerModules, ...segments); + expect(await readlink(linkPath)).toBe( + join(managedModules, ...segments), + ); + } + }); + + it("creates the @qawolf scope dir with flows, emails, and testkit symlinks", async () => { + const depsRoot = await makeTmpDir(); + const execDir = await makeTmpDir(); + await scaffoldManagedRuntime(depsRoot); + + await populateInnerHop({ depsRoot, execDir, fs: makeDefaultFs() }); + + const innerModules = join(execDir, "node_modules"); + const managedModules = join(depsRoot, "node_modules"); + const scopeDir = join(innerModules, "@qawolf"); + expect((await lstat(scopeDir)).isDirectory()).toBe(true); + + for (const pkg of ["flows", "emails", "testkit"]) { + const linkPath = join(scopeDir, pkg); + expect(await readlink(linkPath)).toBe( + join(managedModules, "@qawolf", pkg), + ); + } + }); + }); + + describe("realpath resolution — transitive dep shadowing fix", () => { + it("flow gets outer-hop project version; executor probe gets managed pinned version via realpath", async () => { + const runDir = await makeTmpDir(); + const depsRoot = await makeTmpDir(); + const projectDir = await makeTmpDir(); + + // Scaffold all 7 pinned package dirs in the managed runtime + await scaffoldManagedRuntime(depsRoot); + + // Add a transitive dep "diff" in the managed runtime (executor's hoisted copy) + const managedNm = join(depsRoot, "node_modules"); + await mkdir(join(managedNm, "diff"), { recursive: true }); + await writeFile( + join(managedNm, "diff", "package.json"), + JSON.stringify({ name: "diff", version: "1.0.0-PINNED" }), + ); + + // Place a probe inside the managed @qawolf/flows package that reads diff's version + const flowsDir = join(managedNm, "@qawolf", "flows"); + await writeFile( + join(flowsDir, "probe.mjs"), + [ + "import { createRequire } from 'node:module';", + "const require = createRequire(import.meta.url);", + "process.stdout.write(require('diff/package.json').version + '\\n');", + ].join("\n"), + ); + + // Outer hop: project has a different version of diff + const projectNm = join(projectDir, "node_modules"); + await mkdir(join(projectNm, "diff"), { recursive: true }); + await writeFile( + join(projectNm, "diff", "package.json"), + JSON.stringify({ name: "diff", version: "2.0.0-PROJECT" }), + ); + + // Build the inner hop: real dir with per-package symlinks into managed tree + const execDir = join(runDir, "exec"); + await mkdir(execDir, { recursive: true }); + await populateInnerHop({ depsRoot, execDir, fs: makeDefaultFs() }); + + // Build the outer hop: runDir/node_modules → projectNm + await createDirSymlink(projectNm, join(runDir, "node_modules")); + + // Stage a flow file in execDir that requires diff + const flowFile = join(execDir, "flow.mjs"); + await writeFile( + flowFile, + [ + "import { createRequire } from 'node:module';", + "const require = createRequire(import.meta.url);", + "process.stdout.write(require('diff/package.json').version + '\\n');", + ].join("\n"), + ); + + // Flow misses the inner hop (diff is not pinned) and falls through to outer hop + const flowOutput = execFileSync("node", [flowFile], { + encoding: "utf-8", + }).trim(); + expect(flowOutput).toBe("2.0.0-PROJECT"); + + // Probe inside @qawolf/flows resolves via realpath into the managed tree + const probeFile = join( + execDir, + "node_modules", + "@qawolf", + "flows", + "probe.mjs", + ); + const probeOutput = execFileSync("node", [probeFile], { + encoding: "utf-8", + }).trim(); + expect(probeOutput).toBe("1.0.0-PINNED"); + }); + }); +}); diff --git a/src/domains/runtimeEnv/innerHop.ts b/src/domains/runtimeEnv/innerHop.ts new file mode 100644 index 000000000..d6a5565dc --- /dev/null +++ b/src/domains/runtimeEnv/innerHop.ts @@ -0,0 +1,39 @@ +import { dirname, join } from "node:path"; + +import { type Fs } from "~/shell/fs.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { createDirSymlink } from "./symlinkDir.js"; + +export type PopulateInnerHopArgs = { + depsRoot: string; + execDir: string; + fs: Fs; +}; + +/** + * Builds the inner hop (`execDir/node_modules`) as a real directory holding one + * symlink per pinned package into the managed runtime. Exposing only the pinned + * names — not the managed runtime's fully-hoisted transitive closure — keeps the + * executor authoritative for its own 7 packages while letting a flow's own + * `import ` miss the inner hop and fall through to the outer hop's project + * version. The executor still resolves its pinned transitive deps because Node + * walks each package's realpath inside the managed tree, where they stay hoisted. + */ +export async function populateInnerHop( + args: PopulateInnerHopArgs, +): Promise { + const { depsRoot, execDir, fs } = args; + const managedModules = join(depsRoot, "node_modules"); + const innerModules = join(execDir, "node_modules"); + + await fs.mkdir(innerModules, { recursive: true }); + for (const { name } of pinnedPackages) { + const segments = name.split("/"); + const target = join(innerModules, ...segments); + if (segments.length > 1) { + await fs.mkdir(dirname(target), { recursive: true }); + } + await createDirSymlink(join(managedModules, ...segments), target); + } +} diff --git a/src/domains/runtimeEnv/prepareRunDir.test.ts b/src/domains/runtimeEnv/prepareRunDir.test.ts index 13e062a82..3377784bc 100644 --- a/src/domains/runtimeEnv/prepareRunDir.test.ts +++ b/src/domains/runtimeEnv/prepareRunDir.test.ts @@ -4,7 +4,6 @@ import { lstat, mkdir, mkdtemp, - readFile, readlink, rm, writeFile, @@ -12,6 +11,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pinnedPackages } from "./pinnedPackages.js"; import { prepareRunDir } from "./prepareRunDir.js"; const tmpDirs: string[] = []; @@ -31,18 +31,25 @@ async function makeTmpDir(): Promise { async function makeDepsRoot(): Promise { const depsRoot = await makeTmpDir(); - await mkdir(join(depsRoot, "node_modules"), { recursive: true }); - await writeFile(join(depsRoot, "node_modules", "executor.txt"), "executor"); + const nm = join(depsRoot, "node_modules"); + await mkdir(nm, { recursive: true }); + for (const { name } of pinnedPackages) { + const pkgDir = join(nm, ...name.split("/")); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify({ name, version: "0.0.0" }), + ); + } return depsRoot; } describe("prepareRunDir", () => { describe("inner-hop symlink", () => { - it("symlinks exec/node_modules to depsRoot/node_modules", async () => { + it("builds exec/node_modules as a real dir with per-pinned-package symlinks", async () => { const runRoot = await makeTmpDir(); const depsRoot = await makeDepsRoot(); - const srcDir = await makeTmpDir(); - const flowFile = join(srcDir, "flow.ts"); + const flowFile = join(runRoot, "flow.ts"); await writeFile(flowFile, "// flow"); const result = await prepareRunDir({ @@ -54,8 +61,10 @@ describe("prepareRunDir", () => { tmpDirs.push(result.runDir); const innerHop = join(result.runDir, "exec", "node_modules"); - expect((await lstat(innerHop)).isSymbolicLink()).toBe(true); - expect(await readlink(innerHop)).toBe(join(depsRoot, "node_modules")); + expect((await lstat(innerHop)).isDirectory()).toBe(true); + expect(await readlink(join(innerHop, "@qawolf", "flows"))).toBe( + join(depsRoot, "node_modules", "@qawolf", "flows"), + ); }); }); @@ -63,12 +72,9 @@ describe("prepareRunDir", () => { it("symlinks runDir/node_modules to the nearest ancestor node_modules when projectDir is given", async () => { const runRoot = await makeTmpDir(); const depsRoot = await makeDepsRoot(); - const projectDir = await makeTmpDir(); const projectNm = join(projectDir, "node_modules"); await mkdir(projectNm, { recursive: true }); - await writeFile(join(projectNm, "project-dep.txt"), "project dep"); - const flowFile = join(projectDir, "flow.ts"); await writeFile(flowFile, "// flow"); @@ -90,12 +96,9 @@ describe("prepareRunDir", () => { const depsRoot = await makeDepsRoot(); // Case 1: no projectDir - const srcDir = await makeTmpDir(); - const flowFile1 = join(srcDir, "flow.ts"); - await writeFile(flowFile1, "// flow"); - + await writeFile(join(runRoot, "flow.ts"), "// flow"); const result1 = await prepareRunDir({ - files: [flowFile1], + files: [join(runRoot, "flow.ts")], projectDir: undefined, depsRoot, runRoot, @@ -105,11 +108,9 @@ describe("prepareRunDir", () => { // Case 2: projectDir with no node_modules and no package.json deps const projectDir = await makeTmpDir(); - const flowFile2 = join(projectDir, "flow.ts"); - await writeFile(flowFile2, "// flow"); - + await writeFile(join(projectDir, "flow.ts"), "// flow"); const result2 = await prepareRunDir({ - files: [flowFile2], + files: [join(projectDir, "flow.ts")], projectDir, depsRoot, runRoot, @@ -123,8 +124,7 @@ describe("prepareRunDir", () => { it("copies individual flow files into exec/ when no projectDir", async () => { const runRoot = await makeTmpDir(); const depsRoot = await makeDepsRoot(); - const srcDir = await makeTmpDir(); - const flowFile = join(srcDir, "myFlow.ts"); + const flowFile = join(runRoot, "myFlow.ts"); await writeFile(flowFile, "// my flow"); const result = await prepareRunDir({ @@ -168,7 +168,6 @@ describe("prepareRunDir", () => { const projectDir = await makeTmpDir(); const projectNm = join(projectDir, "node_modules"); await mkdir(projectNm, { recursive: true }); - await writeFile(join(projectNm, "pkg.txt"), "pkg"); const flowFile = join(projectDir, "flow.ts"); await writeFile(flowFile, "// flow"); @@ -180,10 +179,12 @@ describe("prepareRunDir", () => { }); tmpDirs.push(result.runDir); - // exec/node_modules should be the inner-hop symlink, not a copy of project nm + // exec/node_modules is the inner hop — a real dir, not a copy of project nm const execNm = join(result.runDir, "exec", "node_modules"); - expect((await lstat(execNm)).isSymbolicLink()).toBe(true); - expect(await readlink(execNm)).toBe(join(depsRoot, "node_modules")); + expect((await lstat(execNm)).isDirectory()).toBe(true); + expect(await readlink(join(execNm, "@qawolf", "flows"))).toBe( + join(depsRoot, "node_modules", "@qawolf", "flows"), + ); }); }); @@ -191,8 +192,7 @@ describe("prepareRunDir", () => { it("removes the runDir on cleanup()", async () => { const runRoot = await makeTmpDir(); const depsRoot = await makeDepsRoot(); - const srcDir = await makeTmpDir(); - const flowFile = join(srcDir, "flow.ts"); + const flowFile = join(runRoot, "flow.ts"); await writeFile(flowFile, "// flow"); const result = await prepareRunDir({ @@ -211,14 +211,9 @@ describe("prepareRunDir", () => { describe("prefer-pinned invariant", () => { it("executor (inner hop) wins over project copy (outer hop) by path walk-up order", async () => { const runRoot = await makeTmpDir(); - const depsRoot = await makeTmpDir(); - - // Inner hop: depsRoot/node_modules has the executor copy - const innerNm = join(depsRoot, "node_modules"); - await mkdir(join(innerNm, "@qawolf"), { recursive: true }); - await writeFile(join(innerNm, "@qawolf", "flows.txt"), "executor-copy"); + const depsRoot = await makeDepsRoot(); - // Outer hop: projectDir/node_modules has a project copy + // Outer hop: projectDir/node_modules has a project copy of @qawolf/flows const projectDir = await makeTmpDir(); const projectNm = join(projectDir, "node_modules"); await mkdir(join(projectNm, "@qawolf"), { recursive: true }); @@ -235,16 +230,17 @@ describe("prepareRunDir", () => { }); tmpDirs.push(result.runDir); + // Inner hop is a real directory; @qawolf/flows symlinks into the managed tree const innerHop = join(result.runDir, "exec", "node_modules"); - expect(await readlink(innerHop)).toBe(innerNm); + expect((await lstat(innerHop)).isDirectory()).toBe(true); + expect(await readlink(join(innerHop, "@qawolf", "flows"))).toBe( + join(depsRoot, "node_modules", "@qawolf", "flows"), + ); + // Outer hop remains a symlink to the project node_modules const outerHop = join(result.runDir, "node_modules"); + expect((await lstat(outerHop)).isSymbolicLink()).toBe(true); expect(await readlink(outerHop)).toBe(projectNm); - - // A flow file under exec/ resolves @qawolf/flows from the inner hop, - // which is closer in the walk-up than the outer hop. - const execFlowsFile = join(innerHop, "@qawolf", "flows.txt"); - expect(await readFile(execFlowsFile, "utf-8")).toBe("executor-copy"); }); }); }); diff --git a/src/domains/runtimeEnv/prepareRunDir.ts b/src/domains/runtimeEnv/prepareRunDir.ts index 7500b36c9..6930488c6 100644 --- a/src/domains/runtimeEnv/prepareRunDir.ts +++ b/src/domains/runtimeEnv/prepareRunDir.ts @@ -4,8 +4,8 @@ import { basename, dirname, join, resolve, sep } from "node:path"; import { copyDirExcluding } from "~/shell/copyDir.js"; import { type Fs, makeDefaultFs } from "~/shell/fs.js"; +import { populateInnerHop } from "./innerHop.js"; import { populateOuterHop } from "./outerHop.js"; -import { createDirSymlink } from "./symlinkDir.js"; const excludedDirs = new Set(["node_modules", ".git", ".qawolf"]); @@ -45,11 +45,8 @@ export async function prepareRunDir( const execDir = join(runDir, "exec"); await fs.mkdir(execDir, { recursive: true }); - // Inner hop: executor deps (playwright, @qawolf/flows, etc.) resolve from here. - await createDirSymlink( - join(depsRoot, "node_modules"), - join(execDir, "node_modules"), - ); + // Inner hop: only pinned executor packages resolve here — see populateInnerHop. + await populateInnerHop({ depsRoot, execDir, fs }); const stagedFiles = await stageFlowFiles({ files, projectDir, execDir, fs }); From 3156574dafc4741c93279beaf7d8b137c0b5c1fe Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:22:54 -0400 Subject: [PATCH 21/32] fix(runner): relocate run staging out of managed base to fix install clear --- src/commands/flows/hybridRun.test.ts | 5 ++-- src/commands/flows/runDefaults.handle.test.ts | 5 ++-- src/commands/flows/runStagedFlows.ts | 6 ++-- src/domains/runtimeEnv/managedEnvDir.test.ts | 30 ++++++++++++++++++- src/domains/runtimeEnv/managedEnvDir.ts | 11 +++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/commands/flows/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index 1dd6bab21..19b2624f4 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -6,6 +6,7 @@ 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 { runStagingRoot } from "~/domains/runtimeEnv/index.js"; import { handleHybridFlowsRun, type HandleHybridFlowsRunDeps, @@ -146,7 +147,7 @@ describe("handleHybridFlowsRun", () => { expect(flowsRunDeps?.logger).toBeDefined(); }); - it("calls prepareRunDir with expanded files, depsRoot, and a .runs runRoot", async () => { + it("calls prepareRunDir with expanded files, depsRoot, and the sibling run-staging root", async () => { const ctx = makeCtx(); const deps = makeDeps(); const envDir = "/mock/.qawolf/my-env"; @@ -168,7 +169,7 @@ describe("handleHybridFlowsRun", () => { files: [`${envDir}/login.flow.ts`], projectDir: undefined, depsRoot: "/managed", - runRoot: expect.stringContaining(".runs") as unknown, + runRoot: runStagingRoot(), }); }); diff --git a/src/commands/flows/runDefaults.handle.test.ts b/src/commands/flows/runDefaults.handle.test.ts index 044a35d24..029c95ff5 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -7,6 +7,7 @@ import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.j import { makeNoopLogger } from "~/shell/logger.testUtils.js"; import { runnerMessages } from "~/core/messages/index.js"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import { runStagingRoot } from "~/domains/runtimeEnv/index.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; const noopSignals = makeNoopSignals(); @@ -232,7 +233,7 @@ describe("handleFlowsRun", () => { expect(uiIntroMock).not.toHaveBeenCalled(); }); - it("calls prepareRunDir with expanded files, depsRoot, and a .runs runRoot", async () => { + it("calls prepareRunDir with expanded files, depsRoot, and the sibling run-staging root", async () => { expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); resolveDepsRootMock.mockResolvedValue({ depsRoot: "/managed", @@ -246,7 +247,7 @@ describe("handleFlowsRun", () => { files: ["/some/flow.ts"], projectDir: undefined, depsRoot: "/managed", - runRoot: expect.stringContaining(".runs") as unknown, + runRoot: runStagingRoot(), }); }); diff --git a/src/commands/flows/runStagedFlows.ts b/src/commands/flows/runStagedFlows.ts index ac988c290..1af316f2e 100644 --- a/src/commands/flows/runStagedFlows.ts +++ b/src/commands/flows/runStagedFlows.ts @@ -1,5 +1,3 @@ -import { join } from "node:path"; - import { runnerMessages } from "~/core/messages/index.js"; import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; @@ -8,7 +6,7 @@ import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import type { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; import { type EnsureRuntimeEnvResult, - managedEnvBaseDir, + runStagingRoot, } from "~/domains/runtimeEnv/index.js"; import type { PrepareRunDirArgs, @@ -80,7 +78,7 @@ export async function runStagedFlows( files, projectDir, depsRoot: runtimeEnv.depsRoot, - runRoot: join(managedEnvBaseDir(), ".runs"), + runRoot: runStagingRoot(), }); const resolvedDir = runtimeEnv.depsRoot; diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts index 184b12012..02ea479f5 100644 --- a/src/domains/runtimeEnv/managedEnvDir.test.ts +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -1,13 +1,15 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { join, resolve } from "node:path"; +import { join, resolve, sep } from "node:path"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { pinnedPackages } from "./pinnedPackages.js"; import { + managedEnvBaseDir, managedEnvDir, managedEnvHash, runtimeChannel, + runStagingRoot, scaffoldManagedEnv, } from "./managedEnvDir.js"; @@ -132,6 +134,32 @@ describe("managedEnvDir", () => { }); }); +describe("runStagingRoot", () => { + 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("returns -runs and is not inside the managed base", () => { + delete process.env["QAWOLF_RUNTIME_DIR"]; + const base = managedEnvBaseDir(); + const staging = runStagingRoot(); + expect(staging).toBe(`${base}-runs`); + expect(staging.startsWith(base + sep)).toBe(false); + }); + + it("honors QAWOLF_RUNTIME_DIR and returns -runs", () => { + process.env["QAWOLF_RUNTIME_DIR"] = "/custom/cache"; + const expected = `${resolve("/custom/cache")}-runs`; + expect(runStagingRoot()).toBe(expected); + }); +}); + describe("scaffoldManagedEnv", () => { it("creates the directory and writes a package.json with all pinned deps", async () => { const fs = makeMemoryFs(); diff --git a/src/domains/runtimeEnv/managedEnvDir.ts b/src/domains/runtimeEnv/managedEnvDir.ts index e942695cf..98278501a 100644 --- a/src/domains/runtimeEnv/managedEnvDir.ts +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -45,6 +45,17 @@ export function managedEnvBaseDir(): string { return join(getDataDir(), "runtime"); } +/** + * Root for ephemeral per-run staging directories. A SIBLING of the managed + * runtime base (never nested inside it) so clearRuntimeEnv's "the managed base + * contains only versioned hash dirs" invariant holds and `install clear` keeps + * working after a run. Follows QAWOLF_RUNTIME_DIR so run staging lands on the + * same writable volume as the runtime cache. + */ +export function runStagingRoot(): string { + return `${managedEnvBaseDir()}-runs`; +} + /** Absolute path to the versioned managed runtime directory. */ export function managedEnvDir(): string { return join(managedEnvBaseDir(), managedEnvHash()); From 833c6ce4688a53587554b1784a675a270911668e Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:39:37 -0400 Subject: [PATCH 22/32] fix(runner): register run cleanup before setup and drain npm stdout --- src/commands/flows/runStagedFlows.ts | 12 +++++++----- src/domains/runtimeEnv/npmInstall.ts | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/commands/flows/runStagedFlows.ts b/src/commands/flows/runStagedFlows.ts index 1af316f2e..623a2447a 100644 --- a/src/commands/flows/runStagedFlows.ts +++ b/src/commands/flows/runStagedFlows.ts @@ -81,13 +81,15 @@ export async function runStagedFlows( runRoot: runStagingRoot(), }); - const resolvedDir = runtimeEnv.depsRoot; - await deps.configureTestkit(resolvedDir); - const android = createAndroidDeps(resolvedDir, ctx.signals); - const runWebFlowDeps = await deps.runWebFlowDeps(resolvedDir, ctx.signals); - + // Register cleanup before the setup calls below so a throw in configureTestkit + // / runWebFlowDeps never leaks the per-run staging directory. const unregisterCleanup = ctx.signals.register(staged.cleanup); try { + const resolvedDir = runtimeEnv.depsRoot; + await deps.configureTestkit(resolvedDir); + const android = createAndroidDeps(resolvedDir, ctx.signals); + const runWebFlowDeps = await deps.runWebFlowDeps(resolvedDir, ctx.signals); + return await deps.flowsRun( ctx, staged.files, diff --git a/src/domains/runtimeEnv/npmInstall.ts b/src/domains/runtimeEnv/npmInstall.ts index 01120c21d..fd5735b79 100644 --- a/src/domains/runtimeEnv/npmInstall.ts +++ b/src/domains/runtimeEnv/npmInstall.ts @@ -12,6 +12,9 @@ export function spawnNpmInstall(cwd: string): Promise { // 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 = ""; + // Drain stdout so a large install (playwright + appium) can't fill the pipe + // buffer and stall npm; we only need the exit code and stderr. + child.stdout?.resume(); child.stderr?.on("data", (chunk: Buffer) => { stderr += String(chunk); }); From bdfe7c98762a40a4aadce3d3da25ea1484141cf8 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:09:35 -0400 Subject: [PATCH 23/32] fix(runner): abort managed install when a flow-dep shim fails to build --- src/domains/runtimeEnv/shimDeps.test.ts | 15 +++++++++++++++ src/domains/runtimeEnv/shimDeps.ts | 12 +++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/domains/runtimeEnv/shimDeps.test.ts b/src/domains/runtimeEnv/shimDeps.test.ts index 0e8162fec..5ae59fd6d 100644 --- a/src/domains/runtimeEnv/shimDeps.test.ts +++ b/src/domains/runtimeEnv/shimDeps.test.ts @@ -130,6 +130,21 @@ describe("shimFlowsDeps (Bun mode)", () => { expect(mockBuild).not.toHaveBeenCalled(); }); + + it("throws when bun.build fails so the install aborts", async () => { + const fs = makeFlowsFs(); + mockBuild.mockResolvedValue({ + success: false, + outputs: [], + logs: [{ message: "boom" }], + }); + + expect(shimFlowsDeps(envDir, fs, mockBuild)).rejects.toThrow( + "bun.build failed to shim", + ); + + expect(mockBuild).toHaveBeenCalledTimes(1); + }); }); describe("shimFlowsDeps (Node.js mode — no Bun)", () => { diff --git a/src/domains/runtimeEnv/shimDeps.ts b/src/domains/runtimeEnv/shimDeps.ts index 3d7bd429a..7ecde2c94 100644 --- a/src/domains/runtimeEnv/shimDeps.ts +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -114,8 +114,10 @@ type BuildDepShimArgs = { /** * Builds and writes a Bun.build() CJS shim for a single @qawolf/flows * dependency. Skips if the dep is absent, already up-to-date, is a real - * (unmanaged) package directory, or cannot be resolved. See the WIZ-10612 - * rationale comment above for why fully-inlined CJS bundles are required. + * (unmanaged) package directory, or cannot be resolved. Throws when + * bun.build fails so that a degraded runtime (missing shims) is never + * published. See the WIZ-10612 rationale comment above for why fully-inlined + * CJS bundles are required. */ async function buildDepShim(args: BuildDepShimArgs): Promise { const { envDir, flowsDir, dep, fs, build } = args; @@ -163,11 +165,7 @@ async function buildDepShim(args: BuildDepShimArgs): Promise { const [output] = result.outputs; if (!result.success || !output) { const logs = result.logs.map((l) => l.message).join("; "); - // No logger is threaded into this shimming routine; write the build - // diagnostic to stderr directly (same channel as the worker error path). - // oxlint-disable-next-line no-restricted-properties - process.stderr.write(`[qawolf] bun.build failed for ${dep}: ${logs}\n`); - return; + throw new Error(`bun.build failed to shim ${dep}: ${logs}`); } const shimCode = await output.text(); From 72a8bf1660a54a5a3093661acf25270d55c9114c Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:09:43 -0400 Subject: [PATCH 24/32] test(runner): extract shared scaffoldManagedRuntime test helper --- src/domains/runtimeEnv/innerHop.test.ts | 13 +----------- src/domains/runtimeEnv/prepareRunDir.test.ts | 13 ++---------- .../scaffoldManagedRuntime.testUtils.ts | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 src/domains/runtimeEnv/scaffoldManagedRuntime.testUtils.ts diff --git a/src/domains/runtimeEnv/innerHop.test.ts b/src/domains/runtimeEnv/innerHop.test.ts index bee02416f..8398b2d66 100644 --- a/src/domains/runtimeEnv/innerHop.test.ts +++ b/src/domains/runtimeEnv/innerHop.test.ts @@ -16,6 +16,7 @@ import { makeDefaultFs } from "~/shell/fs.js"; import { populateInnerHop } from "./innerHop.js"; import { pinnedPackages } from "./pinnedPackages.js"; +import { scaffoldManagedRuntime } from "./scaffoldManagedRuntime.testUtils.js"; import { createDirSymlink } from "./symlinkDir.js"; const tmpDirs: string[] = []; @@ -35,18 +36,6 @@ async function makeTmpDir(): Promise { return d; } -async function scaffoldManagedRuntime(depsRoot: string): Promise { - const nm = join(depsRoot, "node_modules"); - for (const { name } of pinnedPackages) { - const pkgDir = join(nm, ...name.split("/")); - await mkdir(pkgDir, { recursive: true }); - await writeFile( - join(pkgDir, "package.json"), - JSON.stringify({ name, version: "0.0.0" }), - ); - } -} - describe("populateInnerHop", () => { describe("happy path — directory and symlinks", () => { it("creates execDir/node_modules as a real directory, not a symlink", async () => { diff --git a/src/domains/runtimeEnv/prepareRunDir.test.ts b/src/domains/runtimeEnv/prepareRunDir.test.ts index 3377784bc..20a71db34 100644 --- a/src/domains/runtimeEnv/prepareRunDir.test.ts +++ b/src/domains/runtimeEnv/prepareRunDir.test.ts @@ -11,8 +11,8 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pinnedPackages } from "./pinnedPackages.js"; import { prepareRunDir } from "./prepareRunDir.js"; +import { scaffoldManagedRuntime } from "./scaffoldManagedRuntime.testUtils.js"; const tmpDirs: string[] = []; @@ -31,16 +31,7 @@ async function makeTmpDir(): Promise { async function makeDepsRoot(): Promise { const depsRoot = await makeTmpDir(); - const nm = join(depsRoot, "node_modules"); - await mkdir(nm, { recursive: true }); - for (const { name } of pinnedPackages) { - const pkgDir = join(nm, ...name.split("/")); - await mkdir(pkgDir, { recursive: true }); - await writeFile( - join(pkgDir, "package.json"), - JSON.stringify({ name, version: "0.0.0" }), - ); - } + await scaffoldManagedRuntime(depsRoot); return depsRoot; } diff --git a/src/domains/runtimeEnv/scaffoldManagedRuntime.testUtils.ts b/src/domains/runtimeEnv/scaffoldManagedRuntime.testUtils.ts new file mode 100644 index 000000000..a3ce53741 --- /dev/null +++ b/src/domains/runtimeEnv/scaffoldManagedRuntime.testUtils.ts @@ -0,0 +1,21 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { pinnedPackages } from "./pinnedPackages.js"; + +/** + * Scaffolds a fake managed runtime under `/node_modules` with a stub + * package.json for each pinned package, so tests can exercise the inner-hop / + * deps-resolution code paths without a real npm install. + */ +export async function scaffoldManagedRuntime(depsRoot: string): Promise { + const nm = join(depsRoot, "node_modules"); + for (const { name } of pinnedPackages) { + const pkgDir = join(nm, ...name.split("/")); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify({ name, version: "0.0.0" }), + ); + } +} From 49cd1c8996df44b26027ce19c4243ae9bfac3b73 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:10 -0400 Subject: [PATCH 25/32] build(runner): support embedded worker bundle in compiled binary --- package.json | 2 +- scripts/buildBinary.ts | 25 ++++++++++++++++++++++++- src/shell/embeddedWorkerCli.ts | 29 +++++++++++++++++++++++++++++ src/shell/workerCommand.test.ts | 19 ++++++++++++++++++- src/shell/workerCommand.ts | 33 +++++++++++++++++++++++++++------ 5 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/shell/embeddedWorkerCli.ts diff --git a/package.json b/package.json index 3bdc49ed5..c16b11d1a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ }, "scripts": { "build": "bun run generate && bun scripts/build.ts", - "build:binary": "bun run generate && bun scripts/buildBinary.ts", + "build:binary": "bun run build && bun scripts/buildBinary.ts", "dev": "bun run src/main.ts", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/scripts/buildBinary.ts b/scripts/buildBinary.ts index dfe1cb581..3f828b7ef 100644 --- a/scripts/buildBinary.ts +++ b/scripts/buildBinary.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun // Compiles the standalone binary (default dist/qawolf); the release-binaries workflow passes --target/--outfile per platform. import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; import { parseArgs } from "node:util"; const { values } = parseArgs({ @@ -10,11 +11,33 @@ const { values } = parseArgs({ }, }); +// Generate the compile entry in dist/ (excluded from tsc + knip): it embeds the +// already-built dist/cli.js as a Bun file asset, exports its extracted path via +// env, then runs the CLI from source. Generated rather than kept in src/ so +// neither tsc (which cannot model the file-asset import) nor knip (which cannot +// see a string-arg entry) trips on it. cli.js is the same bundle the runner +// spawns as a BUN_BE_BUN worker so flows resolve their own node_modules — +// including native modules like sharp — at runtime, which the in-process +// compiled resolver cannot. Never bundled into cli.js (build.ts builds +// src/main.ts), so there is no embed cycle. +const entryPath = "dist/binary-entry.ts"; +writeFileSync( + entryPath, + [ + 'import cliAsset from "./cli.js" with { type: "file" };', + "", + "process.env.QAWOLF_EMBEDDED_CLI_PATH = cliAsset;", + "", + 'await import("../src/main.ts");', + "", + ].join("\n"), +); + const buildArgs = [ "build", "--compile", ...(values.target ? [`--target=${values.target}`] : []), - "./src/main.ts", + entryPath, "--outfile", values.outfile, // unlike the npm bundle (build.ts), only these stay external — ensureDeps installs them on demand diff --git a/src/shell/embeddedWorkerCli.ts b/src/shell/embeddedWorkerCli.ts new file mode 100644 index 000000000..42ab78cd3 --- /dev/null +++ b/src/shell/embeddedWorkerCli.ts @@ -0,0 +1,29 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { getDataDir } from "~/core/paths.js"; + +// The compiled binary entry (generated at build time, see scripts/buildBinary.ts) +// embeds cli.js as a file asset and exports its on-disk path through this env +// var before delegating to main. Unset in node/bun runs, where the worker +// executes the entry script directly. +const embeddedCliPathEnv = "QAWOLF_EMBEDDED_CLI_PATH"; + +/** + * Extracts the embedded cli.js bundle to a real on-disk file so a BUN_BE_BUN + * worker subprocess can execute it — a fresh subprocess cannot mount the + * binary's embedded `/$bunfs` filesystem. Returns the on-disk path, or + * undefined when nothing is embedded (non-compiled runs). The asset basename + * carries a content hash, so the extracted file is stable per binary build and + * safely reused across invocations. + */ +export function extractEmbeddedWorkerCli(): string | undefined { + const assetPath = process.env[embeddedCliPathEnv]; + if (assetPath === undefined || assetPath === "") return undefined; + const dir = join(getDataDir(), "worker"); + const dest = join(dir, basename(assetPath)); + if (existsSync(dest)) return dest; + mkdirSync(dir, { recursive: true }); + writeFileSync(dest, readFileSync(assetPath)); + return dest; +} diff --git a/src/shell/workerCommand.test.ts b/src/shell/workerCommand.test.ts index 130b932cc..2b647ddb3 100644 --- a/src/shell/workerCommand.test.ts +++ b/src/shell/workerCommand.test.ts @@ -3,11 +3,26 @@ import { describe, expect, it } from "bun:test"; import { resolveWorkerCommand } from "./workerCommand.js"; describe("resolveWorkerCommand", () => { - it("invokes the binary directly when compiled", () => { + it("runs the embedded cli.js as a Bun runtime when compiled with a worker bundle", () => { const out = resolveWorkerCommand({ execPath: "/usr/local/bin/qawolf", scriptPath: undefined, compiled: true, + workerCliPath: "/data/qawolf/worker/cli-abc123.js", + }); + expect(out).toEqual({ + command: "/usr/local/bin/qawolf", + prefixArgs: ["/data/qawolf/worker/cli-abc123.js"], + env: { BUN_BE_BUN: "1" }, + }); + }); + + it("invokes the binary directly when compiled without an embedded bundle", () => { + const out = resolveWorkerCommand({ + execPath: "/usr/local/bin/qawolf", + scriptPath: undefined, + compiled: true, + workerCliPath: undefined, }); expect(out).toEqual({ command: "/usr/local/bin/qawolf", prefixArgs: [] }); }); @@ -17,6 +32,7 @@ describe("resolveWorkerCommand", () => { execPath: "/usr/bin/node", scriptPath: "/app/dist/cli.js", compiled: false, + workerCliPath: undefined, }); expect(out).toEqual({ command: "/usr/bin/node", @@ -30,6 +46,7 @@ describe("resolveWorkerCommand", () => { execPath: "/usr/bin/node", scriptPath: undefined, compiled: false, + workerCliPath: undefined, }), ).toThrow("worker entrypoint"); }); diff --git a/src/shell/workerCommand.ts b/src/shell/workerCommand.ts index 24215429d..6cc0a68ca 100644 --- a/src/shell/workerCommand.ts +++ b/src/shell/workerCommand.ts @@ -1,16 +1,35 @@ -export type WorkerCommand = { command: string; prefixArgs: string[] }; +import { extractEmbeddedWorkerCli } from "./embeddedWorkerCli.js"; + +export type WorkerCommand = { + command: string; + prefixArgs: string[]; + // Extra env for the worker spawn (merged over process.env). Used to run the + // compiled binary's worker as a normal Bun runtime via BUN_BE_BUN. + env?: Record; +}; /** - * Resolves how to re-invoke this CLI as a worker subprocess. In a Bun - * `--compile` binary, `process.execPath` is the qawolf binary itself, so it is - * invoked directly. Otherwise (Node/Bun running the bundle) the runtime is - * invoked with the entry script as its first argument. + * Resolves how to re-invoke this CLI as a worker subprocess. A compiled binary + * with an embedded cli.js runs that bundle as a normal Bun runtime (BUN_BE_BUN) + * so the worker resolves the flow's own node_modules — including native modules + * like sharp — which the in-process compiled resolver cannot. Without an + * embedded bundle (older binaries / tests), a compiled binary falls back to + * invoking itself directly. Node/Bun running the bundle invoke the runtime with + * the entry script as the first argument. */ export function resolveWorkerCommand(env: { execPath: string; scriptPath: string | undefined; compiled: boolean; + workerCliPath: string | undefined; }): WorkerCommand { + if (env.compiled && env.workerCliPath !== undefined) { + return { + command: env.execPath, + prefixArgs: [env.workerCliPath], + env: { BUN_BE_BUN: "1" }, + }; + } if (env.compiled) return { command: env.execPath, prefixArgs: [] }; if (env.scriptPath === undefined) throw new Error("Cannot resolve worker entrypoint: unknown script path"); @@ -18,9 +37,11 @@ export function resolveWorkerCommand(env: { } export function defaultWorkerCommand(): WorkerCommand { + const compiled = process.env.QAWOLF_COMPILED === "true"; return resolveWorkerCommand({ execPath: process.execPath, scriptPath: process.argv[1], - compiled: process.env.QAWOLF_COMPILED === "true", + compiled, + workerCliPath: compiled ? extractEmbeddedWorkerCli() : undefined, }); } From 464691719e32b7ebaa1c5cc28dd07838fe684e50 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:15 -0400 Subject: [PATCH 26/32] fix(runner): thread worker env through dispatch layer --- src/domains/runner/dispatchViaSubprocess.ts | 9 +++++++-- src/domains/runner/makePooledDispatch.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/domains/runner/dispatchViaSubprocess.ts b/src/domains/runner/dispatchViaSubprocess.ts index 3873dcff4..0ad076bd9 100644 --- a/src/domains/runner/dispatchViaSubprocess.ts +++ b/src/domains/runner/dispatchViaSubprocess.ts @@ -28,12 +28,15 @@ export async function runWorkerOnce(args: { prefixArgs: readonly string[]; flow: ResolvedFlow; optionsJson: string; + workerEnv?: Record | undefined; }): Promise { - const { spawn, command, prefixArgs, flow, optionsJson } = args; + const { spawn, command, prefixArgs, flow, optionsJson, workerEnv } = args; const result = await spawn( command, [...prefixArgs, "flows", "__run-worker", flow.file], - { stdin: optionsJson }, + workerEnv !== undefined + ? { stdin: optionsJson, env: workerEnv } + : { stdin: optionsJson }, ); const line = lastNonEmptyLine(result.stdout); @@ -69,6 +72,7 @@ export function createSubprocessDispatch(env: { spawn: SpawnFn; command: string; prefixArgs: readonly string[]; + workerEnv?: Record | undefined; resolvedDir: string; webOptions: RunWebFlowOptions; androidOptions: RunAndroidFlowOptions; @@ -78,6 +82,7 @@ export function createSubprocessDispatch(env: { spawn: env.spawn, command: env.command, prefixArgs: env.prefixArgs, + workerEnv: env.workerEnv, flow, optionsJson: serializeWorkerInput({ resolvedDir: env.resolvedDir, diff --git a/src/domains/runner/makePooledDispatch.ts b/src/domains/runner/makePooledDispatch.ts index 84f128669..db0d299e4 100644 --- a/src/domains/runner/makePooledDispatch.ts +++ b/src/domains/runner/makePooledDispatch.ts @@ -12,12 +12,16 @@ import type { FlowsRunDeps } from "./runInternals.js"; export function makePooledDispatch( resolvedDir: string, ): NonNullable { - return ({ webOptions, androidOptions }) => - createSubprocessDispatch({ + return ({ webOptions, androidOptions }) => { + const { command, prefixArgs, env: workerEnv } = defaultWorkerCommand(); + return createSubprocessDispatch({ spawn: defaultSpawn, - ...defaultWorkerCommand(), + command, + prefixArgs, + workerEnv, resolvedDir, webOptions, androidOptions, }); + }; } From 82cbb583934e68e863c7960c9d4b3109cb2bcce8 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:21 -0400 Subject: [PATCH 27/32] refactor(runner): extract flow execution dispatcher --- src/domains/runner/dispatchFlows.ts | 97 +++++++++++++++++++++++++++++ src/domains/runner/run.ts | 56 +++++------------ 2 files changed, 114 insertions(+), 39 deletions(-) create mode 100644 src/domains/runner/dispatchFlows.ts diff --git a/src/domains/runner/dispatchFlows.ts b/src/domains/runner/dispatchFlows.ts new file mode 100644 index 000000000..07bb075ae --- /dev/null +++ b/src/domains/runner/dispatchFlows.ts @@ -0,0 +1,97 @@ +import { pluralize } from "~/core/pluralize.js"; +import type { CommandContext } from "~/shell/commandContext.js"; + +import type { RunAndroidFlowOptions } from "./runAndroidFlow.js"; +import type { RunWebFlowOptions } from "./runWebFlow.js"; +import { runFlowsPooled } from "./runFlowsPooled.js"; +import { type FlowCounts, bootAndroidFlows, runFlows } from "./runHelpers.js"; +import type { + AndroidResolvedFlow, + FlowsRunDeps, + FlowsRunFlags, + ResolvedFlow, + WebResolvedFlow, +} from "./runInternals.js"; + +export type ShouldUseSubprocessPoolArgs = { + workers: number; + compiled: boolean; + webFlowCount: number; + androidFlowCount: number; +}; + +/** + * Whether web flows run in a Bun-runtime subprocess pool rather than in-process. + * Required for the compiled binary even at single-worker concurrency: it cannot + * resolve a flow's external node_modules (native modules like sharp) in its own + * process, but a subprocess worker running as a normal Bun runtime can. Android + * flows keep the in-process path (their parallelism needs per-worker emulator + * orchestration, tracked separately); node/bun runs resolve in-process fine. + */ +export function shouldUseSubprocessPool( + args: ShouldUseSubprocessPoolArgs, +): boolean { + if (args.workers > 1) return true; + return args.compiled && args.androidFlowCount === 0 && args.webFlowCount > 0; +} + +export type ExecuteFlowsArgs = { + ctx: CommandContext; + deps: FlowsRunDeps; + flags: FlowsRunFlags; + flows: ResolvedFlow[]; + webFlows: WebResolvedFlow[]; + androidFlows: AndroidResolvedFlow[]; + webOptions: RunWebFlowOptions; + androidOptions: RunAndroidFlowOptions; +}; + +export type ExecuteFlowsResult = + | { counts: FlowCounts; durationMs: number } + | { error: string }; + +/** + * Runs the resolved flows through either the subprocess worker pool (compiled + * binary, or `--workers > 1`) or the in-process path, returning the aggregate + * counts or an error message to surface. Always shuts the android emulator down. + */ +export async function executeFlows( + args: ExecuteFlowsArgs, +): Promise { + const { ctx, deps, flags, flows, webFlows, androidFlows } = args; + const { webOptions, androidOptions } = args; + + const useSubprocessPool = shouldUseSubprocessPool({ + workers: flags.workers, + compiled: process.env.QAWOLF_COMPILED === "true", + webFlowCount: webFlows.length, + androidFlowCount: androidFlows.length, + }); + + try { + if (useSubprocessPool) { + if (!deps.createPooledDispatch) + throw new Error("createPooledDispatch is not wired for pooled runs"); + ctx.ui.outro(`Running ${pluralize(flows.length, "flow")}`); + ctx.ui.write("\n"); + return await runFlowsPooled({ + flows: webFlows, + workers: Math.max(1, flags.workers), + bail: flags.bail, + maxAttempts: flags.retries + 1, + reporter: deps.reporter, + now: deps.now, + dispatch: deps.createPooledDispatch({ webOptions, androidOptions }), + }); + } + + const bootError = await bootAndroidFlows(deps, androidFlows); + if (bootError !== undefined) return { error: bootError }; + // Close the intro block and add a blank line before streamed test output. + ctx.ui.outro(`Running ${pluralize(flows.length, "flow")}`); + ctx.ui.write("\n"); + return await runFlows(flows, flags, deps, webOptions, androidOptions); + } finally { + deps.shutdownAndroid?.(); + } +} diff --git a/src/domains/runner/run.ts b/src/domains/runner/run.ts index b5e5a30e5..594daacf6 100644 --- a/src/domains/runner/run.ts +++ b/src/domains/runner/run.ts @@ -4,10 +4,9 @@ import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import type { RunSummary } from "~/shell/reporter/types.js"; import type { BrowserName } from "~/core/types.js"; import { runnerMessages } from "~/core/messages/index.js"; -import { pluralize } from "~/core/pluralize.js"; -import { bootAndroidFlows, buildRunOptions, runFlows } from "./runHelpers.js"; -import { runFlowsPooled } from "./runFlowsPooled.js"; +import { buildRunOptions } from "./runHelpers.js"; +import { executeFlows } from "./dispatchFlows.js"; import { type AndroidResolvedFlow, type FlowsRunDeps, @@ -91,43 +90,22 @@ export async function flowsRun( } const { webOptions, androidOptions } = buildRunOptions(flags); - let counts: Awaited>["counts"]; - let durationMs: number; - try { - if (flags.workers > 1) { - if (!deps.createPooledDispatch) - throw new Error("createPooledDispatch is not wired for pooled runs"); - ctx.ui.outro(`Running ${pluralize(flows.length, "flow")}`); - ctx.ui.write("\n"); - ({ counts, durationMs } = await runFlowsPooled({ - flows: webFlows, - workers: flags.workers, - bail: flags.bail, - maxAttempts: flags.retries + 1, - reporter: deps.reporter, - now: deps.now, - dispatch: deps.createPooledDispatch({ webOptions, androidOptions }), - })); - } else { - const bootError = await bootAndroidFlows(deps, androidFlows); - if (bootError !== undefined) { - ctx.ui.error(bootError); - return { error: bootError }; - } - // Close the intro block and add a blank line before streamed test output. - ctx.ui.outro(`Running ${pluralize(flows.length, "flow")}`); - ctx.ui.write("\n"); - ({ counts, durationMs } = await runFlows( - flows, - flags, - deps, - webOptions, - androidOptions, - )); - } - } finally { - deps.shutdownAndroid?.(); + + const result = await executeFlows({ + ctx, + deps, + flags, + flows, + webFlows, + androidFlows, + webOptions, + androidOptions, + }); + if ("error" in result) { + ctx.ui.error(result.error); + return { error: result.error }; } + const { counts, durationMs } = result; const summary: RunSummary = { ...counts, From 1b6b2ce3e7507ecfc9e712439bb272c46bc94e2a Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:26 -0400 Subject: [PATCH 28/32] docs(runner): runtime deps target design --- .../2026-06-23-runtime-deps-target-design.md | 197 ++++++++++++++++++ 1 file changed, 197 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..220d1abac --- /dev/null +++ b/docs/plans/2026-06-23-runtime-deps-target-design.md @@ -0,0 +1,197 @@ +--- +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**, and **the deepest (closest) `node_modules` wins**. Compose two independent +roots so the executor is the _closer_ hop — it must win even when the project ships its own copy: + +``` +//node_modules/{@qawolf/flows, playwright, …} ← EXECUTOR cache (CLI-owned, pinned, shared) +//.runs//node_modules/{diff, faker, axios} ← FLOW'S OWN DEPS (outer hop, per run) +//.runs//exec/node_modules → ../../node_modules ← EXECUTOR (inner hop — symlink to the cache) +//.runs//exec/ ← the flow files (staged under exec/) +``` + +The flow is staged under `exec/`, so walk-up hits the executor first: +`import playwright`/`@qawolf/flows` → `exec/node_modules` (executor, **pinned — wins**) → done. +`import diff` → `exec/node_modules` (miss) → `.runs//node_modules` (flow's own, hit). Both +resolve; the executor is shared/cached and **cannot be shadowed by a project copy**; 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** (`.runs/-/`, +keeping the per-run pid isolation from commit b22b0807, moved off the user's cwd — a side win: +no `.qawolf/.local` litter in the project). + +### Prefer-pinned: the executor never loses to a project copy + +The ordering above is load-bearing. If the flow's-own-deps hop were _closer_ than the executor +(the naïve layering), a monorepo that ships its own `playwright` or `@qawolf/flows` would shadow +the pinned executor — the flow would run against the project's version, breaking the +"executor is always CLI-owned/pinned" guarantee (the PR's own `playwright@1.40.0` scenario). +Putting the executor at the **inner** `node_modules` makes prefer-pinned positional and +mechanism-agnostic: it holds whether the flow's deps came from a symlink (case 2) or an install +(cases 1 & 3), so it does not depend on the install-path executor-stripping alone. Executor +internals still resolve from the executor cache (their hoisted transitive deps sit beside them). + +### Child `node_modules` population (the hybrid rule) + +- **Project has an installed `node_modules`** (case 2, monorepo) → symlink the **outer** hop + (`.runs//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. A project copy of an executor here is harmless — the + inner `exec/node_modules` hop wins first (prefer-pinned, above). +- **No installed `node_modules`** (cases 1 & 3) → read the flow's `package.json` and + `npm install` its deps into the outer hop, **stripping the 7 executor packages** (hygiene — the + inner hop already wins, but no point downloading a redundant executor). This restores the + deleted `ensureFlowDeps` behavior, relocated off the project. Empty-dir / no `package.json` → + empty outer hop; the executor still resolves via the inner hop (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`, (ii) a scoped package's transitive bare imports, and (iii) **a monorepo +that ships a conflicting executor version** (e.g. `playwright@1.40.0` in the outer hop) still +binds the pinned inner-hop executor — confirming positional prefer-pinned holds under the binary +resolver, not just under Node. + +- **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 + +All cases bind the executor at the **inner** `exec/node_modules` hop, so the pinned executor wins +over any project copy (prefer-pinned). The flow's own deps resolve at the outer hop. + +| Case | Channel | Executor (a) — inner hop, pinned | Flow's own deps (b) — outer hop | Project writes | +| -------------- | ------- | ----------------------------------- | ------------------------------------ | -------------- | +| 1 managed-only | Node | inner-hop symlink → cache ✓ | npm install flow pkg.json ✓ | none | +| 1 managed-only | Binary | spike: inner-hop, else shim ✓ | inlined by Bun.build OR outer hop ✓ | none | +| 2 monorepo | Node | inner-hop, wins over project copy ✓ | outer → symlink nearest project nm ✓ | none | +| 2 monorepo | Binary | spike: inner-hop / shim ✓ | inlined / outer symlink ✓ | none | +| 3 empty-dir | Node | inner-hop symlink → cache ✓ | npm install flow pkg.json ✓ | none | +| 3 empty-dir | Binary | spike: inner-hop / shim ✓ | inlined OR outer hop ✓ | 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 + a conflicting-executor-version + monorepo (positional prefer-pinned). Records PASS/FAIL. +2. **Layered resolution core** (channel-agnostic, regardless of spike): stage the flow under + `//.runs//exec/` with the **inner** `exec/node_modules` symlinked + to the executor cache and the **outer** `.runs//node_modules` holding the flow's own + deps (symlink-nearest-project-nm vs install-flow-pkg-deps, executor pkgs stripped). The + inner-hop ordering is what guarantees prefer-pinned — assert it with a conflicting-version + test. 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/`. From 28d187d11cf8a439ae4737b59bf2fcca611bec58 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:43:13 -0400 Subject: [PATCH 29/32] chore(runner): untrack runtime-deps design doc as local-only --- .../2026-06-23-runtime-deps-target-design.md | 197 ------------------ 1 file changed, 197 deletions(-) delete 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 deleted file mode 100644 index 220d1abac..000000000 --- a/docs/plans/2026-06-23-runtime-deps-target-design.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -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**, and **the deepest (closest) `node_modules` wins**. Compose two independent -roots so the executor is the _closer_ hop — it must win even when the project ships its own copy: - -``` -//node_modules/{@qawolf/flows, playwright, …} ← EXECUTOR cache (CLI-owned, pinned, shared) -//.runs//node_modules/{diff, faker, axios} ← FLOW'S OWN DEPS (outer hop, per run) -//.runs//exec/node_modules → ../../node_modules ← EXECUTOR (inner hop — symlink to the cache) -//.runs//exec/ ← the flow files (staged under exec/) -``` - -The flow is staged under `exec/`, so walk-up hits the executor first: -`import playwright`/`@qawolf/flows` → `exec/node_modules` (executor, **pinned — wins**) → done. -`import diff` → `exec/node_modules` (miss) → `.runs//node_modules` (flow's own, hit). Both -resolve; the executor is shared/cached and **cannot be shadowed by a project copy**; 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** (`.runs/-/`, -keeping the per-run pid isolation from commit b22b0807, moved off the user's cwd — a side win: -no `.qawolf/.local` litter in the project). - -### Prefer-pinned: the executor never loses to a project copy - -The ordering above is load-bearing. If the flow's-own-deps hop were _closer_ than the executor -(the naïve layering), a monorepo that ships its own `playwright` or `@qawolf/flows` would shadow -the pinned executor — the flow would run against the project's version, breaking the -"executor is always CLI-owned/pinned" guarantee (the PR's own `playwright@1.40.0` scenario). -Putting the executor at the **inner** `node_modules` makes prefer-pinned positional and -mechanism-agnostic: it holds whether the flow's deps came from a symlink (case 2) or an install -(cases 1 & 3), so it does not depend on the install-path executor-stripping alone. Executor -internals still resolve from the executor cache (their hoisted transitive deps sit beside them). - -### Child `node_modules` population (the hybrid rule) - -- **Project has an installed `node_modules`** (case 2, monorepo) → symlink the **outer** hop - (`.runs//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. A project copy of an executor here is harmless — the - inner `exec/node_modules` hop wins first (prefer-pinned, above). -- **No installed `node_modules`** (cases 1 & 3) → read the flow's `package.json` and - `npm install` its deps into the outer hop, **stripping the 7 executor packages** (hygiene — the - inner hop already wins, but no point downloading a redundant executor). This restores the - deleted `ensureFlowDeps` behavior, relocated off the project. Empty-dir / no `package.json` → - empty outer hop; the executor still resolves via the inner hop (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`, (ii) a scoped package's transitive bare imports, and (iii) **a monorepo -that ships a conflicting executor version** (e.g. `playwright@1.40.0` in the outer hop) still -binds the pinned inner-hop executor — confirming positional prefer-pinned holds under the binary -resolver, not just under Node. - -- **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 - -All cases bind the executor at the **inner** `exec/node_modules` hop, so the pinned executor wins -over any project copy (prefer-pinned). The flow's own deps resolve at the outer hop. - -| Case | Channel | Executor (a) — inner hop, pinned | Flow's own deps (b) — outer hop | Project writes | -| -------------- | ------- | ----------------------------------- | ------------------------------------ | -------------- | -| 1 managed-only | Node | inner-hop symlink → cache ✓ | npm install flow pkg.json ✓ | none | -| 1 managed-only | Binary | spike: inner-hop, else shim ✓ | inlined by Bun.build OR outer hop ✓ | none | -| 2 monorepo | Node | inner-hop, wins over project copy ✓ | outer → symlink nearest project nm ✓ | none | -| 2 monorepo | Binary | spike: inner-hop / shim ✓ | inlined / outer symlink ✓ | none | -| 3 empty-dir | Node | inner-hop symlink → cache ✓ | npm install flow pkg.json ✓ | none | -| 3 empty-dir | Binary | spike: inner-hop / shim ✓ | inlined OR outer hop ✓ | 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 + a conflicting-executor-version - monorepo (positional prefer-pinned). Records PASS/FAIL. -2. **Layered resolution core** (channel-agnostic, regardless of spike): stage the flow under - `//.runs//exec/` with the **inner** `exec/node_modules` symlinked - to the executor cache and the **outer** `.runs//node_modules` holding the flow's own - deps (symlink-nearest-project-nm vs install-flow-pkg-deps, executor pkgs stripped). The - inner-hop ordering is what guarantees prefer-pinned — assert it with a conflicting-version - test. 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/`. From c6d51e63d2a4603f6cd8a32a6e8dba3da5175d07 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:43:16 -0400 Subject: [PATCH 30/32] docs(runner): apply comment-style to runtime-deps changes --- src/domains/flows/ensureDeps.ts | 6 +++-- src/domains/runner/bundleFlow.ts | 18 ++++++++----- src/domains/runner/loadFlowDefault.ts | 32 +++++++++++++---------- src/domains/runtimeEnv/clearRuntimeEnv.ts | 8 +++--- src/shell/embeddedWorkerCli.ts | 10 ++++--- src/shell/workerCommand.ts | 3 +-- 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/domains/flows/ensureDeps.ts b/src/domains/flows/ensureDeps.ts index 5c22653cc..7b11f1333 100644 --- a/src/domains/flows/ensureDeps.ts +++ b/src/domains/flows/ensureDeps.ts @@ -34,8 +34,10 @@ 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. +/** + * 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, diff --git a/src/domains/runner/bundleFlow.ts b/src/domains/runner/bundleFlow.ts index 5242d6724..76022e478 100644 --- a/src/domains/runner/bundleFlow.ts +++ b/src/domains/runner/bundleFlow.ts @@ -4,8 +4,10 @@ import { createExternalizeExecutorPlugin, } from "./executorPlugin.js"; -// 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. +/** + * 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", @@ -13,8 +15,10 @@ const browserDrivers = [ "patchright-core", ]; -// Pre-bundles a flow's full import tree into a single ESM source string. -// depsRoot is the executor root whose node_modules holds @qawolf/flows etc. +/** + * Pre-bundles a flow's full import tree into a single ESM source string. + * depsRoot is the executor root whose node_modules holds @qawolf/flows etc. + */ export type FlowBundler = ( flowPath: string, depsRoot: string, @@ -26,8 +30,10 @@ type BunBuildResult = { 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. +/** + * 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; diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index cc587bc29..57bcba695 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -5,27 +5,29 @@ import { runnerMessages } from "~/core/messages/index.js"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; import { type FlowBundler, defaultFlowBundler } from "./bundleFlow.js"; -// 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. +/** + * 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; fs?: Fs; - // Injectable for tests. When defined, the flow is pre-bundled (compiled-binary - // path); when undefined, the flow is imported directly (Node path). + // Injectable for tests; when defined the flow is pre-bundled (compiled-binary path), else imported directly (Node path). bundleFlow?: FlowBundler | undefined; - // Executor root whose node_modules holds @qawolf/flows etc. Required when - // bundleFlow is defined; unused on the direct-import path. + // Executor root whose node_modules holds @qawolf/flows etc.; required only when bundleFlow is defined. depsRoot?: string; }; -// Imports a module by URL 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, @@ -37,9 +39,11 @@ async function importDefaultExport( return exported; } -// 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. +/** + * 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, diff --git a/src/domains/runtimeEnv/clearRuntimeEnv.ts b/src/domains/runtimeEnv/clearRuntimeEnv.ts index e9383ea85..620d0f4b3 100644 --- a/src/domains/runtimeEnv/clearRuntimeEnv.ts +++ b/src/domains/runtimeEnv/clearRuntimeEnv.ts @@ -32,9 +32,11 @@ export async function clearRuntimeEnv(fs: Fs): Promise { 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. +/** + * 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) { diff --git a/src/shell/embeddedWorkerCli.ts b/src/shell/embeddedWorkerCli.ts index 42ab78cd3..142cf7537 100644 --- a/src/shell/embeddedWorkerCli.ts +++ b/src/shell/embeddedWorkerCli.ts @@ -3,10 +3,12 @@ import { basename, join } from "node:path"; import { getDataDir } from "~/core/paths.js"; -// The compiled binary entry (generated at build time, see scripts/buildBinary.ts) -// embeds cli.js as a file asset and exports its on-disk path through this env -// var before delegating to main. Unset in node/bun runs, where the worker -// executes the entry script directly. +/** + * The compiled binary entry (generated at build time, see scripts/buildBinary.ts) + * embeds cli.js as a file asset and exports its on-disk path through this env + * var before delegating to main. Unset in node/bun runs, where the worker + * executes the entry script directly. + */ const embeddedCliPathEnv = "QAWOLF_EMBEDDED_CLI_PATH"; /** diff --git a/src/shell/workerCommand.ts b/src/shell/workerCommand.ts index 6cc0a68ca..98b6731f7 100644 --- a/src/shell/workerCommand.ts +++ b/src/shell/workerCommand.ts @@ -3,8 +3,7 @@ import { extractEmbeddedWorkerCli } from "./embeddedWorkerCli.js"; export type WorkerCommand = { command: string; prefixArgs: string[]; - // Extra env for the worker spawn (merged over process.env). Used to run the - // compiled binary's worker as a normal Bun runtime via BUN_BE_BUN. + // Extra env merged over process.env for the worker spawn; runs the compiled binary's worker as a normal Bun runtime via BUN_BE_BUN. env?: Record; }; From 38f4903b12fea616c4b032307aef8937cc67c151 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:43:18 -0400 Subject: [PATCH 31/32] chore(runner): add minor changeset for layered runtime-deps resolution --- .changeset/layered-runtime-deps-resolution.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/layered-runtime-deps-resolution.md diff --git a/.changeset/layered-runtime-deps-resolution.md b/.changeset/layered-runtime-deps-resolution.md new file mode 100644 index 000000000..556cc5db4 --- /dev/null +++ b/.changeset/layered-runtime-deps-resolution.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +Resolve flow runtime dependencies through a layered, project-isolated `node_modules` so flows run correctly in monorepos, single-package projects, and empty directories across both the Node and compiled-binary channels. The CLI-owned executor is always pinned and never pollutes or is shadowed by the surrounding project, while the flow's own declared dependencies still resolve. Adds `qawolf install clear` to wipe the managed runtime cache. From e4ead06cbd7b3016574310e7c37a1fd6dec03b2b Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:53:41 -0400 Subject: [PATCH 32/32] test(runner): cover error formatter, staged-run orchestrator, and install clear --- src/commands/flows/runStagedFlows.test.ts | 191 ++++++++++++++++++ src/commands/install/clear.test.ts | 83 ++++++++ .../reporter/formatErrorWithCause.test.ts | 76 +++++++ 3 files changed, 350 insertions(+) create mode 100644 src/commands/flows/runStagedFlows.test.ts create mode 100644 src/commands/install/clear.test.ts create mode 100644 src/shell/reporter/formatErrorWithCause.test.ts diff --git a/src/commands/flows/runStagedFlows.test.ts b/src/commands/flows/runStagedFlows.test.ts new file mode 100644 index 000000000..6ee225112 --- /dev/null +++ b/src/commands/flows/runStagedFlows.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +import { runnerMessages } from "~/core/messages/index.js"; +import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import { makeNoopLogger } from "~/shell/logger.testUtils.js"; +import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; +import type { CommandContext } from "~/shell/commandContext.js"; +import { makeFakeUI } from "~/shell/commandContext.testUtils.js"; + +import { runStagedFlows, type StagedRunDeps } from "./runStagedFlows.js"; + +const noopSignals = makeNoopSignals(); + +const resolveDepsRootMock = mock(); +const prepareRunDirMock = mock(); +const configureTestkitMock = mock(); +const flowsRunMock = mock(); +const runWebFlowDepsMock = mock<(...args: unknown[]) => Promise>(); +const cleanupMock = mock<() => Promise>(); +const uiInfoMock = mock<(message: string) => void>(); + +const trackedMocks = [ + resolveDepsRootMock, + prepareRunDirMock, + configureTestkitMock, + flowsRunMock, + runWebFlowDepsMock, + cleanupMock, + uiInfoMock, +]; + +function makeDeps(): StagedRunDeps { + return { + resolveDepsRoot: resolveDepsRootMock, + prepareRunDir: prepareRunDirMock, + configureTestkit: configureTestkitMock, + flowsRun: flowsRunMock, + runWebFlowDeps: + runWebFlowDepsMock as unknown as StagedRunDeps["runWebFlowDeps"], + }; +} + +function defaultFlags(): FlowsRunFlags { + return { + retries: 0, + bail: false, + workers: 1, + timeout: 30_000, + video: "off", + trace: "off", + har: "off", + harContent: "omit", + outputDir: "/tmp", + headed: false, + }; +} + +function makeCtx(): CommandContext { + return { + configDir: "/mock/config", + apiBaseUrl: "https://app.qawolf.com", + outputMode: "human", + isInteractive: false, + signals: noopSignals, + fs: makeMemoryFs(), + log: () => makeNoopLogger(), + ui: { ...makeFakeUI("human"), info: uiInfoMock }, + } as unknown as CommandContext; +} + +beforeEach(() => { + for (const m of trackedMocks) m.mockClear(); + cleanupMock.mockResolvedValue(undefined); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); + prepareRunDirMock.mockResolvedValue({ + files: ["/mock/run/exec/flow.ts"], + runDir: "/mock/run", + cleanup: cleanupMock, + }); + configureTestkitMock.mockResolvedValue(undefined); + flowsRunMock.mockResolvedValue(undefined); + runWebFlowDepsMock.mockResolvedValue({}); +}); + +describe("runStagedFlows", () => { + it("runs the staged files and cleans up the run dir on success", async () => { + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: defaultFlags(), + deps: makeDeps(), + }); + + expect(flowsRunMock).toHaveBeenCalledWith( + expect.anything(), + ["/mock/run/exec/flow.ts"], + expect.anything(), + expect.anything(), + ); + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + + it("still cleans up the run dir when flowsRun throws", async () => { + flowsRunMock.mockRejectedValue(new Error("boom")); + + let caughtError: unknown; + try { + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: defaultFlags(), + deps: makeDeps(), + }); + } catch (e) { + caughtError = e; + } + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe("boom"); + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + + it("cleans up the run dir when setup throws before flow execution", async () => { + configureTestkitMock.mockRejectedValue(new Error("testkit failed")); + + let caughtError: unknown; + try { + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: defaultFlags(), + deps: makeDeps(), + }); + } catch (e) { + caughtError = e; + } + + expect(caughtError).toBeInstanceOf(Error); + expect(flowsRunMock).not.toHaveBeenCalled(); + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + + it("notes the managed runtime when resolveDepsRoot resolves a managed dir", async () => { + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/home/.qawolf/runtime", + source: "managed", + installed: true, + }); + + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: defaultFlags(), + deps: makeDeps(), + }); + + expect(uiInfoMock).toHaveBeenCalledWith( + runnerMessages.managedRuntimeNote("/home/.qawolf/runtime"), + ); + }); + + it("does not note a managed runtime when the source is the project dir", async () => { + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: defaultFlags(), + deps: makeDeps(), + }); + + expect(uiInfoMock).not.toHaveBeenCalled(); + }); + + it("threads the --deps override through to resolveDepsRoot", async () => { + await runStagedFlows({ + ctx: makeCtx(), + files: ["/some/flow.ts"], + flags: { ...defaultFlags(), deps: "/custom/deps" }, + deps: makeDeps(), + }); + + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/flow.ts"], + overrideDir: "/custom/deps", + }); + }); +}); diff --git a/src/commands/install/clear.test.ts b/src/commands/install/clear.test.ts new file mode 100644 index 000000000..a050f2603 --- /dev/null +++ b/src/commands/install/clear.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { installMessages } from "~/core/messages/index.js"; +import { managedEnvBaseDir } from "~/domains/runtimeEnv/index.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import { makeCtx } from "~/shell/commandContext.testUtils.js"; + +import { handleInstallClear } from "./clear.js"; + +describe("handleInstallClear", () => { + const originalRuntimeDir = process.env["QAWOLF_RUNTIME_DIR"]; + + beforeEach(() => { + process.env["QAWOLF_RUNTIME_DIR"] = "/tmp/qawolf-clear-test"; + }); + + afterEach(() => { + if (originalRuntimeDir === undefined) { + delete process.env["QAWOLF_RUNTIME_DIR"]; + } else { + process.env["QAWOLF_RUNTIME_DIR"] = originalRuntimeDir; + } + }); + + it("cancels without clearing when the human declines the confirm", async () => { + const ctx = makeCtx("human", { fs: makeMemoryFs() }); + ctx.ui.confirm = mock(() => Promise.resolve({ ok: true, value: false })); + + await handleInstallClear(ctx, { yes: false }); + + expect(ctx.ui.cancel).toHaveBeenCalledWith(installMessages.clear.cancelled); + expect(ctx.ui.withProgress).not.toHaveBeenCalled(); + }); + + it("clears the cache once the human confirms", async () => { + const ctx = makeCtx("human", { fs: makeMemoryFs() }); + ctx.ui.confirm = mock(() => Promise.resolve({ ok: true, value: true })); + + await handleInstallClear(ctx, { yes: false }); + + expect(ctx.ui.withProgress).toHaveBeenCalledTimes(1); + expect(ctx.ui.output).not.toHaveBeenCalled(); + }); + + it("skips the confirm prompt when --yes is passed", async () => { + const ctx = makeCtx("human", { fs: makeMemoryFs() }); + + await handleInstallClear(ctx, { yes: true }); + + expect(ctx.ui.confirm).not.toHaveBeenCalled(); + expect(ctx.ui.withProgress).toHaveBeenCalledTimes(1); + }); + + it("emits structured output and skips the confirm in non-human mode", async () => { + const ctx = makeCtx("agent", { fs: makeMemoryFs() }); + + await handleInstallClear(ctx, { yes: false }); + + expect(ctx.ui.confirm).not.toHaveBeenCalled(); + expect(ctx.ui.output).toHaveBeenCalledWith( + { cleared: false, dir: managedEnvBaseDir() }, + installMessages.clear.nothingToClear, + ); + }); + + it("reports cleared: true when a managed cache exists", async () => { + const fs = makeMemoryFs(); + const base = managedEnvBaseDir(); + await fs.mkdir(`${base}/abc123`, { recursive: true }); + await fs.writeFile( + `${base}/abc123/package.json`, + '{"name":"qawolf-runtime"}', + ); + const ctx = makeCtx("agent", { fs }); + + await handleInstallClear(ctx, { yes: false }); + + expect(ctx.ui.output).toHaveBeenCalledWith( + { cleared: true, dir: base }, + installMessages.clear.cleared, + ); + }); +}); diff --git a/src/shell/reporter/formatErrorWithCause.test.ts b/src/shell/reporter/formatErrorWithCause.test.ts new file mode 100644 index 000000000..852b8a630 --- /dev/null +++ b/src/shell/reporter/formatErrorWithCause.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; + +import { formatErrorWithCause } from "./formatErrorWithCause.js"; + +describe("formatErrorWithCause", () => { + it("returns just the error string when there is no cause", () => { + expect(formatErrorWithCause(new Error("boom"))).toBe("Error: boom"); + }); + + it("appends the cause Error's stack under a Caused by line", () => { + const cause = new Error("inner"); + cause.stack = "Error: inner\n at foo (/proj/src/a.ts:1:1)"; + const err = new Error("outer", { cause }); + + const out = formatErrorWithCause(err); + + expect(out).toContain("Error: outer"); + expect(out).toContain("Caused by:"); + expect(out).toContain("at foo"); + }); + + it("filters node_modules and dist/cli.js frames out of the cause stack", () => { + const cause = new Error("inner"); + cause.stack = + "Error: inner\n" + + " at keep (/proj/src/a.ts:1:1)\n" + + " at dep (/proj/node_modules/x/index.js:2:2)\n" + + " at boot (/proj/dist/cli.js:3:3)"; + const err = new Error("outer", { cause }); + + const out = formatErrorWithCause(err); + + expect(out).toContain("at keep"); + expect(out).not.toContain("node_modules"); + expect(out).not.toContain("dist/cli.js"); + }); + + it("renders an error-like object cause via its string message", () => { + const err = new Error("outer", { cause: { message: "custom failure" } }); + + expect(formatErrorWithCause(err)).toContain("Caused by: custom failure"); + }); + + it("renders a primitive cause via String()", () => { + const err = new Error("outer", { cause: "plain string cause" }); + + expect(formatErrorWithCause(err)).toContain( + "Caused by: plain string cause", + ); + }); + + it("walks a multi-level Error cause chain", () => { + const root = new Error("root"); + const mid = new Error("mid", { cause: root }); + const top = new Error("top", { cause: mid }); + + const out = formatErrorWithCause(top); + + expect(out).toContain("Error: top"); + expect(out).toContain("mid"); + expect(out).toContain("root"); + expect(out.match(/Caused by:/g)).toHaveLength(2); + }); + + it("stops the chain after a non-Error cause", () => { + const swallowed = new Error("should not appear"); + const err = new Error("outer", { + cause: { message: "object cause", cause: swallowed }, + }); + + const out = formatErrorWithCause(err); + + expect(out).toContain("Caused by: object cause"); + expect(out).not.toContain("should not appear"); + }); +});