test: run jest in one process, fail on an open handle (closes #766)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Jest force-stopped worker processes that could not exit within 500 ms on
a starved CPU, failing runs where every test passed. The test scripts now
pass --runInBand. script/lib/jest.js fails the run at once when jest
warns that it did not exit one second after the run, instead of
hanging. The Dockerfile test stage drops its 90-second timeout.

tests/jestRunner.test.js runs the runner on a fixture that leaves an
interval running and expects exit status 1.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-11 05:21:01 +00:00
1 parent d909567ab9
commit 4cb61d1a10
7 files changed
+72 -39

No files matched your search

+4 -5
View File
@@ -18,9 +18,8 @@ COPY . .
RUN yarn run lint
RUN script/check-censored
# Test phase, same shape and for the same reason: the jest suite (its worker
# cap is in package.json), rerun verbose on failure, then
# script/test-verify-build.
# Test phase, same shape and for the same reason: the jest suite, rerun
# verbose on failure, then script/test-verify-build.
#
# node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
@@ -33,9 +32,9 @@ RUN script/bootstrap
COPY . .
RUN timeout 90 yarn run test || \
RUN yarn run test || \
{ echo "--- Rerunning with --verbose for details ---"; \
timeout 90 yarn run test:verbose; exit 1; }
yarn run test:verbose; exit 1; }
RUN script/test-verify-build
# Development environment with the extension built, and the last stage: a
+6 -5
View File
@@ -1761,8 +1761,8 @@ The `Makefile` targets are thin shims over `script/`:
`package.json`.
- `script/setup`: `script/bootstrap`, then the git pre-commit hook.
- `script/test`: build the `test` stage of the `Dockerfile` with `--no-cache`.
The stage runs the jest suite, stopped at 90 seconds and rerun verbose on
failure, then `script/test-verify-build`.
The stage runs the jest suite, rerun verbose on failure, then
`script/test-verify-build`.
- `script/lint`: build the `lint` stage with `--no-cache`. The stage runs
ESLint, the prettier check and `script/check-censored`. Linting runs only in
Docker.
@@ -1861,9 +1861,10 @@ have left a complete, loadable debug bundle behind. `make build-debug` keeps its
### Tests
`make test` runs the jest suite in Docker. The suite must finish within 60
seconds; the test stage stops it at 90. `make check` runs the tests, the lint
stage and the formatting check.
`make test` runs the jest suite in Docker, in one process. The suite must finish
within 60 seconds. A test that leaves a timer or another open handle running
fails the run. `make check` runs the tests, the lint stage and the formatting
check.
Some unit tests pin facts stated elsewhere in this file:
+7 -2
View File
@@ -135,9 +135,14 @@ module.exports = [
},
},
// Unit tests, and the helpers they require: jest on node.
// Unit tests, the helpers they require, and the test files they hand to
// jest: jest on node.
{
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
files: [
"tests/**/*.test.js",
"tests/support/**/*.js",
"tests/fixtures/**/*.js",
],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "node script/lib/jest.js --maxWorkers=3",
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
"test": "node script/lib/jest.js --runInBand",
"test:verbose": "node script/lib/jest.js --runInBand --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
+17 -25
View File
@@ -1,8 +1,8 @@
// Runs jest with the arguments it is given, and fails the run when jest had to
// force-stop the worker process that ran a test file. Jest itself only prints a
// warning when that happens, and its run still passes. The warning means a test
// left a timer or another open handle running. The test scripts in package.json
// run jest through this file.
// Runs jest with the arguments it is given, and fails the run at once when a
// test left a timer or another open handle running. Such a handle keeps this
// process alive after the last test, so the run would hang. Jest itself only
// prints a warning one second after the run, and then keeps waiting. The test
// scripts in package.json run jest through this file, in this one process.
//
// Jest runs inside this process and writes to the terminal directly, so its
// output looks the same as a plain jest run. This file only checks each write
@@ -10,31 +10,23 @@
"use strict";
// The start of the warning that jest prints on stderr after it force-stops a
// worker process.
const WORKER_WARNING = "A worker process has failed to exit gracefully";
let forceStopped = false;
// The start of the warning that jest prints on stderr when the process has not
// exited one second after the test run.
const OPEN_HANDLE_WARNING =
"Jest did not exit one second after the test run has completed.";
const writeStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => {
if (String(chunk).includes(WORKER_WARNING)) {
forceStopped = true;
}
return writeStderr(chunk, ...rest);
};
// Runs after jest has finished, as the process exits.
process.on("exit", () => {
if (forceStopped) {
console.error(
"script/lib/jest.js: failing the run, because jest force-stopped " +
"a worker process. A test left a timer or another open " +
"handle running.",
const written = writeStderr(chunk, ...rest);
if (String(chunk).includes(OPEN_HANDLE_WARNING)) {
writeStderr(
"script/lib/jest.js: failing the run, because a test left a " +
"timer or another open handle running.\n",
);
process.exitCode = 1;
process.exit(1);
}
});
return written;
};
// Jest's own command-line program. It reads its arguments from process.argv.
require("jest/bin/jest");
+7
View File
@@ -0,0 +1,7 @@
// A test file that starts an interval and never clears it.
// tests/jestRunner.test.js runs script/lib/jest.js on this file alone. The
// name does not end in .test.js, so the normal run never picks it up.
test("starts an interval and never clears it", () => {
setInterval(() => {}, 1000);
});
+29
View File
@@ -0,0 +1,29 @@
// script/lib/jest.js, which package.json's test scripts run, fails a run in
// which a test left an open handle, instead of hanging on it.
const { spawnSync } = require("child_process");
const path = require("path");
const root = path.join(__dirname, "..");
test("a test that leaves an interval running fails the run", () => {
// The run ends about one second after its last test. Without the check in
// script/lib/jest.js it would never end, and the timeout stops it.
const result = spawnSync(
process.execPath,
[
"script/lib/jest.js",
"--runInBand",
"--testMatch",
"<rootDir>/tests/fixtures/leavesIntervalRunning.js",
],
{ cwd: root, encoding: "utf8", timeout: 20000 },
);
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
expect(result.stderr).toContain("1 passed");
expect(result.stderr).toContain(
"a test left a timer or another open handle running",
);
});