Repository: mochajs/mocha
Pull request #5177 (2024-07-15 22:15:13)

ci: github actions workflow improvements
## Overview

The major benefit is that the workflows are lot DRY:er now – main definition is only 40 lines of code since all the repetitive boilerplate is ripped out into a reusable part: https://github.com/mochajs/mocha/blob/19ae1455109e7cd32db73ef6c3714e36dc045c51/.github/workflows/mocha.yml#L16-L56

This also ensures that the caching, installation etc will behave consistently in all the places and makes it easy to evolve it in a consistent manor.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/68658807/badge)](https://coveralls.io/builds/68658807)

coverage: 94.538% (+0.2%) from 94.38%
when pulling **e769953966c9acdf8256c32a3554c25c997dc897 on workflow-improvements**
into **24560c1532b8e67d1254b489fcc6f84c4033c0e1 on main**.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index 31e3b3d9..00000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-jobs:
-  lint:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-node@v4
-      - run: npm install --ignore-scripts
-      - run: npm run lint
-
-name: CI
-
-on:
-  pull_request: ~
-  push:
-    branches:
-      - main
diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml
index 99afe754..cf346a38 100644
--- a/.github/workflows/compliance.yml
+++ b/.github/workflows/compliance.yml
@@ -2,7 +2,7 @@ jobs:
   compliance:
     runs-on: ubuntu-latest
     steps:
-      - uses: mtfoley/pr-compliance-action@main
+      - uses: mtfoley/pr-compliance-action@11b664f0fcf2c4ce954f05ccfcaab6e52b529f86
         with:
           body-auto-close: false
           ignore-team-members: false
diff --git a/.github/workflows/delete-runs.yml b/.github/workflows/delete-runs.yml
deleted file mode 100644
index 326f8424..00000000
--- a/.github/workflows/delete-runs.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: Delete workflow runs
-on:
-  workflow_dispatch:
-    inputs:
-      days:
-        description: 'Number of days'
-        type: number
-        required: true
-        default: 180
-
-jobs:
-  del_runs:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Delete workflow runs
-        uses: Mattraks/delete-workflow-runs@main
-        with:
-          token: ${{ github.token }}
-          repository: ${{ github.repository }}
-          retain_days: ${{ github.event.inputs.days }}
-          keep_minimum_runs: 10
diff --git a/.github/workflows/mocha.yml b/.github/workflows/mocha.yml
index 33e357fd..3897f1da 100644
--- a/.github/workflows/mocha.yml
+++ b/.github/workflows/mocha.yml
@@ -1,205 +1,56 @@
 name: Tests
+
 on:
   push:
     branches:
-      - '**'
-      - '!mochajs.org'
+      - main
     paths-ignore: ['*.md', 'docs/**']
-    tags-ignore:
-      - '**'
   pull_request:
-    types: [opened, synchronize, reopened]
+    branches:
+      - main
     paths-ignore: ['*.md', 'docs/**']
 
+permissions:
+  contents: read
+
 jobs:
-  prevent-double-run:
-    # skip 'push' event when an open PR exists
-    name: Prevent double run
-    runs-on: ubuntu-latest
-    outputs:
-      pr-id: ${{ steps.findPr.outputs.number }}
-    steps:
-      - name: Check event pull_request
-        if: github.event_name == 'pull_request'
-        run: 'echo pull_request: run workflow'
-      - uses: actions/checkout@v4
-        if: github.event_name == 'push'
-        with:
-          persist-credentials: false
-      - name: Check event push
-        id: findPr
-        if: github.event_name == 'push'
-        uses: jwalton/gh-find-current-pr@v1
-        with:
-          state: open
+  lint:
+    uses: ./.github/workflows/npm-script.yml
+    with:
+      npm-script: lint
 
   smoke:
-    name: 'Smoke [Node.js v${{ matrix.node }} / ${{ matrix.os }}]'
-    needs: prevent-double-run
-    if: needs.prevent-double-run.outputs.pr-id == ''
-    runs-on: '${{ matrix.os }}'
-    strategy:
-      matrix:
-        os:
-          - ubuntu-latest
-          - windows-2019
-        node:
-          - 14
-          - 16
-          - 18
-          - 20
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          persist-credentials: false
-      - uses: actions/setup-node@v4
-        with:
-          node-version: '${{ matrix.node }}'
-      - run: npm install --production
-      - run: npm run test:smoke
-
-  lint:
-    name: Linting code and markdown
-    runs-on: ubuntu-latest
-    needs: smoke
-    steps:
-      - uses: actions/setup-node@v4
-        with:
-          node-version: lts/*
-      - uses: actions/checkout@v4
-        with:
-          persist-credentials: false
-      - name: 'Cache node_modules'
-        uses: actions/cache@v4
-        with:
-          path: '~/.npm'
-          key: "ubuntu-latest-node-lts-${{ hashFiles('**/package-lock.json') }}"
-      - name: Install Dependencies
-        run: npm ci --ignore-scripts
-      - name: 'Check lint'
-        run: npm start lint
+    uses: ./.github/workflows/npm-script.yml
+    with:
+      os: 'ubuntu-latest,windows-2019'
+      node-versions: '14,16,18,20,22'
+      npm-script: test:smoke
 
   test-node:
-    name: 'Node.js [v${{ matrix.node }} / ${{ matrix.os }}]'
+    # TODO: Restore "mocha-github-actions-reporter" style reporting without relying on third party module
+    uses: ./.github/workflows/npm-script.yml
     needs: smoke
-    runs-on: '${{ matrix.os }}'
-    env:
-      NODE_OPTIONS: '--trace-warnings'
-    strategy:
-      matrix:
-        os:
-          - ubuntu-latest
-          - windows-2019
-        node:
-          - 14
-          - 16
-          - 18
-          - 20
-        include:
-          - os: ubuntu-latest
-            node: 16
-            env:
-              COVERAGE: 1
-    steps:
-      - uses: actions/setup-node@v4
-        with:
-          node-version: '${{ matrix.node }}'
-      - uses: actions/checkout@v4
-        with:
-          persist-credentials: false
-      - name: Get npm cache directory in Windows
-        id: npm-cache
-        if: ${{ matrix.os == 'windows-2019' }}
-        run: |
-          echo "dir=$(npm config get cache)" >> $env:GITHUB_OUTPUT
-      - name: 'Cache node_modules'
-        uses: actions/cache@v4
-        with:
-          path: ${{ matrix.os == 'ubuntu-latest' && '~/.npm' || steps.npm-cache.outputs.dir }}
-          key: "${{ matrix.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}"
-      - name: Install Dependencies
-        run: npm ci --ignore-scripts
-      - name: Install Annotation Support
-        run: npm install mocha-github-actions-reporter
-      - name: Run All Node.js Tests
-        run: npm start test.node
-        env:
-          COVERAGE: '${{ matrix.env.COVERAGE }}'
-          MOCHA_REPORTER: mocha-github-actions-reporter
-          # this is so mocha-github-actions-reporter can find mocha
-          NODE_PATH: lib
-      - name: Generate Coverage Report (Linux + Node.js latest)
-        if: ${{ matrix.env.COVERAGE }}
-        run: npm start coverage-report-lcov
-      - name: Upload Coverage to Coveralls (Linux + Node.js latest)
-        if: ${{ matrix.env.COVERAGE }}
-        uses: coverallsapp/github-action@master
-        with:
-          github-token: '${{ secrets.GITHUB_TOKEN }}'
+    with:
+      os: 'ubuntu-latest,windows-2019'
+      node-versions: '14,16,18,20,22'
+      npm-script: test:node
+      coverage: true
 
   test-browser-local:
-    name: Browser Test [ChromeHeadless]
-    needs: smoke
-    runs-on: ubuntu-latest
-    timeout-minutes: 20
-    # Don't run forked 'pull_request' without saucelabs token
-    if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork
-    steps:
-      - uses: actions/setup-node@v4
-        with:
-          node-version: lts/*
-      - uses: actions/checkout@v4
-        with:
-          persist-credentials: false
-      - name: 'Cache node_modules'
-        uses: actions/cache@v4
-        with:
-          path: '~/.npm'
-          # this key is different than above, since we are running scripts
-          # (builds, postinstall lifecycle hooks, etc.)
-          key: "ubuntu-latest-node-full-lts-${{ hashFiles('**/package-lock.json') }}"
-      - name: Install Dependencies
-        run: npm ci
-      - name: Run Browser Tests
-        run: npm start test.browser
-        env:
-          BROWSER: ChromeHeadless
+    uses: ./.github/workflows/npm-script.yml
+    with:
+      browsers: ChromeHeadless
+      npm-script: test:browser
 
-  test-browser-saucelabs:
-    name: Browser Tests on SauceLabs [${{ matrix.browser }}]
-    needs:
-      - smoke
-      - test-browser-local
-    runs-on: ubuntu-latest
-    timeout-minutes: 20
-    strategy:
-      matrix:
-        browser:
-          - firefox@latest
-          - chrome@latest
-          - MicrosoftEdge@latest
-          - safari@latest
-    # Don't run forked 'pull_request' without saucelabs token
-    if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork
-    steps:
-      - uses: actions/setup-node@v4
-        with:
-          node-version: lts/*
-      - uses: actions/checkout@v4
-        with:
-          persist-credentials: false
-      - name: 'Cache node_modules'
-        uses: actions/cache@v4
-        with:
-          path: '~/.npm'
-          # this key is different than above, since we are running scripts
-          # (builds, postinstall lifecycle hooks, etc.)
-          key: "ubuntu-latest-node-full-lts-${{ hashFiles('**/package-lock.json') }}"
-      - name: Install Dependencies
-        run: npm ci
-      - name: Run Browser Tests
-        run: npm start test.browser
-        env:
-          SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
-          SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
-          BROWSER: ${{ matrix.browser }}
+  # test-browser-saucelabs:
+  #   # Don't run forked 'pull_request' without saucelabs token
+  #   if: github.event_name == 'push' || !github.event.pull_request.head.repo.fork
+  #   needs:
+  #     - lint
+  #     - smoke
+  #     - test-browser-local
+  #   uses: ./.github/workflows/npm-script.yml
+  #   secrets: inherit
+  #   with:
+  #     browsers: 'firefox@latest,chrome@latest,MicrosoftEdge@latest,safari@latest'
+  #     npm-script: test:browser
diff --git a/.github/workflows/nightly-site-deploy.yml b/.github/workflows/nightly-site-deploy.yml
index bd1ac0e3..f81d2fc8 100644
--- a/.github/workflows/nightly-site-deploy.yml
+++ b/.github/workflows/nightly-site-deploy.yml
@@ -2,15 +2,20 @@
 # This updates the list of supporters
 
 name: Nightly mochajs.org Deploy
+
 on:
   schedule:
     - cron: '0 0 * * *'
+
+permissions:
+  contents: read
+
 jobs:
   deploy:
     runs-on: ubuntu-latest
     steps:
       - name: Webhook Action
-        uses: joelwmale/webhook-action@2.3.2
+        uses: joelwmale/webhook-action@448a17bf857ead98546cfbdbe3b9d4cf979dda95
         env:
           data: ''
           WEBHOOK_URL: ${{ secrets.NETLIFY_NIGHTLY_DEPLOY_URL }}
diff --git a/.github/workflows/npm-script.yml b/.github/workflows/npm-script.yml
new file mode 100644
index 00000000..bed511cf
--- /dev/null
+++ b/.github/workflows/npm-script.yml
@@ -0,0 +1,94 @@
+name: Reusable npm script runner
+
+on:
+  workflow_call:
+    inputs:
+      npm-script:
+        description: 'npm script'
+        required: true
+        type: string
+      browsers:
+        description: 'A comma separated list of browser names to test with.'
+        required: false
+        type: string
+      node-versions:
+        description: 'A comma separated list of Node versions to test with.'
+        required: false
+        type: string
+      os:
+        description: 'A comma separated list of operating systems to test on.'
+        required: false
+        type: string
+      coverage:
+        description: 'Whether to set up coverage reporting or not'
+        required: false
+        type: boolean
+
+permissions:
+  contents: read
+
+jobs:
+  resolve-inputs:
+    runs-on: ubuntu-latest
+    outputs:
+      browsers: ${{ steps.split-browsers.outputs.splitted }}
+      nodeVersions: ${{ steps.split-node-versions.outputs.splitted }}
+      os: ${{ steps.split-os.outputs.splitted }}
+    steps:
+      - id: split-browsers
+        if: inputs.browsers
+        run: echo "splitted=$(echo '${{ inputs.browsers }}' | jq -R -c 'split(",")')" >> $GITHUB_OUTPUT
+      - id: split-node-versions
+        if: inputs.node-versions
+        run: echo "splitted=$(echo '${{ inputs.node-versions }}' | jq -R -c 'split(",")')" >> $GITHUB_OUTPUT
+      - id: split-os
+        if: inputs.os
+        run: echo "splitted=$(echo '${{ inputs.os }}' | jq -R -c 'split(",")')" >> $GITHUB_OUTPUT
+
+  script:
+    name: ${{ inputs.npm-script }}${{ needs.resolve-inputs.outputs.browsers && format('[{0}]', matrix.browser) }}${{ needs.resolve-inputs.outputs.nodeVersions && format(' with node.js {0}', matrix.node_version) }}${{ needs.resolve-inputs.outputs.os && format(' on {0}', matrix.os) }}
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 20
+    needs:
+      - resolve-inputs
+    strategy:
+      fail-fast: false
+      matrix:
+        node_version: ${{ fromJson(needs.resolve-inputs.outputs.nodeVersions || '["lts/*"]') }}
+        os: ${{ fromJson(needs.resolve-inputs.outputs.os || '["ubuntu-latest"]') }}
+        browser: ${{ fromJson(needs.resolve-inputs.outputs.browsers || '[""]') }}
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+      - uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node_version }}
+          cache: 'npm'
+      - run: npm ci --ignore-scripts
+      - run: npm run ${{ inputs.npm-script }}
+        env:
+          BROWSER: ${{ matrix.browser }}
+          COVERAGE: ${{ inputs.coverage && '1'}}
+          NODE_OPTIONS: '--trace-warnings'
+          SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
+          SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
+      - name: Generate coverage report
+        if: always() && inputs.coverage
+        run: npm start coverage-report-lcov
+      - name: Coveralls Parallel
+        if: always() && inputs.coverage
+        uses: coverallsapp/github-action@v2
+        with:
+          flag-name: run-${{ join(matrix.*, '-') }}
+          parallel: true
+
+  upload-coverage:
+    needs: script
+    if: always() && inputs.coverage
+    runs-on: ubuntu-latest
+    steps:
+    - name: Coveralls Finished
+      uses: coverallsapp/github-action@v2
+      with:
+        parallel-finished: true
diff --git a/AUTHORS b/AUTHORS
index 96ef9e7a..0cf88359 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -561,5 +561,7 @@ Ilia Choly <ilia.choly@gmail.com>
 Marjorie Saito <marjorieysaito@gmail.com>
 Khoa Huynh <58313491+khoaHyh@users.noreply.github.com>
 Sam Adams <107990625+sam-super@users.noreply.github.com>
