@nuxt/test-utils
Version:
Test utilities for Nuxt
280 lines (279 loc) • 9.42 kB
JavaScript
import { resolve } from "node:path";
import { defu as defu$1 } from "defu";
import { joinURL, withTrailingSlash } from "ufo";
import { isBun, isWindows } from "std-env";
import { x, xSync } from "tinyexec";
import { getRandomPort, waitForPort } from "get-port-please";
import { createFetch, fetch } from "ofetch";
import { resolve as resolve$1 } from "pathe";
//#region src/e2e/context.ts
let currentContext;
function createTestContext(options) {
const _options = defu$1(options, {
testDir: resolve(process.cwd(), "test"),
fixture: "fixture",
configFile: "nuxt.config",
setupTimeout: isWindows ? 24e4 : 12e4,
teardownTimeout: isWindows ? 6e4 : 3e4,
serverStartTimeout: isWindows ? 12e4 : 6e4,
dev: !!JSON.parse(process.env.NUXT_TEST_DEV || "false"),
logLevel: 1,
server: true,
build: options.browser !== false || options.server !== false,
env: {},
nuxtConfig: { compatibilityDate: "2024-04-03" },
browserOptions: { type: "chromium" },
captureServerLogs: true
});
if (process.env.NUXT_TEST_LOG_LEVEL) _options.logLevel = Number(process.env.NUXT_TEST_LOG_LEVEL);
if (!_options.dev) _options.env.NODE_ENV ||= "production";
if (_options.host) {
_options.build = false;
_options.server = false;
}
if (process.env.VITEST === "true") _options.runner ||= "vitest";
else if (process.env.JEST_WORKER_ID) _options.runner ||= "jest";
else if (isBun) _options.runner ||= "bun";
return setTestContext({
options: _options,
url: withTrailingSlash(_options.host),
serverLogs: []
});
}
function useTestContext() {
recoverContextFromEnv();
if (!currentContext) throw new Error("No context is available. (Forgot calling setup or createContext?)");
return currentContext;
}
function setTestContext(context) {
currentContext = context;
return currentContext;
}
function isDev() {
return useTestContext().options.dev;
}
function recoverContextFromEnv() {
if (!currentContext && process.env.NUXT_TEST_CONTEXT) setTestContext(JSON.parse(process.env.NUXT_TEST_CONTEXT || "{}"));
}
function exposeContextToEnv() {
const { options, browser, url } = currentContext;
process.env.NUXT_TEST_CONTEXT = JSON.stringify({
options,
browser,
url
});
}
//#endregion
//#region src/e2e/server.ts
const globalFetch = globalThis.fetch || fetch;
/**
* Per-context promise that resolves once the server subprocess's output has
* been fully collected into `ctx.serverLogs`. Absent when log capture is off.
*/
const serverLogsCollected = /* @__PURE__ */ new WeakMap();
async function startServer(options = {}) {
const ctx = useTestContext();
await stopServer();
if (ctx.disposed) throw new Error("Test context has been torn down; refusing to start a server.");
ctx.serverLogs = [];
const host = "127.0.0.1";
const port = ctx.options.port || await getRandomPort(host);
ctx.url = `http://${host}:${port}/`;
serverLogsCollected.delete(ctx);
const capture = ctx.options.captureServerLogs !== false;
const stdio = capture ? "pipe" : "inherit";
const logLevel = String(options.logLevel ?? ctx.options.logLevel);
const startedAt = Date.now();
if (ctx.options.dev) ctx.serverProcess = x("nuxi", ["_dev"], {
throwOnError: true,
nodeOptions: {
cwd: ctx.nuxt.options.rootDir,
stdio,
env: {
...process.env,
_PORT: String(port),
PORT: String(port),
HOST: host,
NODE_ENV: "development",
CONSOLA_LEVEL: logLevel,
...ctx.options.env,
...options.env
}
}
});
else {
const outputDir = ctx.nuxt ? ctx.nuxt.options.nitro.output.dir : ctx.options.nuxtConfig.nitro.output.dir;
ctx.serverProcess = x("node", [resolve$1(outputDir, "server/index.mjs")], {
throwOnError: true,
nodeOptions: {
stdio,
env: {
...process.env,
PORT: String(port),
HOST: host,
NODE_ENV: "test",
CONSOLA_LEVEL: logLevel,
...ctx.options.env,
...options.env
}
}
});
}
if (capture) serverLogsCollected.set(ctx, (async () => {
for await (const line of ctx.serverProcess) ctx.serverLogs.push(line);
})().catch(() => {}));
if (ctx.disposed) {
await stopServer();
throw new Error("Test context has been torn down; server was stopped again.");
}
await waitForServer({
host,
port,
startedAt
});
}
function signalCode(proc) {
return proc.process?.signalCode ?? null;
}
function hasExited(proc) {
return !!proc && (proc.killed || proc.exitCode != null || signalCode(proc) !== null);
}
async function flushServerLogs(ctx) {
const collected = serverLogsCollected.get(ctx);
if (!collected) return;
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(resolve, 1e3);
});
try {
await Promise.race([collected, timeout]);
} finally {
clearTimeout(timer);
}
}
function earlyExitError(ctx, elapsed) {
const proc = ctx.serverProcess;
const signal = signalCode(proc);
const message = `Server process exited before becoming ready (${[
signal ? `signal: ${signal}` : `exit code: ${proc.exitCode ?? "unknown"}`,
`killed: ${proc.killed}`,
`after ${elapsed}ms`,
`mode: ${ctx.options.dev ? "dev" : "built"}`
].join(", ")})`;
const output = ctx.serverLogs.slice(-30).join("\n");
if (output) return /* @__PURE__ */ new Error(`${message}\n--- last output from the server process ---\n${output}`);
if (!serverLogsCollected.has(ctx)) return /* @__PURE__ */ new Error(`${message}\n(no output captured: \`captureServerLogs\` is disabled)`);
return /* @__PURE__ */ new Error(`${message}\n(the server process produced no output)`);
}
async function waitForServer({ host, port, startedAt }) {
const ctx = useTestContext();
const dev = ctx.options.dev;
const baseURL = ctx.nuxt?.options.app.baseURL ?? "/";
const deadline = Date.now() + ctx.options.serverStartTimeout;
await waitForPort(port, {
retries: 8,
host
}).catch(() => {});
let lastError;
let scannedLogs = 0;
while (Date.now() < deadline) {
while (scannedLogs < ctx.serverLogs.length) {
const match = /Port \d+ is in use, using port (\d+) instead/.exec(ctx.serverLogs[scannedLogs++]);
if (match) ctx.url = `http://${host}:${match[1]}/`;
}
if (hasExited(ctx.serverProcess)) {
await flushServerLogs(ctx);
const error = earlyExitError(ctx, Date.now() - startedAt);
await stopServer();
throw error;
}
try {
const res = await globalFetch(joinURL(ctx.url, baseURL), { signal: AbortSignal.timeout(1e4) });
if (dev && res.status === 503) lastError = /* @__PURE__ */ new Error(`Server responded with ${res.status} ${res.statusText}`);
else if (dev && (await res.text()).includes("__NUXT_LOADING__")) lastError = /* @__PURE__ */ new Error("Dev server is still starting up");
else return;
} catch (e) {
lastError = e;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
let error;
if (hasExited(ctx.serverProcess)) {
await flushServerLogs(ctx);
error = earlyExitError(ctx, Date.now() - startedAt);
} else {
await flushServerLogs(ctx);
const output = ctx.serverLogs.slice(-30).join("\n");
error = new Error(`Timeout (${ctx.options.serverStartTimeout}ms) waiting for ${dev ? "dev" : "built"} server to become ready at ${ctx.url}` + (output ? `\n--- last output from the server process ---\n${output}` : ""), { cause: lastError });
}
await stopServer();
throw error;
}
/**
* On Windows there are no process groups, and tinyexec routes commands that
* resolve to a `.cmd` shim (such as `nuxi`) through `cmd.exe /d /s /c`.
* `taskkill /T /F` walks the tree and terminates the descendants too.
*/
function killProcessTree(pid) {
try {
xSync("taskkill", [
"/pid",
String(pid),
"/T",
"/F"
], {
throwOnError: false,
nodeOptions: { stdio: "ignore" }
});
} catch {}
}
async function stopServer() {
const ctx = useTestContext();
const proc = ctx.serverProcess;
if (!proc) return;
ctx.serverProcess = void 0;
const exited = Promise.resolve(proc).then(() => {}, () => {});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pid = proc.pid;
if (isWindows && pid !== void 0) {
killProcessTree(pid);
await Promise.race([exited, sleep(5e3)]);
return;
}
proc.kill();
await Promise.race([exited, sleep(5e3)]);
if (proc.exitCode == null) {
proc.kill("SIGKILL");
await Promise.race([exited, sleep(5e3)]);
}
}
/**
* Returns the lines captured from the server subprocess's stdout/stderr since
* the last `startServer()` call (or `clearServerLogs()`).
* Only populated when `captureServerLogs` is `true` (the default).
*/
function getServerLogs() {
return useTestContext().serverLogs;
}
/**
* Clears the captured server log lines. Useful between requests when you want
* to assert only on the logs produced by a specific operation.
*/
function clearServerLogs() {
useTestContext().serverLogs = [];
}
function fetch$1(path, options) {
return globalFetch(url(path), options);
}
const _$fetch = createFetch({ fetch: globalFetch });
const $fetch = function $fetch(path, options) {
return _$fetch(url(path), options);
};
function url(path) {
const ctx = useTestContext();
if (!ctx.url) throw new Error("url is not available (is server option enabled?)");
if (path.startsWith(ctx.url)) return path;
return joinURL(ctx.url, path);
}
//#endregion
export { startServer as a, createTestContext as c, recoverContextFromEnv as d, setTestContext as f, getServerLogs as i, exposeContextToEnv as l, clearServerLogs as n, stopServer as o, useTestContext as p, fetch$1 as r, url as s, $fetch as t, isDev as u };