Simplistic watch mode for runtests (#51461)

* Simplistic watch mode for runtests

* Use esbuild WatchMode object for testRunner updates

* switch AbortController to CancelToken
This commit is contained in:
Ron Buckton 2022-11-09 15:07:08 -05:00 committed by GitHub
parent 6e0a62e8dd
commit e67b06e909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 243 additions and 11 deletions

View File

@ -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<void>} */(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<void>} */(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.",

68
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}
}
}

View File

@ -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<T>} */
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<any>} action
* @param {() => Promise<any> | void} action
*/
constructor(timeout, action) {
this._timeout = timeout;
this._action = action;
}
get empty() { return !this._deferred; }
enqueue() {
if (this._timer) {
clearTimeout(this._timer);