+Marc Durdin <marc@durdin.net>
+Ilya Goncharov <ilgonmic@gmail.com>
 
 # Generated by scripts/update-authors.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 696955c6..2ae91e39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
 # Changelog
 
+## 10.7.0 / 2024-07-20
+
+### :tada: Enhancements
+
+- [#4771](https://github.com/mochajs/mocha/pull/4771) feat: add option to not fail on failing test suite ([**@ilgonmic**](https://github.com/ilgonmic))
+
+## 10.6.1 / 2024-07-20
+
+### :bug: Fixes
+
+- [#3825](https://github.com/mochajs/mocha/pull/3825) fix: do not exit when only unref'd timer is present in test code ([**@boneskull**](https://github.com/boneskull))
+- [#5040](https://github.com/mochajs/mocha/pull/5040) fix: support canonical module ([**@JacobLey**](https://github.com/JacobLey))
+
 ## 10.6.0 / 2024-07-02
 
 ### :tada: Enhancements
diff --git a/docs/index.md b/docs/index.md
index cc3d9eb2..d63d3d0c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -915,9 +915,17 @@ To ensure your tests aren't leaving messes around, here are some ideas to get st
 - Try something like [wtfnode][npm-wtfnode]
 - Use [`.only`](#exclusive-tests) until you find the test that causes Mocha to hang
 
+### `--pass-on-failing-test-suite`
+
+> _New in v10.7.0_
+
+If set to `true`, Mocha returns exit code `0` even if there are failed tests during run.
+
 ### `--fail-zero`
 
-> _New in v9.1.0_ Fail test run if no tests are encountered with `exit-code: 1`.
+> _New in v9.1.0_
+
+Fail test run if no tests are encountered with `exit-code: 1`.
 
 ### `--forbid-only`
 
diff --git a/lib/cli/run-helpers.js b/lib/cli/run-helpers.js
index 0d01afbf..ec65829f 100644
--- a/lib/cli/run-helpers.js
+++ b/lib/cli/run-helpers.js
@@ -21,25 +21,24 @@ const {UnmatchedFile} = require('./collect-files');
 
 /**
  * Exits Mocha when tests + code under test has finished execution (default)
- * @param {number} code - Exit code; typically # of failures
+ * @param {number} clampedCode - Exit code; typically # of failures
  * @ignore
  * @private
  */
-const exitMochaLater = code => {
+const exitMochaLater = clampedCode => {
   process.on('exit', () => {
-    process.exitCode = Math.min(code, 255);
+    process.exitCode = clampedCode;
   });
 };
 
 /**
  * Exits Mocha when Mocha itself has finished execution, regardless of
  * what the tests or code under test is doing.
- * @param {number} code - Exit code; typically # of failures
+ * @param {number} clampedCode - Exit code; typically # of failures
  * @ignore
  * @private
  */
-const exitMocha = code => {
-  const clampedCode = Math.min(code, 255);
+const exitMocha = clampedCode => {
   let draining = 0;
 
   // Eagerly set the process's exit code in case stream.write doesn't
@@ -139,12 +138,17 @@ const handleUnmatchedFiles = (mocha, unmatchedFiles) => {
  * @param {Mocha} mocha - Mocha instance
  * @param {Options} [opts] - Command line options
  * @param {boolean} [opts.exit] - Whether or not to force-exit after tests are complete
+ * @param {boolean} [opts.passOnFailingTestSuite] - Whether or not to fail test run if tests were failed
  * @param {Object} fileCollectParams - Parameters that control test
  *   file collection. See `lib/cli/collect-files.js`.
  * @returns {Promise<Runner>}
  * @private
  */
-const singleRun = async (mocha, {exit}, fileCollectParams) => {
+const singleRun = async (
+  mocha,
+  {exit, passOnFailingTestSuite},
+  fileCollectParams
+) => {
   const fileCollectionObj = collectFiles(fileCollectParams);
 
   if (fileCollectionObj.unmatchedFiles.length > 0) {
@@ -156,7 +160,9 @@ const singleRun = async (mocha, {exit}, fileCollectParams) => {
 
   // handles ESM modules
   await mocha.loadFilesAsync();
-  return mocha.run(exit ? exitMocha : exitMochaLater);
+  return mocha.run(
+    createExitHandler({exit, passOnFailingTestSuite})
+  );
 };
 
 /**
@@ -186,7 +192,9 @@ const parallelRun = async (mocha, options, fileCollectParams) => {
   mocha.files = fileCollectionObj.files;
 
   // note that we DO NOT load any files here; this is handled by the worker
-  return mocha.run(options.exit ? exitMocha : exitMochaLater);
+  return mocha.run(
+    createExitHandler(options)
+  );
 };
 
 /**
@@ -282,3 +290,15 @@ exports.validateLegacyPlugin = (opts, pluginType, map = {}) => {
     }
   }
 };
+
+const createExitHandler = ({ exit, passOnFailingTestSuite }) => {
+  return code => {
+    const clampedCode = passOnFailingTestSuite
+      ? 0
+      : Math.min(code, 255);
+
+    return exit
+      ? exitMocha(clampedCode)
+      : exitMochaLater(clampedCode);
+  };
+};
diff --git a/lib/cli/run-option-metadata.js b/lib/cli/run-option-metadata.js
index 492608fb..83aa70dd 100644
--- a/lib/cli/run-option-metadata.js
+++ b/lib/cli/run-option-metadata.js
@@ -35,6 +35,7 @@ const TYPES = (exports.types = {
     'diff',
     'dry-run',
     'exit',
+    'pass-on-failing-test-suite',
     'fail-zero',
     'forbid-only',
     'forbid-pending',
diff --git a/lib/cli/run.js b/lib/cli/run.js
index 66c8cbbb..3389e6df 100644
--- a/lib/cli/run.js
+++ b/lib/cli/run.js
@@ -98,6 +98,11 @@ exports.builder = yargs =>
         requiresArg: true,
         coerce: list
       },
+      'pass-on-failing-test-suite': {
+        default: false,
+        description: 'Not fail test run if tests were failed',
+        group: GROUPS.RULES
+      },
       'fail-zero': {
         description: 'Fail test run if no test(s) encountered',
         group: GROUPS.RULES
diff --git a/lib/mocha.js b/lib/mocha.js
index f93865df..c6ee2485 100644
--- a/lib/mocha.js
+++ b/lib/mocha.js
@@ -157,6 +157,7 @@ exports.run = function (...args) {
  * @param {boolean} [options.delay] - Delay root suite execution?
  * @param {boolean} [options.diff] - Show diff on failure?
  * @param {boolean} [options.dryRun] - Report tests without running them?
+ * @param {boolean} [options.passOnFailingTestSuite] - Fail test run if tests were failed?
  * @param {boolean} [options.failZero] - Fail test run if zero tests?
  * @param {string} [options.fgrep] - Test filter given string.
  * @param {boolean} [options.forbidOnly] - Tests marked `only` fail the suite?
@@ -216,6 +217,7 @@ function Mocha(options = {}) {
     'delay',
     'diff',
     'dryRun',
+    'passOnFailingTestSuite',
     'failZero',
     'forbidOnly',
     'forbidPending',
@@ -870,6 +872,20 @@ Mocha.prototype.failZero = function (failZero) {
   return this;
 };
 
+/**
+ * Fail test run if tests were failed.
+ *
+ * @public
+ * @see [CLI option](../#-pass-on-failing-test-suite)
+ * @param {boolean} [passOnFailingTestSuite=false] - Whether to fail test run.
+ * @return {Mocha} this
+ * @chainable
+ */
+Mocha.prototype.passOnFailingTestSuite = function(passOnFailingTestSuite) {
+  this.options.passOnFailingTestSuite = passOnFailingTestSuite === true;
+  return this;
+};
+
 /**
  * Causes tests marked `only` to fail the suite.
  *
diff --git a/lib/runnable.js b/lib/runnable.js
index 5e0970b1..98327bf5 100644
--- a/lib/runnable.js
+++ b/lib/runnable.js
@@ -20,6 +20,8 @@ var setTimeout = global.setTimeout;
 var clearTimeout = global.clearTimeout;
 var toString = Object.prototype.toString;
 
+var MAX_TIMEOUT = Math.pow(2, 31) - 1;
+
 module.exports = Runnable;
 
 /**
@@ -95,8 +97,7 @@ Runnable.prototype.timeout = function (ms) {
   }
 
   // Clamp to range
-  var INT_MAX = Math.pow(2, 31) - 1;
-  var range = [0, INT_MAX];
+  var range = [0, MAX_TIMEOUT];
   ms = utils.clamp(ms, range);
 
   // see #1652 for reasoning
@@ -233,11 +234,8 @@ Runnable.prototype.clearTimeout = function () {
  */
 Runnable.prototype.resetTimeout = function () {
   var self = this;
-  var ms = this.timeout();
+  var ms = this.timeout() || MAX_TIMEOUT;
 
-  if (ms === 0) {
-    return;
-  }
   this.clearTimeout();
   this.timer = setTimeout(function () {
     if (self.timeout() === 0) {
diff --git a/lib/utils.js b/lib/utils.js
index 4de9d282..1f21edc6 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -138,7 +138,7 @@ function emptyRepresentation(value, typeHint) {
  * canonicalType(global) // 'global'
  * canonicalType(new String('foo') // 'object'
  * canonicalType(async function() {}) // 'asyncfunction'
- * canonicalType(await import(name)) // 'module'
+ * canonicalType(Object.create(null)) // 'null-prototype'
  */
 var canonicalType = (exports.canonicalType = function canonicalType(value) {
   if (value === undefined) {
@@ -147,7 +147,10 @@ var canonicalType = (exports.canonicalType = function canonicalType(value) {
     return 'null';
   } else if (Buffer.isBuffer(value)) {
     return 'buffer';
+  } else if (Object.getPrototypeOf(value) === null) {
+    return 'null-prototype';
   }
+
   return Object.prototype.toString
     .call(value)
     .replace(/^\[.+\s(.+?)]$/, '$1')
@@ -213,7 +216,7 @@ exports.type = function type(value) {
 exports.stringify = function (value) {
   var typeHint = canonicalType(value);
 
-  if (!~['object', 'array', 'function'].indexOf(typeHint)) {
+  if (!~['object', 'array', 'function', 'null-prototype'].indexOf(typeHint)) {
     if (typeHint === 'buffer') {
       var json = Buffer.prototype.toJSON.call(value);
       // Based on the toJSON result
@@ -399,8 +402,12 @@ exports.canonicalize = function canonicalize(value, stack, typeHint) {
         break;
       }
     /* falls through */
+    case 'null-prototype':
     case 'object':
       canonicalizedObj = canonicalizedObj || {};
+      if (typeHint === 'null-prototype' && Symbol.toStringTag in value) {
+        canonicalizedObj['[Symbol.toStringTag]'] = value[Symbol.toStringTag];
+      }
       withStack(value, function () {
         Object.keys(value)
           .sort()
diff --git a/package-lock.json b/package-lock.json
index 7f2cad20..f2d601ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "mocha",
-  "version": "10.6.0",
+  "version": "10.7.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "mocha",
-      "version": "10.6.0",
+      "version": "10.7.0",
       "license": "MIT",
       "dependencies": {
         "ansi-colors": "^4.1.3",
diff --git a/package.json b/package.json
index 887c775a..590c1fb9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "mocha",
-  "version": "10.6.0",
+  "version": "10.7.0",
   "type": "commonjs",
   "description": "simple, flexible, fun test framework",
   "keywords": [
@@ -48,6 +48,8 @@
     "start": "nps",
     "test": "nps test",
     "version": "nps version",
+    "test:browser": "nps test.browser",
+    "test:node": "nps test.node",
     "test:smoke": "node ./bin/mocha --no-config test/smoke/smoke.spec.js"
   },
   "dependencies": {


Test patch:
diff --git a/.github/workflows/browser-test.yml b/.github/workflows/browser-test.yml
index f9535b16..9e7db64e 100644
--- a/.github/workflows/browser-test.yml
+++ b/.github/workflows/browser-test.yml
@@ -11,34 +11,20 @@ permissions:
 
 jobs:
   test-browser:
-    # TODO: configure to retain build artifacts in `.karma/` dir
     name: 'Browser Tests'
-    timeout-minutes: 20
-    runs-on: ubuntu-latest
     if: contains(github.event.pull_request.labels.*.name, 'run-browser-test')
+    uses: ./.github/workflows/npm-script.yml
+    secrets: inherit
+    with:
+      npm-script: test.browser
+
+  remove-label:
+    needs: test-browser
+    if: always()
+    runs-on: ubuntu-latest
     steps:
-      - uses: actions/setup-node@v4
-        with:
-          node-version: lts/*
-      - uses: actions/checkout@v4
-        with:
-          ref: ${{ github.event.pull_request.head.sha }}
-          persist-credentials: false
-      - name: 'Cache node_modules'
-        uses: actions/cache@v4
-        with:
-          path: '~/.npm'
-          key: "ubuntu-latest-node-full-lts-${{ hashFiles('**/package-lock.json') }}"
-      - name: Install Dependencies
-        run: npm ci
-      - name: Run Browser Tests
-        run: npm start test.browser
-        env:
-          SAUCE_USERNAME: '${{secrets.SAUCE_USERNAME}}'
-          SAUCE_ACCESS_KEY: '${{secrets.SAUCE_ACCESS_KEY}}'
       - name: remove 'run-browser-test' label
-        uses: buildsville/add-remove-label@v2.0.1
-        if: ${{ always() }}
+        uses: buildsville/add-remove-label@ac59c9f0aeb66eb12d6366eb1d69ec1906e9ef9a
         with:
           token: ${{secrets.GITHUB_TOKEN}}
           label: run-browser-test
diff --git a/test/integration/fixtures/failing-sync.fixture.js b/test/integration/fixtures/failing-sync.fixture.js
new file mode 100644
index 00000000..f81f9653
--- /dev/null
+++ b/test/integration/fixtures/failing-sync.fixture.js
@@ -0,0 +1,9 @@
+'use strict';
+
+var assert = require('assert');
+
+describe('a suite', function() {
+  it('should succeed', function() {
+    assert(false);
+  });
+});
diff --git a/test/integration/fixtures/options/timeout-unref.fixture.js b/test/integration/fixtures/options/timeout-unref.fixture.js
new file mode 100644
index 00000000..8f59aea5
--- /dev/null
+++ b/test/integration/fixtures/options/timeout-unref.fixture.js
@@ -0,0 +1,3 @@
+it('unrefs a timeout', function(done) {
+  setTimeout(done, 10).unref();
+});
diff --git a/test/integration/options/passOnFailingTestSuite.spec.js b/test/integration/options/passOnFailingTestSuite.spec.js
new file mode 100644
index 00000000..9f902da7
--- /dev/null
+++ b/test/integration/options/passOnFailingTestSuite.spec.js
@@ -0,0 +1,40 @@
+'use strict';
+
+var helpers = require('../helpers');
+var runMochaJSON = helpers.runMochaJSON;
+
+describe('Enabled --pass-on-failing-test-suite', function() {
+  var args = ['--pass-on-failing-test-suite=true'];
+
+  it('Test should finish with zero code with disabled option', function(done) {
+    var fixture = 'failing-sync.fixture.js';
+    runMochaJSON(fixture, args, function(err, res) {
+      if (err) {
+        return done(err);
+      }
+
+      expect(res, 'to have passed test count', 0)
+        .and('to have test count', 1)
+        .and('to have exit code', 0);
+      done();
+    });
+  });
+});
+
+describe('Disabled --pass-on-failing-test-suite', function() {
+  var args = ['--pass-on-failing-test-suite=false'];
+
+  it('Test should return non-zero code with enabled option', function(done) {
+    var fixture = 'failing-sync.fixture.js';
+    runMochaJSON(fixture, args, function(err, res) {
+      if (err) {
+        return done(err);
+      }
+
+      expect(res, 'to have passed test count', 0)
+        .and('to have test count', 1)
+        .and('to have exit code', 1);
+      done();
+    });
+  });
+});
diff --git a/test/integration/options/timeout.spec.js b/test/integration/options/timeout.spec.js
index 83267bd0..e3ac696e 100644
--- a/test/integration/options/timeout.spec.js
+++ b/test/integration/options/timeout.spec.js
@@ -83,4 +83,15 @@ describe('--timeout', function () {
       }
     );
   });
+
+  it("should complete tests having unref'd async behavior", function(done) {
+    runMochaJSON('options/timeout-unref', ['--timeout', '0'], function(err, res) {
+      if (err) {
+        done(err);
+        return;
+      }
+      expect(res, 'to have passed').and('to have passed test count', 1);
+      done();
+    });
+  });
 });
diff --git a/test/unit/mocha.spec.js b/test/unit/mocha.spec.js
index f966e0c3..e0f60357 100644
--- a/test/unit/mocha.spec.js
+++ b/test/unit/mocha.spec.js
@@ -376,6 +376,28 @@ describe('Mocha', function () {
       });
     });
 
+    describe('passOnFailingTestSuite()', function() {
+      it('should set the passOnFailingTestSuite option to false', function() {
+        mocha.passOnFailingTestSuite();
+        expect(
+          mocha.options,
+          'to have property',
+          'passOnFailingTestSuite',
+          false
+        );
+      });
+
+      it('should set the passOnFailingTestSuite option to true', function() {
+        mocha.passOnFailingTestSuite(true);
+        expect(
+          mocha.options,
+          'to have property',
+          'passOnFailingTestSuite',
+          true
+        );
+      });
+    });
+
     describe('failZero()', function () {
       it('should set the failZero option to true', function () {
         mocha.failZero();
diff --git a/test/unit/runnable.spec.js b/test/unit/runnable.spec.js
index b4ae2964..598f532f 100644
--- a/test/unit/runnable.spec.js
+++ b/test/unit/runnable.spec.js
@@ -702,6 +702,7 @@ describe('Runnable(title, fn)', function () {
       runnable.timeout(10);
       runnable.resetTimeout();
       runnable.timeout(0);
+      runnable.run();
       setTimeout(function () {
         expect(runnable.timedOut, 'to be', false);
         done();
diff --git a/test/unit/utils.spec.js b/test/unit/utils.spec.js
index 46f27b57..0e54bf39 100644
--- a/test/unit/utils.spec.js
+++ b/test/unit/utils.spec.js
@@ -288,6 +288,44 @@ describe('lib/utils', function () {
         ].join('\n');
         expect(stringify(expected), 'to be', actual);
       });
+
+      describe('should represent null prototypes', function () {
+        it('With explicit names', function () {
+          const foo = Object.create(null, {
+            [Symbol.toStringTag]: {value: 'Foo'},
+            bing: {get: () => 'bong', enumerable: true}
+          });
+          const expected = [
+            '{',
+            '  "[Symbol.toStringTag]": "Foo"',
+            '  "bing": "bong"',
+            '}'
+          ].join('\n');
+
+          expect(stringify(foo), 'to be', expected);
+        });
+
+        it('Without names', function () {
+          const unnamed = {
+            bing: 'bong',
+            abc: 123
+          };
+          unnamed.self = unnamed;
+          const expected = [
+            '{',
+            '  "abc": 123',
+            '  "bing": "bong"',
+            '  "self": [Circular]',
+            '}'
+          ].join('\n');
+
+          expect(
+            stringify(Object.setPrototypeOf(unnamed, null)),
+            'to be',
+            expected
+          );
+        });
+      });
     });
 
     it('should canonicalize the object', function () {