From 4757e6bbdaddaf2b73af46271a9fb27dae324a04 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:34:55 -0400 Subject: [PATCH 1/4] fix(runner): resolve #playwright alias in runtime isolates --- .../runtimeEnv/execSubpathImports.test.ts | 97 +++++++++++++++++++ src/domains/runtimeEnv/execSubpathImports.ts | 67 +++++++++++++ src/domains/runtimeEnv/prepareRunDir.ts | 7 ++ 3 files changed, 171 insertions(+) create mode 100644 src/domains/runtimeEnv/execSubpathImports.test.ts create mode 100644 src/domains/runtimeEnv/execSubpathImports.ts diff --git a/src/domains/runtimeEnv/execSubpathImports.test.ts b/src/domains/runtimeEnv/execSubpathImports.test.ts new file mode 100644 index 000000000..243eb42d8 --- /dev/null +++ b/src/domains/runtimeEnv/execSubpathImports.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeDefaultFs } from "~/shell/fs.js"; + +import { writeExecSubpathImports } from "./execSubpathImports.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tmpDirs.map((d) => rm(d, { recursive: true, force: true })), + ); + tmpDirs.length = 0; +}); + +async function makeExecDir(): Promise { + const d = realpathSync( + await mkdtemp(join(tmpdir(), "qawolf-subpath-imports-test-")), + ); + tmpDirs.push(d); + return d; +} + +type PackageJson = { + name?: string; + type?: string; + dependencies?: Record; + imports?: Record; +}; + +async function readPackageJson(execDir: string): Promise { + const content = await readFile(join(execDir, "package.json"), "utf-8"); + return JSON.parse(content) as PackageJson; +} + +describe("writeExecSubpathImports", () => { + it("adds the #playwright alias while preserving existing fields", async () => { + const execDir = await makeExecDir(); + await writeFile( + join(execDir, "package.json"), + JSON.stringify({ + name: "@qawolf/demo", + type: "module", + dependencies: { dotenv: "^16.4.5" }, + }), + ); + + await writeExecSubpathImports({ execDir, fs: makeDefaultFs() }); + + const pkg = await readPackageJson(execDir); + expect(pkg.imports).toEqual({ "#playwright": "playwright" }); + expect(pkg.name).toBe("@qawolf/demo"); + expect(pkg.type).toBe("module"); + expect(pkg.dependencies).toEqual({ dotenv: "^16.4.5" }); + }); + + it("overrides a conflicting #playwright entry but keeps other imports", async () => { + const execDir = await makeExecDir(); + await writeFile( + join(execDir, "package.json"), + JSON.stringify({ + imports: { "#playwright": "patchright", "#utils": "./src/utils.js" }, + }), + ); + + await writeExecSubpathImports({ execDir, fs: makeDefaultFs() }); + + const pkg = await readPackageJson(execDir); + expect(pkg.imports).toEqual({ + "#utils": "./src/utils.js", + "#playwright": "playwright", + }); + }); + + it("creates package.json with imports when none exists", async () => { + const execDir = await makeExecDir(); + + await writeExecSubpathImports({ execDir, fs: makeDefaultFs() }); + + const pkg = await readPackageJson(execDir); + expect(pkg.imports).toEqual({ "#playwright": "playwright" }); + }); + + it("recovers from an invalid package.json by writing a fresh imports map", async () => { + const execDir = await makeExecDir(); + await writeFile(join(execDir, "package.json"), "{ not valid json"); + + await writeExecSubpathImports({ execDir, fs: makeDefaultFs() }); + + const pkg = await readPackageJson(execDir); + expect(pkg.imports).toEqual({ "#playwright": "playwright" }); + }); +}); diff --git a/src/domains/runtimeEnv/execSubpathImports.ts b/src/domains/runtimeEnv/execSubpathImports.ts new file mode 100644 index 000000000..f50f80350 --- /dev/null +++ b/src/domains/runtimeEnv/execSubpathImports.ts @@ -0,0 +1,67 @@ +import { join } from "node:path"; + +import { type Fs } from "~/shell/fs.js"; + +/** + * Subpath-import aliases flow bundles use to reach pinned executor packages. + * The platform drops these from the generated bundle package.json; each target + * is a bare specifier that resolves through the inner-hop node_modules symlink + * (see populateInnerHop) against exec/package.json. "#playwright" points at the + * single browser driver the CLI pins (see pinnedPackages) and is the only alias + * flows use today. + */ +const flowSubpathImports: Record = { + "#playwright": "playwright", +}; + +export type WriteExecSubpathImportsArgs = { + execDir: string; + fs: Fs; +}; + +/** + * Merges the flow subpath-import aliases into exec/package.json so Node and the + * flow bundler resolve "#playwright" against the inner-hop symlink. Preserves + * all existing package.json fields and any pre-existing imports, with the flow + * aliases winning on conflict, and tolerates a missing or invalid package.json. + */ +export async function writeExecSubpathImports( + args: WriteExecSubpathImportsArgs, +): Promise { + const { execDir, fs } = args; + const pkgPath = join(execDir, "package.json"); + + const base = await readPackageJson(pkgPath, fs); + const merged = { + ...base, + imports: { ...readExistingImports(base), ...flowSubpathImports }, + }; + await fs.writeFile(pkgPath, JSON.stringify(merged, undefined, 2)); +} + +async function readPackageJson( + pkgPath: string, + fs: Fs, +): Promise> { + let content: string; + try { + content = await fs.readFile(pkgPath); + } catch { + return {}; + } + try { + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== "object" || parsed === null) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +function readExistingImports( + base: Record, +): Record { + const raw = base["imports"]; + if (typeof raw !== "object" || raw === null) return {}; + return raw as Record; +} diff --git a/src/domains/runtimeEnv/prepareRunDir.ts b/src/domains/runtimeEnv/prepareRunDir.ts index 6930488c6..9729cd678 100644 --- a/src/domains/runtimeEnv/prepareRunDir.ts +++ b/src/domains/runtimeEnv/prepareRunDir.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep } from "node:path"; import { copyDirExcluding } from "~/shell/copyDir.js"; import { type Fs, makeDefaultFs } from "~/shell/fs.js"; +import { writeExecSubpathImports } from "./execSubpathImports.js"; import { populateInnerHop } from "./innerHop.js"; import { populateOuterHop } from "./outerHop.js"; @@ -50,6 +51,12 @@ export async function prepareRunDir( const stagedFiles = await stageFlowFiles({ files, projectDir, execDir, fs }); + // Only the projectDir path copies a package.json into exec; standalone runs + // stage bare files that never use the "#playwright" alias. + if (projectDir !== undefined) { + await writeExecSubpathImports({ execDir, fs }); + } + await populateOuterHop({ projectDir, runDir, fs }); return { From 40ab104054a780864564c314955d61d1580082c7 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:44:21 -0400 Subject: [PATCH 2/4] fix(runner): distinguish ENOENT from other read errors in execSubpathImports --- .../runtimeEnv/execSubpathImports.test.ts | 17 ++++++++++++++++- src/domains/runtimeEnv/execSubpathImports.ts | 12 ++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/domains/runtimeEnv/execSubpathImports.test.ts b/src/domains/runtimeEnv/execSubpathImports.test.ts index 243eb42d8..13b0aeed6 100644 --- a/src/domains/runtimeEnv/execSubpathImports.test.ts +++ b/src/domains/runtimeEnv/execSubpathImports.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { makeDefaultFs } from "~/shell/fs.js"; +import { type Fs, makeDefaultFs } from "~/shell/fs.js"; import { writeExecSubpathImports } from "./execSubpathImports.js"; @@ -94,4 +94,19 @@ describe("writeExecSubpathImports", () => { const pkg = await readPackageJson(execDir); expect(pkg.imports).toEqual({ "#playwright": "playwright" }); }); + + it("propagates a non-ENOENT read error instead of clobbering package.json", async () => { + const execDir = await makeExecDir(); + const ioError = Object.assign(Error("EACCES: permission denied"), { + code: "EACCES", + }); + const fs: Fs = { + ...makeDefaultFs(), + readFile: () => Promise.reject(ioError), + }; + + expect(writeExecSubpathImports({ execDir, fs })).rejects.toThrow( + "permission denied", + ); + }); }); diff --git a/src/domains/runtimeEnv/execSubpathImports.ts b/src/domains/runtimeEnv/execSubpathImports.ts index f50f80350..ecfbd25d1 100644 --- a/src/domains/runtimeEnv/execSubpathImports.ts +++ b/src/domains/runtimeEnv/execSubpathImports.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; +import { isNoEntError } from "~/core/errors.js"; import { type Fs } from "~/shell/fs.js"; /** @@ -23,7 +24,9 @@ export type WriteExecSubpathImportsArgs = { * Merges the flow subpath-import aliases into exec/package.json so Node and the * flow bundler resolve "#playwright" against the inner-hop symlink. Preserves * all existing package.json fields and any pre-existing imports, with the flow - * aliases winning on conflict, and tolerates a missing or invalid package.json. + * aliases winning on conflict. Tolerates a missing (ENOENT) or invalid-JSON + * package.json; other read errors propagate so a transient failure never + * silently clobbers the staged bundle package.json. */ export async function writeExecSubpathImports( args: WriteExecSubpathImportsArgs, @@ -46,8 +49,9 @@ async function readPackageJson( let content: string; try { content = await fs.readFile(pkgPath); - } catch { - return {}; + } catch (err) { + if (isNoEntError(err)) return {}; + throw err; } try { const parsed: unknown = JSON.parse(content); @@ -62,6 +66,6 @@ function readExistingImports( base: Record, ): Record { const raw = base["imports"]; - if (typeof raw !== "object" || raw === null) return {}; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; return raw as Record; } From 2e75b0548d1997ca472db90b19c18dd69cf42e7b Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:32:22 -0400 Subject: [PATCH 3/4] refactor(runner): use zod schema for package.json validation --- src/domains/runtimeEnv/execSubpathImports.ts | 56 +++++++------------- 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/src/domains/runtimeEnv/execSubpathImports.ts b/src/domains/runtimeEnv/execSubpathImports.ts index ecfbd25d1..c6f5324cd 100644 --- a/src/domains/runtimeEnv/execSubpathImports.ts +++ b/src/domains/runtimeEnv/execSubpathImports.ts @@ -1,51 +1,43 @@ import { join } from "node:path"; +import { z } from "zod"; + import { isNoEntError } from "~/core/errors.js"; import { type Fs } from "~/shell/fs.js"; /** - * Subpath-import aliases flow bundles use to reach pinned executor packages. - * The platform drops these from the generated bundle package.json; each target - * is a bare specifier that resolves through the inner-hop node_modules symlink - * (see populateInnerHop) against exec/package.json. "#playwright" points at the - * single browser driver the CLI pins (see pinnedPackages) and is the only alias - * flows use today. + * qawolf's `#playwright` driver alias, which the platform omits from bundle + * package.json — pointed at the pinned playwright the inner hop symlinks in. */ -const flowSubpathImports: Record = { - "#playwright": "playwright", -}; +const flowSubpathImports = { "#playwright": "playwright" } as const; + +// looseObject keeps all fields; imports is coerced to a record ({} if missing or malformed). +const packageJsonSchema = z + .looseObject({ imports: z.record(z.string(), z.unknown()).catch({}) }) + .catch({ imports: {} }); export type WriteExecSubpathImportsArgs = { execDir: string; fs: Fs; }; -/** - * Merges the flow subpath-import aliases into exec/package.json so Node and the - * flow bundler resolve "#playwright" against the inner-hop symlink. Preserves - * all existing package.json fields and any pre-existing imports, with the flow - * aliases winning on conflict. Tolerates a missing (ENOENT) or invalid-JSON - * package.json; other read errors propagate so a transient failure never - * silently clobbers the staged bundle package.json. - */ +/** Adds the flow subpath-import aliases to exec/package.json's imports map. */ export async function writeExecSubpathImports( args: WriteExecSubpathImportsArgs, ): Promise { const { execDir, fs } = args; const pkgPath = join(execDir, "package.json"); + const pkg = packageJsonSchema.parse(await readPackageJson(pkgPath, fs)); - const base = await readPackageJson(pkgPath, fs); - const merged = { - ...base, - imports: { ...readExistingImports(base), ...flowSubpathImports }, - }; + const merged = { ...pkg, imports: { ...pkg.imports, ...flowSubpathImports } }; await fs.writeFile(pkgPath, JSON.stringify(merged, undefined, 2)); } -async function readPackageJson( - pkgPath: string, - fs: Fs, -): Promise> { +/** + * Reads and parses pkgPath. A missing file or malformed JSON yields {}; other + * read errors propagate so a transient failure never clobbers a staged file. + */ +async function readPackageJson(pkgPath: string, fs: Fs): Promise { let content: string; try { content = await fs.readFile(pkgPath); @@ -54,18 +46,8 @@ async function readPackageJson( throw err; } try { - const parsed: unknown = JSON.parse(content); - if (typeof parsed !== "object" || parsed === null) return {}; - return parsed as Record; + return JSON.parse(content); } catch { return {}; } } - -function readExistingImports( - base: Record, -): Record { - const raw = base["imports"]; - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; - return raw as Record; -} From 5852b474b21573ab61251fcc96a19c073d1889e2 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:55:19 -0400 Subject: [PATCH 4/4] test(runner): make non-ENOENT error propagation test deterministic --- src/domains/runtimeEnv/execSubpathImports.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/domains/runtimeEnv/execSubpathImports.test.ts b/src/domains/runtimeEnv/execSubpathImports.test.ts index 13b0aeed6..6d6daf8ff 100644 --- a/src/domains/runtimeEnv/execSubpathImports.test.ts +++ b/src/domains/runtimeEnv/execSubpathImports.test.ts @@ -105,8 +105,13 @@ describe("writeExecSubpathImports", () => { readFile: () => Promise.reject(ioError), }; - expect(writeExecSubpathImports({ execDir, fs })).rejects.toThrow( - "permission denied", - ); + let caught: unknown; + try { + await writeExecSubpathImports({ execDir, fs }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("permission denied"); }); });