Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/domains/runtimeEnv/execSubpathImports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const d = realpathSync(
await mkdtemp(join(tmpdir(), "qawolf-subpath-imports-test-")),
);
tmpDirs.push(d);
return d;
}

type PackageJson = {
name?: string;
type?: string;
dependencies?: Record<string, string>;
imports?: Record<string, unknown>;
};

async function readPackageJson(execDir: string): Promise<PackageJson> {
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" });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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");
});
Comment thread
michael-pr marked this conversation as resolved.
});
53 changes: 53 additions & 0 deletions src/domains/runtimeEnv/execSubpathImports.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<unknown> {
let content: string;
try {
content = await fs.readFile(pkgPath);
} catch (err) {
if (isNoEntError(err)) return {};
throw err;
}
try {
return JSON.parse(content);
} catch {
return {};
}
}
7 changes: 7 additions & 0 deletions src/domains/runtimeEnv/prepareRunDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down