UNPKG

@adonisjs/assembler

Version:

Provides utilities to run AdonisJS development server and build project for production

1,139 lines (1,129 loc) 33.7 kB
// src/bundler.ts import slash from "slash"; import dedent from "dedent"; import fs from "node:fs/promises"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { join as join2, relative as relative2 } from "node:path"; import { cliui } from "@poppinss/cliui"; import { detectPackageManager } from "@antfu/install-pkg"; // src/hooks.ts import { RuntimeException } from "@poppinss/utils"; import Hooks from "@poppinss/hooks"; var AssemblerHooks = class { #config; #hooks = new Hooks(); constructor(config) { this.#config = config; } /** * Resolve the hook by importing the file and returning the default export */ async #resolveHookNode(node) { const exports = await node(); if (!exports.default) { throw new RuntimeException("Assembler hook must be defined using the default export"); } return exports.default; } /** * Resolve hooks needed for dev-time and register them to the Hooks instance */ async registerDevServerHooks() { await Promise.all([ ...(this.#config?.onDevServerStarted || []).map( async (node) => this.#hooks.add("onDevServerStarted", await this.#resolveHookNode(node)) ), ...(this.#config?.onSourceFileChanged || []).map( async (node) => this.#hooks.add("onSourceFileChanged", await this.#resolveHookNode(node)) ) ]); } /** * Resolve hooks needed for build-time and register them to the Hooks instance */ async registerBuildHooks() { await Promise.all([ ...(this.#config?.onBuildStarting || []).map( async (node) => this.#hooks.add("onBuildStarting", await this.#resolveHookNode(node)) ), ...(this.#config?.onBuildCompleted || []).map( async (node) => this.#hooks.add("onBuildCompleted", await this.#resolveHookNode(node)) ) ]); } /** * When the dev server is started */ async onDevServerStarted(...args) { await this.#hooks.runner("onDevServerStarted").run(...args); } /** * When a source file changes */ async onSourceFileChanged(...args) { await this.#hooks.runner("onSourceFileChanged").run(...args); } /** * When the build process is starting */ async onBuildStarting(...args) { await this.#hooks.runner("onBuildStarting").run(...args); } /** * When the build process is completed */ async onBuildCompleted(...args) { await this.#hooks.runner("onBuildCompleted").run(...args); } }; // src/helpers.ts import { isJunk } from "junk"; import fastGlob from "fast-glob"; import getRandomPort from "get-port"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { execaNode, execa } from "execa"; import { copyFile, mkdir } from "node:fs/promises"; import { EnvLoader, EnvParser } from "@adonisjs/env"; import { ConfigParser, Watcher } from "@poppinss/chokidar-ts"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; // src/debug.ts import { debuglog } from "node:util"; var debug_default = debuglog("adonisjs:assembler"); // src/helpers.ts var DEFAULT_NODE_ARGS = [ // Use ts-node/esm loader. The project must install it process.versions.tsNodeMaintained ? "--import=ts-node-maintained/register/esm" : "--loader=ts-node/esm", // Enable source maps, since TSNode source maps are broken "--enable-source-maps" ]; if (process.allowedNodeEnvironmentFlags.has("--disable-warning")) { DEFAULT_NODE_ARGS.push("--disable-warning=ExperimentalWarning"); } else { DEFAULT_NODE_ARGS.push("--no-warnings"); } function parseConfig(cwd, ts) { const { config, error } = new ConfigParser(cwd, "tsconfig.json", ts).parse(); if (error) { const compilerHost = ts.createCompilerHost({}); console.log(ts.formatDiagnosticsWithColorAndContext([error], compilerHost)); return; } if (config.errors.length) { const compilerHost = ts.createCompilerHost({}); console.log(ts.formatDiagnosticsWithColorAndContext(config.errors, compilerHost)); return; } return config; } function runNode(cwd, options) { const childProcess = execaNode(options.script, options.scriptArgs, { nodeOptions: DEFAULT_NODE_ARGS.concat(options.nodeArgs), preferLocal: true, windowsHide: false, localDir: cwd, cwd, reject: options.reject ?? false, buffer: false, stdio: options.stdio || "inherit", env: { ...options.stdio === "pipe" ? { FORCE_COLOR: "true" } : {}, ...options.env } }); return childProcess; } function run(cwd, options) { const childProcess = execa(options.script, options.scriptArgs, { preferLocal: true, windowsHide: false, localDir: cwd, cwd, buffer: false, stdio: options.stdio || "inherit", env: { ...options.stdio === "pipe" ? { FORCE_COLOR: "true" } : {}, ...options.env } }); return childProcess; } function watch(cwd, ts, options) { const config = parseConfig(cwd, ts); if (!config) { return; } const watcher = new Watcher(typeof cwd === "string" ? cwd : fileURLToPath(cwd), config); const chokidar = watcher.watch(["."], { usePolling: options.poll }); return { watcher, chokidar }; } function isDotEnvFile(filePath) { if (filePath === ".env") { return true; } return filePath.includes(".env."); } async function getPort(cwd) { if (process.env.PORT) { return getRandomPort({ port: Number(process.env.PORT) }); } const files = await new EnvLoader(cwd).load(); for (let file of files) { const envVariables = await new EnvParser(file.contents).parse(); if (envVariables.PORT) { return getRandomPort({ port: Number(envVariables.PORT) }); } } return getRandomPort({ port: 3333 }); } async function copyFiles(files, cwd, outDir) { const { paths, patterns } = files.reduce( (result, file) => { if (fastGlob.isDynamicPattern(file)) { result.patterns.push(file); return result; } if (existsSync(join(cwd, file))) { result.paths.push(file); } return result; }, { patterns: [], paths: [] } ); debug_default("copyFiles inputs: %O, paths: %O, patterns: %O", files, paths, patterns); const filePaths = paths.concat(await fastGlob(patterns, { cwd, dot: true })).filter((file) => { return !isJunk(basename(file)); }); debug_default('copying files %O to destination "%s"', filePaths, outDir); const copyPromises = filePaths.map(async (file) => { const src = isAbsolute(file) ? file : join(cwd, file); const dest = join(outDir, relative(cwd, src)); await mkdir(dirname(dest), { recursive: true }); return copyFile(src, dest); }); return await Promise.all(copyPromises); } // src/bundler.ts var SUPPORT_PACKAGE_MANAGERS = { "npm": { packageManagerFiles: ["package-lock.json"], installCommand: 'npm ci --omit="dev"' }, "yarn": { packageManagerFiles: ["yarn.lock"], installCommand: "yarn install --production" }, "yarn@berry": { packageManagerFiles: ["yarn.lock", ".yarn/**/*", ".yarnrc.yml"], installCommand: "yarn workspaces focus --production" }, "pnpm": { packageManagerFiles: ["pnpm-lock.yaml"], installCommand: "pnpm i --prod" }, "bun": { packageManagerFiles: ["bun.lockb"], installCommand: "bun install --production" } }; var ui = cliui(); var Bundler = class { #cwd; #cwdPath; #ts; #logger = ui.logger; #hooks; #options; /** * Getting reference to colors library from logger */ get #colors() { return this.#logger.getColors(); } constructor(cwd, ts, options) { this.#cwd = cwd; this.#cwdPath = fileURLToPath2(this.#cwd); this.#ts = ts; this.#options = options; this.#hooks = new AssemblerHooks(options.hooks); } /** * Returns the relative unix path for an absolute * file path */ #getRelativeName(filePath) { return slash(relative2(this.#cwdPath, filePath)); } /** * Cleans up the build directory */ async #cleanupBuildDirectory(outDir) { await fs.rm(outDir, { recursive: true, force: true, maxRetries: 5 }); } /** * Runs assets bundler command to build assets */ async #buildAssets() { const assetsBundler = this.#options.assets; if (!assetsBundler?.enabled) { return true; } try { this.#logger.info("compiling frontend assets", { suffix: assetsBundler.cmd }); await run(this.#cwd, { stdio: "inherit", reject: true, script: assetsBundler.cmd, scriptArgs: assetsBundler.args }); return true; } catch { return false; } } /** * Runs tsc command to build the source. */ async #runTsc(outDir) { try { await run(this.#cwd, { stdio: "inherit", script: "tsc", scriptArgs: ["--outDir", outDir] }); return true; } catch { return false; } } /** * Copy meta files to the output directory */ async #copyMetaFiles(outDir, additionalFilesToCopy) { const metaFiles = (this.#options.metaFiles || []).map((file) => file.pattern).concat(additionalFilesToCopy); await copyFiles(metaFiles, this.#cwdPath, outDir); } /** * Detect the package manager used by the project * and return the lockfile name and install command * related to it. */ async #getPackageManager(client) { let pkgManager = client; if (!pkgManager) { pkgManager = await detectPackageManager(this.#cwdPath); } if (!pkgManager) { pkgManager = "npm"; } if (!Object.keys(SUPPORT_PACKAGE_MANAGERS).includes(pkgManager)) { return null; } return SUPPORT_PACKAGE_MANAGERS[pkgManager]; } /** * Rewrite the ace file since the original one * is importing ts-node which is not installed * in a production environment. */ async #createAceFile(outDir) { const aceFileLocation = join2(outDir, "ace.js"); const aceFileContent = dedent( /* JavaScript */ ` /** * This file is auto-generated by the build process. * If you had any custom code inside this file, then * instead write it inside the "bin/console.js" file. */ await import('./bin/console.js') ` ); await fs.writeFile(aceFileLocation, aceFileContent); this.#logger.info("rewrited ace file", { suffix: this.#getRelativeName(aceFileLocation) }); } /** * Set a custom CLI UI logger */ setLogger(logger) { this.#logger = logger; return this; } /** * Bundles the application to be run in production */ async bundle(stopOnError = true, client) { await this.#hooks.registerBuildHooks(); const config = parseConfig(this.#cwd, this.#ts); if (!config) { return false; } const outDir = config.options.outDir || fileURLToPath2(new URL("build/", this.#cwd)); this.#logger.info("cleaning up output directory", { suffix: this.#getRelativeName(outDir) }); await this.#cleanupBuildDirectory(outDir); if (!await this.#buildAssets()) { return false; } await this.#hooks.onBuildStarting({ colors: ui.colors, logger: this.#logger }); this.#logger.info("compiling typescript source", { suffix: "tsc" }); const buildCompleted = await this.#runTsc(outDir); await this.#createAceFile(outDir); if (!buildCompleted && stopOnError) { await this.#cleanupBuildDirectory(outDir); const instructions = ui.sticker().fullScreen().drawBorder((borderChar, colors) => colors.red(borderChar)); instructions.add( this.#colors.red("Cannot complete the build process as there are TypeScript errors.") ); instructions.add( this.#colors.red( 'Use "--ignore-ts-errors" flag to ignore TypeScript errors and continue the build.' ) ); this.#logger.logError(instructions.prepare()); return false; } const pkgManager = await this.#getPackageManager(client); const pkgFiles = pkgManager ? ["package.json", ...pkgManager.packageManagerFiles] : ["package.json"]; this.#logger.info("copying meta files to the output directory"); await this.#copyMetaFiles(outDir, pkgFiles); this.#logger.success("build completed"); this.#logger.log(""); await this.#hooks.onBuildCompleted({ colors: ui.colors, logger: this.#logger }); ui.instructions().useRenderer(this.#logger.getRenderer()).heading("Run the following commands to start the server in production").add(this.#colors.cyan(`cd ${this.#getRelativeName(outDir)}`)).add( this.#colors.cyan( pkgManager ? pkgManager.installCommand : "Install production dependencies" ) ).add(this.#colors.cyan("node bin/server.js")).render(); return true; } }; // src/dev_server.ts import picomatch from "picomatch"; import { relative as relative3 } from "node:path"; import prettyHrtime from "pretty-hrtime"; import { fileURLToPath as fileURLToPath3 } from "node:url"; import { cliui as cliui3 } from "@poppinss/cliui"; // src/assets_dev_server.ts import { cliui as cliui2 } from "@poppinss/cliui"; var ui2 = cliui2(); var AssetsDevServer = class { #cwd; #logger = ui2.logger; #options; #devServer; /** * Getting reference to colors library from logger */ get #colors() { return this.#logger.getColors(); } constructor(cwd, options) { this.#cwd = cwd; this.#options = options; } /** * Logs messages from vite dev server stdout and stderr */ #logViteDevServerMessage(data) { const dataString = data.toString(); const lines = dataString.split("\n"); if (dataString.includes("Local") && dataString.includes("Network")) { const sticker = ui2.sticker().useColors(this.#colors).useRenderer(this.#logger.getRenderer()); lines.forEach((line) => { if (line.trim()) { sticker.add(line); } }); sticker.render(); return; } if (dataString.includes("ready in")) { console.log(""); console.log(dataString.trim()); return; } lines.forEach((line) => { if (line.trim()) { console.log(line); } }); } /** * Logs messages from assets dev server stdout and stderr */ #logAssetsDevServerMessage(data) { const dataString = data.toString(); const lines = dataString.split("\n"); lines.forEach((line) => { if (line.trim()) { console.log(line); } }); } /** * Set a custom CLI UI logger */ setLogger(logger) { this.#logger = logger; return this; } /** * Starts the assets bundler server. The assets bundler server process is * considered as the secondary process and therefore we do not perform * any cleanup if it dies. */ start() { if (!this.#options?.enabled) { return; } this.#logger.info(`starting "${this.#options.driver}" dev server...`); this.#devServer = run(this.#cwd, { script: this.#options.cmd, reject: true, /** * We do not inherit the stdio for vite and encore, because in * inherit mode they own the stdin and interrupts the * `Ctrl + C` command. */ stdio: "pipe", scriptArgs: this.#options.args }); this.#devServer.stdout?.on("data", (data) => { if (this.#options.driver === "vite") { this.#logViteDevServerMessage(data); } else { this.#logAssetsDevServerMessage(data); } }); this.#devServer.stderr?.on("data", (data) => { if (this.#options.driver === "vite") { this.#logViteDevServerMessage(data); } else { this.#logAssetsDevServerMessage(data); } }); this.#devServer.then((result) => { this.#logger.warning( `"${this.#options.driver}" dev server closed with status code "${result.exitCode}"` ); }).catch((error) => { this.#logger.warning(`unable to connect to "${this.#options.driver}" dev server`); this.#logger.fatal(error); }); } /** * Stop the dev server */ stop() { if (this.#devServer) { this.#devServer.removeAllListeners(); this.#devServer.kill("SIGKILL"); this.#devServer = void 0; } } }; // src/dev_server.ts var ui3 = cliui3(); var DevServer = class { #cwd; #logger = ui3.logger; #options; /** * Flag to know if the dev server is running in watch * mode */ #isWatching = false; /** * Script file to start the development server */ #scriptFile = "bin/server.js"; /** * Picomatch matcher function to know if a file path is a * meta file with reloadServer option enabled */ #isMetaFileWithReloadsEnabled; /** * Picomatch matcher function to know if a file path is a * meta file with reloadServer option disabled */ #isMetaFileWithReloadsDisabled; /** * External listeners that are invoked when child process * gets an error or closes */ #onError; #onClose; /** * Reference to the child process */ #httpServer; /** * Reference to the watcher */ #watcher; /** * Reference to the assets server */ #assetsServer; /** * Hooks to execute custom actions during the dev server lifecycle */ #hooks; /** * Getting reference to colors library from logger */ get #colors() { return this.#logger.getColors(); } constructor(cwd, options) { this.#cwd = cwd; this.#options = options; this.#hooks = new AssemblerHooks(options.hooks); if (this.#options.hmr) { this.#options.nodeArgs = this.#options.nodeArgs.concat(["--import=hot-hook/register"]); } this.#isMetaFileWithReloadsEnabled = picomatch( (this.#options.metaFiles || []).filter(({ reloadServer }) => reloadServer === true).map(({ pattern }) => pattern) ); this.#isMetaFileWithReloadsDisabled = picomatch( (this.#options.metaFiles || []).filter(({ reloadServer }) => reloadServer !== true).map(({ pattern }) => pattern) ); } /** * Inspect if child process message is from AdonisJS HTTP server */ #isAdonisJSReadyMessage(message) { return message !== null && typeof message === "object" && "isAdonisJS" in message && "environment" in message && message.environment === "web"; } /** * Inspect if child process message is coming from Hot Hook */ #isHotHookMessage(message) { return message !== null && typeof message === "object" && "type" in message && typeof message.type === "string" && message.type.startsWith("hot-hook:"); } /** * Conditionally clear the terminal screen */ #clearScreen() { if (this.#options.clearScreen) { process.stdout.write("\x1Bc"); } } /** * Starts the HTTP server */ #startHTTPServer(port, mode) { const hooksArgs = { colors: this.#colors, logger: this.#logger }; this.#httpServer = runNode(this.#cwd, { script: this.#scriptFile, env: { PORT: port, ...this.#options.env }, nodeArgs: this.#options.nodeArgs, reject: true, scriptArgs: this.#options.scriptArgs }); this.#httpServer.on("message", async (message) => { if (this.#isHotHookMessage(message)) { const path = relative3(fileURLToPath3(this.#cwd), message.path || message.paths?.[0]); this.#hooks.onSourceFileChanged(hooksArgs, path); if (message.type === "hot-hook:full-reload") { this.#clearScreen(); this.#logger.log(`${this.#colors.green("full-reload")} ${path}`); this.#restartHTTPServer(port); this.#hooks.onDevServerStarted(hooksArgs); } else if (message.type === "hot-hook:invalidated") { this.#logger.log(`${this.#colors.green("invalidated")} ${path}`); } } if (this.#isAdonisJSReadyMessage(message)) { const host = message.host === "0.0.0.0" ? "127.0.0.1" : message.host; const displayMessage = ui3.sticker().useColors(this.#colors).useRenderer(this.#logger.getRenderer()).add(`Server address: ${this.#colors.cyan(`http://${host}:${message.port}`)}`); const watchMode = this.#options.hmr ? "HMR" : this.#isWatching ? "Legacy" : "None"; displayMessage.add(`Watch Mode: ${this.#colors.cyan(watchMode)}`); if (message.duration) { displayMessage.add(`Ready in: ${this.#colors.cyan(prettyHrtime(message.duration))}`); } displayMessage.render(); await this.#hooks.onDevServerStarted({ colors: ui3.colors, logger: this.#logger }); } }); this.#httpServer.then((result) => { if (mode === "nonblocking") { this.#onClose?.(result.exitCode); this.#watcher?.close(); this.#assetsServer?.stop(); } else { this.#logger.info("Underlying HTTP server closed. Still watching for changes"); } }).catch((error) => { if (mode === "nonblocking") { this.#onError?.(error); this.#watcher?.close(); this.#assetsServer?.stop(); } else { this.#logger.info("Underlying HTTP server died. Still watching for changes"); } }); } /** * Starts the assets server */ #startAssetsServer() { this.#assetsServer = new AssetsDevServer(this.#cwd, this.#options.assets); this.#assetsServer.setLogger(this.#logger); this.#assetsServer.start(); } /** * Restarts the HTTP server in the watch mode. Do not call this * method when not in watch mode */ #restartHTTPServer(port) { if (this.#httpServer) { this.#httpServer.removeAllListeners(); this.#httpServer.kill("SIGKILL"); } this.#startHTTPServer(port, "blocking"); } /** * Handles a non TypeScript file change */ #handleFileChange(action, port, relativePath) { if (isDotEnvFile(relativePath)) { this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); this.#restartHTTPServer(port); return; } if (this.#isMetaFileWithReloadsEnabled(relativePath)) { this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); this.#restartHTTPServer(port); return; } if (this.#isMetaFileWithReloadsDisabled(relativePath)) { this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); } } /** * Handles TypeScript source file change */ async #handleSourceFileChange(action, port, relativePath) { await this.#hooks.onSourceFileChanged({ colors: ui3.colors, logger: this.#logger }, relativePath); this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); this.#restartHTTPServer(port); } /** * Set a custom CLI UI logger */ setLogger(logger) { this.#logger = logger; this.#assetsServer?.setLogger(logger); return this; } /** * Add listener to get notified when dev server is * closed */ onClose(callback) { this.#onClose = callback; return this; } /** * Add listener to get notified when dev server exists * with an error */ onError(callback) { this.#onError = callback; return this; } /** * Close watchers and running child processes */ async close() { await this.#watcher?.close(); this.#assetsServer?.stop(); if (this.#httpServer) { this.#httpServer.removeAllListeners(); this.#httpServer.kill("SIGKILL"); } } /** * Start the development server */ async start() { await this.#hooks.registerDevServerHooks(); this.#clearScreen(); this.#logger.info("starting HTTP server..."); this.#startHTTPServer(String(await getPort(this.#cwd)), "nonblocking"); this.#startAssetsServer(); } /** * Start the development server in watch mode */ async startAndWatch(ts, options) { await this.#hooks.registerDevServerHooks(); const port = String(await getPort(this.#cwd)); this.#isWatching = true; this.#clearScreen(); this.#logger.info("starting HTTP server..."); this.#startHTTPServer(port, "blocking"); this.#startAssetsServer(); const output = watch(this.#cwd, ts, options || {}); if (!output) { this.#onClose?.(1); return; } this.#watcher = output.chokidar; output.watcher.on("watcher:ready", () => { this.#logger.info("watching file system for changes..."); }); output.chokidar.on("error", (error) => { this.#logger.warning("file system watcher failure"); this.#logger.fatal(error); this.#onError?.(error); output.chokidar.close(); }); output.watcher.on( "source:add", ({ relativePath }) => this.#handleSourceFileChange("add", port, relativePath) ); output.watcher.on( "source:change", ({ relativePath }) => this.#handleSourceFileChange("update", port, relativePath) ); output.watcher.on( "source:unlink", ({ relativePath }) => this.#handleSourceFileChange("delete", port, relativePath) ); output.watcher.on( "add", ({ relativePath }) => this.#handleFileChange("add", port, relativePath) ); output.watcher.on( "change", ({ relativePath }) => this.#handleFileChange("update", port, relativePath) ); output.watcher.on( "unlink", ({ relativePath }) => this.#handleFileChange("delete", port, relativePath) ); } }; // src/test_runner.ts import picomatch2 from "picomatch"; import { cliui as cliui4 } from "@poppinss/cliui"; var ui4 = cliui4(); var TestRunner = class { #cwd; #logger = ui4.logger; #options; /** * The script file to run as a child process */ #scriptFile = "bin/test.js"; /** * Pico matcher function to check if the filepath is * part of the `metaFiles` glob patterns */ #isMetaFile; /** * Pico matcher function to check if the filepath is * part of a test file. */ #isTestFile; /** * Arguments to pass to the "bin/test.js" file. */ #scriptArgs; /** * Set of initial filters applied when running the test * command. In watch mode, we will append an additional * filter to run tests only for the file that has been * changed. */ #initialFiltersArgs; /** * In watch mode, after a file is changed, we wait for the current * set of tests to finish before triggering a re-run. Therefore, * we use this flag to know if we are already busy in running * tests and ignore file-changes. */ #isBusy = false; /** * External listeners that are invoked when child process * gets an error or closes */ #onError; #onClose; /** * Reference to the test script child process */ #testScript; /** * Reference to the watcher */ #watcher; /** * Reference to the assets server */ #assetsServer; /** * Getting reference to colors library from logger */ get #colors() { return this.#logger.getColors(); } constructor(cwd, options) { this.#cwd = cwd; this.#options = options; this.#isMetaFile = picomatch2((this.#options.metaFiles || []).map(({ pattern }) => pattern)); this.#isTestFile = picomatch2( this.#options.suites.filter((suite) => { if (this.#options.filters.suites) { return this.#options.filters.suites.includes(suite.name); } return true; }).map((suite) => suite.files).flat(1) ); this.#scriptArgs = this.#convertOptionsToArgs().concat(this.#options.scriptArgs); this.#initialFiltersArgs = this.#convertFiltersToArgs(this.#options.filters); } /** * Convert test runner options to the CLI args */ #convertOptionsToArgs() { const args = []; if (this.#options.reporters) { args.push("--reporters"); args.push(this.#options.reporters.join(",")); } if (this.#options.timeout !== void 0) { args.push("--timeout"); args.push(String(this.#options.timeout)); } if (this.#options.failed) { args.push("--failed"); } if (this.#options.retries !== void 0) { args.push("--retries"); args.push(String(this.#options.retries)); } return args; } /** * Converts all known filters to CLI args. * * The following code snippet may seem like repetitive code. But, it * is done intentionally to have visibility around how each filter * is converted to an arg. */ #convertFiltersToArgs(filters) { const args = []; if (filters.suites) { args.push(...filters.suites); } if (filters.files) { args.push("--files"); args.push(filters.files.join(",")); } if (filters.groups) { args.push("--groups"); args.push(filters.groups.join(",")); } if (filters.tags) { args.push("--tags"); args.push(filters.tags.join(",")); } if (filters.tests) { args.push("--tests"); args.push(filters.tests.join(",")); } return args; } /** * Conditionally clear the terminal screen */ #clearScreen() { if (this.#options.clearScreen) { process.stdout.write("\x1Bc"); } } /** * Runs tests */ #runTests(port, mode, filters) { this.#isBusy = true; const scriptArgs = filters ? this.#convertFiltersToArgs(filters).concat(this.#scriptArgs) : this.#initialFiltersArgs.concat(this.#scriptArgs); this.#testScript = runNode(this.#cwd, { script: this.#scriptFile, reject: true, env: { PORT: port, ...this.#options.env }, nodeArgs: this.#options.nodeArgs, scriptArgs }); this.#testScript.then((result) => { if (mode === "nonblocking") { this.#onClose?.(result.exitCode); this.close(); } }).catch((error) => { if (mode === "nonblocking") { this.#onError?.(error); this.close(); } }).finally(() => { this.#isBusy = false; }); } /** * Re-run tests with additional inline filters. Should be * executed in watch mode only. */ #rerunTests(port, filters) { if (this.#testScript) { this.#testScript.removeAllListeners(); this.#testScript.kill("SIGKILL"); } this.#runTests(port, "blocking", filters); } /** * Starts the assets server */ #startAssetsServer() { this.#assetsServer = new AssetsDevServer(this.#cwd, this.#options.assets); this.#assetsServer.setLogger(this.#logger); this.#assetsServer.start(); } /** * Handles a non TypeScript file change */ #handleFileChange(action, port, relativePath) { if (this.#isBusy) { return; } if (isDotEnvFile(relativePath) || this.#isMetaFile(relativePath)) { this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); this.#rerunTests(port); } } /** * Handles TypeScript source file change */ #handleSourceFileChange(action, port, relativePath) { if (this.#isBusy) { return; } this.#clearScreen(); this.#logger.log(`${this.#colors.green(action)} ${relativePath}`); if (this.#isTestFile(relativePath)) { this.#rerunTests(port, { ...this.#options.filters, files: [relativePath] }); return; } this.#rerunTests(port); } /** * Set a custom CLI UI logger */ setLogger(logger) { this.#logger = logger; this.#assetsServer?.setLogger(logger); return this; } /** * Add listener to get notified when dev server is * closed */ onClose(callback) { this.#onClose = callback; return this; } /** * Add listener to get notified when dev server exists * with an error */ onError(callback) { this.#onError = callback; return this; } /** * Close watchers and running child processes */ async close() { await this.#watcher?.close(); this.#assetsServer?.stop(); if (this.#testScript) { this.#testScript.removeAllListeners(); this.#testScript.kill("SIGKILL"); } } /** * Runs tests */ async run() { const port = String(await getPort(this.#cwd)); this.#clearScreen(); this.#startAssetsServer(); this.#logger.info("booting application to run tests..."); this.#runTests(port, "nonblocking"); } /** * Run tests in watch mode */ async runAndWatch(ts, options) { const port = String(await getPort(this.#cwd)); this.#clearScreen(); this.#startAssetsServer(); this.#logger.info("booting application to run tests..."); this.#runTests(port, "blocking"); const output = watch(this.#cwd, ts, options || {}); if (!output) { this.#onClose?.(1); return; } this.#watcher = output.chokidar; output.watcher.on("watcher:ready", () => { this.#logger.info("watching file system for changes..."); }); output.chokidar.on("error", (error) => { this.#logger.warning("file system watcher failure"); this.#logger.fatal(error); this.#onError?.(error); output.chokidar.close(); }); output.watcher.on( "source:add", ({ relativePath }) => this.#handleSourceFileChange("add", port, relativePath) ); output.watcher.on( "source:change", ({ relativePath }) => this.#handleSourceFileChange("update", port, relativePath) ); output.watcher.on( "source:unlink", ({ relativePath }) => this.#handleSourceFileChange("delete", port, relativePath) ); output.watcher.on( "add", ({ relativePath }) => this.#handleFileChange("add", port, relativePath) ); output.watcher.on( "change", ({ relativePath }) => this.#handleFileChange("update", port, relativePath) ); output.watcher.on( "unlink", ({ relativePath }) => this.#handleFileChange("delete", port, relativePath) ); } }; export { Bundler, DevServer, TestRunner }; //# sourceMappingURL=index.js.map