diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 9ae0279cc67..f10c0de0304 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -6,12 +6,15 @@ import { task } from "hereby"; import _glob from "glob"; import util from "util"; import chalk from "chalk"; -import { exec, readJson, getDiffTool, getDirSize, memoize, needsUpdate } from "./scripts/build/utils.mjs"; -import { runConsoleTests, refBaseline, localBaseline, refRwcBaseline, localRwcBaseline } from "./scripts/build/tests.mjs"; +import { exec, readJson, getDiffTool, getDirSize, memoize, needsUpdate, Debouncer, Deferred } from "./scripts/build/utils.mjs"; +import { runConsoleTests, refBaseline, localBaseline, refRwcBaseline, localRwcBaseline, cleanTestDirs } from "./scripts/build/tests.mjs"; import { buildProject as realBuildProject, cleanProject, watchProject } from "./scripts/build/projects.mjs"; import { localizationDirectories } from "./scripts/build/localization.mjs"; import cmdLineOptions from "./scripts/build/options.mjs"; import esbuild from "esbuild"; +import chokidar from "chokidar"; +import { EventEmitter } from "events"; +import { CancelToken } from "@esfx/canceltoken"; const glob = util.promisify(_glob); @@ -191,6 +194,7 @@ async function runDtsBundler(entrypoint, output) { * @property {string[]} [external] * @property {boolean} [exportIsTsObject] * @property {boolean} [treeShaking] + * @property {esbuild.WatchMode} [watchMode] */ function createBundler(entrypoint, outfile, taskOptions = {}) { const getOptions = memoize(async () => { @@ -269,7 +273,7 @@ function createBundler(entrypoint, outfile, taskOptions = {}) { return { build: async () => esbuild.build(await getOptions()), - watch: async () => esbuild.build({ ...await getOptions(), watch: true, logLevel: "info" }), + watch: async () => esbuild.build({ ...await getOptions(), watch: taskOptions.watchMode ?? true, logLevel: "info" }), }; } @@ -461,7 +465,7 @@ export const dts = task({ const testRunner = "./built/local/run.js"; - +const watchTestsEmitter = new EventEmitter(); const { main: tests, watch: watchTests } = entrypointBuildTask({ name: "tests", description: "Builds the test infrastructure", @@ -482,6 +486,11 @@ const { main: tests, watch: watchTests } = entrypointBuildTask({ "mocha", "ms", ], + watchMode: { + onRebuild() { + watchTestsEmitter.emit("rebuild"); + } + } }, }); export { tests, watchTests }; @@ -625,6 +634,117 @@ export const runTests = task({ // " --shardId": "1-based ID of this shard (default: 1)", // }; +export const runTestsAndWatch = task({ + name: "runtests-watch", + dependencies: [watchTests], + run: async () => { + if (!cmdLineOptions.tests && !cmdLineOptions.failed) { + console.log(chalk.redBright(`You must specifiy either --tests/-t or --failed to use 'runtests-watch'.`)); + return; + } + + let watching = true; + let running = true; + let lastTestChangeTimeMs = Date.now(); + let testsChangedDeferred = /** @type {Deferred} */(new Deferred()); + let testsChangedCancelSource = CancelToken.source(); + + const testsChangedDebouncer = new Debouncer(1_000, endRunTests); + const testCaseWatcher = chokidar.watch([ + "tests/cases/**/*.*", + "tests/lib/**/*.*", + "tests/projects/**/*.*", + ], { + ignorePermissionErrors: true, + alwaysStat: true + }); + + process.on("SIGINT", endWatchMode); + process.on("SIGKILL", endWatchMode); + process.on("beforeExit", endWatchMode); + watchTestsEmitter.on("rebuild", onRebuild); + testCaseWatcher.on("all", onChange); + + while (watching) { + const promise = testsChangedDeferred.promise; + const token = testsChangedCancelSource.token; + if (!token.signaled) { + running = true; + try { + await runConsoleTests(testRunner, "mocha-fivemat-progress-reporter", /*runInParallel*/ false, { token, watching: true }); + } + catch { + // ignore + } + running = false; + } + if (watching) { + console.log(chalk.yellowBright(`[watch] test run complete, waiting for changes...`)); + await promise; + } + } + + function onRebuild() { + beginRunTests(testRunner); + } + + /** + * @param {'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'} eventName + * @param {string} path + * @param {fs.Stats | undefined} stats + */ + function onChange(eventName, path, stats) { + switch (eventName) { + case "change": + case "unlink": + case "unlinkDir": + break; + case "add": + case "addDir": + // skip files that are detected as 'add' but haven't actually changed since the last time tests were + // run. + if (stats && stats.mtimeMs <= lastTestChangeTimeMs) { + return; + } + break; + } + beginRunTests(path); + } + + /** + * @param {string} path + */ + function beginRunTests(path) { + if (testsChangedDebouncer.empty) { + console.log(chalk.yellowBright(`[watch] tests changed due to '${path}', restarting...`)); + if (running) { + console.log(chalk.yellowBright("[watch] aborting in-progress test run...")); + } + testsChangedCancelSource.cancel(); + testsChangedCancelSource = CancelToken.source(); + } + + testsChangedDebouncer.enqueue(); + } + + function endRunTests() { + lastTestChangeTimeMs = Date.now(); + testsChangedDeferred.resolve(); + testsChangedDeferred = /** @type {Deferred} */(new Deferred()); + } + + function endWatchMode() { + if (watching) { + watching = false; + console.log(chalk.yellowBright("[watch] exiting watch mode...")); + testsChangedCancelSource.cancel(); + testCaseWatcher.close(); + watchTestsEmitter.off("rebuild", onRebuild); + } + } + }, +}); + export const runTestsParallel = task({ name: "runtests-parallel", description: "Runs all the tests in parallel using the built run.js file.", diff --git a/package-lock.json b/package-lock.json index a093d15787f..a0b07c8f12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "tsserver": "bin/tsserver" }, "devDependencies": { + "@esfx/canceltoken": "^1.0.0", "@octokit/rest": "latest", "@types/chai": "latest", "@types/fs-extra": "^9.0.13", @@ -30,6 +31,7 @@ "azure-devops-node-api": "^11.2.0", "chai": "latest", "chalk": "^4.1.2", + "chokidar": "^3.5.3", "del": "^6.1.1", "diff": "^5.1.0", "esbuild": "^0.15.13", @@ -88,6 +90,38 @@ "node": ">=12" } }, + "node_modules/@esfx/cancelable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/cancelable/-/cancelable-1.0.0.tgz", + "integrity": "sha512-2dry/TuOT9ydpw86f396v09cyi/gLeGPIZSH4Gx+V/qKQaS/OXCRurCY+Cn8zkBfTAgFsjk9NE15d+LPo2kt9A==", + "dev": true, + "dependencies": { + "@esfx/disposable": "^1.0.0" + } + }, + "node_modules/@esfx/canceltoken": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/canceltoken/-/canceltoken-1.0.0.tgz", + "integrity": "sha512-/TgdzC5O89w5v0TgwE2wcdtampWNAFOxzurCtb4RxYVr3m72yk3Bg82vMdznx+H9nnf28zVDR0PtpZO9FxmOkw==", + "dev": true, + "dependencies": { + "@esfx/cancelable": "^1.0.0", + "@esfx/disposable": "^1.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@esfx/canceltoken/node_modules/tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", + "dev": true + }, + "node_modules/@esfx/disposable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/disposable/-/disposable-1.0.0.tgz", + "integrity": "sha512-hu7EI+YxlEWEKrb2himbS13HNaq5mlUePASf99KeQqkiNeqiAZbKqG4w59uDcLZs8JrV3qJqS/NYib5ZMhbfTQ==", + "dev": true + }, "node_modules/@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -4472,6 +4506,40 @@ "dev": true, "optional": true }, + "@esfx/cancelable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/cancelable/-/cancelable-1.0.0.tgz", + "integrity": "sha512-2dry/TuOT9ydpw86f396v09cyi/gLeGPIZSH4Gx+V/qKQaS/OXCRurCY+Cn8zkBfTAgFsjk9NE15d+LPo2kt9A==", + "dev": true, + "requires": { + "@esfx/disposable": "^1.0.0" + } + }, + "@esfx/canceltoken": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/canceltoken/-/canceltoken-1.0.0.tgz", + "integrity": "sha512-/TgdzC5O89w5v0TgwE2wcdtampWNAFOxzurCtb4RxYVr3m72yk3Bg82vMdznx+H9nnf28zVDR0PtpZO9FxmOkw==", + "dev": true, + "requires": { + "@esfx/cancelable": "^1.0.0", + "@esfx/disposable": "^1.0.0", + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==", + "dev": true + } + } + }, + "@esfx/disposable": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@esfx/disposable/-/disposable-1.0.0.tgz", + "integrity": "sha512-hu7EI+YxlEWEKrb2himbS13HNaq5mlUePASf99KeQqkiNeqiAZbKqG4w59uDcLZs8JrV3qJqS/NYib5ZMhbfTQ==", + "dev": true + }, "@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", diff --git a/package.json b/package.json index b2e6200b3b9..7f8ab916307 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "!**/.gitattributes" ], "devDependencies": { + "@esfx/canceltoken": "^1.0.0", "@octokit/rest": "latest", "@types/chai": "latest", "@types/fs-extra": "^9.0.13", @@ -56,6 +57,7 @@ "azure-devops-node-api": "^11.2.0", "chai": "latest", "chalk": "^4.1.2", + "chokidar": "^3.5.3", "del": "^6.1.1", "diff": "^5.1.0", "esbuild": "^0.15.13", diff --git a/scripts/build/tests.mjs b/scripts/build/tests.mjs index 82cb9b88732..4db74ec5c07 100644 --- a/scripts/build/tests.mjs +++ b/scripts/build/tests.mjs @@ -2,9 +2,11 @@ import del from "del"; import fs from "fs"; import os from "os"; import path from "path"; +import chalk from "chalk"; import cmdLineOptions from "./options.mjs"; import { exec } from "./utils.mjs"; import { findUpFile, findUpRoot } from "./findUpDir.mjs"; +import { CancelError } from "@esfx/canceltoken"; const mochaJs = path.resolve(findUpRoot(), "node_modules", "mocha", "bin", "_mocha"); export const localBaseline = "tests/baselines/local/"; @@ -17,8 +19,11 @@ export const localTest262Baseline = "internal/baselines/test262/local"; * @param {string} runJs * @param {string} defaultReporter * @param {boolean} runInParallel + * @param {object} options + * @param {import("@esfx/canceltoken").CancelToken} [options.token] + * @param {boolean} [options.watching] */ -export async function runConsoleTests(runJs, defaultReporter, runInParallel) { +export async function runConsoleTests(runJs, defaultReporter, runInParallel, options = {}) { let testTimeout = cmdLineOptions.timeout; const tests = cmdLineOptions.tests; const inspect = cmdLineOptions.break || cmdLineOptions.inspect; @@ -31,7 +36,14 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) { const shards = +cmdLineOptions.shards || undefined; const shardId = +cmdLineOptions.shardId || undefined; if (!cmdLineOptions.dirty) { + if (options.watching) { + console.log(chalk.yellowBright(`[watch] cleaning test directories...`)); + } await cleanTestDirs(); + + if (options.token?.signaled) { + return; + } } if (fs.existsSync(testConfigFile)) { @@ -56,6 +68,10 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) { testTimeout = 400000; } + if (options.watching) { + console.log(chalk.yellowBright(`[watch] running tests...`)); + } + if (tests || runners || light || testTimeout || taskConfigsFolder || keepFailed || shards || shardId) { writeTestConfigFile(tests, runners, light, taskConfigsFolder, workerCount, stackTraceLimit, testTimeout, keepFailed, shards, shardId); } @@ -114,7 +130,8 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) { try { setNodeEnvToDevelopment(); - const { exitCode } = await exec(process.execPath, args); + + const { exitCode } = await exec(process.execPath, args, { token: options.token }); if (exitCode !== 0) { errorStatus = exitCode; error = new Error(`Process exited with status code ${errorStatus}.`); @@ -132,8 +149,17 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) { await deleteTemporaryProjectOutput(); if (error !== undefined) { - process.exitCode = typeof errorStatus === "number" ? errorStatus : 2; - throw error; + if (error instanceof CancelError) { + throw error; + } + + if (options.watching) { + console.error(`${chalk.redBright(error.name)}: ${error.message}`); + } + else { + process.exitCode = typeof errorStatus === "number" ? errorStatus : 2; + throw error; + } } } diff --git a/scripts/build/utils.mjs b/scripts/build/utils.mjs index 3bf99758d8d..8c97fac0a54 100644 --- a/scripts/build/utils.mjs +++ b/scripts/build/utils.mjs @@ -7,6 +7,7 @@ import which from "which"; import { spawn } from "child_process"; import assert from "assert"; import JSONC from "jsonc-parser"; +import { CancelError } from "@esfx/canceltoken"; /** * Executes the provided command once with the supplied arguments. @@ -18,6 +19,7 @@ import JSONC from "jsonc-parser"; * @property {boolean} [ignoreExitCode] * @property {boolean} [hidePrompt] * @property {boolean} [waitForExit=true] + * @property {import("@esfx/canceltoken").CancelToken} [token] */ export async function exec(cmd, args, options = {}) { return /**@type {Promise<{exitCode?: number}>}*/(new Promise((resolve, reject) => { @@ -26,16 +28,24 @@ export async function exec(cmd, args, options = {}) { if (!options.hidePrompt) console.log(`> ${chalk.green(cmd)} ${args.join(" ")}`); const proc = spawn(which.sync(cmd), args, { stdio: waitForExit ? "inherit" : "ignore" }); if (waitForExit) { + const onCanceled = () => { + proc.kill(); + }; + const subscription = options.token?.subscribe(onCanceled); proc.on("exit", exitCode => { if (exitCode === 0 || ignoreExitCode) { resolve({ exitCode: exitCode ?? undefined }); } else { - reject(new Error(`Process exited with code: ${exitCode}`)); + const reason = options.token?.signaled ? options.token.reason ?? new CancelError() : + new Error(`Process exited with code: ${exitCode}`); + reject(reason); } + subscription?.unsubscribe(); }); proc.on("error", error => { reject(error); + subscription?.unsubscribe(); }); } else { @@ -150,8 +160,12 @@ export function getDirSize(root) { .reduce((acc, num) => acc + num, 0); } -class Deferred { +/** + * @template T + */ +export class Deferred { constructor() { + /** @type {Promise} */ this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; @@ -162,13 +176,15 @@ class Deferred { export class Debouncer { /** * @param {number} timeout - * @param {() => Promise} action + * @param {() => Promise | void} action */ constructor(timeout, action) { this._timeout = timeout; this._action = action; } + get empty() { return !this._deferred; } + enqueue() { if (this._timer) { clearTimeout(this._timer);