Skip to content

Commit e67b06e

Browse files
authored
Simplistic watch mode for runtests (#51461)
* Simplistic watch mode for runtests * Use esbuild WatchMode object for testRunner updates * switch AbortController to CancelToken
1 parent 6e0a62e commit e67b06e

File tree

5 files changed

+243
-11
lines changed

5 files changed

+243
-11
lines changed

Herebyfile.mjs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import { task } from "hereby";
66
import _glob from "glob";
77
import util from "util";
88
import chalk from "chalk";
9-
import { exec, readJson, getDiffTool, getDirSize, memoize, needsUpdate } from "./scripts/build/utils.mjs";
10-
import { runConsoleTests, refBaseline, localBaseline, refRwcBaseline, localRwcBaseline } from "./scripts/build/tests.mjs";
9+
import { exec, readJson, getDiffTool, getDirSize, memoize, needsUpdate, Debouncer, Deferred } from "./scripts/build/utils.mjs";
10+
import { runConsoleTests, refBaseline, localBaseline, refRwcBaseline, localRwcBaseline, cleanTestDirs } from "./scripts/build/tests.mjs";
1111
import { buildProject as realBuildProject, cleanProject, watchProject } from "./scripts/build/projects.mjs";
1212
import { localizationDirectories } from "./scripts/build/localization.mjs";
1313
import cmdLineOptions from "./scripts/build/options.mjs";
1414
import esbuild from "esbuild";
15+
import chokidar from "chokidar";
16+
import { EventEmitter } from "events";
17+
import { CancelToken } from "@esfx/canceltoken";
1518

1619
const glob = util.promisify(_glob);
1720

@@ -191,6 +194,7 @@ async function runDtsBundler(entrypoint, output) {
191194
* @property {string[]} [external]
192195
* @property {boolean} [exportIsTsObject]
193196
* @property {boolean} [treeShaking]
197+
* @property {esbuild.WatchMode} [watchMode]
194198
*/
195199
function createBundler(entrypoint, outfile, taskOptions = {}) {
196200
const getOptions = memoize(async () => {
@@ -269,7 +273,7 @@ function createBundler(entrypoint, outfile, taskOptions = {}) {
269273

270274
return {
271275
build: async () => esbuild.build(await getOptions()),
272-
watch: async () => esbuild.build({ ...await getOptions(), watch: true, logLevel: "info" }),
276+
watch: async () => esbuild.build({ ...await getOptions(), watch: taskOptions.watchMode ?? true, logLevel: "info" }),
273277
};
274278
}
275279

@@ -461,7 +465,7 @@ export const dts = task({
461465

462466

463467
const testRunner = "./built/local/run.js";
464-
468+
const watchTestsEmitter = new EventEmitter();
465469
const { main: tests, watch: watchTests } = entrypointBuildTask({
466470
name: "tests",
467471
description: "Builds the test infrastructure",
@@ -482,6 +486,11 @@ const { main: tests, watch: watchTests } = entrypointBuildTask({
482486
"mocha",
483487
"ms",
484488
],
489+
watchMode: {
490+
onRebuild() {
491+
watchTestsEmitter.emit("rebuild");
492+
}
493+
}
485494
},
486495
});
487496
export { tests, watchTests };
@@ -625,6 +634,117 @@ export const runTests = task({
625634
// " --shardId": "1-based ID of this shard (default: 1)",
626635
// };
627636

637+
export const runTestsAndWatch = task({
638+
name: "runtests-watch",
639+
dependencies: [watchTests],
640+
run: async () => {
641+
if (!cmdLineOptions.tests && !cmdLineOptions.failed) {
642+
console.log(chalk.redBright(`You must specifiy either --tests/-t or --failed to use 'runtests-watch'.`));
643+
return;
644+
}
645+
646+
let watching = true;
647+
let running = true;
648+
let lastTestChangeTimeMs = Date.now();
649+
let testsChangedDeferred = /** @type {Deferred<void>} */(new Deferred());
650+
let testsChangedCancelSource = CancelToken.source();
651+
652+
const testsChangedDebouncer = new Debouncer(1_000, endRunTests);
653+
const testCaseWatcher = chokidar.watch([
654+
"tests/cases/**/*.*",
655+
"tests/lib/**/*.*",
656+
"tests/projects/**/*.*",
657+
], {
658+
ignorePermissionErrors: true,
659+
alwaysStat: true
660+
});
661+
662+
process.on("SIGINT", endWatchMode);
663+
process.on("SIGKILL", endWatchMode);
664+
process.on("beforeExit", endWatchMode);
665+
watchTestsEmitter.on("rebuild", onRebuild);
666+
testCaseWatcher.on("all", onChange);
667+
668+
while (watching) {
669+
const promise = testsChangedDeferred.promise;
670+
const token = testsChangedCancelSource.token;
671+
if (!token.signaled) {
672+
running = true;
673+
try {
674+
await runConsoleTests(testRunner, "mocha-fivemat-progress-reporter", /*runInParallel*/ false, { token, watching: true });
675+
}
676+
catch {
677+
// ignore
678+
}
679+
running = false;
680+
}
681+
if (watching) {
682+
console.log(chalk.yellowBright(`[watch] test run complete, waiting for changes...`));
683+
await promise;
684+
}
685+
}
686+
687+
function onRebuild() {
688+
beginRunTests(testRunner);
689+
}
690+
691+
/**
692+
* @param {'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'} eventName
693+
* @param {string} path
694+
* @param {fs.Stats | undefined} stats
695+
*/
696+
function onChange(eventName, path, stats) {
697+
switch (eventName) {
698+
case "change":
699+
case "unlink":
700+
case "unlinkDir":
701+
break;
702+
case "add":
703+
case "addDir":
704+
// skip files that are detected as 'add' but haven't actually changed since the last time tests were
705+
// run.
706+
if (stats && stats.mtimeMs <= lastTestChangeTimeMs) {
707+
return;
708+
}
709+
break;
710+
}
711+
beginRunTests(path);
712+
}
713+
714+
/**
715+
* @param {string} path
716+
*/
717+
function beginRunTests(path) {
718+
if (testsChangedDebouncer.empty) {
719+
console.log(chalk.yellowBright(`[watch] tests changed due to '${path}', restarting...`));
720+
if (running) {
721+
console.log(chalk.yellowBright("[watch] aborting in-progress test run..."));
722+
}
723+
testsChangedCancelSource.cancel();
724+
testsChangedCancelSource = CancelToken.source();
725+
}
726+
727+
testsChangedDebouncer.enqueue();
728+
}
729+
730+
function endRunTests() {
731+
lastTestChangeTimeMs = Date.now();
732+
testsChangedDeferred.resolve();
733+
testsChangedDeferred = /** @type {Deferred<void>} */(new Deferred());
734+
}
735+
736+
function endWatchMode() {
737+
if (watching) {
738+
watching = false;
739+
console.log(chalk.yellowBright("[watch] exiting watch mode..."));
740+
testsChangedCancelSource.cancel();
741+
testCaseWatcher.close();
742+
watchTestsEmitter.off("rebuild", onRebuild);
743+
}
744+
}
745+
},
746+
});
747+
628748
export const runTestsParallel = task({
629749
name: "runtests-parallel",
630750
description: "Runs all the tests in parallel using the built run.js file.",

package-lock.json

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"!**/.gitattributes"
4040
],
4141
"devDependencies": {
42+
"@esfx/canceltoken": "^1.0.0",
4243
"@octokit/rest": "latest",
4344
"@types/chai": "latest",
4445
"@types/fs-extra": "^9.0.13",
@@ -56,6 +57,7 @@
5657
"azure-devops-node-api": "^11.2.0",
5758
"chai": "latest",
5859
"chalk": "^4.1.2",
60+
"chokidar": "^3.5.3",
5961
"del": "^6.1.1",
6062
"diff": "^5.1.0",
6163
"esbuild": "^0.15.13",

scripts/build/tests.mjs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import del from "del";
22
import fs from "fs";
33
import os from "os";
44
import path from "path";
5+
import chalk from "chalk";
56
import cmdLineOptions from "./options.mjs";
67
import { exec } from "./utils.mjs";
78
import { findUpFile, findUpRoot } from "./findUpDir.mjs";
9+
import { CancelError } from "@esfx/canceltoken";
810

911
const mochaJs = path.resolve(findUpRoot(), "node_modules", "mocha", "bin", "_mocha");
1012
export const localBaseline = "tests/baselines/local/";
@@ -17,8 +19,11 @@ export const localTest262Baseline = "internal/baselines/test262/local";
1719
* @param {string} runJs
1820
* @param {string} defaultReporter
1921
* @param {boolean} runInParallel
22+
* @param {object} options
23+
* @param {import("@esfx/canceltoken").CancelToken} [options.token]
24+
* @param {boolean} [options.watching]
2025
*/
21-
export async function runConsoleTests(runJs, defaultReporter, runInParallel) {
26+
export async function runConsoleTests(runJs, defaultReporter, runInParallel, options = {}) {
2227
let testTimeout = cmdLineOptions.timeout;
2328
const tests = cmdLineOptions.tests;
2429
const inspect = cmdLineOptions.break || cmdLineOptions.inspect;
@@ -31,7 +36,14 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) {
3136
const shards = +cmdLineOptions.shards || undefined;
3237
const shardId = +cmdLineOptions.shardId || undefined;
3338
if (!cmdLineOptions.dirty) {
39+
if (options.watching) {
40+
console.log(chalk.yellowBright(`[watch] cleaning test directories...`));
41+
}
3442
await cleanTestDirs();
43+
44+
if (options.token?.signaled) {
45+
return;
46+
}
3547
}
3648

3749
if (fs.existsSync(testConfigFile)) {
@@ -56,6 +68,10 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) {
5668
testTimeout = 400000;
5769
}
5870

71+
if (options.watching) {
72+
console.log(chalk.yellowBright(`[watch] running tests...`));
73+
}
74+
5975
if (tests || runners || light || testTimeout || taskConfigsFolder || keepFailed || shards || shardId) {
6076
writeTestConfigFile(tests, runners, light, taskConfigsFolder, workerCount, stackTraceLimit, testTimeout, keepFailed, shards, shardId);
6177
}
@@ -114,7 +130,8 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) {
114130

115131
try {
116132
setNodeEnvToDevelopment();
117-
const { exitCode } = await exec(process.execPath, args);
133+
134+
const { exitCode } = await exec(process.execPath, args, { token: options.token });
118135
if (exitCode !== 0) {
119136
errorStatus = exitCode;
120137
error = new Error(`Process exited with status code ${errorStatus}.`);
@@ -132,8 +149,17 @@ export async function runConsoleTests(runJs, defaultReporter, runInParallel) {
132149
await deleteTemporaryProjectOutput();
133150

134151
if (error !== undefined) {
135-
process.exitCode = typeof errorStatus === "number" ? errorStatus : 2;
136-
throw error;
152+
if (error instanceof CancelError) {
153+
throw error;
154+
}
155+
156+
if (options.watching) {
157+
console.error(`${chalk.redBright(error.name)}: ${error.message}`);
158+
}
159+
else {
160+
process.exitCode = typeof errorStatus === "number" ? errorStatus : 2;
161+
throw error;
162+
}
137163
}
138164
}
139165

0 commit comments

Comments
 (0)