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. 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/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/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 1726eb3a2..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] @@ -155,6 +172,9 @@ 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; or set QAWOLF_RUNTIME_DIR + to relocate the managed runtime -h, --help display help for command Examples: diff --git a/src/commands/doctor/handler.ts b/src/commands/doctor/handler.ts index 1edab0ca4..300263592 100644 --- a/src/commands/doctor/handler.ts +++ b/src/commands/doctor/handler.ts @@ -6,8 +6,9 @@ import { resolveApiKey } from "~/domains/auth/resolve.js"; import { runChecks } from "~/domains/doctor/checks/index.js"; import { renderResults } from "~/domains/doctor/render.js"; import type { CheckResult } from "~/domains/doctor/types.js"; -import { resolveUniqueEnvDir } from "~/domains/flows/ensureDeps.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; import { expandPatterns, makePeekFlowMeta } from "~/domains/flows/expand.js"; +import { resolveDepsRootIfPresent } from "~/domains/runtimeEnv/index.js"; import { resolveAppiumBin } from "~/shell/appium/resolveAppiumBin.js"; import { type CommandContext, @@ -39,17 +40,17 @@ export async function handleDoctor( const cwd = process.cwd(); const flowFiles = await expandPatterns([], cwd, undefined, fs); - // Playwright lives in the env dir (installed by ensureFlowDeps), not in cwd. - // Silently fall back to cwd when no env dir is found or flows span multiple packages. - let envDir: string | undefined; - try { - envDir = resolveUniqueEnvDir([...flowFiles], fs); - } catch { - // multiple env dirs — fall back to cwd - } + // Playwright/Appium live in the resolved runtime dir (managed env or project), not cwd. + const projectDir = resolveProjectDirSafe([...flowFiles], fs); + const envDir = resolveDepsRootIfPresent( + projectDir !== undefined ? { projectDir } : {}, + fs, + ); 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/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/hybridRun.test.ts b/src/commands/flows/hybridRun.test.ts index 2cc936cdf..19b2624f4 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -1,9 +1,12 @@ +// 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"; 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, @@ -15,7 +18,8 @@ afterEach(() => { const expandPatternsMock = mock(); const pullEnvMock = mock(); -const ensureFlowDepsMock = mock<(envDir: string) => Promise>(); +const resolveDepsRootMock = mock(); +const prepareRunDirMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<() => Promise>(); @@ -23,7 +27,8 @@ const runWebFlowDepsMock = mock<() => Promise>(); const trackedMocks = [ expandPatternsMock, pullEnvMock, - ensureFlowDepsMock, + resolveDepsRootMock, + prepareRunDirMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -33,7 +38,16 @@ beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); pullEnvMock.mockResolvedValue(undefined); - ensureFlowDepsMock.mockResolvedValue(undefined); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); + prepareRunDirMock.mockResolvedValue({ + files: [], + runDir: "/mock/run", + cleanup: async () => {}, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({} as unknown); @@ -47,6 +61,7 @@ function makeCtx(): AuthCommandContext { isInteractive: false, apiKeySource: "env", platform: {} as unknown, + fs: makeMemoryFs(), signals: makeNoopSignals(), ui: makeFakeUI("human"), log: () => makeNoopLogger(), @@ -72,7 +87,8 @@ function makeDeps(): HandleHybridFlowsRunDeps { return { expandPatterns: expandPatternsMock, pullEnv: pullEnvMock, - ensureFlowDeps: ensureFlowDepsMock, + resolveDepsRoot: resolveDepsRootMock, + prepareRunDir: prepareRunDirMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -102,6 +118,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, @@ -126,6 +147,83 @@ describe("handleHybridFlowsRun", () => { expect(flowsRunDeps?.logger).toBeDefined(); }); + 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"; + 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: runStagingRoot(), + }); + }); + + 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(); @@ -133,6 +231,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, @@ -196,6 +299,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 b17558cf8..e9f2cf01c 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -1,46 +1,26 @@ import { join, resolve } from "node:path"; -import { validateEnvId } from "~/domains/flows/pull/pull.js"; +import { buildPatternArgs } from "~/core/patternArgs.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 { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; +import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; +import { prepareRunDir as defaultPrepareRunDir } from "~/domains/runtimeEnv/prepareRunDir.js"; 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 { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { ensureFlowDeps as defaultEnsureFlowDeps } from "~/domains/flows/ensureDeps.js"; import type { Fs } from "~/shell/fs.js"; -import type { Logger } from "~/shell/logger.js"; -import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js"; -import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; -import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; -import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; -import { buildPatternArgs } from "~/core/patternArgs.js"; -import { runnerMessages } from "~/core/messages/index.js"; -import { loadEnvFile } from "./loadEnvFile.js"; +import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; +import { resolveDepsRoot } from "~/commands/resolveDepsRoot.js"; -export type HandleHybridFlowsRunDeps = { - expandPatterns: ( - patterns: string[], - cwd: string, - logger?: Logger, - ) => Promise; +import { type HandleFlowsRunDeps } from "./runDefaults.js"; +import { runStagedFlows } from "./runStagedFlows.js"; + +export type HandleHybridFlowsRunDeps = HandleFlowsRunDeps & { pullEnv: (ctx: AuthCommandContext, envId: string) => Promise; - ensureFlowDeps: (envDir: string) => Promise; - configureTestkit: (dir: string) => Promise; - flowsRun: typeof defaultFlowsRun; - runWebFlowDeps: typeof defaultRunWebFlowDeps; }; function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { @@ -48,7 +28,8 @@ function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), pullEnv: (ctx, envId) => handleFlowsPull(ctx, { env: envId, yes: true }), - ensureFlowDeps: (envDir) => defaultEnsureFlowDeps(envDir, fs), + resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), + prepareRunDir: (args) => defaultPrepareRunDir({ ...args, fs }), configureTestkit: defaultConfigureTestkit, flowsRun: defaultFlowsRun, runWebFlowDeps: defaultRunWebFlowDeps, @@ -69,22 +50,16 @@ export async function handleHybridFlowsRun( const envDir = resolve(join(".qawolf", flags.env)); const patternArgs = buildPatternArgs(pattern); + const globFlows = (): Promise => + 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: @@ -96,46 +71,5 @@ export async function handleHybridFlowsRun( } } - ctx.ui.gap(); - ctx.ui.intro("flows run"); - - await ctx.ui.withProgress( - [ - { - message: runnerMessages.preparingEnvironment, - task: () => resolvedDeps.ensureFlowDeps(envDir), - }, - ], - () => runnerMessages.environmentReady, - ); - await loadEnvFile(envDir); - await resolvedDeps.configureTestkit(envDir); - const android = createAndroidDeps(envDir, ctx.signals); - - return resolvedDeps.flowsRun(ctx, files, flags, { - peekFlowMeta: makePeekFlowMeta(ctx.fs), - installBrowsers: (innerCtx, browsers) => - installBrowserList(innerCtx, browsers, { - spawn: defaultSpawn, - platform: process.platform, - playwrightCliPath: resolvePlaywrightCli(envDir, process.platform), - }), - runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(envDir, ctx.signals), - runAndroidFlow: defaultRunAndroidFlow, - runAndroidFlowDeps: android.deps, - bootAndroid: android.boot, - shutdownAndroid: android.shutdown, - createPooledDispatch: makePooledDispatch(envDir), - findFlowStamp: defaultFindFlowStamp, - warn: (message) => ctx.ui.warn(message), - logger: ctx.log("runner"), - // Route reporter output through ctx.ui so streamed test logs stay inside the run's timeline. - reporter: buildRunReporter(flags, { - fs: ctx.fs, - stdout: { write: (text: string) => ctx.ui.write(text) }, - stderr: { write: (text: string) => ctx.ui.write(text) }, - }), - now: () => Date.now(), - }); + return runStagedFlows({ ctx, files, flags, envDir, deps: resolvedDeps }); } diff --git a/src/commands/flows/run.register.ts b/src/commands/flows/run.register.ts index 462c94510..c0bd26253 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; 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 70a640ba6..029c95ff5 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"; @@ -5,6 +6,8 @@ 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 { runStagingRoot } from "~/domains/runtimeEnv/index.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; const noopSignals = makeNoopSignals(); @@ -12,30 +15,32 @@ const noopSignals = makeNoopSignals(); // handleFlowsRun accepts injectable deps, so no mock.module() is needed. const expandPatternsMock = mock(); -const resolveUniqueEnvDirMock = mock<(files: string[]) => string | undefined>(); -const ensureFlowDepsMock = mock<(envDir: string) => Promise>(); +const resolveDepsRootMock = mock(); +const prepareRunDirMock = 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, + resolveDepsRootMock, + prepareRunDirMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, uiInfoMock, uiIntroMock, + uiNoteMock, ]; function makeDeps(): HandleFlowsRunDeps { return { expandPatterns: expandPatternsMock, - resolveUniqueEnvDir: resolveUniqueEnvDirMock, - ensureFlowDeps: ensureFlowDepsMock, + resolveDepsRoot: resolveDepsRootMock, + prepareRunDir: prepareRunDirMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -65,26 +70,42 @@ function makeCtx(): CommandContext { outputMode: "human", isInteractive: false, signals: noopSignals, + fs: makeMemoryFs(), log: () => makeNoopLogger(), - ui: { ...makeFakeUI("human"), info: uiInfoMock, intro: uiIntroMock }, + ui: { + ...makeFakeUI("human"), + info: uiInfoMock, + intro: uiIntroMock, + note: uiNoteMock, + }, } as unknown as CommandContext; } beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); - resolveUniqueEnvDirMock.mockReturnValue(undefined); - ensureFlowDepsMock.mockResolvedValue(undefined); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); + prepareRunDirMock.mockResolvedValue({ + files: [], + runDir: "/mock/run", + cleanup: async () => {}, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({}); }); describe("handleFlowsRun", () => { - it("returns error with exitCode 2 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"); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/managed", + source: "managed", + installed: true, }); const result = await handleFlowsRun( @@ -94,12 +115,11 @@ describe("handleFlowsRun", () => { makeDeps(), ); - expect(result).toEqual({ - error: "files span multiple env dirs", - exitCode: 2, + expect(result).toBeUndefined(); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/file.flow.ts"], }); - expect(ensureFlowDepsMock).not.toHaveBeenCalled(); - expect(flowsRunMock).not.toHaveBeenCalled(); + expect(flowsRunMock).toHaveBeenCalledTimes(1); }); it("returns early and skips all setup when no flows match", async () => { @@ -112,38 +132,93 @@ describe("handleFlowsRun", () => { expect(result).toBeUndefined(); expect(uiInfoMock).toHaveBeenCalledWith(runnerMessages.noFlowsMatched); - expect(ensureFlowDepsMock).not.toHaveBeenCalled(); + expect(resolveDepsRootMock).not.toHaveBeenCalled(); expect(configureTestkitMock).not.toHaveBeenCalled(); expect(flowsRunMock).not.toHaveBeenCalled(); }); - it("skips ensureFlowDeps when flows found but envDir is 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(ensureFlowDepsMock).not.toHaveBeenCalled(); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: ["/some/flow.ts"], + }); 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("configures testkit with the depsRoot returned by resolveDepsRoot", async () => { const envDir = "/mock/.qawolf/env1"; expandPatternsMock.mockResolvedValue([`${envDir}/login.flow.ts`]); - resolveUniqueEnvDirMock.mockReturnValue(envDir); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: envDir, + source: "project", + installed: false, + }); await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); - expect(ensureFlowDepsMock).toHaveBeenCalledWith(envDir); + expect(resolveDepsRootMock).toHaveBeenCalledWith({ + files: [`${envDir}/login.flow.ts`], + }); expect(configureTestkitMock).toHaveBeenCalledWith(envDir); expect(flowsRunMock).toHaveBeenCalledTimes(1); }); + it("emits managed runtime info when resolveDepsRoot source is managed", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/home/.qawolf/runtime", + source: "managed", + installed: false, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(uiInfoMock).toHaveBeenCalledWith( + runnerMessages.managedRuntimeNote("/home/.qawolf/runtime"), + ); + }); + + it("does not emit managed runtime info when source is project", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); + + await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps()); + + expect(uiInfoMock).not.toHaveBeenCalled(); + }); + + it("threads --deps flag to resolveDepsRoot as overrideDir", async () => { + expandPatternsMock.mockResolvedValue(["/some/flow.ts"]); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/custom/deps", + source: "override", + installed: false, + }); + + await handleFlowsRun( + makeCtx(), + undefined, + { ...defaultFlags(), deps: "/custom/deps" }, + makeDeps(), + ); + + 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()); @@ -157,4 +232,55 @@ describe("handleFlowsRun", () => { expect(uiIntroMock).not.toHaveBeenCalled(); }); + + it("calls prepareRunDir with expanded files, depsRoot, and the sibling run-staging root", 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: runStagingRoot(), + }); + }); + + 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 e0149a063..182b5d0a0 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,16 @@ function makeDeps( ): HandleFlowsRunDeps { return { expandPatterns: async () => ["/fake/flow.flow.ts"], - resolveUniqueEnvDir: () => undefined, - ensureFlowDeps: async () => {}, + resolveDepsRoot: async () => ({ + depsRoot: "/env", + 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 7741304f5..c4ef07ca9 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -1,53 +1,33 @@ -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 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 { - ensureFlowDeps as defaultEnsureFlowDeps, - resolveUniqueEnvDir as defaultResolveUniqueEnvDir, -} from "~/domains/flows/ensureDeps.js"; -import type { Fs } from "~/shell/fs.js"; -import type { Logger } from "~/shell/logger.js"; -import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.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 { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.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 } from "~/commands/resolveDepsRoot.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; - resolveUniqueEnvDir: (files: string[]) => string | undefined; - ensureFlowDeps: (envDir: string) => Promise; - configureTestkit: (dir: string) => Promise; - runWebFlowDeps: typeof defaultRunWebFlowDeps; - flowsRun: typeof defaultFlowsRun; }; function makeDefaultDeps(fs: Fs): HandleFlowsRunDeps { return { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), - resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, fs), - ensureFlowDeps: (envDir) => defaultEnsureFlowDeps(envDir, fs), + resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), + prepareRunDir: (args) => defaultPrepareRunDir({ ...args, fs }), configureTestkit: defaultConfigureTestkit, runWebFlowDeps: defaultRunWebFlowDeps, flowsRun: defaultFlowsRun, @@ -77,60 +57,10 @@ export async function handleFlowsRun( return; } - let envDir: string | undefined; - try { - envDir = resolvedDeps.resolveUniqueEnvDir(expandedFiles); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - return { error, exitCode: 2 }; - } - - 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, - ); - await loadEnvFile(dir); - } - - // Resolve playwright from the env dir; falls back to CWD for local flows. - const resolvedDir = envDir ?? cwd; - - 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(), + return runStagedFlows({ + ctx, + files: expandedFiles, + flags, + deps: resolvedDeps, }); } 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/flows/runStagedFlows.ts b/src/commands/flows/runStagedFlows.ts new file mode 100644 index 000000000..623a2447a --- /dev/null +++ b/src/commands/flows/runStagedFlows.ts @@ -0,0 +1,103 @@ +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, + runStagingRoot, +} 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: runStagingRoot(), + }); + + // 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, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); + } finally { + unregisterCleanup(); + await staged.cleanup(); + } +} 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/all.test.ts b/src/commands/install/all.test.ts index 5957ffac4..917b23a67 100644 --- a/src/commands/install/all.test.ts +++ b/src/commands/install/all.test.ts @@ -1,37 +1,30 @@ 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 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 peekFlowMetaMock = mock<(filePath: string) => Promise>(); +const resolveDepsRootMock = + mock<(files: string[]) => Promise>(); +const installBrowsersMock = mock(); +const installAndroidMock = mock(); const trackedMocks = [ expandPatternsMock, peekFlowMetaMock, - resolveUniqueEnvDirMock, + resolveDepsRootMock, installBrowsersMock, installAndroidMock, ]; @@ -63,16 +56,20 @@ function makeDeps(): InstallAllDeps { cwd: "/project", expandPatterns: expandPatternsMock, peekFlowMeta: peekFlowMetaMock, - resolveUniqueEnvDir: resolveUniqueEnvDirMock, + resolveDepsRoot: resolveDepsRootMock, 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); + resolveDepsRootMock.mockResolvedValue(mockEnvResult()); installBrowsersMock.mockResolvedValue(undefined); installAndroidMock.mockResolvedValue(undefined); }); @@ -87,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); @@ -107,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, @@ -117,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( @@ -125,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); @@ -187,63 +187,61 @@ 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 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("/project/.qawolf/staging"); + resolveDepsRootMock.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(resolveDepsRootMock).toHaveBeenCalledWith([ + "web.flow.ts", + "android.flow.ts", + ]); + 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 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(installBrowsersMock).toHaveBeenCalledWith( - ctx, - undefined, - "/project", - ); + expect(resolveDepsRootMock).toHaveBeenCalledWith(["web.flow.ts"]); + expect(installBrowsersMock).toHaveBeenCalledWith(ctx, undefined, "/env"); }); - it("should return exitCode 2 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(result).toEqual({ - error: "Pattern matches flows from 2 packages", - exitCode: 2, - }); - 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 48834d8cb..d53d329cf 100644 --- a/src/commands/install/all.ts +++ b/src/commands/install/all.ts @@ -2,7 +2,7 @@ import { expandPatterns as defaultExpandPatterns, makePeekFlowMeta, } from "~/domains/flows/expand.js"; -import { resolveUniqueEnvDir as defaultResolveUniqueEnvDir } from "~/domains/flows/ensureDeps.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"; @@ -10,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"; @@ -20,7 +21,9 @@ export type InstallAllDeps = { cwd?: string, ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; - readonly resolveUniqueEnvDir: (files: string[]) => string | undefined; + readonly resolveDepsRoot: ( + files: string[], + ) => Promise; readonly installBrowsers: ( ctx: CommandContext, pattern: string | undefined, @@ -41,13 +44,6 @@ export async function installAll( const patterns = buildPatternArgs(pattern); const files = await deps.expandPatterns(patterns, deps.cwd); - let envDir: string; - try { - envDir = deps.resolveUniqueEnvDir(files) ?? deps.cwd; - } catch (err: unknown) { - return { error: errorMessage(err), exitCode: 2 }; - } - let hasWeb = false; let hasAndroid = false; let hasIos = false; @@ -73,11 +69,13 @@ export async function installAll( return; } + const { depsRoot } = await deps.resolveDepsRoot(files); + let firstError: { error: string; exitCode?: number } | undefined; 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 +84,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) }; @@ -110,7 +108,7 @@ export async function handleInstall( expandPatterns: (patterns, cwd) => defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - resolveUniqueEnvDir: (files) => defaultResolveUniqueEnvDir(files, 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 26ff663a7..44e2d7536 100644 --- a/src/commands/install/android.ts +++ b/src/commands/install/android.ts @@ -1,11 +1,12 @@ 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 { 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"; import { defaultSpawn } from "~/shell/spawn.js"; import { installAndroid } from "~/domains/install/android/index.js"; @@ -23,18 +24,6 @@ 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; - } - }; - return installAndroid(ctx, pattern, { cwd: process.cwd(), spawn: defaultSpawn, @@ -58,7 +47,8 @@ export async function handleInstallAndroid( expandPatterns: (patterns, cwd) => defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - resolveEnvDir, + 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 8af376eba..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 { buildPatternArgs } from "~/core/patternArgs.js"; -import { errorMessage } from "~/core/errors.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"; @@ -17,20 +15,7 @@ export async function handleInstallBrowsers( ): Promise { const cwd = process.cwd(); const { fs } = ctx; - let resolvedDir = envDir; - if (!resolvedDir) { - const files = await defaultExpandPatterns( - buildPatternArgs(pattern), - cwd, - undefined, - fs, - ); - try { - resolvedDir = resolveUniqueEnvDir(files, fs) ?? cwd; - } catch (err: unknown) { - return { error: errorMessage(err), exitCode: 2 }; - } - } + return installBrowsers(ctx, pattern, { cwd, spawn: defaultSpawn, @@ -38,6 +23,10 @@ export async function handleInstallBrowsers( expandPatterns: (patterns, dir) => defaultExpandPatterns(patterns, dir ?? cwd, undefined, fs), peekFlowMeta: makePeekFlowMeta(fs), - playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform), + resolvePlaywrightCliPath: async (files) => { + const depsRoot = + envDir ?? (await resolveDepsRoot({ files, fs })).depsRoot; + return resolvePlaywrightCli(depsRoot, process.platform); + }, }); } 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/commands/install/clear.ts b/src/commands/install/clear.ts new file mode 100644 index 000000000..c0b982f1a --- /dev/null +++ b/src/commands/install/clear.ts @@ -0,0 +1,54 @@ +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); + ctx.ui.note(dir, installMessages.clear.locationLabel); + + const result = await ctx.ui.confirm(installMessages.clear.confirmPrompt, { + destructive: true, + }); + if (!result.ok || !result.value) { + ctx.ui.cancel(installMessages.clear.cancelled); + return; + } + } + + const [{ existed }] = await ctx.ui.withProgress( + [ + { + message: installMessages.clear.removing, + task: () => clearRuntimeEnv(ctx.fs), + }, + ], + ([result]) => + result.existed + ? installMessages.clear.cleared + : installMessages.clear.nothingToClear, + ); + + if (ctx.ui.mode !== "human") { + ctx.ui.output( + { cleared: existed, dir }, + existed + ? installMessages.clear.cleared + : installMessages.clear.nothingToClear, + ); + } +} 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/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..42150d7ac --- /dev/null +++ b/src/commands/resolveDepsRoot.ts @@ -0,0 +1,34 @@ +import { resolveProjectDirSafe } 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(); + const projectDir = resolveProjectDirSafe(args.files, fs); + return ensureRuntimeEnv( + { + ...(projectDir !== undefined ? { projectDir } : {}), + ...(args.overrideDir !== undefined + ? { overrideDir: args.overrideDir } + : {}), + }, + { fs }, + ); +} 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/install.ts b/src/core/messages/install.ts index ae90719b4..e26be3bc0 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", + locationLabel: "Location", + confirmPrompt: "Remove the managed runtime cache?", + cancelled: "Clear cancelled.", + 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.", licensesAlreadyAccepted: "Android SDK licenses already accepted.", diff --git a/src/core/messages/runner.ts b/src/core/messages/runner.ts index 875f3f00e..66cea4b2c 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) => + `Using managed runtime — override with --deps or QAWOLF_RUNTIME_DIR:\n${dir}`, } 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..7b11f1333 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( @@ -82,73 +34,17 @@ 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()), - ); +/** + * 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; } - await shimFlowsDeps(envDir, fs); } diff --git a/src/domains/flows/shimDeps.ts b/src/domains/flows/shimDeps.ts deleted file mode 100644 index 245be2077..000000000 --- a/src/domains/flows/shimDeps.ts +++ /dev/null @@ -1,175 +0,0 @@ -// oxlint-disable eslint/max-lines -- readShimMarker helper is colocated with shimFlowsDeps; extracting it would split tightly coupled logic -import { join } from "node:path"; - -import { makeDefaultFs, type Fs } from "~/shell/fs.js"; -import { resolveFromEnvDir } from "~/shell/resolveExport.js"; - -// Bun binary scoped-package traversal bug (WIZ-10612): when a module inside -// node_modules/@scope/pkg/dist/ imports a bare specifier, Bun stops walking -// at the @scope/ level and never reaches the outer node_modules/. -// -// This affects two classes of deps for @qawolf/flows: -// 1. UNSCOPED direct deps (e.g. expect, pngjs): Bun stops at -// envDir/node_modules/@qawolf/ and never reaches envDir/node_modules/expect. -// 2. SCOPED direct deps (e.g. @qawolf/flow-targets): the package itself IS -// found (Bun searches within the stopped scope), but its own unscoped -// transitive deps (e.g. zod) fail for the same reason — from inside -// @qawolf/flow-targets/dist/, zod is unreachable. -// -// Shim all @qawolf/flows direct deps at @qawolf/flows/node_modules// -// so each bare specifier is found at traversal step 3, before the bug -// triggers. Each shim is a Bun.build() bundle — fully inlined with no bare -// specifier imports — so all transitive deps are covered. Simpler shims (ESM -// re-export, CJS require) fail because Bun propagates the @qawolf/flows -// resolution context to the loaded module, so transitive bare imports still fail. -// In Node.js mode Bun.build() is absent AND Node.js resolves correctly, so -// shimming is skipped entirely. A CJS require() shim for an ESM-only package -// like @qawolf/flow-targets would break named imports in Node.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. -export type BuildFn = (config: { - entrypoints: string[]; - target?: string; - format?: string; -}) => Promise<{ - success: boolean; - outputs: Blob[]; - 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. -function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { - try { - const pkg = JSON.parse( - fs.readFileSync(join(shimDir, "package.json")), - ) as Partial; - if (pkg._qawolf_format) { - return { - _qawolf_version: pkg._qawolf_version ?? "", - _qawolf_format: pkg._qawolf_format, - }; - } - } catch { - // missing or unreadable — not a managed shim - } - return undefined; -} - -export async function shimFlowsDeps( - envDir: string, - fs: Fs = makeDefaultFs(), - // undefined = auto-detect from globalThis.Bun; false = Node.js mode (no Bun) - bunBuild?: BuildFn | false, -): Promise { - const flowsDir = join(envDir, "node_modules", "@qawolf", "flows"); - if (!fs.existsSync(flowsDir)) return; - - let flowsDeps: string[]; - try { - const flowsPkg = JSON.parse( - fs.readFileSync(join(flowsDir, "package.json")), - ) as { dependencies?: Record }; - flowsDeps = Object.keys(flowsPkg.dependencies ?? {}); - } catch { - 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; - // 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 }); - } - } - } - 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); - } -} 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/runner/bundleFlow.ts b/src/domains/runner/bundleFlow.ts new file mode 100644 index 000000000..76022e478 --- /dev/null +++ b/src/domains/runner/bundleFlow.ts @@ -0,0 +1,95 @@ +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/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/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/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/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 new file mode 100644 index 000000000..cd2dd376c --- /dev/null +++ b/src/domains/runner/executorPlugin.ts @@ -0,0 +1,86 @@ +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. + // 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/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..9a89dd81d 100644 --- a/src/domains/runner/loadFlowDefault.test.ts +++ b/src/domains/runner/loadFlowDefault.test.ts @@ -1,71 +1,15 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { mkdir, 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 { defaultFlowBundler } from "./bundleFlow.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 { @@ -73,9 +17,10 @@ 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"), + bundleFlow: undefined, + }); expect(result).toEqual({ name: "test-flow" }); } finally { await rm(tmp, { recursive: true }); @@ -88,7 +33,10 @@ 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"), + bundleFlow: undefined, + }); } catch (e) { caught = e; } @@ -100,92 +48,122 @@ 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" }, - }), - ); - await writeFile( - path.join(flowsDir, "index.js"), - "export const flows = {};\n", - ); - await writeFile( - path.join(flowsDir, "helpers.js"), - "export const help = true;\n", +describe("loadFlowDefault (bundle path)", () => { + function tempBundlePath(flowPath: string): string { + return path.join( + path.dirname(flowPath), + `.${path.basename(flowPath)}.qawolf-bundle.mjs`, ); - 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(); + 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 { ok: true };\n`, - ); - const result = await loadFlowDefault<{ ok: boolean }>(flowPath); - expect(result).toEqual({ ok: true }); + bundleFlow, + }); + expect(result).toEqual({ name: "x" }); + expect(await pathExists(tempBundlePath(flowPath))).toBe(false); } 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(); + 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, - `import {} from '@qawolf/flows/helpers';\nexport default { sub: true };\n`, - ); - const result = await loadFlowDefault<{ sub: boolean }>(flowPath); - expect(result).toEqual({ sub: 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("sources the data: URI back to the original flow path", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); +// ── 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 { - const flowPath = path.join(flowsDir2, "flow.mjs"); + // 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 {} from '@qawolf/flows';\nexport default 42;\n`, + `import { page } from "@qawolf/flows/web"; +import { helper } from "inline-dep"; +export default { page, helper }; +`, ); - const result = await loadFlowDefault(flowPath); - expect(result).toBe(42); - } 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(); - 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 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(tmp, { recursive: true }); + await rm(depsRoot, { recursive: true }); } }); }); diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 73cc58fe3..57bcba695 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -3,104 +3,78 @@ 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"; +import { type FlowBundler, defaultFlowBundler } from "./bundleFlow.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; - } -} +/** + * 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; -// 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; - } - }, - ); -} +type LoadFlowDefaultArgs = { + flowPath: string; + fs?: Fs; + // 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 only when bundleFlow is defined. + depsRoot?: string; +}; -export async function loadFlowDefault( +/** + * 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, - fs: Fs = makeDefaultFs(), ): Promise { - // 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"; + 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; +} - // 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; +/** + * 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 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 = 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, + depsRoot = "", + } = args; - 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; + if (bundleFlow === undefined) { + 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; + const code = await bundleFlow(flowPath, depsRoot); + return importBundledFlow(flowPath, code, fs); } 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, }); + }; } 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, 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 10c86fe2e..ef1dfa3bb 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, @@ -123,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 ae8d3fa5c..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,12 +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/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..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, @@ -39,6 +40,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 +144,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 = pluralize(attempts, "attempt"); + 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.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 6c851a06a..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,9 +41,25 @@ export async function runWebFlow({ options: RunWebFlowOptions; flowPath: string; }): Promise { - await initFlowRuntime(flowPath, { timeout: options.timeout }); - - 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/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/clearRuntimeEnv.test.ts b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts new file mode 100644 index 000000000..26e718f08 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts @@ -0,0 +1,74 @@ +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("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"}'); + + 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); + }); + + 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..620d0f4b3 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.ts @@ -0,0 +1,60 @@ +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) 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; + } +} diff --git a/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts new file mode 100644 index 000000000..4e9660ea3 --- /dev/null +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.test.ts @@ -0,0 +1,148 @@ +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(); + 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( + {}, + { fs, install, resolveManagedDir: () => managedDir }, + ); + + expect(result).toEqual({ + depsRoot: managedDir, + source: "managed", + installed: 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 () => { + 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..bbfba3815 --- /dev/null +++ b/src/domains/runtimeEnv/ensureRuntimeEnv.ts @@ -0,0 +1,71 @@ +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); + 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/index.ts b/src/domains/runtimeEnv/index.ts new file mode 100644 index 000000000..3ccf77f3c --- /dev/null +++ b/src/domains/runtimeEnv/index.ts @@ -0,0 +1,4 @@ +export * from "./clearRuntimeEnv.js"; +export * from "./ensureRuntimeEnv.js"; +export * from "./managedEnvDir.js"; +export * from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/innerHop.test.ts b/src/domains/runtimeEnv/innerHop.test.ts new file mode 100644 index 000000000..8398b2d66 --- /dev/null +++ b/src/domains/runtimeEnv/innerHop.test.ts @@ -0,0 +1,168 @@ +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 { scaffoldManagedRuntime } from "./scaffoldManagedRuntime.testUtils.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; +} + +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/installPinned.test.ts b/src/domains/runtimeEnv/installPinned.test.ts new file mode 100644 index 000000000..77c1e40ce --- /dev/null +++ b/src/domains/runtimeEnv/installPinned.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +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}`; + +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(); + + 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 target is fully resolved", async () => { + const fs = makeMemoryFs(); + // Simulate a previously completed install with all pinned versions present + seedFullEnv(fs, targetDir); + + 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..bbcd64deb --- /dev/null +++ b/src/domains/runtimeEnv/installPinned.ts @@ -0,0 +1,55 @@ +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; + +import { spawnNpmInstall, type SpawnInstallResult } from "./npmInstall.js"; +import { scaffoldManagedEnv } from "./managedEnvDir.js"; +import { allPinnedResolved } from "./resolvePinned.js"; +import { shimFlowsDeps } from "./shimDeps.js"; + +type SpawnInstallFn = (cwd: string) => Promise; + +export type InstallPinnedDeps = { fs: Fs; spawnInstall: SpawnInstallFn }; + +export const defaultSpawnInstall = spawnNpmInstall; + +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 (allPinnedResolved(targetDir, deps.fs)) { + 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 = allPinnedResolved(targetDir, deps.fs); + 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..02ea479f5 --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it } from "bun:test"; +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"; + +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 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", () => { + 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(resolve("/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(); + expect(managedEnvDir()).toContain(join("runtime", hash)); + }); +}); + +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(); + 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..98278501a --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -0,0 +1,88 @@ +import { createHash } from "node:crypto"; +import { join, resolve } from "node:path"; + +import type { Fs } from "~/shell/fs.js"; +import { getDataDir } from "~/core/paths.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; + +/** + * 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}`), + `channel:${runtimeChannel()}`, + ].join("\n"); + 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. + */ +export function managedEnvBaseDir(): string { + const override = process.env["QAWOLF_RUNTIME_DIR"]?.trim(); + if (override) return resolve(override); + 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()); +} + +/** + * 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/npmInstall.ts b/src/domains/runtimeEnv/npmInstall.ts new file mode 100644 index 000000000..fd5735b79 --- /dev/null +++ b/src/domains/runtimeEnv/npmInstall.ts @@ -0,0 +1,26 @@ +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 = ""; + // 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); + }); + 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 new file mode 100644 index 000000000..8ccf92107 --- /dev/null +++ b/src/domains/runtimeEnv/outerHop.ts @@ -0,0 +1,117 @@ +import { type Stats } from "node:fs"; +import { lstat } from "node:fs/promises"; +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"; + +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()}`, + ); + } +} 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/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.test.ts b/src/domains/runtimeEnv/prepareRunDir.test.ts new file mode 100644 index 000000000..20a71db34 --- /dev/null +++ b/src/domains/runtimeEnv/prepareRunDir.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it } from "bun:test"; +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 { prepareRunDir } from "./prepareRunDir.js"; +import { scaffoldManagedRuntime } from "./scaffoldManagedRuntime.testUtils.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 scaffoldManagedRuntime(depsRoot); + return depsRoot; +} + +describe("prepareRunDir", () => { + describe("inner-hop symlink", () => { + it("builds exec/node_modules as a real dir with per-pinned-package symlinks", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const flowFile = join(runRoot, "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)).isDirectory()).toBe(true); + expect(await readlink(join(innerHop, "@qawolf", "flows"))).toBe( + join(depsRoot, "node_modules", "@qawolf", "flows"), + ); + }); + }); + + 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 }); + 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 + await writeFile(join(runRoot, "flow.ts"), "// flow"); + const result1 = await prepareRunDir({ + files: [join(runRoot, "flow.ts")], + 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(); + await writeFile(join(projectDir, "flow.ts"), "// flow"); + const result2 = await prepareRunDir({ + files: [join(projectDir, "flow.ts")], + 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 flowFile = join(runRoot, "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 }); + 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 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)).isDirectory()).toBe(true); + expect(await readlink(join(execNm, "@qawolf", "flows"))).toBe( + join(depsRoot, "node_modules", "@qawolf", "flows"), + ); + }); + }); + + describe("cleanup", () => { + it("removes the runDir on cleanup()", async () => { + const runRoot = await makeTmpDir(); + const depsRoot = await makeDepsRoot(); + const flowFile = join(runRoot, "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 makeDepsRoot(); + + // 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 }); + 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); + + // Inner hop is a real directory; @qawolf/flows symlinks into the managed tree + const innerHop = join(result.runDir, "exec", "node_modules"); + 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); + }); + }); +}); diff --git a/src/domains/runtimeEnv/prepareRunDir.ts b/src/domains/runtimeEnv/prepareRunDir.ts new file mode 100644 index 000000000..6930488c6 --- /dev/null +++ b/src/domains/runtimeEnv/prepareRunDir.ts @@ -0,0 +1,125 @@ +import { createHash } from "node:crypto"; +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"; + +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: only pinned executor packages resolve here — see populateInnerHop. + await populateInnerHop({ depsRoot, execDir, fs }); + + 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); + // 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}`; +} + +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)); + } + + 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)))), + ); + 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/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..5d7e2e2db --- /dev/null +++ b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts @@ -0,0 +1,23 @@ +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; + +import { type EnsureRuntimeEnvArgs } from "./ensureRuntimeEnv.js"; +import { managedEnvDir } from "./managedEnvDir.js"; +import { allPinnedResolved } from "./resolvePinned.js"; + +/** + * 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: EnsureRuntimeEnvArgs, + 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..49cb91421 --- /dev/null +++ b/src/domains/runtimeEnv/resolvePinned.test.ts @@ -0,0 +1,115 @@ +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); + }); + + 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 new file mode 100644 index 000000000..5593480cb --- /dev/null +++ b/src/domains/runtimeEnv/resolvePinned.ts @@ -0,0 +1,50 @@ +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 { + // 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( + ({ name, version }) => readInstalledVersion(dir, name, fs) === version, + ); +} 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" }), + ); + } +} diff --git a/src/domains/flows/shimDeps.test.ts b/src/domains/runtimeEnv/shimDeps.test.ts similarity index 93% rename from src/domains/flows/shimDeps.test.ts rename to src/domains/runtimeEnv/shimDeps.test.ts index 0e8162fec..5ae59fd6d 100644 --- a/src/domains/flows/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 new file mode 100644 index 000000000..7ecde2c94 --- /dev/null +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -0,0 +1,212 @@ +// 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"; +import { resolveFromEnvDir } from "~/shell/resolveExport.js"; + +// Bun binary scoped-package traversal bug (WIZ-10612): when a module inside +// node_modules/@scope/pkg/dist/ imports a bare specifier, Bun stops walking +// at the @scope/ level and never reaches the outer node_modules/. +// +// This affects two classes of deps for @qawolf/flows: +// 1. UNSCOPED direct deps (e.g. expect, pngjs): Bun stops at +// envDir/node_modules/@qawolf/ and never reaches envDir/node_modules/expect. +// 2. SCOPED direct deps (e.g. @qawolf/flow-targets): the package itself IS +// found (Bun searches within the stopped scope), but its own unscoped +// transitive deps (e.g. zod) fail for the same reason — from inside +// @qawolf/flow-targets/dist/, zod is unreachable. +// +// Shim all @qawolf/flows direct deps at @qawolf/flows/node_modules// +// so each bare specifier is found at traversal step 3, before the bug +// triggers. Each shim is a Bun.build() bundle — fully inlined with no bare +// specifier imports — so all transitive deps are covered. Simpler shims (ESM +// re-export, CJS require) fail because Bun propagates the @qawolf/flows +// resolution context to the loaded module, so transitive bare imports still fail. +// In Node.js mode Bun.build() is absent AND Node.js resolves correctly, so +// shimming is skipped entirely. A CJS require() shim for an ESM-only package +// like @qawolf/flow-targets would break named imports in Node.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. + */ +export type BuildFn = (config: { + entrypoints: string[]; + target?: string; + format?: string; +}) => Promise<{ + success: boolean; + outputs: Blob[]; + 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. + */ +function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { + try { + const pkg = JSON.parse( + fs.readFileSync(join(shimDir, "package.json")), + ) as Partial; + if (pkg._qawolf_format) { + return { + _qawolf_version: pkg._qawolf_version ?? "", + _qawolf_format: pkg._qawolf_format, + }; + } + } catch { + // missing or unreadable — not a managed shim + } + 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 }; +} + +/** + * 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. 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; + 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("; "); + throw new Error(`bun.build failed to shim ${dep}: ${logs}`); + } + 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(), + // undefined = auto-detect from globalThis.Bun; false = Node.js mode (no Bun) + bunBuild?: BuildFn | false, +): Promise { + const flowsDir = join(envDir, "node_modules", "@qawolf", "flows"); + if (!fs.existsSync(flowsDir)) return; + + let flowsDeps: string[]; + try { + const flowsPkg = JSON.parse( + fs.readFileSync(join(flowsDir, "package.json")), + ) as { dependencies?: Record }; + flowsDeps = Object.keys(flowsPkg.dependencies ?? {}); + } catch { + return; + } + + const bun = resolveBunBuild(bunBuild); + if (!bun) { + await removeStaleShims(flowsDir, flowsDeps, fs); + return; + } + + for (const dep of flowsDeps) { + await buildDepShim({ envDir, flowsDir, dep, fs, build: bun.build }); + } +} 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); +} 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)), + }), + ), + ); +} diff --git a/src/shell/embeddedWorkerCli.ts b/src/shell/embeddedWorkerCli.ts new file mode 100644 index 000000000..142cf7537 --- /dev/null +++ b/src/shell/embeddedWorkerCli.ts @@ -0,0 +1,31 @@ +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/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.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"); + }); +}); 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"); +} 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>; 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..98b6731f7 100644 --- a/src/shell/workerCommand.ts +++ b/src/shell/workerCommand.ts @@ -1,16 +1,34 @@ -export type WorkerCommand = { command: string; prefixArgs: string[] }; +import { extractEmbeddedWorkerCli } from "./embeddedWorkerCli.js"; + +export type WorkerCommand = { + command: string; + prefixArgs: string[]; + // 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; +}; /** - * 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 +36,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, }); }