diff --git a/src/domains/runtimeEnv/execSubpathImports.test.ts b/src/domains/runtimeEnv/execSubpathImports.test.ts new file mode 100644 index 000000000..6d6daf8ff --- /dev/null +++ b/src/domains/runtimeEnv/execSubpathImports.test.ts @@ -0,0 +1,117 @@ +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 { type Fs, 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" }); + }); + + 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), + }; + + let caught: unknown; + try { + await writeExecSubpathImports({ execDir, fs }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("permission denied"); + }); +}); diff --git a/src/domains/runtimeEnv/execSubpathImports.ts b/src/domains/runtimeEnv/execSubpathImports.ts new file mode 100644 index 000000000..c6f5324cd --- /dev/null +++ b/src/domains/runtimeEnv/execSubpathImports.ts @@ -0,0 +1,53 @@ +import { join } from "node:path"; + +import { z } from "zod"; + +import { isNoEntError } from "~/core/errors.js"; +import { type Fs } from "~/shell/fs.js"; + +/** + * 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 = { "#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; +}; + +/** 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 merged = { ...pkg, imports: { ...pkg.imports, ...flowSubpathImports } }; + await fs.writeFile(pkgPath, JSON.stringify(merged, undefined, 2)); +} + +/** + * 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); + } catch (err) { + if (isNoEntError(err)) return {}; + throw err; + } + try { + return JSON.parse(content); + } catch { + return {}; + } +} 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 {