diff --git a/docs/plans/2026-06-23-runtime-deps-target-design.md b/docs/plans/2026-06-23-runtime-deps-target-design.md new file mode 100644 index 000000000..c831d4763 --- /dev/null +++ b/docs/plans/2026-06-23-runtime-deps-target-design.md @@ -0,0 +1,170 @@ +--- +title: Runtime-dependency resolution — target architecture (design) +date: 2026-06-23 +branch: wiz-10907-potential-incompatibility-with-other-monorepo-and-single +status: approved-pending-spike +supersedes-investigation: docs/plans/2026-06-23-runtime-deps-architecture-redesign.local.md +tags: [design, runner, runtime-deps, architecture] +--- + +# Runtime-dependency resolution — target architecture + +## Problem + +PR #1381 moved the pinned flow runtime deps out of the user's project into an isolated managed +dir to stop monorepo `node_modules` pollution. It fixed pollution but broke running real flows: +a full empirical `/dd-compat` pass returned NOT-YET with three ship-blockers. + +The current design merged two concerns that must be separated: + +- **(a) Executor / native-runtime resolution** — `@qawolf/flows`, `playwright`, + `@qawolf/testkit`, `@qawolf/emails`, the appium drivers. CLI-owned, pinned, and must never + touch the project's `package.json` or `node_modules`. +- **(b) The flow project's own declared deps** — `diff`, `@faker-js/faker`, `axios`, workspace + packages, etc. These must still resolve. + +The design resolves (a) into a managed dir, then via staging + `linkManagedDeps` _replaces_ the +project's `node_modules` with the managed one — dropping (b) entirely. The unifying requirement +is: **executor resolution is independent of, and never pollutes, the surrounding project — but +the flow's own declared dependencies must still resolve.** + +### The three ship-blockers (from the empirical verification) + +1. **Managed runtime omits the flow's own deps** (both channels; regression). The managed dir + installs only the 7 pinned pkgs; staging excludes `node_modules` and `linkManagedDeps` + symlinks only the managed tree. The removed `ensureFlowDeps` (on `main`) used to install the + full project tree. +2. **Shared runtime dir corrupts across channels.** The dir is keyed on package versions only, + not channel. The binary writes CJS `Bun.build` shims; the Node channel removes them; whoever + installs first wins and the other channel breaks. +3. **Binary never runs a web flow end-to-end.** `loadFlowDefault` pre-bundles the flow under the + compiled binary, inlining a _second_ copy of `@qawolf/flows` whose AsyncLocalStorage instance + differs from the runner's → `page` undefined. + +## Decisions + +1. **Flow-deps model → hybrid / layered.** The executor is always CLI-owned and isolated; the + flow's own deps resolve from the project when present, else are CLI-installed into the run dir. +2. **Binary loading → drop the pre-bundle + shim, gated by an early spike**, with a keep-but-fix + fallback. + +## Core mechanism — layered `node_modules` via filesystem walk-up (fixes Blocker 1) + +Node and Bun resolve a bare import by walking `node_modules` up the **importing file's real +directory chain**, first match wins. Compose two independent roots without merging or polluting: + +``` +//node_modules/{@qawolf/flows, playwright, …} ← EXECUTOR (CLI-owned, cached, shared) +//.runs//node_modules/{diff, faker, axios} ← FLOW'S OWN DEPS (per run) +//.runs// ← the flow files (copied) +``` + +`import playwright` from a staged flow → child `.runs//node_modules` (miss) → walk up to +`/node_modules` (hit, executor). `import diff` → child (hit, flow's own). Both +resolve; the executor is shared and cached; the project is never written. + +This replaces today's stage-to-cwd + `linkManagedDeps`-replace path (which dropped the flow's +deps). **Per-run staging relocates under the managed version dir** so the executor is a real +ancestor: `.runs/-/` (keeps the per-run pid isolation from commit b22b0807, +moved off the user's cwd — a side win: no `.qawolf/.local` litter in the project). + +### Child `node_modules` population (the hybrid rule) + +- **Project has an installed `node_modules`** (case 2, monorepo) → symlink the child + `node_modules` to the **nearest ancestor `node_modules`** of the flow. This captures hoisted, + workspace, and private deps; workspace symlinks resolve onward into the real monorepo. Never + writes into the project. +- **No installed `node_modules`** (cases 1 & 3) → read the flow's `package.json` and + `npm install` its deps into the child, **stripping the 7 executor packages** so the pinned + parent stays authoritative. This restores the deleted `ensureFlowDeps` behavior, relocated off + the project. Empty-dir / no `package.json` → empty child; the executor still resolves via the + parent (the minor "Cannot find @qawolf/flows" failure disappears). + +The layered core is **channel-agnostic and low-risk**: the flow's own deps are plain packages +resolved by ordinary walk-up, and the flow file is **not** inside an `@scope/` dir, so its +first-hop bare imports are not subject to WIZ-10612. + +## Binary executor resolution (Blockers 2 & 3) — spike-gated + +WIZ-10612 (documented in `src/domains/runtimeEnv/shimDeps.ts:7-27`, current as of pinned +**Bun 1.3.13**) makes the **compiled binary** mis-resolve from inside an `@scope/` package: +`@qawolf/flows` (exports map `.`, `./web`, `./_runner`, …; deps `@qawolf/flow-targets`→`zod`, +`expect`, `pngjs`, …) cannot reach its own bare deps in the outer `node_modules`. That is _why_ +the pre-bundle and CJS shims exist — and the pre-bundle inlining a second `@qawolf/flows` copy +is the root of Blocker 3. + +**Blocker 3 fix is independent of the spike.** Whatever the binary does, **externalize the +executor packages** (`@qawolf/flows`, `@qawolf/testkit`, `@qawolf/emails`, browser drivers) from +any flow bundle so the flow imports the **same** `@qawolf/flows` instance the runner's +`initFlowRuntime` configured. Today `loadFlowDefault` externalizes only `browserDrivers`. + +**Spike (gates the binary branch).** Build the binary on pinned Bun and test whether the +compiled binary resolves, from the layered tree, (i) `@qawolf/flows/web` subpath exports from an +external `node_modules`, and (ii) a scoped package's transitive bare imports. + +- **PASS (WIZ-10612 effectively gone)** → drop the pre-bundle **and** the shim entirely. The + binary path becomes the Node path (`import()` + walk-up). Channel-keying the hash is + unnecessary. Blockers 2 & 3 vanish with the machinery. +- **FAIL (bug still bites)** → keep-but-fix: + - Pre-bundle the flow with **`@qawolf/flows`/testkit/emails/browser-drivers externalized** + (Bun.build inlines the flow's _own_ deps, resolved from the layered child) → fixes Blocker 3. + - **Channel-key `managedEnvHash()`** (`node` vs `binary`) so the binary's shims live in a + separate runtime dir and never corrupt the Node channel → fixes Blocker 2. + - Keep the executor-dep shim only inside the binary-keyed dir. + +Either branch fixes all three blockers; the spike only decides how much binary machinery remains. + +## Per-case × channel resolution matrix + +| Case | Channel | Executor (a) | Flow's own deps (b) | Project writes | +| -------------- | ------- | ---------------------------------- | ------------------------------------ | -------------- | +| 1 managed-only | Node | parent walk-up ✓ | child = npm install flow pkg.json ✓ | none | +| 1 managed-only | Binary | spike: parent walk-up, else shim ✓ | inlined by Bun.build OR child ✓ | none | +| 2 monorepo | Node | parent walk-up ✓ | child → symlink nearest project nm ✓ | none | +| 2 monorepo | Binary | spike: parent / shim ✓ | inlined / child symlink ✓ | none | +| 3 empty-dir | Node | parent walk-up ✓ | child = npm install flow pkg.json ✓ | none | +| 3 empty-dir | Binary | spike: parent / shim ✓ | inlined OR child ✓ | none | + +**Known limitation:** a monorepo that puts deps in a _leaf-local_ `node_modules` under the flow +(not hoisted) layers only the nearest one. Hoisted / workspace layouts (the norm) are fully +covered. + +## Observability (table stakes) + +The console reporter already prints the cause chain (`createConsoleReporter.formatErrorWithCause`, +lines 57-66, 100-108). Close the real gaps: + +- Surface **load-time** failures (`loadFlowDefault` / `initFlowRuntime`, which throw before + `runner.run` wraps a `FlowRunError`) with their structured cause. +- Add the cause chain to the **JUnit** reporter (currently `err.message` only). +- Audit the **json / markdown** renderers. + +## Implementation order (Slice 3) + +1. **Spike (gating):** build binary on pinned Bun; test layered-tree resolution of + `@qawolf/flows/web` exports + scoped transitive imports. Records PASS/FAIL. +2. **Layered resolution core** (channel-agnostic, regardless of spike): relocate staging into + `//.runs//`; add the child-`node_modules` step + (symlink-nearest-project-nm vs install-flow-pkg-deps with executor pkgs stripped); rewire + `runDefaults.ts` / `hybridRunDefaults.ts`. Keep `ensureRuntimeEnv` for the executor root only. +3. **Blocker 3 fix:** externalize `@qawolf/flows` / testkit / emails from `loadFlowDefault`'s + bundler. +4. **Binary branch (spike-driven):** PASS → delete the pre-bundle path, `shimDeps.ts`, and the + `QAWOLF_COMPILED` bundling fork; FAIL → channel-key `managedEnvHash()` and keep the shim in + the keyed dir. +5. **Observability:** load-time cause surfacing + JUnit cause + renderer audit. +6. Unit tests for the new resolution model; `lint:fix`, `format`, `typecheck`, `knip` clean. + +Likely-touched modules: +`src/domains/runtimeEnv/{ensureRuntimeEnv,managedEnvDir,linkManagedDeps,shimDeps,installPinned}.ts`, +`src/domains/flows/{stageFlows,ensureDeps}.ts`, +`src/domains/runner/{loadFlowDefault,initFlowRuntime}.ts`, +`src/commands/flows/{runDefaults,hybridRunDefaults}.ts`, +`src/shell/reporter/createJUnitReporter.ts`. + +## Verification (Slice 4) + +Re-run the `/dd-compat` harness: a real flow on BOTH channels in all 3 cases, zero project +pollution, no cross-channel corruption, flow-failure causes visible. Real env to pull: +`ckzese4wg5893850qb6x1r01pd`. Managed dir on this machine: +`~/Library/Application Support/qawolf-nodejs/runtime/`. 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/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..1f8ad6e9a 100644 --- a/src/commands/flows/hybridRun.test.ts +++ b/src/commands/flows/hybridRun.test.ts @@ -4,6 +4,7 @@ import { makeFakeUI } from "~/shell/commandContext.testUtils.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { makeNoopLogger } from "~/shell/logger.testUtils.js"; import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { handleHybridFlowsRun, type HandleHybridFlowsRunDeps, @@ -15,7 +16,7 @@ afterEach(() => { const expandPatternsMock = mock(); const pullEnvMock = mock(); -const ensureFlowDepsMock = mock<(envDir: string) => Promise>(); +const resolveDepsRootMock = mock(); const configureTestkitMock = mock<(dir: string) => Promise>(); const flowsRunMock = mock(); const runWebFlowDepsMock = mock<() => Promise>(); @@ -23,7 +24,7 @@ const runWebFlowDepsMock = mock<() => Promise>(); const trackedMocks = [ expandPatternsMock, pullEnvMock, - ensureFlowDepsMock, + resolveDepsRootMock, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, @@ -33,7 +34,11 @@ beforeEach(() => { for (const m of trackedMocks) m.mockClear(); expandPatternsMock.mockResolvedValue([]); pullEnvMock.mockResolvedValue(undefined); - ensureFlowDepsMock.mockResolvedValue(undefined); + resolveDepsRootMock.mockResolvedValue({ + depsRoot: "/env", + source: "project", + installed: false, + }); configureTestkitMock.mockResolvedValue(undefined); flowsRunMock.mockResolvedValue(undefined); runWebFlowDepsMock.mockResolvedValue({} as unknown); @@ -47,6 +52,7 @@ function makeCtx(): AuthCommandContext { isInteractive: false, apiKeySource: "env", platform: {} as unknown, + fs: makeMemoryFs(), signals: makeNoopSignals(), ui: makeFakeUI("human"), log: () => makeNoopLogger(), @@ -72,7 +78,7 @@ function makeDeps(): HandleHybridFlowsRunDeps { return { expandPatterns: expandPatternsMock, pullEnv: pullEnvMock, - ensureFlowDeps: ensureFlowDepsMock, + resolveDepsRoot: resolveDepsRootMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: diff --git a/src/commands/flows/hybridRunDefaults.ts b/src/commands/flows/hybridRunDefaults.ts index b17558cf8..6d9ab0728 100644 --- a/src/commands/flows/hybridRunDefaults.ts +++ b/src/commands/flows/hybridRunDefaults.ts @@ -6,28 +6,27 @@ import type { AuthCommandContext, CommandResult, } from "~/shell/commandContext.js"; -import { - expandPatterns as defaultExpandPatterns, - makePeekFlowMeta, -} from "~/domains/flows/expand.js"; -import { findFlowStamp as defaultFindFlowStamp } from "~/shell/manifest/lookup.js"; -import { installBrowserList } from "~/domains/install/browsers.js"; -import { defaultSpawn } from "~/shell/spawn.js"; -import { resolvePlaywrightCli } from "~/shell/playwright.js"; -import { buildRunReporter } from "./buildRunReporter.js"; -import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js"; -import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { stageFlows } from "~/domains/flows/stageFlows.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; -import { ensureFlowDeps as defaultEnsureFlowDeps } from "~/domains/flows/ensureDeps.js"; +import { + type EnsureRuntimeEnvResult, + linkManagedDeps, +} from "~/domains/runtimeEnv/index.js"; +import { + resolveDepsRoot, + type ResolveDepsRootArgs, +} from "~/commands/resolveDepsRoot.js"; import type { Fs } from "~/shell/fs.js"; import type { Logger } from "~/shell/logger.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js"; import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.js"; +import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; import { loadEnvFile } from "./loadEnvFile.js"; export type HandleHybridFlowsRunDeps = { @@ -37,7 +36,9 @@ export type HandleHybridFlowsRunDeps = { logger?: Logger, ) => Promise; pullEnv: (ctx: AuthCommandContext, envId: string) => Promise; - ensureFlowDeps: (envDir: string) => Promise; + resolveDepsRoot: ( + args: Omit, + ) => Promise; configureTestkit: (dir: string) => Promise; flowsRun: typeof defaultFlowsRun; runWebFlowDeps: typeof defaultRunWebFlowDeps; @@ -48,7 +49,7 @@ function makeDefaultHybridDeps(fs: Fs): HandleHybridFlowsRunDeps { expandPatterns: (patterns, cwd, logger) => defaultExpandPatterns(patterns, cwd, logger, fs), pullEnv: (ctx, envId) => handleFlowsPull(ctx, { env: envId, yes: true }), - ensureFlowDeps: (envDir) => defaultEnsureFlowDeps(envDir, fs), + resolveDepsRoot: (args) => resolveDepsRoot({ ...args, fs }), configureTestkit: defaultConfigureTestkit, flowsRun: defaultFlowsRun, runWebFlowDeps: defaultRunWebFlowDeps, @@ -69,22 +70,16 @@ export async function handleHybridFlowsRun( const envDir = resolve(join(".qawolf", flags.env)); const patternArgs = buildPatternArgs(pattern); + const globFlows = () => + resolvedDeps.expandPatterns(patternArgs, envDir, ctx.log("flows")); - let files = await resolvedDeps.expandPatterns( - patternArgs, - envDir, - ctx.log("flows"), - ); + let files = await globFlows(); if (files.length === 0) { const pullResult = await resolvedDeps.pullEnv(ctx, flags.env); if (pullResult !== undefined) return pullResult; - files = await resolvedDeps.expandPatterns( - patternArgs, - envDir, - ctx.log("flows"), - ); + files = await globFlows(); if (files.length === 0) { return { error: @@ -99,43 +94,55 @@ export async function handleHybridFlowsRun( ctx.ui.gap(); ctx.ui.intro("flows run"); - await ctx.ui.withProgress( + const [runtimeEnv] = await ctx.ui.withProgress( [ { message: runnerMessages.preparingEnvironment, - task: () => resolvedDeps.ensureFlowDeps(envDir), + task: () => + resolvedDeps.resolveDepsRoot({ + files, + ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), + }), }, ], () => runnerMessages.environmentReady, ); + if (runtimeEnv.source === "managed") { + ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); + } await loadEnvFile(envDir); - await resolvedDeps.configureTestkit(envDir); - const android = createAndroidDeps(envDir, ctx.signals); - return resolvedDeps.flowsRun(ctx, files, flags, { - peekFlowMeta: makePeekFlowMeta(ctx.fs), - installBrowsers: (innerCtx, browsers) => - installBrowserList(innerCtx, browsers, { - spawn: defaultSpawn, - platform: process.platform, - playwrightCliPath: resolvePlaywrightCli(envDir, process.platform), - }), - runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(envDir, ctx.signals), - runAndroidFlow: defaultRunAndroidFlow, - runAndroidFlowDeps: android.deps, - bootAndroid: android.boot, - shutdownAndroid: android.shutdown, - createPooledDispatch: makePooledDispatch(envDir), - findFlowStamp: defaultFindFlowStamp, - warn: (message) => ctx.ui.warn(message), - logger: ctx.log("runner"), - // Route reporter output through ctx.ui so streamed test logs stay inside the run's timeline. - reporter: buildRunReporter(flags, { - fs: ctx.fs, - stdout: { write: (text: string) => ctx.ui.write(text) }, - stderr: { write: (text: string) => ctx.ui.write(text) }, - }), - now: () => Date.now(), + const projectDir = resolveProjectDirSafe(files, ctx.fs); + const staged = await stageFlows({ + files, + projectDir, + cwd: process.cwd(), + fs: ctx.fs, }); + if (runtimeEnv.source !== "project" && staged.bundleRoot !== undefined) { + await linkManagedDeps(staged.bundleRoot, runtimeEnv.depsRoot, ctx.fs); + } + + const resolvedDir = runtimeEnv.depsRoot; + await resolvedDeps.configureTestkit(resolvedDir); + const android = createAndroidDeps(resolvedDir, ctx.signals); + const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( + resolvedDir, + ctx.signals, + ); + + const unregisterCleanup = staged.cleanup + ? ctx.signals.register(staged.cleanup) + : undefined; + try { + return await resolvedDeps.flowsRun( + ctx, + staged.files, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); + } finally { + unregisterCleanup?.(); + await staged.cleanup?.(); + } } 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..dbcaeb6bb 100644 --- a/src/commands/flows/runDefaults.handle.test.ts +++ b/src/commands/flows/runDefaults.handle.test.ts @@ -5,6 +5,7 @@ import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; import { makeNoopLogger } from "~/shell/logger.testUtils.js"; import { runnerMessages } from "~/core/messages/index.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; const noopSignals = makeNoopSignals(); @@ -12,30 +13,29 @@ 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 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, configureTestkitMock, flowsRunMock, runWebFlowDepsMock, uiInfoMock, uiIntroMock, + uiNoteMock, ]; function makeDeps(): HandleFlowsRunDeps { return { expandPatterns: expandPatternsMock, - resolveUniqueEnvDir: resolveUniqueEnvDirMock, - ensureFlowDeps: ensureFlowDepsMock, + resolveDepsRoot: resolveDepsRootMock, configureTestkit: configureTestkitMock, flowsRun: flowsRunMock, runWebFlowDeps: @@ -65,26 +65,37 @@ 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, + }); 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 +105,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 +122,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()); diff --git a/src/commands/flows/runDefaults.reporterWiring.test.ts b/src/commands/flows/runDefaults.reporterWiring.test.ts index e0149a063..ed0051713 100644 --- a/src/commands/flows/runDefaults.reporterWiring.test.ts +++ b/src/commands/flows/runDefaults.reporterWiring.test.ts @@ -7,6 +7,7 @@ import type { } from "~/domains/runner/runInternals.js"; import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; import { makeNoopLogger } from "~/shell/logger.testUtils.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { handleFlowsRun, type HandleFlowsRunDeps } from "./runDefaults.js"; // Integration test: proves handleFlowsRun wires the reporter all the way from @@ -41,6 +42,7 @@ function makeCtx(): CommandContext { outputMode: "human", isInteractive: false, signals: noopSignals, + fs: makeMemoryFs(), log: () => makeNoopLogger(), ui: { ...makeFakeUI("human"), @@ -60,8 +62,11 @@ function makeDeps( ): HandleFlowsRunDeps { return { expandPatterns: async () => ["/fake/flow.flow.ts"], - resolveUniqueEnvDir: () => undefined, - ensureFlowDeps: async () => {}, + resolveDepsRoot: async () => ({ + depsRoot: "/env", + source: "project" as const, + installed: false, + }), configureTestkit: async () => {}, flowsRun: flowsRun as HandleFlowsRunDeps["flowsRun"], runWebFlowDeps: (async () => diff --git a/src/commands/flows/runDefaults.ts b/src/commands/flows/runDefaults.ts index 7741304f5..ced4e4aff 100644 --- a/src/commands/flows/runDefaults.ts +++ b/src/commands/flows/runDefaults.ts @@ -1,32 +1,28 @@ -import { - expandPatterns as defaultExpandPatterns, - makePeekFlowMeta, -} from "~/domains/flows/expand.js"; -import { findFlowStamp as defaultFindFlowStamp } from "~/shell/manifest/lookup.js"; -import { installBrowserList } from "~/domains/install/browsers.js"; -import { defaultSpawn } from "~/shell/spawn.js"; +import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { buildPatternArgs } from "~/core/patternArgs.js"; import { runnerMessages } from "~/core/messages/index.js"; -import { resolvePlaywrightCli } from "~/shell/playwright.js"; -import { buildRunReporter } from "./buildRunReporter.js"; -import { runAndroidFlow as defaultRunAndroidFlow } from "~/domains/runner/runAndroidFlow.js"; -import { runWebFlow as defaultRunWebFlow } from "~/domains/runner/runWebFlow.js"; import { configureTestkit as defaultConfigureTestkit } from "~/shell/testkit.js"; import { pluralize } from "~/core/pluralize.js"; +import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js"; +import { stageFlows } from "~/domains/flows/stageFlows.js"; +import { + type EnsureRuntimeEnvResult, + linkManagedDeps, +} from "~/domains/runtimeEnv/index.js"; import { - ensureFlowDeps as defaultEnsureFlowDeps, - resolveUniqueEnvDir as defaultResolveUniqueEnvDir, -} from "~/domains/flows/ensureDeps.js"; + resolveDepsRoot, + type ResolveDepsRootArgs, +} from "~/commands/resolveDepsRoot.js"; import type { Fs } from "~/shell/fs.js"; import type { Logger } from "~/shell/logger.js"; import { defaultRunWebFlowDeps } from "~/domains/runner/runWebFlowDeps.js"; -import { makePooledDispatch } from "~/domains/runner/makePooledDispatch.js"; import { flowsRun as defaultFlowsRun } from "~/domains/runner/run.js"; import { createAndroidDeps } from "~/domains/runner/runAndroidFlowDeps.js"; import type { FlowsRunFlags } from "~/domains/runner/runInternals.js"; +import { buildFlowsRunDeps } from "./buildFlowsRunDeps.js"; import { loadEnvFile } from "./loadEnvFile.js"; export type HandleFlowsRunDeps = { @@ -35,8 +31,9 @@ export type HandleFlowsRunDeps = { cwd: string, logger?: Logger, ) => Promise; - resolveUniqueEnvDir: (files: string[]) => string | undefined; - ensureFlowDeps: (envDir: string) => Promise; + resolveDepsRoot: ( + args: Omit, + ) => Promise; configureTestkit: (dir: string) => Promise; runWebFlowDeps: typeof defaultRunWebFlowDeps; flowsRun: typeof defaultFlowsRun; @@ -46,8 +43,7 @@ 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 }), configureTestkit: defaultConfigureTestkit, runWebFlowDeps: defaultRunWebFlowDeps, flowsRun: defaultFlowsRun, @@ -77,60 +73,61 @@ 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); + const [runtimeEnv] = await ctx.ui.withProgress( + [ + { + message: runnerMessages.preparingEnvironment, + task: () => + resolvedDeps.resolveDepsRoot({ + files: expandedFiles, + ...(flags.deps !== undefined ? { overrideDir: flags.deps } : {}), + }), + }, + ], + () => runnerMessages.environmentReady, + ); + + if (runtimeEnv.source === "managed") { + ctx.ui.info(runnerMessages.managedRuntimeNote(runtimeEnv.depsRoot)); + } + + // Load the user's project .env from the project dir (NOT the deps dir). + const projectDir = resolveProjectDirSafe(expandedFiles, ctx.fs); + await loadEnvFile(projectDir ?? cwd); + + const staged = await stageFlows({ + files: expandedFiles, + projectDir, + cwd, + fs: ctx.fs, + }); + if (runtimeEnv.source !== "project" && staged.bundleRoot !== undefined) { + await linkManagedDeps(staged.bundleRoot, runtimeEnv.depsRoot, ctx.fs); } - // Resolve playwright from the env dir; falls back to CWD for local flows. - const resolvedDir = envDir ?? cwd; + const resolvedDir = runtimeEnv.depsRoot; await resolvedDeps.configureTestkit(resolvedDir); const android = createAndroidDeps(resolvedDir, ctx.signals); - return resolvedDeps.flowsRun(ctx, expandedFiles, flags, { - peekFlowMeta: makePeekFlowMeta(ctx.fs), - installBrowsers: (innerCtx, browsers) => - installBrowserList(innerCtx, browsers, { - spawn: defaultSpawn, - platform: process.platform, - playwrightCliPath: resolvePlaywrightCli(resolvedDir, process.platform), - }), - runWebFlow: defaultRunWebFlow, - runWebFlowDeps: await resolvedDeps.runWebFlowDeps(resolvedDir, ctx.signals), - runAndroidFlow: defaultRunAndroidFlow, - runAndroidFlowDeps: android.deps, - bootAndroid: android.boot, - shutdownAndroid: android.shutdown, - createPooledDispatch: makePooledDispatch(resolvedDir), - findFlowStamp: defaultFindFlowStamp, - warn: (message) => ctx.ui.warn(message), - logger: ctx.log("runner"), - // Route reporter output through ctx.ui so streamed test logs stay inside the run's timeline. - reporter: buildRunReporter(flags, { - fs: ctx.fs, - stdout: { write: (text: string) => ctx.ui.write(text) }, - stderr: { write: (text: string) => ctx.ui.write(text) }, - }), - now: () => Date.now(), - }); + const runWebFlowDeps = await resolvedDeps.runWebFlowDeps( + resolvedDir, + ctx.signals, + ); + const unregisterCleanup = staged.cleanup + ? ctx.signals.register(staged.cleanup) + : undefined; + try { + return await resolvedDeps.flowsRun( + ctx, + staged.files, + flags, + buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }), + ); + } finally { + unregisterCleanup?.(); + await staged.cleanup?.(); + } } diff --git a/src/commands/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.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..5c22653cc 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,15 @@ 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/stageFlows.test.ts b/src/domains/flows/stageFlows.test.ts new file mode 100644 index 000000000..55bffdebb --- /dev/null +++ b/src/domains/flows/stageFlows.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { pathExists } from "~/shell/fs.js"; + +import { stageFlows } from "./stageFlows.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tmpDirs.map((d) => rm(d, { recursive: true, force: true })), + ); + tmpDirs.length = 0; +}); + +async function makeTmpDir(): Promise { + const d = realpathSync(await mkdtemp(join(tmpdir(), "qawolf-stage-test-"))); + tmpDirs.push(d); + return d; +} + +describe("stageFlows", () => { + it("passes through unchanged when there is no project dir", async () => { + const files = ["/some/a.flow.ts", "/some/b.flow.ts"]; + + const result = await stageFlows({ + files, + projectDir: undefined, + cwd: "/cwd", + }); + + expect(result).toEqual({ files, bundleRoot: undefined }); + }); + + it("uses the project in place when it already lives under .qawolf", async () => { + const cwd = await makeTmpDir(); + const projectDir = join(cwd, ".qawolf", "my-env"); + const files = [join(projectDir, "login.flow.ts")]; + + const result = await stageFlows({ files, projectDir, cwd }); + + expect(result).toEqual({ files, bundleRoot: projectDir }); + expect(await pathExists(join(cwd, ".qawolf", ".local"))).toBe(false); + }); + + it("stages a raw in-place project and remaps flow paths", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await mkdir(join(projectDir, "flows"), { recursive: true }); + await writeFile(join(projectDir, "flows", "a.flow.ts"), "export {};"); + await mkdir(join(projectDir, "node_modules", "dep"), { recursive: true }); + await writeFile(join(projectDir, "node_modules", "dep", "i.js"), ""); + await mkdir(join(projectDir, ".git"), { recursive: true }); + await writeFile(join(projectDir, ".git", "HEAD"), "ref"); + await mkdir(join(projectDir, ".qawolf"), { recursive: true }); + await writeFile(join(projectDir, ".qawolf", "x"), ""); + + const files = [join(projectDir, "flows", "a.flow.ts")]; + const result = await stageFlows({ files, projectDir, cwd }); + + const stagedDir = result.bundleRoot; + expect(stagedDir).toBeDefined(); + expect(stagedDir?.startsWith(join(cwd, ".qawolf", ".local"))).toBe(true); + + // Copied source is present. + expect(await pathExists(join(stagedDir as string, "package.json"))).toBe( + true, + ); + expect( + await pathExists(join(stagedDir as string, "flows", "a.flow.ts")), + ).toBe(true); + + // Excluded dirs are absent. + expect(await pathExists(join(stagedDir as string, "node_modules"))).toBe( + false, + ); + expect(await pathExists(join(stagedDir as string, ".git"))).toBe(false); + expect(await pathExists(join(stagedDir as string, ".qawolf"))).toBe(false); + + // Flow paths are remapped onto the staged copy. + expect(result.files).toEqual([ + join(stagedDir as string, "flows", "a.flow.ts"), + ]); + }); + + it("stages when cwd is the project dir (staged dir nested under source)", async () => { + // The real flows-run case: you run from your project, so the staged dir + // (/.qawolf/.local/) lives INSIDE projectDir. A single recursive + // copy would reject with EINVAL here; entry-by-entry copy must succeed. + const projectDir = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await mkdir(join(projectDir, "src"), { recursive: true }); + await writeFile(join(projectDir, "src", "a.flow.ts"), "export {};"); + + const files = [join(projectDir, "src", "a.flow.ts")]; + const result = await stageFlows({ files, projectDir, cwd: projectDir }); + + const stagedDir = result.bundleRoot as string; + expect(stagedDir.startsWith(join(projectDir, ".qawolf", ".local"))).toBe( + true, + ); + expect(await pathExists(join(stagedDir, "src", "a.flow.ts"))).toBe(true); + // The staging dir itself must not be recursively copied into itself. + expect(await pathExists(join(stagedDir, ".qawolf"))).toBe(false); + expect(result.files).toEqual([join(stagedDir, "src", "a.flow.ts")]); + }); + + it("refreshes the staged dir on re-run so edits are picked up", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + await writeFile(join(projectDir, "a.flow.ts"), "v1"); + const files = [join(projectDir, "a.flow.ts")]; + + const first = await stageFlows({ files, projectDir, cwd }); + await writeFile(join(projectDir, "a.flow.ts"), "v2"); + const second = await stageFlows({ files, projectDir, cwd }); + + expect(second.bundleRoot).toBe(first.bundleRoot as string); + const staged = await stat(join(second.bundleRoot as string, "a.flow.ts")); + expect(staged.isFile()).toBe(true); + expect( + await readFile(join(second.bundleRoot as string, "a.flow.ts"), "utf8"), + ).toBe("v2"); + }); + + it("returns a cleanup that removes the staged dir; passthrough has none", async () => { + const projectDir = await makeTmpDir(); + const cwd = await makeTmpDir(); + await writeFile(join(projectDir, "package.json"), "{}"); + const files = [join(projectDir, "a.flow.ts")]; + + const result = await stageFlows({ files, projectDir, cwd }); + expect(await pathExists(result.bundleRoot as string)).toBe(true); + await result.cleanup?.(); + expect(await pathExists(result.bundleRoot as string)).toBe(false); + + // No staged copy was created for the in-place .qawolf case → no cleanup. + const qawolfProject = join(cwd, ".qawolf", "env"); + const passthrough = await stageFlows({ + files: [join(qawolfProject, "x.flow.ts")], + projectDir: qawolfProject, + cwd, + }); + expect(passthrough.cleanup).toBeUndefined(); + }); +}); diff --git a/src/domains/flows/stageFlows.ts b/src/domains/flows/stageFlows.ts new file mode 100644 index 000000000..460e0fc52 --- /dev/null +++ b/src/domains/flows/stageFlows.ts @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { join, resolve, sep } from "node:path"; + +import { copyDirExcluding } from "~/shell/copyDir.js"; +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; + +type StageFlowsArgs = { + files: string[]; + projectDir: string | undefined; + cwd: string; + fs?: Fs; +}; + +type StageFlowsResult = { + files: string[]; + bundleRoot: string | undefined; + // Removes the staged copy. Present only when this call created one; callers + // run it after the flows finish (and register it for interrupt cleanup). + cleanup?: () => Promise; +}; + +const excludedDirs = new Set(["node_modules", ".git", ".qawolf"]); + +/** + * Prepares a flow bundle root that a `node_modules` symlink can be written into + * without polluting the user's project. Flows already living under a CLI-managed + * `.qawolf` dir are used in place; a raw in-place project is copied into + * `/.qawolf/.local/` (excluding node_modules/.git/.qawolf) and its + * flow paths remapped onto the staged copy. Returns the staged files plus the + * bundle root the symlink should target, or `undefined` when there is no project + * dir (managed-only fallback). + */ +export async function stageFlows( + args: StageFlowsArgs, +): Promise { + const { files, projectDir, cwd } = args; + const fs = args.fs ?? makeDefaultFs(); + + if (projectDir === undefined) return { files, bundleRoot: undefined }; + + if (isInsideQawolfDir(projectDir)) { + return { files, bundleRoot: projectDir }; + } + + // The dir is per-run (pid-suffixed) so concurrent `flows run` on the same + // project never delete each other's active staging tree; the caller removes + // it when the run ends. + const stagedDir = join( + cwd, + ".qawolf", + ".local", + `${hashProjectDir(projectDir)}-${process.pid}`, + ); + await fs.rm(stagedDir, { recursive: true, force: true }); + await fs.mkdir(stagedDir, { recursive: true }); + await copyDirExcluding(projectDir, stagedDir, excludedDirs); + + const stagedFiles = files.map((f) => remapPath(f, projectDir, stagedDir)); + return { + files: stagedFiles, + bundleRoot: stagedDir, + cleanup: () => fs.rm(stagedDir, { recursive: true, force: true }), + }; +} + +function isInsideQawolfDir(dir: string): boolean { + return dir.split(sep).includes(".qawolf"); +} + +function hashProjectDir(projectDir: string): string { + return createHash("sha256") + .update(resolve(projectDir)) + .digest("hex") + .slice(0, 16); +} + +function remapPath( + file: string, + projectDir: string, + stagedDir: string, +): string { + if (file === projectDir) return stagedDir; + if (file.startsWith(projectDir + sep)) { + return join(stagedDir, file.slice(projectDir.length + 1)); + } + return file; +} diff --git a/src/domains/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/createRunner.guards.test.ts b/src/domains/runner/createRunner.guards.test.ts index a46216e55..46a330390 100644 --- a/src/domains/runner/createRunner.guards.test.ts +++ b/src/domains/runner/createRunner.guards.test.ts @@ -16,6 +16,7 @@ function makeDeps(): RunnerDeps { kill: () => {}, }), signals: makeNoopSignals(), + depsRoot: "/tmp", createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/createRunner.test.ts b/src/domains/runner/createRunner.test.ts index adf233fe1..d993ef991 100644 --- a/src/domains/runner/createRunner.test.ts +++ b/src/domains/runner/createRunner.test.ts @@ -16,6 +16,7 @@ function makeDeps(): RunnerDeps { kill: () => {}, }), signals: makeNoopSignals(), + depsRoot: "/tmp", createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/initFlowRuntime.test.ts b/src/domains/runner/initFlowRuntime.test.ts index 74bb3b3e1..73a92454e 100644 --- a/src/domains/runner/initFlowRuntime.test.ts +++ b/src/domains/runner/initFlowRuntime.test.ts @@ -2,7 +2,11 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; -import { _resetInitCache, initFlowRuntime } from "./initFlowRuntime.js"; +import { + _resetInitCache, + initFlowRuntime, + runnerPathInDir, +} from "./initFlowRuntime.js"; afterEach(() => { _resetInitCache(); @@ -125,4 +129,93 @@ describe("initFlowRuntime", () => { await rm(tmp, { recursive: true }); } }); + + it("uses depsRoot to resolve _runner when provided, skipping walk-up", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-init-depsroot-")); + try { + // Build a minimal @qawolf/flows in depsRoot with a _runner export + const pkgDir = path.join(tmp, "node_modules", "@qawolf", "flows"); + await mkdir(pkgDir, { recursive: true }); + const runnerJs = path.join(pkgDir, "runner.js"); + await writeFile( + runnerJs, + `export async function configureFlowRuntime() {}\n`, + ); + await writeFile( + path.join(pkgDir, "package.json"), + JSON.stringify({ + exports: { "./_runner": { import: "./runner.js" } }, + }), + ); + + // Flow file is in an isolated tmp directory — no @qawolf/flows ancestor + const flowTmp = await mkdtemp( + path.join(tmpdir(), "qawolf-init-isolated-"), + ); + try { + await initFlowRuntime(path.join(flowTmp, "my.flow.ts"), { + timeout: 30_000, + depsRoot: tmp, + }); + // If we reach here, depsRoot resolution succeeded + } finally { + await rm(flowTmp, { recursive: true }); + } + } finally { + await rm(tmp, { recursive: true }); + } + }); + + it("throws when depsRoot is set but @qawolf/flows is not found there", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-init-test-")); + try { + let caught: unknown; + try { + await initFlowRuntime(path.join(thisDir, "fake.flow.ts"), { + timeout: 30_000, + depsRoot: tmp, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain( + "not found in node_modules of depsRoot", + ); + } finally { + await rm(tmp, { recursive: true }); + } + }); +}); + +describe("runnerPathInDir", () => { + it("returns undefined when @qawolf/flows is not present in the directory", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-runner-path-")); + try { + const { makeDefaultFs } = await import("~/shell/fs.js"); + const result = await runnerPathInDir(tmp, makeDefaultFs()); + expect(result).toBeUndefined(); + } finally { + await rm(tmp, { recursive: true }); + } + }); + + it("returns the resolved runner path when package.json has a valid _runner export", async () => { + const tmp = await mkdtemp(path.join(tmpdir(), "qawolf-runner-path-")); + try { + const pkgDir = path.join(tmp, "node_modules", "@qawolf", "flows"); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + path.join(pkgDir, "package.json"), + JSON.stringify({ + exports: { "./_runner": { import: "./runner.js" } }, + }), + ); + const { makeDefaultFs } = await import("~/shell/fs.js"); + const result = await runnerPathInDir(tmp, makeDefaultFs()); + expect(result).toBe(path.join(pkgDir, "runner.js")); + } finally { + await rm(tmp, { recursive: true }); + } + }); }); diff --git a/src/domains/runner/initFlowRuntime.ts b/src/domains/runner/initFlowRuntime.ts index b5af90a06..46558ec0c 100644 --- a/src/domains/runner/initFlowRuntime.ts +++ b/src/domains/runner/initFlowRuntime.ts @@ -1,5 +1,4 @@ -import { makeDefaultFs } from "~/shell/fs.js"; -import type { Fs } from "~/shell/fs.js"; +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { isNoEntError } from "~/core/errors.js"; @@ -18,41 +17,56 @@ export type InitFlowRuntimeOptions = { * applied separately via `context.setDefaultTimeout` at launch. */ timeout: number; + // When set, resolve @qawolf/flows/_runner from this dir instead of walking up from the flow file. + depsRoot?: string; }; +/** + * Reads the @qawolf/flows/_runner export path from a single directory's + * node_modules. Returns undefined when the package is not present (ENOENT); + * re-throws any other error (e.g. malformed package.json, missing export). + */ +export async function runnerPathInDir( + dir: string, + fs: Fs, +): Promise { + const pkgPath = path.join( + dir, + "node_modules", + "@qawolf", + "flows", + "package.json", + ); + try { + const pkg = JSON.parse(await fs.readFile(pkgPath)) as { + exports?: Record; + }; + const entry = pkg.exports?.["./_runner"]; + const importPath = + typeof entry === "object" && entry !== null ? entry.import : undefined; + if (typeof importPath !== "string") { + throw new Error( + `@qawolf/flows at ${pkgPath} does not export "./_runner" with an "import" condition`, + ); + } + return path.resolve(path.dirname(pkgPath), importPath); + } catch (err) { + if (!isNoEntError(err)) throw err; + return undefined; + } +} + async function findFlowsRunnerPath(flowPath: string, fs: Fs): Promise { let dir = path.dirname(flowPath); while (true) { - const pkgPath = path.join( - dir, - "node_modules", - "@qawolf", - "flows", - "package.json", - ); - try { - const pkg = JSON.parse(await fs.readFile(pkgPath)) as { - exports?: Record; - }; - const entry = pkg.exports?.["./_runner"]; - const importPath = - typeof entry === "object" && entry !== null ? entry.import : undefined; - if (typeof importPath !== "string") { - throw new Error( - `@qawolf/flows at ${pkgPath} does not export "./_runner" with an "import" condition`, - ); - } - return path.resolve(path.dirname(pkgPath), importPath); - } catch (err) { - if (!isNoEntError(err)) throw err; - const parent = path.dirname(dir); - if (parent === dir) - throw new Error( - `@qawolf/flows not found in node_modules above: ${flowPath}`, - { cause: err }, - ); - dir = parent; - } + const result = await runnerPathInDir(dir, fs); + if (result !== undefined) return result; + const parent = path.dirname(dir); + if (parent === dir) + throw new Error( + `@qawolf/flows not found in node_modules above: ${flowPath}`, + ); + dir = parent; } } @@ -62,8 +76,20 @@ async function doInit( flowPath: string, timeout: number, fs: Fs, + depsRoot?: string, ): Promise { - const runnerPath = await findFlowsRunnerPath(flowPath, fs); + let runnerPath: string; + if (depsRoot !== undefined) { + const found = await runnerPathInDir(depsRoot, fs); + if (found === undefined) { + throw new Error( + `@qawolf/flows not found in node_modules of depsRoot: ${depsRoot}`, + ); + } + runnerPath = found; + } else { + runnerPath = await findFlowsRunnerPath(flowPath, fs); + } const mod = (await import(pathToFileURL(runnerPath).href)) as { configureFlowRuntime?: ConfigureFlowRuntime; }; @@ -93,7 +119,7 @@ export function initFlowRuntime( // single run-global flag, so every flow in a process shares one value. let p = initCache.get(startDir); if (!p) { - p = doInit(flowPath, options.timeout, fs); + p = doInit(flowPath, options.timeout, fs, options.depsRoot); initCache.set(startDir, p); } return p; diff --git a/src/domains/runner/loadFlowDefault.test.ts b/src/domains/runner/loadFlowDefault.test.ts index ff9f19cd7..42639c660 100644 --- a/src/domains/runner/loadFlowDefault.test.ts +++ b/src/domains/runner/loadFlowDefault.test.ts @@ -1,71 +1,14 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { describe, expect, it } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { loadFlowDefault, rewriteFlowImports } from "./loadFlowDefault.js"; +import { pathExists } from "~/shell/fs.js"; +import { loadFlowDefault } from "./loadFlowDefault.js"; -// ── rewriteFlowImports ──────────────────────────────────────────────────────── +// ── Node path (direct import) ─────────────────────────────────────────────── -describe("rewriteFlowImports", () => { - const resolve = (s: string) => - `file:///resolved/${s.replace("@qawolf/", "")}`; - - it("rewrites static from import of root specifier", () => { - const out = rewriteFlowImports( - `import { foo } from '@qawolf/flows';`, - resolve, - ); - expect(out).toBe(`import { foo } from 'file:///resolved/flows';`); - }); - - it("rewrites static from import of subpath specifier", () => { - const out = rewriteFlowImports( - `import { bar } from '@qawolf/flows/helpers';`, - resolve, - ); - expect(out).toBe(`import { bar } from 'file:///resolved/flows/helpers';`); - }); - - it("rewrites re-export (export ... from) of subpath", () => { - const out = rewriteFlowImports( - `export { baz } from '@qawolf/flows/utils';`, - resolve, - ); - expect(out).toBe(`export { baz } from 'file:///resolved/flows/utils';`); - }); - - it("rewrites dynamic import() of root specifier", () => { - const out = rewriteFlowImports(`import('@qawolf/flows')`, resolve); - expect(out).toBe(`import('file:///resolved/flows')`); - }); - - it("rewrites dynamic import() of subpath specifier", () => { - const out = rewriteFlowImports(`import('@qawolf/flows/client')`, resolve); - expect(out).toBe(`import('file:///resolved/flows/client')`); - }); - - it("leaves specifier unchanged when resolve throws", () => { - const out = rewriteFlowImports(`import {} from '@qawolf/flows';`, () => { - throw new Error("not found"); - }); - expect(out).toBe(`import {} from '@qawolf/flows';`); - }); - - it("does not rewrite unrelated imports", () => { - const src = `import { x } from 'playwright';\nimport { y } from '@qawolf/testkit';`; - expect(rewriteFlowImports(src, resolve)).toBe(src); - }); - - it("rewrites double-quoted specifiers", () => { - const out = rewriteFlowImports(`import foo from "@qawolf/flows";`, resolve); - expect(out).toBe(`import foo from "file:///resolved/flows";`); - }); -}); - -// ── loadFlowDefault ─────────────────────────────────────────────────────────── - -describe("loadFlowDefault", () => { +describe("loadFlowDefault (Node path)", () => { it("returns the default export when present", async () => { const tmp = await mkdtemp(path.join(tmpdir(), "load-flow-test-")); try { @@ -73,9 +16,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 +32,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,90 +47,46 @@ 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( - flowPath, - `import {} from '@qawolf/flows';\nexport default { ok: true };\n`, - ); - const result = await loadFlowDefault<{ ok: boolean }>(flowPath); - expect(result).toEqual({ ok: true }); - } finally { - await rm(tmp, { recursive: true }); - } - }); - - it("rewrites and imports a flow that uses a @qawolf/flows subpath", async () => { - process.env.QAWOLF_COMPILED = "true"; - const { tmp, flowsDir2 } = await makeEnv(); - try { - const flowPath = path.join(flowsDir2, "flow.mjs"); - await writeFile( + 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/helpers';\nexport default { sub: true };\n`, - ); - const result = await loadFlowDefault<{ sub: boolean }>(flowPath); - expect(result).toEqual({ sub: true }); + bundleFlow, + }); + expect(result).toEqual({ name: "x" }); + 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(); + 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';\nexport default 42;\n`, - ); - 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 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 }); } diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index 73cc58fe3..0ada6fd52 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -3,104 +3,138 @@ import { pathToFileURL } from "node:url"; import { runnerMessages } from "~/core/messages/index.js"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; -import { resolveFromEnvDir } from "~/shell/resolveExport.js"; -// Walk up from flowPath to find the directory that holds node_modules/@qawolf/flows. -function findFlowsEnvDir( - flowPath: string, - fs: Fs = makeDefaultFs(), -): string | undefined { - let dir = path.dirname(flowPath); - while (true) { - if (fs.existsSync(path.join(dir, "node_modules", "@qawolf", "flows"))) - return dir; - const parent = path.dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } -} +// Native browser drivers stay bare so they resolve via the node_modules symlink +// at the flow's bundle root instead of being inlined into the bundle. +const browserDrivers = [ + "playwright", + "playwright-core", + "patchright", + "patchright-core", +]; -// 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; - } - }, - ); -} +// Pre-bundles a flow's full import tree into a single ESM source string. +type FlowBundler = (flowPath: string) => Promise; -export async function loadFlowDefault( - 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"; +type BunBuildResult = { + success: boolean; + outputs: { text(): Promise }[]; + logs: { message: string }[]; +}; - // 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; - } +// Structural type read from globalThis to avoid the no-restricted-globals lint +// rule; Bun.build exists in the compiled binary but not the Node.js build. +type BunBuild = (config: { + entrypoints: string[]; + target?: string; + format?: string; + external?: string[]; +}) => Promise; - // 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); +function getBunBuild(): BunBuild | undefined { + return (globalThis as { Bun?: { build?: BunBuild } }).Bun?.build; +} - const transformed = envDir - ? rewriteFlowImports( - content, - (specifier) => - pathToFileURL(resolveFromEnvDir(envDir, specifier, "esm", fs)).href, - ) - : content; +// Compiled Bun binaries cannot resolve exports-map bare specifiers from external +// node_modules, but Bun.build (available inside the binary) can. Pre-bundle the +// flow so everything except the native browser drivers is inlined. +async function defaultFlowBundler(flowPath: string): Promise { + const build = getBunBuild(); + if (build === undefined) + throw new Error("Cannot bundle flow: Bun.build is unavailable"); - 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; + // Bun.build throws an AggregateError (with per-error messages on `.errors`) on + // resolve/parse failures rather than returning success:false, so surface those + // messages — otherwise the flow fails with an opaque "Bundle failed". + let result: BunBuildResult; + try { + result = await build({ + entrypoints: [flowPath], + target: "bun", + format: "esm", + external: browserDrivers, + }); + } catch (err) { + const aggregate = err as { errors?: { message?: string }[] }; + const detail = Array.isArray(aggregate.errors) + ? aggregate.errors.map((e) => e.message ?? "unknown error").join("\n") + : err instanceof Error + ? err.message + : "unknown error"; + throw new Error(`Failed to bundle flow ${flowPath}:\n${detail}`, { + cause: err, + }); } + const [output] = result.outputs; + if (!result.success || !output) { + const logs = result.logs.map((entry) => entry.message).join("\n"); + throw new Error(`Failed to bundle flow ${flowPath}:\n${logs}`); + } + return output.text(); +} - const annotated = `${transformed}\n//# sourceURL=${pathToFileURL(flowPath).href}`; - const dataUri = `data:text/javascript,${encodeURIComponent(annotated)}`; - const mod = (await import(dataUri)) as Record; +// Only the compiled binary needs bundling — it alone cannot resolve exports-map +// bare specifiers from external node_modules. Node and `bun run`/`bun test` +// resolve them directly, so they take the direct-import path. QAWOLF_COMPILED is +// injected via --define at binary build time (see build:binary in package.json). +// Tests inject bundleFlow explicitly to exercise either path deterministically. +const defaultBundleFlow: FlowBundler | undefined = + process.env.QAWOLF_COMPILED === "true" ? defaultFlowBundler : undefined; + +type LoadFlowDefaultArgs = { + flowPath: string; + fs?: Fs; + // Injectable for tests. When defined, the flow is pre-bundled (compiled-binary + // path); when undefined, the flow is imported directly (Node path). + bundleFlow?: FlowBundler | undefined; +}; + +// 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, +): Promise { + const mod = (await import(moduleUrl)) as Record; const exported = mod["default"] as T | undefined; if (exported === undefined) throw new Error(runnerMessages.noDefaultExport(flowPath)); return exported; } + +// 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 }); + } +} + +export async function loadFlowDefault( + args: LoadFlowDefaultArgs, +): Promise { + const { + flowPath, + fs = makeDefaultFs(), + bundleFlow = defaultBundleFlow, + } = args; + + if (bundleFlow === undefined) { + return importDefaultExport(pathToFileURL(flowPath).href, flowPath); + } + + const code = await bundleFlow(flowPath); + return importBundledFlow(flowPath, code, fs); +} diff --git a/src/domains/runner/runAndroidFlow.test.ts b/src/domains/runner/runAndroidFlow.test.ts index 10c86fe2e..375c72328 100644 --- a/src/domains/runner/runAndroidFlow.test.ts +++ b/src/domains/runner/runAndroidFlow.test.ts @@ -24,6 +24,8 @@ function makeRunnerDeps() { }, spawn: () => ({ exitCode: Promise.resolve(0), kill: () => {} }), signals: makeNoopSignals(), + // Point to the CLI project root where @qawolf/flows is installed in tests. + depsRoot: join(import.meta.dirname, "../../.."), createStorage: () => ({ run: async (_store: T, callback: () => Promise) => callback(), getStore: () => undefined, diff --git a/src/domains/runner/runAndroidFlow.ts b/src/domains/runner/runAndroidFlow.ts index ae8d3fa5c..e7fc1da59 100644 --- a/src/domains/runner/runAndroidFlow.ts +++ b/src/domains/runner/runAndroidFlow.ts @@ -41,7 +41,9 @@ export async function runAndroidFlow({ options: RunAndroidFlowOptions; flowPath: string; }): Promise { - const exported = await loadFlowDefault(flowPath); + const exported = await loadFlowDefault({ + flowPath, + }); if (typeof exported === "function") { // (D2) Android legacy flows have no target; AVD derivation is impossible. throw new Error( diff --git a/src/domains/runner/runAndroidFlowDeps.ts b/src/domains/runner/runAndroidFlowDeps.ts index 0a6fda589..d942b4c6f 100644 --- a/src/domains/runner/runAndroidFlowDeps.ts +++ b/src/domains/runner/runAndroidFlowDeps.ts @@ -59,7 +59,7 @@ export function createAndroidDeps( let serverStarted = false; const deps: RunAndroidFlowDeps = { - ...createRunnerDeps(signals), + ...createRunnerDeps(signals, envDir), appiumServer: serverHandle, emulatorPool: pool, createSession, diff --git a/src/domains/runner/runInternals.ts b/src/domains/runner/runInternals.ts index 12143e500..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.ts b/src/domains/runner/runWebFlow.ts index 6c851a06a..be0b70ad9 100644 --- a/src/domains/runner/runWebFlow.ts +++ b/src/domains/runner/runWebFlow.ts @@ -41,9 +41,12 @@ export async function runWebFlow({ options: RunWebFlowOptions; flowPath: string; }): Promise { - await initFlowRuntime(flowPath, { timeout: options.timeout }); + await initFlowRuntime(flowPath, { + timeout: options.timeout, + depsRoot: deps.depsRoot, + }); - const exported = await loadFlowDefault(flowPath); + const exported = await loadFlowDefault({ flowPath }); 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..1bda707c2 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.test.ts @@ -0,0 +1,67 @@ +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"}'); + + expect(clearRuntimeEnv(fs)).rejects.toThrow("Refusing to delete"); + expect(await fs.pathExists(override)).toBe(true); + }); + + it("honors QAWOLF_RUNTIME_DIR and returns its resolved path", async () => { + const override = "/tmp/qawolf-rt-test"; + process.env["QAWOLF_RUNTIME_DIR"] = override; + + 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..e9383ea85 --- /dev/null +++ b/src/domains/runtimeEnv/clearRuntimeEnv.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; + +import { type Fs } from "~/shell/fs.js"; + +import { managedEnvBaseDir } from "./managedEnvDir.js"; + +// Marker written into every managed hash dir's package.json by scaffoldManagedEnv. +const runtimeSentinel = "qawolf-runtime"; + +export type ClearRuntimeEnvResult = { dir: string; existed: boolean }; + +/** + * Removes the entire managed runtime base directory (every versioned hash dir). + * Honors QAWOLF_RUNTIME_DIR. Returns the resolved path and whether anything existed. + * Fails closed when the resolved dir is not a managed runtime — a misconfigured + * QAWOLF_RUNTIME_DIR (e.g. a repo root) must never be recursively deleted. + */ +export async function clearRuntimeEnv(fs: Fs): Promise { + const dir = managedEnvBaseDir(); + const existed = await fs.pathExists(dir); + if (!existed) 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..b84917735 --- /dev/null +++ b/src/domains/runtimeEnv/index.ts @@ -0,0 +1,5 @@ +export * from "./clearRuntimeEnv.js"; +export * from "./ensureRuntimeEnv.js"; +export * from "./linkManagedDeps.js"; +export * from "./managedEnvDir.js"; +export * from "./resolveDepsRootIfPresent.js"; diff --git a/src/domains/runtimeEnv/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..dec75fdb4 --- /dev/null +++ b/src/domains/runtimeEnv/installPinned.ts @@ -0,0 +1,68 @@ +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; +import { spawn as nodeSpawn } from "~/shell/spawn.js"; + +import { scaffoldManagedEnv } from "./managedEnvDir.js"; +import { allPinnedResolved } from "./resolvePinned.js"; +import { shimFlowsDeps } from "./shimDeps.js"; + +type SpawnInstallResult = { exitCode: number; stderr: string }; + +type SpawnInstallFn = (cwd: string) => Promise; + +export type InstallPinnedDeps = { fs: Fs; spawnInstall: SpawnInstallFn }; + +export function defaultSpawnInstall(cwd: string): Promise { + return new Promise((resolve) => { + // npm 7+ strict peer-dep resolution rejects peerOptional conflicts — revert to npm 6 behaviour. + const child = nodeSpawn("npm", ["install", "--legacy-peer-deps"], { cwd }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += String(chunk); + }); + child.on("error", () => resolve({ exitCode: -1, stderr })); + child.on("close", (code) => resolve({ exitCode: code ?? -1, stderr })); + }); +} + +export async function installPinned( + targetDir: string, + deps: InstallPinnedDeps = { + fs: makeDefaultFs(), + spawnInstall: defaultSpawnInstall, + }, +): Promise { + // Short-circuit: another process or a previous run already completed the install. + if (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/linkManagedDeps.test.ts b/src/domains/runtimeEnv/linkManagedDeps.test.ts new file mode 100644 index 000000000..8b7b68418 --- /dev/null +++ b/src/domains/runtimeEnv/linkManagedDeps.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + readlink, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { linkManagedDeps } from "./linkManagedDeps.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tmpDirs.map((d) => rm(d, { recursive: true, force: true })), + ); + tmpDirs.length = 0; +}); + +async function makeTmpDir(): Promise { + const d = realpathSync( + await mkdtemp(join(tmpdir(), "qawolf-linkdeps-test-")), + ); + tmpDirs.push(d); + return d; +} + +type Roots = { bundleRoot: string; depsRoot: string; source: string }; + +async function makeRoots(): Promise { + const bundleRoot = await makeTmpDir(); + const depsRoot = await makeTmpDir(); + const source = join(depsRoot, "node_modules"); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "marker.txt"), "deps"); + return { bundleRoot, depsRoot, source }; +} + +describe("linkManagedDeps", () => { + it("creates a fresh symlink to the managed node_modules", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + + await linkManagedDeps(bundleRoot, depsRoot); + + const target = join(bundleRoot, "node_modules"); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await readlink(target)).toBe(source); + }); + + it("is idempotent when re-run with the same roots", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + + await linkManagedDeps(bundleRoot, depsRoot); + await linkManagedDeps(bundleRoot, depsRoot); + + const target = join(bundleRoot, "node_modules"); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await readlink(target)).toBe(source); + }); + + it("never clobbers a real node_modules directory", async () => { + const { bundleRoot, depsRoot } = await makeRoots(); + const target = join(bundleRoot, "node_modules"); + await mkdir(target, { recursive: true }); + await writeFile(join(target, "real.txt"), "user deps"); + + await linkManagedDeps(bundleRoot, depsRoot); + + expect((await lstat(target)).isSymbolicLink()).toBe(false); + expect((await lstat(join(target, "real.txt"))).isFile()).toBe(true); + }); + + it("refreshes a stale symlink to point at the managed deps", async () => { + const { bundleRoot, depsRoot, source } = await makeRoots(); + const stale = await makeTmpDir(); + const target = join(bundleRoot, "node_modules"); + await symlink(stale, target, "dir"); + + await linkManagedDeps(bundleRoot, depsRoot); + + expect(await readlink(target)).toBe(source); + }); + + it("no-ops when bundleRoot equals depsRoot", async () => { + const { depsRoot } = await makeRoots(); + const target = join(depsRoot, "node_modules"); + + await linkManagedDeps(depsRoot, depsRoot); + + // The pre-existing real node_modules is untouched (not turned into a symlink). + expect((await lstat(target)).isSymbolicLink()).toBe(false); + }); +}); diff --git a/src/domains/runtimeEnv/linkManagedDeps.ts b/src/domains/runtimeEnv/linkManagedDeps.ts new file mode 100644 index 000000000..3b933fc44 --- /dev/null +++ b/src/domains/runtimeEnv/linkManagedDeps.ts @@ -0,0 +1,46 @@ +import { type Stats } from "node:fs"; +import { lstat, readlink, symlink } from "node:fs/promises"; +import { join } from "node:path"; + +import { makeDefaultFs, type Fs } from "~/shell/fs.js"; + +/** + * Makes the managed runtime's `node_modules` resolvable from a flow bundle by + * symlinking `/node_modules -> /node_modules`. Idempotent + * and safe: never clobbers a real `node_modules`, refreshes a stale symlink, + * and no-ops when the bundle already resolves deps from its own dir + * (`bundleRoot === depsRoot`). + */ +export async function linkManagedDeps( + bundleRoot: string, + depsRoot: string, + fs: Fs = makeDefaultFs(), +): Promise { + if (bundleRoot === depsRoot) return; + + const target = join(bundleRoot, "node_modules"); + const source = join(depsRoot, "node_modules"); + + const existing = await lstatOrUndefined(target); + if (existing?.isSymbolicLink()) { + const current = await readlink(target); + if (current === source) return; + await fs.rm(target, { recursive: true, force: true }); + } else if (existing !== undefined) { + return; + } + + // On Windows a "dir" symlink needs the "Create symbolic links" privilege + // (admin / Developer Mode); a junction links directories without elevation. + // The qawolf binary ships for Windows, so prefer junction there. + const linkType = process.platform === "win32" ? "junction" : "dir"; + await symlink(source, target, linkType); +} + +async function lstatOrUndefined(path: string): Promise { + try { + return await lstat(path); + } catch { + return undefined; + } +} diff --git a/src/domains/runtimeEnv/managedEnvDir.test.ts b/src/domains/runtimeEnv/managedEnvDir.test.ts new file mode 100644 index 000000000..719405258 --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { join, resolve } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { pinnedPackages } from "./pinnedPackages.js"; +import { + managedEnvDir, + managedEnvHash, + scaffoldManagedEnv, +} from "./managedEnvDir.js"; + +describe("managedEnvHash", () => { + it("returns exactly 16 hex characters", () => { + const hash = managedEnvHash(); + expect(hash).toHaveLength(16); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + + it("is stable across multiple calls", () => { + expect(managedEnvHash()).toBe(managedEnvHash()); + }); +}); + +describe("managedEnvDir", () => { + 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("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..60b6e5688 --- /dev/null +++ b/src/domains/runtimeEnv/managedEnvDir.ts @@ -0,0 +1,63 @@ +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"; + +/** + * Deterministic 16-hex-char SHA-256 digest of the pinned package specs. A + * new hash is produced whenever any pinned version changes, so each release + * gets its own isolated install directory. + */ +export function managedEnvHash(): string { + const content = pinnedPackages + .map(({ name, version }) => `${name}@${version}`) + .join("\n"); + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +/** + * 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"); +} + +/** 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/pinnedPackages.ts b/src/domains/runtimeEnv/pinnedPackages.ts new file mode 100644 index 000000000..a538e0200 --- /dev/null +++ b/src/domains/runtimeEnv/pinnedPackages.ts @@ -0,0 +1,25 @@ +import { + appiumUiautomator2DriverVersion, + appiumVersion, + appiumXcuitestDriverVersion, + emailsVersion, + flowsVersion, + playwrightVersion, + testkitVersion, +} from "~/generated/dependencyVersions.js"; + +export type PinnedPackage = { name: string; version: string }; + +/** Canonical list of pinned runtime packages the CLI installs and resolves from. */ +export const pinnedPackages: PinnedPackage[] = [ + { name: "@qawolf/flows", version: flowsVersion }, + { name: "playwright", version: playwrightVersion }, + { name: "@qawolf/emails", version: emailsVersion }, + { name: "@qawolf/testkit", version: testkitVersion }, + { name: "appium", version: appiumVersion }, + { name: "appium-xcuitest-driver", version: appiumXcuitestDriverVersion }, + { + name: "appium-uiautomator2-driver", + version: appiumUiautomator2DriverVersion, + }, +]; diff --git a/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts b/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts new file mode 100644 index 000000000..9c5e6da48 --- /dev/null +++ b/src/domains/runtimeEnv/resolveDepsRootIfPresent.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { managedEnvDir } from "./managedEnvDir.js"; +import { pinnedPackages } from "./pinnedPackages.js"; +import { resolveDepsRootIfPresent } from "./resolveDepsRootIfPresent.js"; + +function seedFullEnv(fs: ReturnType, dir: string): void { + for (const { name, version } of pinnedPackages) { + const pkgDir = join(dir, "node_modules", ...name.split("/")); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version })); + } + const binDir = join(dir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(join(binDir, "playwright"), "#!/bin/sh"); +} + +describe("resolveDepsRootIfPresent", () => { + it("returns overrideDir when override has all pinned deps", () => { + const fs = makeMemoryFs(); + const overrideDir = "/override/env"; + seedFullEnv(fs, overrideDir); + + const result = resolveDepsRootIfPresent({ overrideDir }, fs); + + expect(result).toBe(overrideDir); + }); + + it("skips override and returns projectDir when override is absent but project is present", () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + + const result = resolveDepsRootIfPresent( + { overrideDir: "/missing/override", projectDir }, + fs, + ); + + expect(result).toBe(projectDir); + }); + + it("returns projectDir when project has all pinned deps and no override is given", () => { + const fs = makeMemoryFs(); + const projectDir = "/user/project"; + seedFullEnv(fs, projectDir); + + const result = resolveDepsRootIfPresent({ projectDir }, fs); + + expect(result).toBe(projectDir); + }); + + it("returns managed dir when only managed env is installed", () => { + const fs = makeMemoryFs(); + const managed = managedEnvDir(); + seedFullEnv(fs, managed); + + const result = resolveDepsRootIfPresent({}, fs); + + expect(result).toBe(managed); + }); + + it("returns undefined when no directory has all pinned deps installed", () => { + const fs = makeMemoryFs(); + + const result = resolveDepsRootIfPresent( + { projectDir: "/missing/project" }, + fs, + ); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when called with no args and managed env is absent", () => { + const fs = makeMemoryFs(); + + const result = resolveDepsRootIfPresent({}, fs); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts b/src/domains/runtimeEnv/resolveDepsRootIfPresent.ts new file mode 100644 index 000000000..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/flows/shimDeps.test.ts b/src/domains/runtimeEnv/shimDeps.test.ts similarity index 100% rename from src/domains/flows/shimDeps.test.ts rename to src/domains/runtimeEnv/shimDeps.test.ts diff --git a/src/domains/flows/shimDeps.ts b/src/domains/runtimeEnv/shimDeps.ts similarity index 85% rename from src/domains/flows/shimDeps.ts rename to src/domains/runtimeEnv/shimDeps.ts index 245be2077..fa00eee55 100644 --- a/src/domains/flows/shimDeps.ts +++ b/src/domains/runtimeEnv/shimDeps.ts @@ -28,9 +28,11 @@ import { resolveFromEnvDir } from "~/shell/resolveExport.js"; type ShimMarker = { _qawolf_version: string; _qawolf_format: string }; -// Uses a structural type instead of `typeof Bun.build` to avoid the -// no-restricted-globals lint rule. Injected in tests — globalThis.Bun is -// read-only in the Bun runtime and cannot be reassigned. +/** + * Uses a structural type instead of `typeof Bun.build` to avoid the + * no-restricted-globals lint rule. Injected in tests — globalThis.Bun is + * read-only in the Bun runtime and cannot be reassigned. + */ export type BuildFn = (config: { entrypoints: string[]; target?: string; @@ -41,8 +43,11 @@ export type BuildFn = (config: { logs: { message: string }[]; }>; -// Returns the shim marker if shimDir is a qawolf-managed shim, undefined otherwise. -// A directory without the marker is a real package — must not be touched. +/** + * Returns the shim marker if shimDir is a qawolf-managed shim, undefined + * otherwise. A directory without the marker is a real package — must not be + * touched. + */ function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { try { const pkg = JSON.parse( @@ -60,6 +65,20 @@ function readShimMarker(shimDir: string, fs: Fs): ShimMarker | undefined { return undefined; } +/** + * Resolves the Bun.build function from the injected override or the runtime: + * an explicit BuildFn (tests), false for Node.js mode (no Bun), or + * auto-detection from globalThis.Bun. Reading Bun via globalThis works in both + * the compiled binary (Bun available) and the Node.js CLI build (Bun absent). + */ +function resolveBunBuild( + bunBuild: BuildFn | false | undefined, +): { build: BuildFn } | undefined { + if (bunBuild === undefined) + return (globalThis as { Bun?: { build: BuildFn } }).Bun; + return bunBuild === false ? undefined : { build: bunBuild }; +} + export async function shimFlowsDeps( envDir: string, fs: Fs = makeDefaultFs(), @@ -79,16 +98,7 @@ export async function shimFlowsDeps( return; } - // Access Bun.build via globalThis — works in both the compiled binary (Bun - // available) and the Node.js CLI build (Bun absent). Uses a structural type - // instead of `typeof Bun.build` to avoid the no-restricted-globals lint rule. - // bunBuild is injected in tests (globalThis.Bun is read-only in the runtime). - const bun = - bunBuild !== undefined - ? bunBuild !== false - ? { build: bunBuild } - : undefined - : (globalThis as { Bun?: { build: BuildFn } }).Bun; + const bun = resolveBunBuild(bunBuild); // Node.js resolves bare specifiers correctly; shimming is unnecessary and // a CJS require() fallback for ESM-only packages would break named imports. // But stale Bun-built CJS shims from a prior binary run must be removed — 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/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>;