70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
351 lines
9.5 KiB
TypeScript
351 lines
9.5 KiB
TypeScript
import assert from "node:assert";
|
|
import childProcess from "node:child_process";
|
|
import events from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { setTimeout } from "node:timers/promises";
|
|
import util from "node:util";
|
|
import { removeDir } from "@cloudflare/workers-utils";
|
|
import { stripAnsi } from "miniflare";
|
|
import kill from "tree-kill";
|
|
import {
|
|
afterAll,
|
|
beforeAll,
|
|
inject,
|
|
onTestFailed,
|
|
onTestFinished,
|
|
vi,
|
|
} from "vitest";
|
|
import wranglerPackage from "../../wrangler/package.json";
|
|
import vitePluginPackage from "../package.json";
|
|
|
|
const debuglog = util.debuglog("vite-plugin:test");
|
|
|
|
const testEnv = {
|
|
...process.env,
|
|
// The following env vars are set to ensure that package managers
|
|
// do not use the same global cache and accidentally hit race conditions.
|
|
YARN_CACHE_FOLDER: "./.yarn/cache",
|
|
YARN_ENABLE_GLOBAL_CACHE: "false",
|
|
PNPM_HOME: "./.pnpm",
|
|
npm_config_cache: "./.npm/cache",
|
|
// unset the VITEST env variable as this causes e2e issues with some frameworks
|
|
VITEST: undefined,
|
|
};
|
|
|
|
const strictPeerDeps = {
|
|
pnpm: "--strict-peer-dependencies",
|
|
npm: "--strict-peer-deps",
|
|
// yarn does not have an option for strict checks
|
|
yarn: "",
|
|
};
|
|
|
|
/** Seed a test project from a fixture. */
|
|
export function seed(
|
|
fixture: string,
|
|
{
|
|
pm,
|
|
replacements = {},
|
|
useStrictPeerDeps = true,
|
|
}: {
|
|
pm: "pnpm" | "yarn" | "npm";
|
|
replacements?: Record<string, string>;
|
|
useStrictPeerDeps?: boolean;
|
|
}
|
|
) {
|
|
const root = inject("root");
|
|
const projectPath = path.resolve(root, fixture, pm);
|
|
|
|
beforeAll(async () => {
|
|
await fs.cp(path.resolve(__dirname, "fixtures", fixture), projectPath, {
|
|
recursive: true,
|
|
errorOnExist: true,
|
|
});
|
|
debuglog("Fixture copied to " + projectPath);
|
|
await updateVitePluginAndWranglerVersion(projectPath);
|
|
debuglog("Fixing up replacements in seeded files");
|
|
await fixupReplacements(projectPath, replacements);
|
|
debuglog("Updated vite-plugin version in package.json");
|
|
runCommand(
|
|
`${pm} install ${useStrictPeerDeps ? strictPeerDeps[pm] : ""}`,
|
|
projectPath,
|
|
{
|
|
attempts: 2,
|
|
}
|
|
);
|
|
debuglog("Installed node modules");
|
|
}, 200_000);
|
|
|
|
afterAll(async () => {
|
|
if (!process.env.CLOUDFLARE_VITE_E2E_KEEP_TEMP_DIRS) {
|
|
debuglog("Deleting project path", projectPath);
|
|
await removeDir(projectPath);
|
|
}
|
|
}, 40_000);
|
|
|
|
return projectPath;
|
|
}
|
|
|
|
export type AnyString = string & {};
|
|
|
|
/** Starts a command and wraps its outputs. */
|
|
export async function runLongLived(
|
|
pm: "pnpm" | "yarn" | "npm",
|
|
command: "dev" | "buildAndPreview" | AnyString,
|
|
projectPath: string,
|
|
customEnv: Record<string, string | undefined> = {}
|
|
) {
|
|
debuglog(`starting \`${command}\` for ${projectPath}`);
|
|
const proc = childProcess.exec(`${pm} run ${command}`, {
|
|
cwd: projectPath,
|
|
env: {
|
|
...testEnv,
|
|
...customEnv,
|
|
},
|
|
});
|
|
|
|
// Create a promise that resolves when the process actually exits
|
|
const closePromise = events.once(proc, "close");
|
|
|
|
onTestFinished(async () => {
|
|
debuglog(`Closing down process`);
|
|
const pid = proc.pid;
|
|
if (!pid) {
|
|
debuglog("Process had no pid");
|
|
return;
|
|
}
|
|
|
|
debuglog(`Killing process tree, pid:${pid}`);
|
|
|
|
// Send kill signal to process tree
|
|
await new Promise<void>((resolve) => {
|
|
kill(pid, "SIGKILL", (error) => {
|
|
if (error) {
|
|
debuglog("Error sending kill signal:", error);
|
|
} else {
|
|
debuglog(`Kill signal sent for pid:${pid}`);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
// Wait for the process to actually exit, not just the kill signal to be sent
|
|
debuglog(`Waiting for process ${pid} to fully exit...`);
|
|
await closePromise;
|
|
debuglog(`Process ${pid} has exited`);
|
|
|
|
// Small delay to ensure OS resources (ports, file handles) are fully released
|
|
await setTimeout(100);
|
|
debuglog(`Cleanup complete for pid:${pid}`);
|
|
});
|
|
|
|
return wrap(proc);
|
|
}
|
|
|
|
export interface Process {
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly exitCode: Promise<number>;
|
|
}
|
|
|
|
/**
|
|
* Wraps a long running child process to capture its stdio and make it available programmatically.
|
|
*/
|
|
function wrap(proc: childProcess.ChildProcess): Process {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
proc.stdout?.setEncoding("utf8");
|
|
proc.stderr?.setEncoding("utf8");
|
|
proc.stdout?.on("data", (chunk) => {
|
|
if (debuglog.enabled) {
|
|
process.stdout.write(chunk);
|
|
}
|
|
stdout += chunk;
|
|
});
|
|
proc.stderr?.on("data", (chunk) => {
|
|
if (debuglog.enabled) {
|
|
process.stderr.write(chunk);
|
|
}
|
|
stderr += chunk;
|
|
});
|
|
const closePromise = events.once(proc, "close");
|
|
const wrappedProc = {
|
|
get stdout() {
|
|
return stripAnsi(stdout);
|
|
},
|
|
get stderr() {
|
|
return stripAnsi(stderr);
|
|
},
|
|
get exitCode() {
|
|
return closePromise.then(([exitCode]) => exitCode ?? -1);
|
|
},
|
|
};
|
|
|
|
onTestFailed(() => {
|
|
console.log(
|
|
`Wrapped process logs (${proc.spawnfile} ${proc.spawnargs.join(" ")}):`
|
|
);
|
|
console.log(wrappedProc.stdout);
|
|
console.error(wrappedProc.stderr);
|
|
});
|
|
|
|
return wrappedProc;
|
|
}
|
|
|
|
async function updateVitePluginAndWranglerVersion(projectPath: string) {
|
|
const pkg = JSON.parse(
|
|
await fs.readFile(path.resolve(projectPath, "package.json"), "utf8")
|
|
);
|
|
const fields = ["dependencies", "devDependencies", "peerDependencies"];
|
|
for (const field of fields) {
|
|
if (pkg[field]?.["@cloudflare/vite-plugin"]) {
|
|
pkg[field]["@cloudflare/vite-plugin"] = vitePluginPackage.version;
|
|
}
|
|
// Some fixtures require the current version of wrangler to be installed
|
|
if (pkg[field]?.["wrangler"] === "*") {
|
|
pkg[field]["wrangler"] = wranglerPackage.version;
|
|
}
|
|
}
|
|
await fs.writeFile(
|
|
path.resolve(projectPath, "package.json"),
|
|
JSON.stringify(pkg, null, 2)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Runs a short-running command in a child process.
|
|
*
|
|
* Will attempt to run the command `attempts` times, retrying on failure.
|
|
* If `canFail` is true, the command will not throw when it runs out of attempts.
|
|
* If `timeout` is set, each attempt will be killed after the specified time in milliseconds.
|
|
*/
|
|
export function runCommand(
|
|
command: string,
|
|
cwd: string,
|
|
{ attempts = 1, canFail = false, timeout = 0 } = {}
|
|
) {
|
|
while (attempts > 0) {
|
|
debuglog("Running command:", command);
|
|
try {
|
|
const output = childProcess.execSync(command, {
|
|
cwd,
|
|
stdio: "pipe",
|
|
env: testEnv,
|
|
timeout,
|
|
encoding: "utf8",
|
|
});
|
|
if (debuglog.enabled) {
|
|
process.stdout.write(output);
|
|
}
|
|
return output;
|
|
} catch (e) {
|
|
attempts--;
|
|
if (attempts > 0) {
|
|
debuglog(`Retrying failed command (${e})`);
|
|
} else if (canFail) {
|
|
debuglog(`Command failed but canFail is true, not throwing: ${e}`);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getWranglerCommand(command: string) {
|
|
// Enforce a `wrangler` prefix to make commands clearer to read
|
|
assert(
|
|
command.startsWith("wrangler "),
|
|
"Commands must start with `wrangler` (e.g. `wrangler dev`) but got " +
|
|
command
|
|
);
|
|
const wranglerBin = path.resolve(
|
|
`${__dirname}/../../../packages/wrangler/bin/wrangler.js`
|
|
);
|
|
return `node ${wranglerBin} ${command.slice("wrangler ".length)}`;
|
|
}
|
|
|
|
export async function runWrangler(
|
|
wranglerCommand: string,
|
|
{ cwd }: { cwd?: string } = {}
|
|
) {
|
|
return runCommand(getWranglerCommand(wranglerCommand), cwd);
|
|
}
|
|
|
|
/**
|
|
* Fetches JSON data from the `url` using the `info` to create the request.
|
|
*
|
|
* Will retry the fetch if it fails or times out (5 seconds), waiting 1 second between attempts.
|
|
* If the request has still not succeeded after 20 secs it will fail.
|
|
*/
|
|
export async function fetchJson(url: string, info?: RequestInit) {
|
|
return vi.waitFor(
|
|
async () => {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { "MF-Disable-Pretty-Error": "true" },
|
|
...info,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await response.text();
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch (cause) {
|
|
const err = new Error(`Failed to parse JSON from:\n${text}`);
|
|
err.cause = cause;
|
|
throw err;
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch JSON from:" + url, error);
|
|
throw error;
|
|
}
|
|
},
|
|
{ timeout: 40_000, interval: 1000 }
|
|
);
|
|
}
|
|
|
|
/** Wait until a `vite dev` process is ready and capture the url on which it is listening. */
|
|
export async function waitForReady(proc: Process) {
|
|
const match = await vi.waitUntil(
|
|
() => proc.stdout.match(/Local:\s+(http:\/\/localhost:\d+)/),
|
|
// buildAndPreview does a full build before starting the server,
|
|
// so allow more time than the default.
|
|
{ interval: 100, timeout: 30_000 }
|
|
);
|
|
return match[1];
|
|
}
|
|
|
|
async function fixupReplacements(
|
|
projectPath: string,
|
|
replacements: Record<string, string>
|
|
) {
|
|
const files = await fs.readdir(projectPath, { withFileTypes: true });
|
|
for (const file of files) {
|
|
if (file.isDirectory()) {
|
|
await fixupReplacements(path.join(projectPath, file.name), replacements);
|
|
} else if (file.isFile()) {
|
|
let content = await fs.readFile(
|
|
path.join(projectPath, file.name),
|
|
"utf8"
|
|
);
|
|
for (const [key, value] of Object.entries(replacements)) {
|
|
content = content.replaceAll(key, value);
|
|
}
|
|
await fs.writeFile(path.join(projectPath, file.name), content, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `buildAndPreview` commands (i.e. `vite build && vite preview`) don't work in CI on windows
|
|
* this needs to be investigated and solved: https://jira.cfdata.org/browse/DEVX-2030
|
|
*
|
|
* This minimal utility simply detects if the command is being tested on windows
|
|
*
|
|
* @param command the command being tested (either 'dev' or 'buildAndPreview')
|
|
* @returns true is the command is buildAndPreview and the os is windows
|
|
*/
|
|
export function isBuildAndPreviewOnWindows(command: "dev" | "buildAndPreview") {
|
|
const isWindows = process.platform === "win32";
|
|
return isWindows && command === "buildAndPreview";
|
|
}
|