UNPKG

@nuxt/test-utils

Version:
280 lines (279 loc) 9.42 kB
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 };