New Upstream Release - btest

Ready changes

Summary

Merged new upstream version: 1.0 (was: 0.72).

Resulting package

Built on 2023-04-17T16:54 (took 7m55s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases btest

Lintian Result

Diff

diff --git a/CHANGES b/CHANGES
index 4755102..b23a586 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,182 @@
+1.0 | 2023-02-01 16:07:31 -0700
+
+  * Release 1.0.
+
+0.72-71 | 2023-02-01 16:07:09 -0700
+
+  * Fix test cloning bug that caused "subtests" to run without numeric suffix (Christian Kreibich, Corelight)
+
+    When running with retries, failing tests created via @TEST-START-NEXT were
+    cloned for retry in a way that lost their numeric suffix, which could cause
+    btest to consult the wrong baselines.
+
+  * Add testcase for retried subtests (Christian Kreibich, Corelight)
+
+    This highlights a bug in which a subtest (via @TEST-START-NEXT) other than the
+    first always gets retried as the first, but with the wrong baseline.
+
+0.72-68 | 2023-01-31 18:29:53 -0800
+
+  * Automated code formatting and modernization (Christian Kreibich, Corelight)
+
+    - Final touches on top of automated reformatting.
+    - setup.cfg/pre-commit-config: Add flake8 hook and config
+    - Minor tweaks in preparation for flake8
+    - Automatically update Python sources to use python-3.7 syntax
+    - Minor tweaks to clue in pyupgrade
+    - Add `.git-blame-ignore-revs` file.
+    - Reformat Python code with Black.
+    - Minor Python tweaks in anticipation of reformatting
+    - Minor indentation tweak in pre-commit config
+    - Modernize action versions in pre-commit workflow
+    - Don't ignore the sphinx folder, it has revision-controlled content
+    - Remove unused .travis.yml setup
+
+0.72-55 | 2023-01-25 16:06:26 -0800
+
+  * Whitespace-align the thread prefixes in output handlers (Christian Kreibich, Corelight)
+
+  * Prevent output garbling when running with "-A -j" (Christian Kreibich, Corelight)
+
+  * Switch Console and CompactConsole to using base class's output file member (Christian Kreibich, Corelight)
+
+  * Make OutputHandler's default output file a constructor argument (Christian Kreibich, Corelight)
+
+  * Prevent console.test from garbling output (Christian Kreibich, Corelight)
+
+  * Comment-only tweaks, no other change (Christian Kreibich, Corelight)
+
+0.72-48 | 2023-01-13 15:53:55 -0700
+
+  * Add Windows Caveats to README, add bash.exe check at startup (Tim Wojtulewicz)
+
+  * Add tests.environment-windows btest (Tim Wojtulewicz)
+
+    The original tests.environment btest doesn't work on Windows due to some
+    path differences in the output. This adds a new test that does the same
+    things except does some additional conversions in the test script itself
+    to remove those differences.
+
+  * Open .stdout and .stderr in append mode (Tim Wojtulewicz)
+
+    This fixes a problem on Windows where multiple TEST-EXEC statements in a
+    test could cause those files to be overwritten by subsequent TEST-EXECs,
+    causing failures.
+
+  * Fix tests.multiple-baseline-dirs btest to use pathsep (Tim Wojtulewicz)
+
+  * Fix strip-test-base script to handle Windows paths correctly (Tim Wojtulewicz)
+
+  * Fix diff-remove-abspath to handle Windows drive letters (Tim Wojtulewicz)
+
+  * Add testing script to check for Windows, use it to disable some tests (Tim Wojtulewicz)
+
+  * Return error if trying to use Sphinx features on Windows (Tim Wojtulewicz)
+
+  * Force output to use unix-style line endings for consistency (Tim Wojtulewicz)
+
+  * Use binascii.crc32 for computing hashes for TEST-SERIALIZE commands (Tim Wojtulewicz)
+
+    Windows has some issue where `hash()` returns different values for the
+    same string in the different child processes. crc32() returns the same
+    values in each.
+
+  * Rework how test processes are called on Windows (Tim Wojtulewicz)
+
+    This changes how runSubprocess works on Windows to insert all of the
+    calls within a temporary bash script. This ensures that the entire
+    environment is available when running the processes, which doesn't
+    work when simply calling subprocess.check_call().
+
+  * Fix an error when attempting to delete the tmp dirs on Windows (Tim Wojtulewicz)
+
+  * Rebuild globals() table in child processes on Windows (Tim Wojtulewicz)
+
+    Using the 'spawn' method for multiprocessing causes the global
+    state to get lost when moving from the parent into the child
+    processes. Rebuilding it by looping over a subset and reinserting
+    them into globals() ensures that they exist.
+
+  * Move option parsing to a method (Tim Wojtulewicz)
+
+  * Use named pipes on Windows since AF_UNIX is not supported (Tim Wojtulewicz)
+
+  * Fix running tests with dot-notation for their name (Tim Wojtulewicz)
+
+  * Add method for normalizing paths on both Windows and POSIX (Tim Wojtulewicz)
+
+  * Avoid isinstance() to determine whether a cmd is a CmdSeq (Christian Kreibich, Corelight)
+
+    As per the comment, some serializer/unserializers don't produce the identical
+    type when unserializing, failing isinstance().
+
+  * Add -s/--set command-line argument for overriding config defaults (Tim Wojtulewicz)
+
+  * Switch to https://pypi.org/project/multiprocess/ on Windows (Tim Wojtulewicz)
+
+    The reason for this switch is primarily because the stock
+    multiprocessing library has very poor support for pickling of
+    non-primitive types on Windows.
+
+  * Move outputhandler creation to separate function (Tim Wojtulewicz)
+
+  * Rename WSL bash so it doesn't override Git bash for Windows CI builds (Tim Wojtulewicz)
+
+  * Set git's autocrlf option to false when running tests on Github (Tim Wojtulewicz)
+
+    If this option isn't here, the Windows runners will reset all of the
+    line endings when it clones to \r\n. This breaks a few of the tests
+    because the comparison will have the wrong line endings.
+
+  * Bump required python version to 3.7, update github workflows (Tim Wojtulewicz)
+
+    This also adds a new workflow to test setup.py to ensure that the
+    package installs correctly and you can run the internal tests against
+    the installed version.
+
+0.72-20 | 2022-12-13 09:12:13 +0100
+
+  * Add back CI run for Python 3.5. (Benjamin Bannier, Corelight)
+
+0.72-18 | 2022-12-09 14:08:00 -0800
+
+  * GH-75: Do not ignore case of options. (Benjamin Bannier, Corelight)
+
+0.72-16 | 2022-12-06 11:22:53 -0800
+
+  * CI: remove explicit use of Python 3.5, EOL (Christian Kreibich, Corelight)
+
+  * Explain recent alternatives-related changes in the README. (Christian Kreibich, Corelight)
+
+  * Fix handling of specific alternatives in combination with the default (Christian Kreibich, Corelight)
+
+  * Fail when -a/--alternative includes an alternative not defined in the config (Christian Kreibich, Corelight)
+
+  * Add test for use of an undefined alternative (Christian Kreibich, Corelight)
+
+0.72-10 | 2022-11-16 14:44:01 +0100
+
+  * Always use UTF-8 encoding for BTest input and output files.
+    (Benjamin Bannier, Corelight)
+
+0.72-7 | 2022-11-16 10:23:44 +0100
+
+  * Python cleanup (Benjamin Bannier, Corelight)
+
+    - Use `locale.getlocale` instead of deprecated `locale.getdefaultlocale`.
+
+    - Remove code for Python 2 compatibility.
+
+    - Use `python3` instead of `python` binary in test. Newer versions
+      of e.g., macOS do not provide a `python` binary anymore.
+
+  * Bump pre-commit checks. (Benjamin Bannier, Corelight)
+
+0.72-2 | 2022-11-08 10:34:21 +0100
+
+  * Remove dependency on distutils. The distutils module will be
+    removed from Python in 3.12. (Cyril Rolando)
+
 0.72 | 2022-03-22 09:21:34 +0100
 
   * Release 0.72.
@@ -5,7 +184,7 @@
 0.71-4 | 2022-03-22 09:19:53 +0100
 
   * Make test `duplication-selection` independent of actual
-    scheduling. (Benjamin Bannier, Corelight)
+    scheduling.
 
 0.71-2 | 2022-02-02 12:47:42 +0100
 
diff --git a/MANIFEST b/MANIFEST
deleted file mode 100644
index 9dbe784..0000000
--- a/MANIFEST
+++ /dev/null
@@ -1,225 +0,0 @@
-# file GENERATED by distutils, do NOT edit
-CHANGES
-COPYING
-MANIFEST
-MANIFEST.in
-Makefile
-README
-VERSION
-btest
-btest-ask-update
-btest-bg-run
-btest-bg-run-helper
-btest-bg-wait
-btest-diff
-btest-progress
-btest-setsid
-btest.cfg.example
-setup.py
-Baseline/examples.t4/dots
-Baseline/examples.t5/output
-Baseline/examples.t5-2/output
-Baseline/examples.t6/output
-Baseline/examples.t7/output
-Baseline/examples.unstable/output
-examples/alternative
-examples/my-filter
-examples/t1
-examples/t2
-examples/t3.sh
-examples/t4.awk
-examples/t5.sh
-examples/t6.sh
-examples/t7
-examples/t7.sh#1
-examples/t7.sh#2
-examples/t7.sh#3
-examples/unstable.sh
-examples/sphinx/.gitignore
-examples/sphinx/Makefile
-examples/sphinx/btest.cfg
-examples/sphinx/conf.py
-examples/sphinx/index.rst
-examples/sphinx/Baseline/tests.sphinx.hello-world/btest-tests.sphinx.hello-world#1
-examples/sphinx/Baseline/tests.sphinx.hello-world/btest-tests.sphinx.hello-world#2
-examples/sphinx/Baseline/tests.sphinx.hello-world/btest-tests.sphinx.hello-world#3
-examples/sphinx/tests/sphinx/hello-world.btest
-examples/sphinx/tests/sphinx/hello-world.btest#2
-examples/sphinx/tests/sphinx/hello-world.btest#3
-sphinx/btest-diff-rst
-sphinx/btest-rst-cmd
-sphinx/btest-rst-include
-sphinx/btest-rst-pipe
-sphinx/btest-sphinx.py
-testing/.gitignore
-testing/Makefile
-testing/btest.cfg
-testing/btest.tests.cfg
-testing/Baseline/tests.abort-on-failure/output
-testing/Baseline/tests.abort-on-failure-with-only-known-fails/output
-testing/Baseline/tests.alternatives-environment/child-output
-testing/Baseline/tests.alternatives-environment/output
-testing/Baseline/tests.alternatives-filter/child-output
-testing/Baseline/tests.alternatives-filter/output
-testing/Baseline/tests.alternatives-keywords/output
-testing/Baseline/tests.alternatives-substitution/child-output
-testing/Baseline/tests.alternatives-substitution/output
-testing/Baseline/tests.alternatives-testbase/output
-testing/Baseline/tests.brief/out1
-testing/Baseline/tests.brief/out2
-testing/Baseline/tests.btest-cfg/abspath
-testing/Baseline/tests.btest-cfg/nopath
-testing/Baseline/tests.btest-cfg/relpath
-testing/Baseline/tests.console/output
-testing/Baseline/tests.crlf-line-terminators/crlfs.dat
-testing/Baseline/tests.crlf-line-terminators/input
-testing/Baseline/tests.diag/output
-testing/Baseline/tests.diag-all/output
-testing/Baseline/tests.diag-file/diag
-testing/Baseline/tests.diag-file/output
-testing/Baseline/tests.diff-brief/output
-testing/Baseline/tests.diff-max-lines/output1
-testing/Baseline/tests.diff-max-lines/output2
-testing/Baseline/tests.doc/md
-testing/Baseline/tests.doc/rst
-testing/Baseline/tests.environment/output
-testing/Baseline/tests.exit-codes/out1
-testing/Baseline/tests.exit-codes/out2
-testing/Baseline/tests.groups/output
-testing/Baseline/tests.ignore/output
-testing/Baseline/tests.known-failure/output
-testing/Baseline/tests.known-failure-and-success/output
-testing/Baseline/tests.known-failure-succeeds/output
-testing/Baseline/tests.list/out
-testing/Baseline/tests.macros/output
-testing/Baseline/tests.measure-time/output
-testing/Baseline/tests.measure-time-options/output
-testing/Baseline/tests.multiple-baseline-dirs/fail.log
-testing/Baseline/tests.parts/output
-testing/Baseline/tests.parts-error-part/output
-testing/Baseline/tests.parts-error-start-next/output
-testing/Baseline/tests.parts-glob/output
-testing/Baseline/tests.parts-initializer-finalizer/output
-testing/Baseline/tests.parts-skipping/output
-testing/Baseline/tests.parts-teardown/output
-testing/Baseline/tests.progress/output
-testing/Baseline/tests.progress-back-to-back/output
-testing/Baseline/tests.quiet/out1
-testing/Baseline/tests.quiet/out2
-testing/Baseline/tests.requires/output
-testing/Baseline/tests.requires-with-start-next/output
-testing/Baseline/tests.rerun/output
-testing/Baseline/tests.sphinx.rst-cmd/output
-testing/Baseline/tests.sphinx.run-sphinx/_build.text.index.txt
-testing/Baseline/tests.start-file/output
-testing/Baseline/tests.start-next/output
-testing/Baseline/tests.start-next-dir/output
-testing/Baseline/tests.statefile/mystate1
-testing/Baseline/tests.statefile/mystate2
-testing/Baseline/tests.statefile-sorted/mystate
-testing/Baseline/tests.teardown/output
-testing/Baseline/tests.testdirs/out1
-testing/Baseline/tests.testdirs/out2
-testing/Baseline/tests.threads/output.j0
-testing/Baseline/tests.threads/output.j1
-testing/Baseline/tests.threads/output.j5
-testing/Baseline/tests.tracing/output
-testing/Baseline/tests.unstable/output
-testing/Baseline/tests.unstable-dir/output
-testing/Baseline/tests.verbose/output
-testing/Baseline/tests.versioning/output
-testing/Baseline/tests.xml/output-j2.xml
-testing/Baseline/tests.xml/output.xml
-testing/Files/local_alternative/btest.tests.cfg
-testing/Files/local_alternative/Baseline/tests.local-alternative-show-env/output
-testing/Files/local_alternative/Baseline/tests.local-alternative-show-test-baseline/output
-testing/Files/local_alternative/Baseline/tests.local-alternative-show-testbase/output
-testing/Files/local_alternative/tests/local-alternative-found.test
-testing/Files/local_alternative/tests/local-alternative-show-env.test
-testing/Files/local_alternative/tests/local-alternative-show-test-baseline.test
-testing/Files/local_alternative/tests/local-alternative-show-testbase.test
-testing/Scripts/diff-remove-abspath
-testing/Scripts/dummy-script
-testing/Scripts/script-command
-testing/Scripts/strip-iso8601-date
-testing/Scripts/strip-test-base
-testing/Scripts/test-filter
-testing/Scripts/test-perf
-testing/tests/abort-on-failure-with-only-known-fails.btest
-testing/tests/abort-on-failure.btest
-testing/tests/alternatives-baseline-dir.test
-testing/tests/alternatives-environment.test
-testing/tests/alternatives-filter.test
-testing/tests/alternatives-keywords.test
-testing/tests/alternatives-overwrite-env.test
-testing/tests/alternatives-reread-config-baselinedir.test
-testing/tests/alternatives-reread-config.test
-testing/tests/alternatives-substitution.test
-testing/tests/alternatives-testbase.test
-testing/tests/baseline-dir-env.test
-testing/tests/basic-fail.test
-testing/tests/basic-succeed.test
-testing/tests/binary-mode.test
-testing/tests/brief.test
-testing/tests/btest-cfg.test
-testing/tests/canonifier-cmdline.test
-testing/tests/canonifier-conversion.test
-testing/tests/canonifier-fail.test
-testing/tests/canonifier.test
-testing/tests/console.test
-testing/tests/copy-file.test
-testing/tests/crlf-line-terminators.test
-testing/tests/diag-all.test
-testing/tests/diag-file.test
-testing/tests/diag.test
-testing/tests/diff-brief.test
-testing/tests/diff-max-lines.test
-testing/tests/diff.test
-testing/tests/doc.test
-testing/tests/environment.test
-testing/tests/exit-codes.test
-testing/tests/finalizer.test
-testing/tests/groups.test
-testing/tests/ignore.test
-testing/tests/initializer.test
-testing/tests/known-failure-and-success.btest
-testing/tests/known-failure-succeeds.btest
-testing/tests/known-failure.btest
-testing/tests/list.test
-testing/tests/macros.test
-testing/tests/measure-time-options.test
-testing/tests/measure-time.tests
-testing/tests/multiple-baseline-dirs.test
-testing/tests/parts-error-part.test
-testing/tests/parts-error-start-next.test
-testing/tests/parts-glob.test
-testing/tests/parts-initializer-finalizer.test
-testing/tests/parts-skipping.tests
-testing/tests/parts-teardown.test
-testing/tests/parts.tests
-testing/tests/ports.test
-testing/tests/progress-back-to-back.test
-testing/tests/progress.test
-testing/tests/quiet.test
-testing/tests/requires-with-start-next.test
-testing/tests/requires.test
-testing/tests/rerun.test
-testing/tests/start-file.test
-testing/tests/start-next-dir.test
-testing/tests/start-next-naming.test
-testing/tests/start-next.test
-testing/tests/statefile-sorted.test
-testing/tests/statefile.test
-testing/tests/teardown.test
-testing/tests/test-base.test
-testing/tests/testdirs.test
-testing/tests/threads.test
-testing/tests/tmps.test
-testing/tests/tracing.test
-testing/tests/unstable-dir.test
-testing/tests/unstable.test
-testing/tests/verbose.test
-testing/tests/versioning.test
-testing/tests/xml.test
-testing/tests/sphinx/rst-cmd.sh
-testing/tests/sphinx/run-sphinx
diff --git a/PKG-INFO b/PKG-INFO
index 20063d0..9f2a7a9 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,13 +1,12 @@
 Metadata-Version: 2.1
 Name: btest
-Version: 0.72
+Version: 1.0
 Summary: A powerful system testing framework
 Home-page: https://github.com/zeek/btest
-Author: Robin Sommer
-Author-email: robin@icir.org
+Author: The Zeek Team
+Author-email: info@zeek.org
 License: 3-clause BSD License
 Keywords: system tests testing framework baselines
-Platform: UNKNOWN
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Environment :: Console
 Classifier: License :: OSI Approved :: BSD License
@@ -15,7 +14,7 @@ Classifier: Operating System :: POSIX :: Linux
 Classifier: Operating System :: MacOS :: MacOS X
 Classifier: Programming Language :: Python :: 3
 Classifier: Topic :: Utilities
+Requires-Python: >=3.7
 License-File: COPYING
 
 See https://github.com/zeek/btest
-
diff --git a/README b/README
index 91731e9..b0cad68 100644
--- a/README
+++ b/README
@@ -2,7 +2,7 @@
 ..
 .. Version number is filled in automatically.
 
-.. |version| replace:: 0.72
+.. |version| replace:: 1.0
 
 ==================================================
 BTest - A Generic Driver for Powerful System Tests
@@ -27,20 +27,35 @@ Prerequisites
 
 BTest has the following prerequisites:
 
-- Python version >= 3.5 (older versions may work, but are not
+- Python version >= 3.7 (older versions may work, but are not
   well-tested).
 
-- Bash (note that on FreeBSD and Alpine Linux, bash is not installed
-  by default).
+- Bash. Note that on FreeBSD and Alpine Linux, bash is not installed by
+  default. This is also required on Windows, in the form of Git's msys2, Cygwin,
+  etc.
 
 BTest has the following optional prerequisites to enable additional
 functionality:
 
-- Sphinx.
+- Sphinx. Sphinx functionality is currently disabled on Windows.
 
 - perf (Linux only).  Note that on Debian/Ubuntu, you also need to install
   the "linux-tools" package.
 
+Windows Caveats
+---------------
+
+When running BTest on Windows, you must have a bash shell installed of some
+sort. This can be from WSL, Cygwin, msys2, Git, or any number of other methods,
+but ``bash.exe`` must be available. BTest will check for its existence at
+startup and exit if it is not available.
+
+A minor change must be made to any configuration value that is a path list. For
+example, if you are setting the ``PATH`` environment variable from your
+btest.cfg. In these cases, you should use ``$(pathsep)s`` in the configuration
+instead of bare ``:`` or ``;`` values to separate the paths. This ensures that
+both POSIX and Windows systems handle the path lists correctly.
+
 Download and Installation
 =========================
 
@@ -253,9 +268,11 @@ and 1 otherwise. Exit code 1 can also result in case of other errors.
     Activates an alternative_ configuration defined in the
     configuration file. Multiple alternatives can be given as a
     comma-separated list (in this case, all specified tests are run
-    once for each specified alternative). If ``ALTERNATIVE`` is ``-``
-    that refers to running with the standard setup, which can be used
-    to run tests both with and without alternatives by giving both.
+    once for each specified alternative). The alternatives ``-``
+    and ``default`` refer to the standard setup, allowing tests to
+    run with combinations of the latter and select alternatives.
+    If an alternative is not defined in the configuration, ``btest``
+    fails with exit code 1 and an according error message on stderr.
 
 -A, --show-all
     Shows an output line for all tests that were run (this includes tests
@@ -328,6 +345,13 @@ and 1 otherwise. Exit code 1 can also result in case of other errors.
     Markdown. In the output each test includes the documentation
     string that's defined for it through ``@TEST-DOC``.
 
+-s <kv>, --set=<kv>
+    Takes a ``key=value`` argument and uses it to override a value
+    used during parsing of the configuration file read by btest at
+    startup. This can be used to override various default values
+    prior to parsing. Can be passed multiple times to override
+    different keys. See `defaults`_ for an example.
+
 -t, --tmp-keep
     Does not delete any temporary files created for running the
     tests (including their outputs). By default, the temporary
@@ -373,8 +397,8 @@ and 1 otherwise. Exit code 1 can also result in case of other errors.
     If the file exists already, it is overwritten.
 
 -z RETRIES, --retries=RETRIES
-     Retry any failed tests up to this many times to determine if
-     they are unstable.
+    Retry any failed tests up to this many times to determine if
+    they are unstable.
 
 .. _configuration file: configuration_
 .. _configuration:
@@ -408,6 +432,30 @@ include the output of external commands (e.g., xyz=`\echo test\`).
 Note that the backtick expansion is performed after any ``%(..)``
 have already been replaced (including within the backticks).
 
+.. _default: `defaults`_
+.. _defaults:
+
+Defaults
+~~~~~~~~
+
+There is a special section that can be added to the configuration file that will
+set default values to be used during the parsing of other configuration
+directives. For example::
+
+    [DEFAULT]
+    val=abcd
+
+    [environment]
+    ENV_VALUE=%(val)s
+
+The configuration parser reads the keys and values from the DEFAULT section
+prior to reading the other sections. It uses those keys to replace the ``%()s``
+macros as described earlier. The values stored in these keys can be overridden
+at runtime by using the ``-s``/``--set`` command-line argument. For example to
+override the ``val`` default above, the ``-s val=other`` argument can be
+passed. In that case, ``ENV_VALUE`` would be set to ``other`` instead of
+``abcd``.
+
 .. _option: `options`_
 .. _options:
 
diff --git a/VERSION b/VERSION
index b214dd9..d3827e7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.72
+1.0
diff --git a/btest b/btest
index c997947..2652586 100755
--- a/btest
+++ b/btest
@@ -2,25 +2,23 @@
 #
 # Main test driver.
 #
-# pylint: disable=line-too-long,too-many-lines,invalid-name,missing-function-docstring,missing-class-docstring
-
-from __future__ import print_function
+# pylint: disable=line-too-long,too-many-lines,invalid-name,missing-function-docstring,
+# pylint: disable=missing-class-docstring
 
 import atexit
+import binascii
+import configparser
 import copy
 import fnmatch
 import glob
-import io
 import json
-import locale
-import multiprocessing
-import multiprocessing.managers
-import multiprocessing.sharedctypes
 import optparse
 import os
 import os.path
+import pathlib
 import platform as pform
 import re
+import shlex
 import shutil
 import signal
 import socket
@@ -34,14 +32,32 @@ import xml.dom.minidom
 
 from datetime import datetime
 
-try:
-    import ConfigParser as configparser
-except ImportError:
-    import configparser
-
-VERSION = "0.72"  # Automatically filled in.
+# We require the external multiprocess library on Windows due to pickling issues
+# with the standard one.
+if sys.platform == "win32":
+    try:
+        import multiprocess as mp
+        import multiprocess.managers as mp_managers
+        import multiprocess.sharedctypes as mp_sharedctypes
+    except ImportError as error:
+        print(
+            "error: btest failed to import the 'multiprocess' library\n"
+            "\n"
+            "This library is required for btest to function on Windows. "
+            "It can be installed from pip like:\n"
+            "\n"
+            "    pip install multiprocess\n"
+            "\n"
+            "Also check the following exception output for possible alternate explanations:\n\n"
+            "{}: {}".format(type(error).__name__, error),
+            file=sys.stderr,
+        )
+else:
+    import multiprocessing as mp
+    import multiprocessing.managers as mp_managers
+    import multiprocessing.sharedctypes as mp_sharedctypes
 
-using_py3 = (sys.version_info[0] == 3)
+VERSION = "1.0"  # Automatically filled in.
 
 Name = "btest"
 Config = None
@@ -52,9 +68,37 @@ except KeyError:
     ConfigDefault = "btest.cfg"
 
 
+def normalize_path(path):
+    """Ensures that paths on Windows convert backslashes to forward slashes, to
+    make path handling easier in lots of other places. On non-Windows platforms
+    this is a no-op beyond converting things to absolute paths."""
+    os_path = os.path.abspath(path)
+    windows_path = pathlib.PureWindowsPath(os_path)
+    return windows_path.as_posix()
+
+
+def normalize_path_join(*args):
+    return normalize_path(os.path.join(*args))
+
+
+def reopen_std_file(stdfile):
+    """Reopens one of the stderr or stdout files, but resets the newline
+    used in the output to "\n" in order to force that line ending on Windows.
+    Without this, Windows will use "\r\n" which breaks a lot of tests."""
+    return open(
+        stdfile.fileno(),
+        mode=stdfile.mode,
+        buffering=1,
+        encoding=stdfile.encoding,
+        errors=stdfile.errors,
+        newline="\n",
+        closefd=False,
+    )
+
+
 def output(msg, nl=True, file=None):
     if not file:
-        file = sys.stderr
+        file = reopen_std_file(sys.__stderr__)
 
     if nl:
         print(msg, file=file)
@@ -63,7 +107,7 @@ def output(msg, nl=True, file=None):
 
 
 def warning(msg):
-    print("warning: %s" % msg, file=sys.stderr)
+    print(f"warning: {msg}", file=sys.stderr)
 
 
 def error(msg):
@@ -76,11 +120,11 @@ def mkdir(folder):
         try:
             os.makedirs(folder)
         except OSError as exc:
-            error("cannot create directory %s: %s" % (folder, exc))
+            error(f"cannot create directory {folder}: {exc}")
 
     else:
         if not os.path.isdir(folder):
-            error("path %s exists but is not a directory" % folder)
+            error(f"path {folder} exists but is not a directory")
 
 
 def which(cmd):
@@ -108,21 +152,17 @@ def platform():
     return pform.system()
 
 
-def getDefaultBtestEncoding():
-    if locale.getdefaultlocale()[1] is None:
-        return 'utf-8'
-
-    return locale.getpreferredencoding()
-
-
 def validate_version_requirement(required: str, present: str):
-    '''Helper function to validate that a `present` version is semantically newer or equal than a `required` version.'''
+    """Validates that `present` version semantically satisfies `required` version."""
+
     def extract_version(v: str):
-        '''Helper function to extract version components from a string.'''
+        """Helper function to extract version components from a string."""
         try:
-            xyz = [int(x) for x in re.split(r'\.|-', v)]
+            xyz = [int(x) for x in re.split(r"[.-]", v)]
         except ValueError:
-            error("invalid version %s: versions must contain only numeric identifiers" % v)
+            error(
+                "invalid version %s: versions must contain only numeric identifiers" % v
+            )
 
         return xyz
 
@@ -130,8 +170,10 @@ def validate_version_requirement(required: str, present: str):
     v_required = extract_version(required)
 
     if v_present < v_required:
-        error("%s requires at least BTest %s, this is %s. Please upgrade." %
-              (Options.config, min_version, VERSION))
+        error(
+            "%s requires at least BTest %s, this is %s. Please upgrade."
+            % (Options.config, min_version, VERSION)
+        )
 
 
 # Get the value of the specified option in the specified section (or
@@ -148,7 +190,7 @@ def getOption(key, default, section="btest"):
     return ExpandBackticks(value)
 
 
-reBackticks = re.compile(r"`(([^`]|\`)*)`")
+reBackticks = re.compile(r"`(([^`]|`)*)`")
 
 
 def readStateFile():
@@ -165,12 +207,33 @@ def readStateFile():
 
         tests = findTests(tests)
 
-    except IOError:
+    except OSError:
         return (False, [])
 
     return (True, tests)
 
 
+def _build_win_subprocess_cmd_script(cmd, tmpdir=None):
+    """
+    Builds a bash file for running subprocess commands under Windows.
+
+    :param cmd The command line to be run under bash.
+    :param tmpdir An optional directory path where the script file will be written.
+     If None, it will be written to the system's temp directory.
+    :return A tuple containing a file object pointing at the script file and a bash
+     command for running the script.
+    """
+    tf = tempfile.NamedTemporaryFile(
+        mode="w", encoding="utf-8", suffix=".sh", dir=tmpdir, delete=True
+    )
+    fcontents = f"#!/usr/bin/env bash\n{cmd}\n"
+    tf.write(fcontents)
+    tf.flush()
+
+    bash_cmd = ["bash.exe", "-c", normalize_path(tf.name)]
+    return tf, bash_cmd
+
+
 # Expand backticks in a config option value and return the result.
 def ExpandBackticks(origvalue):
     def _exec(m):
@@ -178,14 +241,25 @@ def ExpandBackticks(origvalue):
         if not cmd:
             return ""
 
-        try:
-            pp = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
-        except OSError as e:
-            error("cannot execute '%s': %s" % (cmd, e))
+        tf = None
+        if sys.platform == "win32":
+            try:
+                tf, bash_cmd = _build_win_subprocess_cmd_script(cmd, None)
+                pp = subprocess.Popen(bash_cmd, stdout=subprocess.PIPE)
+            except OSError as e:
+                error(f"cannot execute '{cmd}': {e}")
+        else:
+            try:
+                pp = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
+            except OSError as e:
+                error(f"cannot execute '{cmd}': {e}")
 
         out = pp.communicate()[0]
         out = out.decode()
 
+        if tf:
+            tf.close()
+
         return out.strip()
 
     value = reBackticks.sub(_exec, origvalue)
@@ -205,9 +279,17 @@ def cpItemsNoDefaults(self, section):
     except KeyError:
         raise configparser.NoSectionError(section)
 
+    # Override any of the defaults with ones that we read from the command-line
+    # options before expanding macros below.
+    cfg_defaults = self.defaults()
+    if Options.defaults:
+        for d in Options.defaults:
+            k, v = d.split("=", 1)
+            cfg_defaults[k] = v.strip("'\"")
+
     result = {}
 
-    for (key, rawvalue) in items:
+    for key, rawvalue in items:
         # Python 2 includes a key of "__name__" that we don't want (Python 3
         # doesn't include this)
         if not key.startswith("__"):
@@ -219,6 +301,22 @@ def cpItemsNoDefaults(self, section):
     return result.items()
 
 
+def getcfgparser(defaults):
+    configparser.ConfigParser.itemsNoDefaults = cpItemsNoDefaults
+
+    cfg = configparser.ConfigParser()
+
+    # We make all key lookups case-sensitive to avoid aliasing of
+    # case-sensitive environment variables.
+    cfg.optionxform = lambda optionstr: optionstr
+
+    default_section = cfg.defaults()
+    for key, value in defaults.items():
+        default_section[key] = value
+
+    return cfg
+
+
 # Replace environment variables in string.
 def replaceEnvs(s):
     def replace_with_env(m):
@@ -251,7 +349,18 @@ def runTestCommandLine(cmdline, measure_time, **kwargs):
 def runSubprocess(*args, **kwargs):
     def child(q):
         try:
-            subprocess.check_call(*args, **kwargs)
+            if sys.platform == "win32":
+                tmpdir = normalize_path(kwargs.get("cwd", ""))
+                if len(args) > 1:
+                    cmd = shlex.join(args)
+                else:
+                    cmd = args[0]
+
+                tf, bash_cmd = _build_win_subprocess_cmd_script(cmd, tmpdir)
+                with tf:
+                    subprocess.check_call(bash_cmd, **kwargs)
+            else:
+                subprocess.check_call(*args, **kwargs)
             success = True
             rc = 0
 
@@ -266,8 +375,8 @@ def runSubprocess(*args, **kwargs):
         q.put([success, rc])
 
     try:
-        q = multiprocessing.Queue()
-        p = multiprocessing.Process(target=child, args=(q, ))
+        q = mp.Queue()
+        p = mp.Process(target=child, args=(q,))
         p.start()
         result = q.get()
         p.join()
@@ -275,25 +384,27 @@ def runSubprocess(*args, **kwargs):
     except KeyboardInterrupt:
         # Bail out here directly as otherwise we'd get a bunch of errors.
         # from all the childs.
-        os._exit(1)
+        sys.exit(1)
 
     return result
 
 
-def getcfgparser(defaults):
-    configparser.ConfigParser.itemsNoDefaults = cpItemsNoDefaults
-    cfg = configparser.ConfigParser(defaults)
-    return cfg
-
-
 # Description of an alternative configuration.
 class Alternative:
+    DEFAULT = "default"
+
     def __init__(self, name):
         self.name = name
         self.filters = {}
         self.substitutions = {}
         self.envs = {}
 
+    def is_default(self):
+        return self.name == Alternative.DEFAULT
+
+    def is_empty(self):
+        return not (self.filters or self.substitutions or self.envs)
+
 
 # Exception class thrown to signal manager to abort processing.
 # The message passed to the constructor will be printed to the console.
@@ -302,9 +413,9 @@ class Abort(Exception):
 
 
 # Main class distributing the work across threads.
-class TestManager(multiprocessing.managers.SyncManager):
+class TestManager(mp_managers.SyncManager):
     def __init__(self, *args, **kwargs):
-        super(TestManager, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         self._output_handler = None
         self._lock = None
@@ -322,14 +433,27 @@ class TestManager(multiprocessing.managers.SyncManager):
     def run(self, tests, output_handler):
         self.start()
 
+        mgr_data = self.dict()
+        mgr_data["Alternatives"] = Alternatives
+        mgr_data["BaselineDirs"] = BaselineDirs
+        mgr_data["Initializer"] = Initializer
+        mgr_data["Finalizer"] = Finalizer
+        mgr_data["Teardown"] = Teardown
+        mgr_data["Options"] = Options
+        mgr_data["TestBase"] = TestBase
+        mgr_data["TmpDir"] = TmpDir
+        mgr_data["RE_INPUT"] = RE_INPUT
+        mgr_data["RE_DIR"] = RE_DIR
+        mgr_data["RE_ENV"] = RE_ENV
+
         output_handler.prepare(self)
         self._output_handler = output_handler
         self._lock = self.RLock()
-        self._succeeded = multiprocessing.sharedctypes.RawValue('i', 0)
-        self._failed = multiprocessing.sharedctypes.RawValue('i', 0)
-        self._failed_expected = multiprocessing.sharedctypes.RawValue('i', 0)
-        self._unstable = multiprocessing.sharedctypes.RawValue('i', 0)
-        self._skipped = multiprocessing.sharedctypes.RawValue('i', 0)
+        self._succeeded = mp_sharedctypes.RawValue("i", 0)
+        self._failed = mp_sharedctypes.RawValue("i", 0)
+        self._failed_expected = mp_sharedctypes.RawValue("i", 0)
+        self._unstable = mp_sharedctypes.RawValue("i", 0)
+        self._skipped = mp_sharedctypes.RawValue("i", 0)
         self._tests = self.list(tests)
         self._failed_tests = self.list([])
         self._num_tests = len(self._tests)
@@ -340,7 +464,7 @@ class TestManager(multiprocessing.managers.SyncManager):
         port_range_hi = int(port_range.split("-")[1])
 
         if port_range_lo > port_range_hi:
-            error("invalid PortRange value: {0}".format(port_range))
+            error(f"invalid PortRange value: {port_range}")
 
         max_test_ports = 0
         test_with_most_ports = None
@@ -351,8 +475,11 @@ class TestManager(multiprocessing.managers.SyncManager):
                 test_with_most_ports = t
 
         if max_test_ports > port_range_hi - port_range_lo + 1:
-            error("PortRange {0} cannot satisfy requirement of {1} ports in test {2}".format(
-                port_range, max_test_ports, test_with_most_ports.name))
+            error(
+                "PortRange {} cannot satisfy requirement of {} ports in test {}".format(
+                    port_range, max_test_ports, test_with_most_ports.name
+                )
+            )
 
         self._ports = self.list([p for p in range(port_range_lo, port_range_hi + 1)])
 
@@ -363,12 +490,15 @@ class TestManager(multiprocessing.managers.SyncManager):
         # processes post-btest-exit when using CTRL-C during the input
         # stage.
         if Options.mode == "UPDATE_INTERACTIVE":
-            self.threadRun(0)
+            self.threadRun(0, mgr_data)
         else:
             try:
+                # Create a set of processes for running each of the tests. This isn't the actual
+                # zeek processes, but runner processes executing individual test commands.
                 for i in range(Options.threads):
-                    t = multiprocessing.Process(name="#%d" % (i + 1),
-                                                target=lambda: self.threadRun(i))
+                    t = mp.Process(
+                        name="#%d" % (i + 1), target=lambda: self.threadRun(i, mgr_data)
+                    )
                     t.start()
                     threads += [t]
 
@@ -380,7 +510,11 @@ class TestManager(multiprocessing.managers.SyncManager):
                     t.terminate()
                     t.join()
 
-        if Options.abort_on_failure and self._failed.value > 0 and self._failed.value > self._failed_expected.value:
+        if (
+            Options.abort_on_failure
+            and self._failed.value > 0
+            and self._failed.value > self._failed_expected.value
+        ):
             # Signal abort. The child processes will already have
             # finished because the join() above still ran.
             raise Abort("Aborted after first failure.")
@@ -388,8 +522,8 @@ class TestManager(multiprocessing.managers.SyncManager):
         # Record failed tests if not updating.
         if Options.mode != "UPDATE" and Options.mode != "UPDATE_INTERACTIVE":
             try:
-                state = open(StateFile, "w")
-            except IOError:
+                state = open(StateFile, "w", encoding="utf-8")
+            except OSError:
                 error("cannot open state file %s" % StateFile)
 
             for t in sorted(self._failed_tests):
@@ -397,8 +531,13 @@ class TestManager(multiprocessing.managers.SyncManager):
 
             state.close()
 
-        return (self._succeeded.value, self._failed.value, self._skipped.value,
-                self._unstable.value, self._failed_expected.value)
+        return (
+            self._succeeded.value,
+            self._failed.value,
+            self._skipped.value,
+            self._unstable.value,
+            self._failed_expected.value,
+        )
 
     def percentage(self):
         if not self._num_tests:
@@ -407,20 +546,35 @@ class TestManager(multiprocessing.managers.SyncManager):
         count = self._succeeded.value + self._failed.value + self._skipped.value
         return 100.0 * count / self._num_tests
 
-    def threadRun(self, thread_num):
+    # Worker method for each of the "threads" specified by the "-j" argument passed
+    # at run time. This basically segments the list of tests into chunks and runs
+    # until we're out of chunks.
+    def threadRun(self, thread_num, mgr_data):
+        # This should prevent the child processes from receiving SIGINT signals and
+        # let the KeyboardInterrupt handler in the manager's run() method handle
+        # those.
         signal.signal(signal.SIGINT, signal.SIG_IGN)
 
         all_tests = []
 
+        # Globals get lost moving from the parent to the child on Windows, so we need to use
+        # the data proxied from the manager to rebuild the dict of globals before continuing.
+        if sys.platform == "win32":
+            for global_key, global_value in mgr_data.items():
+                globals()[global_key] = global_value
+
         while True:
-            tests = self.nextTests(thread_num)
-            if tests is None:
+            # Pull the next test from the list that was built at startup. This may
+            # be more than one test if there were alternatives requested in the
+            # arguments passed to btest.
+            thread_tests = self.nextTests(thread_num)
+            if thread_tests is None:
                 # No more work for us.
                 return
 
-            all_tests += tests
+            all_tests += thread_tests
 
-            for t in tests:
+            for t in thread_tests:
                 t.run(self)
                 self.testReplayOutput(t)
 
@@ -433,7 +587,11 @@ class TestManager(multiprocessing.managers.SyncManager):
 
     def nextTests(self, thread_num):
         with self._lock:
-            if Options.abort_on_failure and self._failed.value > 0 and self._failed.value > self._failed_expected.value:
+            if (
+                Options.abort_on_failure
+                and self._failed.value > 0
+                and self._failed.value > self._failed_expected.value
+            ):
                 # Don't hand out any more tests if we are to abort after
                 # first failure. Doing so will let all the processes terminate.
                 return None
@@ -444,7 +602,7 @@ class TestManager(multiprocessing.managers.SyncManager):
                 if not t:
                     continue
 
-                if t.serialize and hash(t.serialize) % Options.threads != thread_num:
+                if t.serialize and t.serialize_hash() % Options.threads != thread_num:
                     # Not ours.
                     continue
 
@@ -455,26 +613,31 @@ class TestManager(multiprocessing.managers.SyncManager):
                     tests = []
 
                     for alternative in Options.alternatives:
-
                         if alternative in t.ignore_alternatives:
                             continue
 
-                        if t.include_alternatives and alternative not in t.include_alternatives:
+                        if (
+                            t.include_alternatives
+                            and alternative not in t.include_alternatives
+                        ):
                             continue
 
                         alternative_test = copy.deepcopy(t)
 
-                        if alternative == "-":
+                        if alternative == Alternative.DEFAULT:
                             alternative = ""
 
                         alternative_test.setAlternative(alternative)
                         tests += [alternative_test]
 
                 else:
-                    if t.include_alternatives and "default" not in t.include_alternatives:
+                    if (
+                        t.include_alternatives
+                        and Alternative.DEFAULT not in t.include_alternatives
+                    ):
                         tests = []
 
-                    elif "default" in t.ignore_alternatives:
+                    elif Alternative.DEFAULT in t.ignore_alternatives:
                         tests = []
 
                     else:
@@ -492,7 +655,6 @@ class TestManager(multiprocessing.managers.SyncManager):
 
     def getAvailablePorts(self, count):
         with self._lock:
-
             if count > len(self._ports):
                 return []
 
@@ -529,14 +691,14 @@ class TestManager(multiprocessing.managers.SyncManager):
                     # effect of allowing multiple sockets to bind to the
                     # same port, even if REUSEPORT is off, so just try to
                     # ensure both are off.
-                    if hasattr(socket, 'SO_REUSEADDR'):
+                    if hasattr(socket, "SO_REUSEADDR"):
                         sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
-                    if hasattr(socket, 'SO_REUSEPORT'):
+                    if hasattr(socket, "SO_REUSEPORT"):
                         sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 0)
 
                     try:
-                        sock.bind(('', next_port))
-                    except:
+                        sock.bind(("", next_port))
+                    except Exception:
                         self._ports.append(next_port)
                         continue
                     else:
@@ -581,7 +743,7 @@ class TestManager(multiprocessing.managers.SyncManager):
                     self._failed_expected.value -= 1
 
                 self._unstable.value += 1
-                msg += " on retry #{0}, unstable".format(test.reruns)
+                msg += f" on retry #{test.reruns}, unstable"
                 self._output_handler.testUnstable(test, msg)
 
             self._output_handler.testFinished(test, msg)
@@ -592,7 +754,7 @@ class TestManager(multiprocessing.managers.SyncManager):
         msg = "failed"
 
         if test.reruns > 0:
-            msg += " on retry #{0}".format(test.reruns)
+            msg += f" on retry #{test.reruns}"
 
         if test.known_failure:
             msg += " (expected)"
@@ -670,7 +832,7 @@ class TestManager(multiprocessing.managers.SyncManager):
 
             out = open(path, "w")
 
-            for (k, v) in timing.items():
+            for k, v in timing.items():
                 print("%s %u" % (k, v), file=out)
 
             out.close()
@@ -682,6 +844,7 @@ class CmdLine:
     These commands can be provided by @TEST-{EXEC,REQUIRES} instructions, an
     Initializer, Finalizer, or Teardown, or their part-specific equivalents.
     """
+
     def __init__(self, cmdline, expect_success, part, file):
         self.cmdline = cmdline
         self.expect_success = expect_success
@@ -696,17 +859,19 @@ class CmdSeq:
     fail. Commands can be invidual CmdLines or CmdSeq instances. Test.run()
     processes the latter recursively.
     """
+
     def __init__(self):
         self.cmds = []  # CmdLine or CmdSeq instances
         self.teardown = None
 
 
 # One test.
-class Test(object):
+class Test:
     def __init__(self, file=None, directory=None):  # Allow dir to be directly defined
-
-        if file is not None: self.dir = os.path.abspath(os.path.dirname(file))
-        else: self.dir = directory
+        if file is not None:
+            self.dir = normalize_path(os.path.dirname(file))
+        else:
+            self.dir = normalize_path(directory)
 
         self.alternative = None
         self.baselines = []
@@ -750,28 +915,33 @@ class Test(object):
     def __lt__(self, value):
         return self.name and value.name and self.name < value.name
 
+    def serialize_hash(self):
+        if not self.serialize:
+            return 0
+
+        return int(binascii.crc32(self.serialize.encode("utf-8")))
+
     def displayName(self):
         name = self.name
 
         if self.alternative:
-            name = "%s [%s]" % (name, self.alternative)
+            name = f"{name} [{self.alternative}]"
 
         return name
 
     def setAlternative(self, alternative):
         self.alternative = alternative
 
-        # Parse the test's content.
+    # Parse the test's content.
     def parse(self, content, file):
         cmds = {}
         for line in content:
-
             m = RE_IGNORE.search(line)
             if m:
                 # Ignore this file.
                 return False
 
-            for (tag, regexp, multiple, optional, group1, group2) in Commands:
+            for tag, regexp, multiple, optional, group1, group2 in Commands:
                 m = regexp.search(line)
 
                 if m:
@@ -785,7 +955,7 @@ class Test(object):
 
                     if not multiple:
                         if tag in cmds:
-                            error("%s: %s defined multiple times." % (file, tag))
+                            error(f"{file}: {tag} defined multiple times.")
 
                         cmds[tag] = value
 
@@ -796,13 +966,14 @@ class Test(object):
                             cmds[tag] = [value]
 
         # Make sure all non-optional commands are there.
-        for (tag, regexp, multiple, optional, group1, group2) in Commands:
+        for tag, regexp, multiple, optional, group1, group2 in Commands:
             if not optional and tag not in cmds:
                 if tag == "exec":
-                    error("%s: mandatory keyword '@TEST-EXEC' or '@TEST-EXEC-FAIL' is missing." %
-                          file)
+                    error(
+                        f"{file}: mandatory keyword '@TEST-EXEC' or '@TEST-EXEC-FAIL' is missing."
+                    )
                 else:
-                    error("%s: mandatory %s command not found." % (file, tag))
+                    error(f"{file}: mandatory {tag} command not found.")
 
         basename = file
 
@@ -816,7 +987,7 @@ class Test(object):
         name = os.path.relpath(basename, TestBase)
         (name, ext) = os.path.splitext(name)
 
-        name = name.replace("/", ".")
+        name = name.replace(os.sep, ".")
         while name.startswith("."):
             name = name[1:]
 
@@ -829,18 +1000,31 @@ class Test(object):
 
         if PartInitializer:
             seq.cmds.append(
-                CmdLine("%s %s" % (PartInitializer, self.name), True, part, "<PartInitializer>"))
-
-        for (cmd, success) in cmds["exec"]:
+                CmdLine(
+                    f"{PartInitializer} {self.name}",
+                    True,
+                    part,
+                    "<PartInitializer>",
+                )
+            )
+
+        for cmd, success in cmds["exec"]:
             seq.cmds.append(CmdLine(cmd.strip(), success != "-FAIL", part, file))
 
         if PartFinalizer:
             seq.cmds.append(
-                CmdLine("%s %s" % (PartFinalizer, self.name), True, part, "<PartFinalizer>"))
+                CmdLine(
+                    f"{PartFinalizer} {self.name}",
+                    True,
+                    part,
+                    "<PartFinalizer>",
+                )
+            )
 
         if PartTeardown:
-            seq.teardown = CmdLine("%s %s" % (PartTeardown, self.name), True, part,
-                                   "<PartTeardown>")
+            seq.teardown = CmdLine(
+                f"{PartTeardown} {self.name}", True, part, "<PartTeardown>"
+            )
 
         self.cmdseqs.append(seq)
 
@@ -848,10 +1032,10 @@ class Test(object):
             self.serialize = cmds["serialize"]
 
         if "port" in cmds:
-            self.ports |= set(cmd.strip() for cmd in cmds['port'])
+            self.ports |= {cmd.strip() for cmd in cmds["port"]}
 
         if "group" in cmds:
-            self.groups |= set(cmd.strip() for cmd in cmds["group"])
+            self.groups |= {cmd.strip() for cmd in cmds["group"]}
 
         if "requires" in cmds:
             for cmd in cmds["requires"]:
@@ -880,14 +1064,17 @@ class Test(object):
     # Copies all control information over to a new Test but replacing the test's
     # content with a new one.
     def clone(self, content=None, increment=True):
-        clone = Test("")
+        # Cloning the class like this ensures that the globals continue to exist in
+        # cloned object just as they are in the original object.
+        clone = self.__class__("")
         clone.number = self.number
         clone.basename = self.basename
-        clone.name = self.basename
 
         if increment:
             clone.number = self.number + 1
             clone.name = "%s-%d" % (self.basename, clone.number)
+        elif self.name:
+            clone.name = self.name
 
         clone.requires = self.requires
         clone.reruns = self.reruns
@@ -912,9 +1099,10 @@ class Test(object):
         return clone
 
     def mergePart(self, part):
-
         if self.cloned or part.cloned:
-            error("cannot use @TEST-START-NEXT with tests split across parts (%s)" % self.basename)
+            error(
+                f"cannot use @TEST-START-NEXT with tests split across parts ({self.basename})"
+            )
 
         self.serialize += part.serialize
         self.ports |= part.ports
@@ -945,10 +1133,13 @@ class Test(object):
             attempts -= 1
 
             if attempts == 0:
-                error("failed to obtain {0} ports for test {1}".format(count, self.name))
+                error(f"failed to obtain {count} ports for test {self.name}")
 
-            warning("failed to obtain {0} ports for test {1}, will try {2} more times".format(
-                count, self.name, attempts))
+            warning(
+                "failed to obtain {} ports for test {}, will try {} more times".format(
+                    count, self.name, attempts
+                )
+            )
 
             time.sleep(15)
 
@@ -964,10 +1155,12 @@ class Test(object):
         self.mgr = mgr
         mgr.testStart(self)
 
-        self.tmpdir = os.path.abspath(os.path.join(TmpDir, self.name))
-        self.diag = os.path.join(self.tmpdir, ".diag")
-        self.verbose = os.path.join(self.tmpdir, ".verbose")
-        self.baselines = [os.path.abspath(os.path.join(d, self.name)) for d in BaselineDirs]
+        self.tmpdir = normalize_path_join(TmpDir, self.name)
+        self.diag = normalize_path_join(self.tmpdir, ".diag")
+        self.verbose = normalize_path_join(self.tmpdir, ".verbose")
+        self.baselines = []
+        for d in BaselineDirs:
+            self.baselines.append(normalize_path_join(d, self.name))
         self.diagmsgs = []
         self.utime = -1
         self.utime_base = self.mgr.testTimingBaseline(self)
@@ -980,7 +1173,7 @@ class Test(object):
         for d in self.baselines:
             mkdir(d)
 
-        for (fname, lines) in self.files:
+        for fname, lines in self.files:
             fname = os.path.join(self.tmpdir, fname)
 
             subdir = os.path.dirname(fname)
@@ -988,9 +1181,9 @@ class Test(object):
             if subdir != "":
                 mkdir(subdir)
             try:
-                ffile = open(fname, "w")
-            except IOError as e:
-                error("cannot write test's additional file '%s'" % fname)
+                ffile = open(fname, "w", newline="\n")
+            except OSError:
+                error(f"cannot write test's additional file '{fname}'")
 
             for line in lines:
                 ffile.write(line)
@@ -1001,24 +1194,24 @@ class Test(object):
             src = replaceEnvs(file)
             try:
                 shutil.copy2(src, self.tmpdir)
-            except IOError as e:
-                error("cannot copy %s: %s" % (src, e))
+            except OSError as e:
+                error(f"cannot copy {src}: {e}")
 
-        for (file, content) in self.contents:
-            localfile = os.path.join(self.tmpdir, os.path.basename(file))
-            out = io.open(localfile, "w", encoding=getDefaultBtestEncoding())
+        for file, content in self.contents:
+            localfile = normalize_path_join(self.tmpdir, os.path.basename(file))
+            out = open(localfile, "w", encoding="utf-8", newline="\n")
 
             try:
                 for line in content:
                     out.write(line)
             except UnicodeEncodeError as e:
-                error("unicode encode error in file %s: %s" % (localfile, e))
+                error(f"unicode encode error in file {localfile}: {e}")
 
             out.close()
 
-        self.log = open(os.path.join(self.tmpdir, ".log"), "w")
-        self.stdout = open(os.path.join(self.tmpdir, ".stdout"), "w")
-        self.stderr = open(os.path.join(self.tmpdir, ".stderr"), "w")
+        self.log = open(os.path.join(self.tmpdir, ".log"), "w", encoding="utf-8")
+        self.stdout = open(os.path.join(self.tmpdir, ".stdout"), "a", encoding="utf-8")
+        self.stderr = open(os.path.join(self.tmpdir, ".stderr"), "a", encoding="utf-8")
 
         for cmd in self.requires:
             (success, rc) = self.execute(cmd, apply_alternative=self.alternative)
@@ -1026,7 +1219,7 @@ class Test(object):
             if not success:
                 self.mgr.testSkipped(self)
                 if not Options.tmps:
-                    self.rmTmp()
+                    self.rmTmp(with_close=True)
                 self.finish()
                 return
 
@@ -1050,15 +1243,17 @@ class Test(object):
         seq = CmdSeq()
 
         if Initializer:
-            seq.cmds.append(CmdLine("%s %s" % (Initializer, self.name), True, 1, "<Initializer>"))
+            seq.cmds.append(
+                CmdLine(f"{Initializer} {self.name}", True, 1, "<Initializer>")
+            )
 
         seq.cmds += self.cmdseqs
 
         if Finalizer:
-            seq.cmds.append(CmdLine("%s %s" % (Finalizer, self.name), True, 1, "<Finalizer>"))
+            seq.cmds.append(CmdLine(f"{Finalizer} {self.name}", True, 1, "<Finalizer>"))
 
         if Teardown:
-            seq.teardown = CmdLine("%s %s" % (Teardown, self.name), True, 1, "<Teardown>")
+            seq.teardown = CmdLine(f"{Teardown} {self.name}", True, 1, "<Teardown>")
 
         failures = 0
         rc = 0
@@ -1080,14 +1275,22 @@ class Test(object):
                     # first. This processes teardowns for those sequences as
                     # needed, and skips them when nothing was actually run in a
                     # CmdSeq.
-                    if isinstance(cmd, CmdSeq):
+                    #
+                    # The use of isinstance() to determine "is a CmdSeq" is
+                    # dangerous since e.g. the dill serializer creates a
+                    # different type upon un-serializing, failing
+                    # isinstance(). So we take the class name as a sufficent
+                    # signal.
+                    if type(cmd).__name__ == "CmdSeq":
                         need_teardown |= run_cmdseq(cmd)
                         continue
 
                     if skip_part >= 0 and skip_part == cmd.part:
                         continue
 
-                    (success, rc) = self.execute(cmd, apply_alternative=self.alternative)
+                    (success, rc) = self.execute(
+                        cmd, apply_alternative=self.alternative
+                    )
                     need_teardown = True
 
                     if not success:
@@ -1107,12 +1310,14 @@ class Test(object):
                             break
 
             if need_teardown and seq.teardown:
-                (success, teardown_rc) = self.execute(seq.teardown,
-                                                      apply_alternative=self.alternative,
-                                                      addl_envs={
-                                                          'TEST_FAILED': int(failures > 0),
-                                                          'TEST_LAST_RETCODE': rc
-                                                      })
+                (success, teardown_rc) = self.execute(
+                    seq.teardown,
+                    apply_alternative=self.alternative,
+                    addl_envs={
+                        "TEST_FAILED": int(failures > 0),
+                        "TEST_LAST_RETCODE": rc,
+                    },
+                )
 
                 # A teardown can fail an otherwise successful test run, with the
                 # same special-casing of return codes 100 and 200. When failing
@@ -1149,13 +1354,15 @@ class Test(object):
             # on systems that can't measure execution time, the test will just pass.
             if self.utime_base >= 0 and self.utime >= 0:
                 delta = getOption("TimingDeltaPerc", "1.0")
-                self.utime_perc = (100.0 * (self.utime - self.utime_base) / self.utime_base)
-                self.utime_exceeded = (abs(self.utime_perc) > float(delta))
+                self.utime_perc = (
+                    100.0 * (self.utime - self.utime_base) / self.utime_base
+                )
+                self.utime_exceeded = abs(self.utime_perc) > float(delta)
 
             if self.utime_exceeded and not Options.update_times:
                 self.diagmsgs += [
-                    "'%s' exceeded permitted execution time deviation%s" %
-                    (self.name, self.timePostfix())
+                    "'%s' exceeded permitted execution time deviation%s"
+                    % (self.name, self.timePostfix())
                 ]
                 self.mgr.testFailed(self)
 
@@ -1163,7 +1370,7 @@ class Test(object):
                 self.mgr.testSucceeded(self)
 
             if not Options.tmps and self.reruns == 0:
-                self.rmTmp()
+                self.rmTmp(with_close=True)
 
         self.finish()
 
@@ -1175,7 +1382,8 @@ class Test(object):
 
         for d in self.baselines:
             try:
-                # Try removing the baseline directory. If it works, it's empty, i.e., no baseline was created.
+                # Try removing the baseline directory. If it works, it's empty,
+                # i.e., no baseline was created.
                 os.rmdir(d)
             except OSError:
                 pass
@@ -1195,7 +1403,6 @@ class Test(object):
 
         # Apply alternative if requested.
         if apply_alternative:
-
             alt = Alternatives[apply_alternative]
 
             try:
@@ -1204,25 +1411,30 @@ class Test(object):
             except LookupError:
                 pass
 
-            for (key, val) in alt.substitutions.items():
+            for key, val in alt.substitutions.items():
                 cmdline = re.sub("\\b" + re.escape(key) + "\\b", val, cmdline)
 
             env = alt.envs
 
-        localfile = os.path.join(self.tmpdir, os.path.basename(cmd.file))
+        localfile = normalize_path_join(self.tmpdir, os.path.basename(cmd.file))
 
-        if filter_cmd and cmd.expect_success:  # Do not apply filter if we expect failure.
+        # Do not apply filter if we expect failure.
+        if filter_cmd and cmd.expect_success:
             # This is not quite correct as it does not necessarily need to be
             # the %INPUT file which we are filtering ...
-            filtered = os.path.join(self.tmpdir, "filtered-%s" % os.path.basename(localfile))
+            filtered = normalize_path_join(
+                self.tmpdir, "filtered-%s" % os.path.basename(localfile)
+            )
 
-            filter = CmdLine("%s %s %s" % (filter_cmd, localfile, filtered), True, 1, "<Filter>")
+            filter = CmdLine(
+                f"{filter_cmd} {localfile} {filtered}", True, 1, "<Filter>"
+            )
 
             (success, rc) = self.execute(filter, apply_alternative=None)
             if not success:
                 return (False, rc)
 
-            mv = CmdLine("mv %s %s" % (filtered, localfile), True, 1, "<Filter-Move>")
+            mv = CmdLine(f"mv {filtered} {localfile}", True, 1, "<Filter-Move>")
             (success, rc) = self.execute(mv, apply_alternative=None)
 
             if not success:
@@ -1237,8 +1449,10 @@ class Test(object):
 
         cmdline = RE_DIR.sub(self.dir, cmdline)
 
-        print("%s (expect %s)" % (cmdline, ("failure", "success")[cmd.expect_success]),
-              file=self.log)
+        print(
+            f"{cmdline}: (expect {'success' if cmd.expect_success else 'failure'})",
+            file=self.log,
+        )
 
         # Additional environment variables provided by the caller override any
         # existing ones, but are generally not assumed to collide:
@@ -1246,15 +1460,19 @@ class Test(object):
             env.update(addl_envs)
 
         env = self.prepareEnv(cmd, env)
-        measure_time = self.measure_time and (Options.update_times or self.utime_base >= 0)
-
-        (success, rc, utime) = runTestCommandLine(cmdline,
-                                                  measure_time,
-                                                  cwd=self.tmpdir,
-                                                  shell=True,
-                                                  env=env,
-                                                  stderr=self.stderr,
-                                                  stdout=self.stdout)
+        measure_time = self.measure_time and (
+            Options.update_times or self.utime_base >= 0
+        )
+
+        (success, rc, utime) = runTestCommandLine(
+            cmdline,
+            measure_time,
+            cwd=self.tmpdir,
+            shell=True,
+            env=env,
+            stderr=self.stderr,
+            stdout=self.stdout,
+        )
 
         if utime > 0:
             self.utime += utime
@@ -1270,25 +1488,32 @@ class Test(object):
             if not cmd.expect_success:
                 return (True, rc)
 
-            self.diagmsgs += ["'%s' failed unexpectedly (exit code %s)" % (cmdline, rc)]
+            self.diagmsgs += [f"'{cmdline}' failed unexpectedly (exit code {rc})"]
             return (False, rc)
 
-    def rmTmp(self):
+    def rmTmp(self, *, with_close=False):
+        if with_close:
+            self.log.close()
+            self.stdout.close()
+            self.stderr.close()
+
         try:
             if os.path.isfile(self.tmpdir):
                 os.remove(self.tmpdir)
 
             if os.path.isdir(self.tmpdir):
-                subprocess.call("rm -rf %s 2>/dev/null" % self.tmpdir, shell=True)
+                subprocess.call(["rm", "-rf", self.tmpdir], stderr=subprocess.DEVNULL)
 
         except OSError as e:
-            error("cannot remove tmp directory %s: %s" % (self.tmpdir, e))
+            error(f"cannot remove tmp directory {self.tmpdir}: {e}")
 
     # Prepares the environment for the child processes.
-    def prepareEnv(self, cmd, addl={}):
+    def prepareEnv(self, cmd, addl=None):
+        if addl is None:
+            addl = {}
         env = copy.deepcopy(os.environ)
 
-        env["TEST_BASELINE"] = ":".join(self.baselines)
+        env["TEST_BASELINE"] = os.pathsep.join(self.baselines)
         env["TEST_DIAGNOSTICS"] = self.diag
         env["TEST_MODE"] = Options.mode.upper()
         env["TEST_NAME"] = self.name
@@ -1296,10 +1521,10 @@ class Test(object):
         env["TEST_PART"] = str(cmd.part)
         env["TEST_BASE"] = TestBase
 
-        for (key, val) in addl.items():
+        for key, val in addl.items():
             # Convert val to string since otherwise os.environ (and our clone)
             # trigger a TypeError upon insertion, and the caller may be unaware.
-            env[key.upper()] = str(val)
+            env[key] = str(val)
 
         for idx, key in enumerate(sorted(self.ports)):
             env[key] = str(self.bound_ports[idx]) + "/tcp"
@@ -1330,7 +1555,7 @@ class Test(object):
                         self.mgr.testProgress(self, msg)
 
                     os.unlink(file)
-                except (IOError, OSError):
+                except OSError:
                     pass
 
 
@@ -1338,7 +1563,7 @@ class Test(object):
 
 
 class OutputHandler:
-    def __init__(self, options):
+    def __init__(self, options, outfile=sys.__stderr__):
         """Base class for reporting progress and results to user. We derive
         several classes from this one, with the one being used depending on
         which output the users wants.
@@ -1349,9 +1574,12 @@ class OutputHandler:
         concurrently.
 
         options: An optparser with the global options.
+
+        outfile: The destination file object to write output to.
         """
         self._buffered_output = {}
         self._options = options
+        self._outfile = outfile
 
     def prepare(self, mgr):
         """The TestManager calls this with itself as an argument just before
@@ -1367,13 +1595,17 @@ class OutputHandler:
         a form suitable to prefix output with. With a single thread, returns
         the empty string."""
         if self.options().threads > 1:
-            return "[%s]" % multiprocessing.current_process().name
+            # TestManager.run() defines the process names to "#<n>".  Align the
+            # prefixes by using enough space for the number of threads
+            # requested, plus 1 for "#".
+            pat = "[%%+%ds]" % (len(str(self.options().threads)) + 1)
+            return pat % mp.current_process().name
         else:
             return ""
 
     def _output(self, msg, nl=True, file=None):
         if not file:
-            file = sys.stderr
+            file = reopen_std_file(self._outfile)
 
         if nl:
             print(msg, file=file)
@@ -1403,7 +1635,7 @@ class OutputHandler:
         if test.name not in self._buffered_output:
             return
 
-        for (msg, nl, file) in self._buffered_output[test.name]:
+        for msg, nl, file in self._buffered_output[test.name]:
             self._output(msg, nl, file)
 
         self._buffered_output[test.name] = []
@@ -1413,7 +1645,7 @@ class OutputHandler:
         """Called just before a test begins."""
 
     def testCommand(self, test, cmdline):
-        """Called just before a command line is exected for a trace."""
+        """Called just before a command line is executed for a trace."""
 
     def testProgress(self, test, msg):
         """Called when a test signals having made progress."""
@@ -1448,6 +1680,7 @@ class Forwarder(OutputHandler):
 
     handlers: List of output handlers to forward to.
     """
+
     def __init__(self, options, handlers):
         OutputHandler.__init__(self, options)
         self._handlers = handlers
@@ -1463,7 +1696,7 @@ class Forwarder(OutputHandler):
             h.testStart(test)
 
     def testCommand(self, test, cmdline):
-        """Called just before a command line is exected for a trace."""
+        """Called just before a command line is executed for a trace."""
         for h in self._handlers:
             h.testCommand(test, cmdline)
 
@@ -1505,6 +1738,13 @@ class Forwarder(OutputHandler):
 
 
 class Standard(OutputHandler):
+    """
+    The default output handler, writing plain lines with test outcome.
+
+    Each test result is reported. For parallelized operation, the output
+    includes the thread number processing the test.
+    """
+
     def testStart(self, test):
         self.output(test, self.threadPrefix(), nl=False)
         self.output(test, "%s ..." % test.displayName(), nl=False)
@@ -1544,7 +1784,7 @@ class Standard(OutputHandler):
 
 class Console(OutputHandler):
     """
-    Output handler that writes colorful progress report to the console.
+    Output handler that writes colorful progress report to stdout.
 
     This handler works well in settings that can handle coloring but not
     cursor placement commands (for example because moving to the beginning of
@@ -1552,6 +1792,7 @@ class Console(OutputHandler):
     ``--show-all`` output uses. In contrast, the *CompactConsole* handler uses
     cursor placement in addition for a more space-efficient output.
     """
+
     Green = "\033[32m"
     Red = "\033[31m"
     Yellow = "\033[33m"
@@ -1560,17 +1801,16 @@ class Console(OutputHandler):
     Normal = "\033[0m"
 
     def __init__(self, options):
-        OutputHandler.__init__(self, options)
-        self.show_all = True
+        OutputHandler.__init__(self, options, reopen_std_file(sys.__stdout__))
 
     def testStart(self, test):
         msg = "[%3d%%] %s ..." % (test.mgr.percentage(), test.displayName())
-        self._consoleOutput(test, msg, False)
+        self.output(test, msg, nl=False)
 
     def testProgress(self, test, msg):
         """Called when a test signals having made progress."""
         msg = self.DarkGray + "(%s)" % msg + self.Normal
-        self._consoleOutput(test, msg, True)
+        self.output(test, msg)
 
     def testSucceeded(self, test, msg):
         if test.known_failure:
@@ -1578,7 +1818,7 @@ class Console(OutputHandler):
         else:
             msg = self.Green + msg + self.Normal
 
-        self._consoleOutput(test, msg, self.show_all)
+        self.output(test, msg)
 
     def testFailed(self, test, msg):
         if test.known_failure:
@@ -1586,71 +1826,81 @@ class Console(OutputHandler):
         else:
             msg = self.Red + msg + self.Normal
 
-        self._consoleOutput(test, msg, True)
+        self.output(test, msg)
 
     def testUnstable(self, test, msg):
         msg = self.Yellow + msg + self.Normal
-        self._consoleOutput(test, msg, True)
+        self.output(test, msg)
 
     def testSkipped(self, test, msg):
         msg = self.Gray + msg + self.Normal
-        self._consoleOutput(test, msg, self.show_all)
-
-    def finished(self):
-        sys.stdout.flush()
-
-    def _consoleOutput(self, test, msg, sticky):
-        self._consoleWrite(test, msg, sticky)
-
-    def _consoleWrite(self, test, msg, sticky):
-        sys.stdout.write(msg.strip() + " ")
-
-        if sticky:
-            sys.stdout.write("\n")
-
-        sys.stdout.flush()
+        self.output(test, msg)
 
 
 class CompactConsole(Console):
     """
     Output handler that writes compact, colorful progress report to
-    the console while also keeping the output compact by keeping
+    stdout while also keeping the output compact by keeping
     output only for failing tests.
 
     This handler adds cursor mods and navigation to the coloring provided by
     the Console class and hence needs settings that can handle both.
     """
+
     CursorOff = "\033[?25l"
     CursorOn = "\033[?25h"
     EraseToEndOfLine = "\033[2K"
 
     def __init__(self, options):
         Console.__init__(self, options)
-        self.show_all = False
 
         def cleanup():
-            sys.stdout.write(self.CursorOn)
+            self._outfile.write(self.CursorOn)
 
         atexit.register(cleanup)
 
     def testStart(self, test):
         test.console_last_line = None
         self._consoleOutput(test, "", False)
-        sys.stdout.write(self.CursorOff)
+        self._outfile.write(self.CursorOff)
 
     def testProgress(self, test, msg):
         """Called when a test signals having made progress."""
         msg = " " + self.DarkGray + "(%s)" % msg + self.Normal
         self._consoleAugment(test, msg)
 
+    def testSucceeded(self, test, msg):
+        if test.known_failure:
+            msg = self.Yellow + msg + self.Normal
+        else:
+            msg = self.Green + msg + self.Normal
+
+        self._consoleOutput(test, msg, False)
+
+    def testFailed(self, test, msg):
+        if test.known_failure:
+            msg = self.Yellow + msg + self.Normal
+        else:
+            msg = self.Red + msg + self.Normal
+
+        self._consoleOutput(test, msg, True)
+
     def testFinished(self, test, msg):
         test.console_last_line = None
 
+    def testUnstable(self, test, msg):
+        msg = self.Yellow + msg + self.Normal
+        self._consoleOutput(test, msg, True)
+
+    def testSkipped(self, test, msg):
+        msg = self.Gray + msg + self.Normal
+        self._consoleOutput(test, msg, False)
+
     def finished(self):
-        sys.stdout.write(self.EraseToEndOfLine)
-        sys.stdout.write("\r")
-        sys.stdout.write(self.CursorOn)
-        sys.stdout.flush()
+        self._outfile.write(self.EraseToEndOfLine)
+        self._outfile.write("\r")
+        self._outfile.write(self.CursorOn)
+        self._outfile.flush()
 
     def _consoleOutput(self, test, msg, sticky):
         line = "[%3d%%] %s ..." % (test.mgr.percentage(), test.displayName())
@@ -1662,24 +1912,25 @@ class CompactConsole(Console):
         self._consoleWrite(test, line, sticky)
 
     def _consoleAugment(self, test, msg):
-        sys.stdout.write(self.EraseToEndOfLine)
-        sys.stdout.write(" %s" % msg.strip())
-        sys.stdout.write("\r%s" % test.console_last_line)
-        sys.stdout.flush()
+        self._outfile.write(self.EraseToEndOfLine)
+        self._outfile.write(" %s" % msg.strip())
+        self._outfile.write("\r%s" % test.console_last_line)
+        self._outfile.flush()
 
     def _consoleWrite(self, test, msg, sticky):
-        sys.stdout.write(chr(27) + '[2K')
-        sys.stdout.write("\r%s" % msg.strip())
+        self._outfile.write(chr(27) + "[2K")
+        self._outfile.write("\r%s" % msg.strip())
 
         if sticky:
-            sys.stdout.write("\n")
+            self._outfile.write("\n")
             test.console_last_line = None
 
-        sys.stdout.flush()
+        self._outfile.flush()
 
 
 class Brief(OutputHandler):
     """Output handler for producing the brief output format."""
+
     def testStart(self, test):
         pass
 
@@ -1695,11 +1946,11 @@ class Brief(OutputHandler):
 
     def testFailed(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
-        self.output(test, "%s ... %s" % (test.displayName(), msg))
+        self.output(test, f"{test.displayName()} ... {msg}")
 
     def testUnstable(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
-        self.output(test, "%s ... %s" % (test.displayName(), msg))
+        self.output(test, f"{test.displayName()} ... {msg}")
 
     def testSkipped(self, test, msg):
         pass
@@ -1707,6 +1958,7 @@ class Brief(OutputHandler):
 
 class Verbose(OutputHandler):
     """Output handler for producing the verbose output format."""
+
     def testStart(self, test):
         self.output(test, self.threadPrefix(), nl=False)
         self.output(test, "%s ..." % test.displayName())
@@ -1718,7 +1970,7 @@ class Verbose(OutputHandler):
             part = " [part #%d]" % cmdline.part
 
         self.output(test, self.threadPrefix(), nl=False)
-        self.output(test, "  > %s%s" % (cmdline.cmdline, part))
+        self.output(test, f"  > {cmdline.cmdline}{part}")
 
     def testProgress(self, test, msg):
         """Called when a test signals having made progress."""
@@ -1727,22 +1979,22 @@ class Verbose(OutputHandler):
     def testSucceeded(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
         self.showTestVerbose(test)
-        self.output(test, "... %s %s" % (test.displayName(), msg))
+        self.output(test, f"... {test.displayName()} {msg}")
 
     def testFailed(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
         self.showTestVerbose(test)
-        self.output(test, "... %s %s" % (test.displayName(), msg))
+        self.output(test, f"... {test.displayName()} {msg}")
 
     def testUnstable(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
         self.showTestVerbose(test)
-        self.output(test, "... %s %s" % (test.displayName(), msg))
+        self.output(test, f"... {test.displayName()} {msg}")
 
     def testSkipped(self, test, msg):
         self.output(test, self.threadPrefix(), nl=False)
         self.showTestVerbose(test)
-        self.output(test, "... %s %s" % (test.displayName(), msg))
+        self.output(test, f"... {test.displayName()} {msg}")
 
     def showTestVerbose(self, test):
         if not os.path.exists(test.verbose):
@@ -1794,24 +2046,24 @@ class Diag(OutputHandler):
     def testSucceeded(self, test, msg):
         if self._all:
             if self._file:
-                self.output(test, "%s ... %s" % (test.displayName(), msg), True, self._file)
+                self.output(test, f"{test.displayName()} ... {msg}", True, self._file)
 
             self.showDiag(test)
 
     def testFailed(self, test, msg):
         if self._file:
-            self.output(test, "%s ... %s" % (test.displayName(), msg), True, self._file)
+            self.output(test, f"{test.displayName()} ... {msg}", True, self._file)
 
         if (not test.known_failure) or self._all:
             self.showDiag(test)
 
     def testUnstable(self, test, msg):
         if self._file:
-            self.output(test, "%s ... %s" % (test.displayName(), msg), True, self._file)
+            self.output(test, f"{test.displayName()} ... {msg}", True, self._file)
 
     def testSkipped(self, test, msg):
         if self._file:
-            self.output(test, "%s ... %s" % (test.displayName(), msg), True, self._file)
+            self.output(test, f"{test.displayName()} ... {msg}", True, self._file)
 
 
 class SphinxOutput(OutputHandler):
@@ -1825,12 +2077,15 @@ class SphinxOutput(OutputHandler):
         OutputHandler.__init__(self, options)
 
         self._output = None
+        self._part = None
 
         try:
             self._rst_output = os.environ["BTEST_RST_OUTPUT"]
         except KeyError:
-            print("warning: environment variable BTEST_RST_OUTPUT not set, will not produce output",
-                  file=sys.stderr)
+            print(
+                "warning: environment variable BTEST_RST_OUTPUT not set, will not produce output",
+                file=sys.stderr,
+            )
             self._rst_output = None
 
     def testStart(self, test):
@@ -1840,7 +2095,7 @@ class SphinxOutput(OutputHandler):
         if not self._rst_output:
             return
 
-        self._output = "%s#%s" % (self._rst_output, cmdline.part)
+        self._output = f"{self._rst_output}#{cmdline.part}"
         self._part = cmdline.part
 
     def testSucceeded(self, test, msg):
@@ -1850,11 +2105,14 @@ class SphinxOutput(OutputHandler):
         if not self._output:
             return
 
-        out = open(self._output, "a")
+        out = open(self._output, "a", newline="\n")
 
         print("\n.. code-block:: none ", file=out)
-        print("\n  ERROR executing test '%s' (part %s)\n" % (test.displayName(), self._part),
-              file=out)
+        print(
+            "\n  ERROR executing test '%s' (part %s)\n"
+            % (test.displayName(), self._part),
+            file=out,
+        )
 
         for line in test.diagmsgs:
             print("  % " + line, file=out)
@@ -1867,7 +2125,7 @@ class SphinxOutput(OutputHandler):
 
             if os.path.isfile(f):
                 print("  % cat " + os.path.basename(f), file=out)
-                for line in open(f):
+                for line in open(f, newline="\n"):
                     print("   %s" % line.strip(), file=out)
                 print(file=out)
 
@@ -1879,7 +2137,6 @@ class SphinxOutput(OutputHandler):
 
 
 class XMLReport(OutputHandler):
-
     RESULT_PASS = "pass"
     RESULT_FAIL = "failure"
     RESULT_SKIP = "skipped"
@@ -1896,6 +2153,7 @@ class XMLReport(OutputHandler):
         self._file = xmlfile
         self._start = time.time()
         self._timestamp = datetime.now().isoformat()
+        self._results = None
 
     def prepare(self, mgr):
         self._results = mgr.list([])
@@ -1906,8 +2164,9 @@ class XMLReport(OutputHandler):
     def testCommand(self, test, cmdline):
         pass
 
-    def makeTestCaseElement(self, doc, testsuite, name, duration):
-        parts = name.split('.')
+    @staticmethod
+    def makeTestCaseElement(doc, testsuite, name, duration):
+        parts = name.split(".")
         if len(parts) > 1:
             classname = ".".join(parts[:-1])
             name = parts[-1]
@@ -1923,7 +2182,8 @@ class XMLReport(OutputHandler):
 
         return e
 
-    def getContext(self, test, context_file):
+    @staticmethod
+    def getContext(test, context_file):
         context = ""
         for line in test.diagmsgs:
             context += "  % " + line + "\n"
@@ -1934,7 +2194,7 @@ class XMLReport(OutputHandler):
 
             if os.path.isfile(f):
                 context += "  % cat " + os.path.basename(f) + "\n"
-                for line in open(f):
+                for line in open(f, newline="\n"):
                     context += "  " + line.strip() + "\n"
 
         return context
@@ -1974,7 +2234,9 @@ class XMLReport(OutputHandler):
         doc.appendChild(testsuite)
 
         for res in self._results:
-            test_case = self.makeTestCaseElement(doc, testsuite, res["name"], res["duration"])
+            test_case = self.makeTestCaseElement(
+                doc, testsuite, res["name"], res["duration"]
+            )
 
             if res["status"] != self.RESULT_PASS:
                 e = doc.createElement(res["status"])
@@ -2013,29 +2275,91 @@ class ChromeTracing(OutputHandler):
     Output files can be loaded into Chrome browser under about:tracing, or
     converted to standalone HTML files with `trace2html`.
     """
+
     def __init__(self, options, tracefile):
         OutputHandler.__init__(self, options)
         self._file = tracefile
+        self._results = None
 
     def prepare(self, mgr):
         self._results = mgr.list([])
 
     def testFinished(self, test, _):
-        self._results.append({
-            "name": test.name,
-            "ts": test.start * 1e6,
-            "tid": multiprocessing.current_process().pid,
-            "pid": 1,
-            "ph": "X",
-            "cat": "test",
-            "dur": (time.time() - test.start) * 1e6,
-        })
+        self._results.append(
+            {
+                "name": test.name,
+                "ts": test.start * 1e6,
+                "tid": mp.current_process().pid,
+                "pid": 1,
+                "ph": "X",
+                "cat": "test",
+                "dur": (time.time() - test.start) * 1e6,
+            }
+        )
 
     def finished(self):
         print(json.dumps(list(self._results)), file=self._file)
         self._file.close()
 
 
+def create_output_handler(options):
+    output_handlers = []
+
+    if options.verbose:
+        output_handlers += [Verbose(options)]
+
+    elif options.brief:
+        output_handlers += [Brief(options)]
+
+    else:
+        if sys.stdout.isatty():
+            if options.show_all:
+                output_handlers += [Console(options)]
+            else:
+                output_handlers += [CompactConsole(options)]
+        else:
+            output_handlers += [Standard(options)]
+
+    if options.diagall:
+        output_handlers += [Diag(options, True, None)]
+
+    elif options.diag:
+        output_handlers += [Diag(options, False, None)]
+
+    if options.diagfile:
+        try:
+            diagfile = open(options.diagfile, "w", 1, newline="\n")
+            output_handlers += [Diag(options, options.diagall, diagfile)]
+
+        except OSError as e:
+            print(f"cannot open {options.diagfile}: {e}", file=sys.stderr)
+
+    if options.sphinx:
+        if sys.platform == "win32":
+            print("Sphinx support is disabled on Windows", file=sys.stderr)
+            sys.exit(1)
+
+        output_handlers += [SphinxOutput(options)]
+
+    if options.xmlfile:
+        try:
+            xmlfile = open(options.xmlfile, "w", 1, newline="\n")
+            output_handlers += [XMLReport(options, xmlfile)]
+
+        except OSError as e:
+            print(f"cannot open {options.xmlfile}: {e}", file=sys.stderr)
+
+    if options.tracefile:
+        try:
+            tracefile = open(options.tracefile, "w", 1, newline="\n")
+            output_handlers += [ChromeTracing(options, tracefile)]
+
+        except OSError as e:
+            print(f"cannot open {options.tracefile}: {e}", file=sys.stderr)
+
+    return Forwarder(options, output_handlers)
+
+
 ### Timing measurements.
 
 
@@ -2067,8 +2391,9 @@ class LinuxTimer(TimerBase):
             return False
 
         # Make sure it works.
-        (success, rc) = runSubprocess("%s stat -o /dev/null true 2>/dev/null" % self.perf,
-                                      shell=True)
+        (success, rc) = runSubprocess(
+            "%s stat -o /dev/null true 2>/dev/null" % self.perf, shell=True
+        )
         return success and rc == 0
 
     def timeSubprocess(self, *args, **kwargs):
@@ -2077,7 +2402,10 @@ class LinuxTimer(TimerBase):
         cargs = args
         ckwargs = kwargs
 
+        # fmt: off
         targs = [self.perf, "stat", "-o", ".timing", "-x", " ", "-e", "instructions", "sh", "-c"]
+        # fmt: on
+
         targs += [" ".join(cargs)]
         cargs = [targs]
         del ckwargs["shell"]
@@ -2096,7 +2424,7 @@ class LinuxTimer(TimerBase):
                     except ValueError:
                         pass
 
-        except IOError:
+        except OSError:
             pass
 
         return (success, rc, utime)
@@ -2127,12 +2455,14 @@ def findTests(paths, expand_globs=False):
         if os.path.isdir(path) and os.path.basename(path) in ignore_dirs:
             continue
 
-        ignores = [os.path.join(path, dir) for dir in ignore_dirs]
+        ignores = [normalize_path_join(path, dir) for dir in ignore_dirs]
 
         m = RE_PART.match(rpath)
         if m:
-            error("Do not specify files with part numbers directly, use the base test name (%s)" %
-                  rpath)
+            error(
+                "Do not specify files with part numbers directly, use the base test name (%s)"
+                % rpath
+            )
 
         if os.path.isfile(path):
             tests += readTestFile(path)
@@ -2142,12 +2472,11 @@ def findTests(paths, expand_globs=False):
                 tests += readTestFile(part)
 
         elif os.path.isdir(path):
-            for (dirpath, dirnames, filenames) in os.walk(path):
-
+            for dirpath, dirnames, filenames in os.walk(path):
                 ign = os.path.join(dirpath, ".btest-ignore")
 
                 if os.path.isfile(os.path.join(ign)):
-                    del dirnames[0:len(dirnames)]
+                    del dirnames[0 : len(dirnames)]
                     continue
 
                 for file in filenames:
@@ -2158,9 +2487,11 @@ def findTests(paths, expand_globs=False):
                         tests += readTestFile(os.path.join(dirpath, file))
 
                 # Don't recurse into these.
-                for (dir, path) in [(dir, os.path.join(dirpath, dir)) for dir in dirnames]:
+                for dir, dir_path in [
+                    (dir, normalize_path_join(dirpath, dir)) for dir in dirnames
+                ]:
                     for skip in ignores:
-                        if path == skip:
+                        if dir_path == skip:
                             dirnames.remove(dir)
 
         else:
@@ -2219,8 +2550,8 @@ def readTestFile(filename):
         return []
 
     try:
-        input = io.open(filename, encoding=getDefaultBtestEncoding(), newline='')
-    except IOError as e:
+        input = open(filename, encoding="utf-8", newline="")
+    except OSError as e:
         error("cannot read test file: %s" % e)
 
     tests = []
@@ -2241,10 +2572,9 @@ def readTestFile(filename):
         # none of the locale environment variables LANG, LC_CTYPE, or LC_ALL,
         # were defined prior to running btest).  However, if all test files
         # are ASCII, then this error should never occur.
-        error("unicode decode error in file %s: %s" % (filename, e))
+        error(f"unicode decode error in file {filename}: {e}")
 
     for line in lines:
-
         if state == "test":
             m = RE_START_FILE.search(line)
             if m:
@@ -2254,7 +2584,7 @@ def readTestFile(filename):
 
             m = RE_END_FILE.search(line)
             if m:
-                error("%s: unexpected %sEND-FILE" % (filename, CommandPrefix))
+                error(f"{filename}: unexpected {CommandPrefix}END-FILE")
 
             m = RE_START_NEXT_TEST.search(line)
             if not m:
@@ -2300,9 +2630,9 @@ def readTestFile(filename):
 
 
 def jOption(option, _, __, parser):
-    val = multiprocessing.cpu_count()
+    val = mp.cpu_count()
 
-    if parser.rargs and not parser.rargs[0].startswith('-'):
+    if parser.rargs and not parser.rargs[0].startswith("-"):
         try:
             # Next argument should be the non-negative number of threads.
             # Turn 0 into 1, for backward compatibility.
@@ -2347,9 +2677,9 @@ def outputDocumentation(tests, fmt):
             print()
 
             for t in tests:
-                print("%s``%s``:" % (indent(1), t.name))
+                print(f"{indent(1)}``{t.name}``:")
                 for d in doc(t):
-                    print("%s%s" % (indent(2), d))
+                    print(f"{indent(2)}{d}")
                 print()
 
         if fmt == "md":
@@ -2359,537 +2689,635 @@ def outputDocumentation(tests, fmt):
             for t in tests:
                 print("* `%s`:" % t.name)
                 for d in doc(t):
-                    print("%s%s" % (indent(1), d))
+                    print(f"{indent(1)}{d}")
 
             print()
 
 
+def parse_options():
+    optparser = optparse.OptionParser(
+        usage="%prog [options] <directories>", version=VERSION
+    )
+    optparser.add_option(
+        "-U",
+        "--update-baseline",
+        action="store_const",
+        dest="mode",
+        const="UPDATE",
+        help="create a new baseline from the tests' output",
+    )
+    optparser.add_option(
+        "-u",
+        "--update-interactive",
+        action="store_const",
+        dest="mode",
+        const="UPDATE_INTERACTIVE",
+        help="interactively asks whether to update baseline for a failed test",
+    )
+    optparser.add_option(
+        "-d",
+        "--diagnostics",
+        action="store_true",
+        dest="diag",
+        default=False,
+        help="show diagnostic output for failed tests",
+    )
+    optparser.add_option(
+        "-D",
+        "--diagnostics-all",
+        action="store_true",
+        dest="diagall",
+        default=False,
+        help="show diagnostic output for ALL tests",
+    )
+    optparser.add_option(
+        "-f",
+        "--file-diagnostics",
+        action="store",
+        type="string",
+        dest="diagfile",
+        default="",
+        help=(
+            "write diagnostic output for failed tests into file; "
+            "if file exists, it is overwritten"
+        ),
+    )
+    optparser.add_option(
+        "-v",
+        "--verbose",
+        action="store_true",
+        dest="verbose",
+        default=False,
+        help="show commands as they are executed",
+    )
+    optparser.add_option(
+        "-w",
+        "--wait",
+        action="store_true",
+        dest="wait",
+        default=False,
+        help="wait for <enter> after each failed (with -d) or all (with -D) tests",
+    )
+    optparser.add_option(
+        "-b",
+        "--brief",
+        action="store_true",
+        dest="brief",
+        default=False,
+        help="outputs only failed tests",
+    )
+    optparser.add_option(
+        "-c",
+        "--config",
+        action="store",
+        type="string",
+        dest="config",
+        default=ConfigDefault,
+        help="configuration file",
+    )
+    optparser.add_option(
+        "-t",
+        "--tmp-keep",
+        action="store_true",
+        dest="tmps",
+        default=False,
+        help="do not delete tmp files created for running tests",
+    )
+    optparser.add_option(
+        "-j",
+        "--jobs",
+        action="callback",
+        callback=jOption,
+        dest="threads",
+        default=1,
+        help="number of threads running tests in parallel; with no argument will use all CPUs",
+    )
+    optparser.add_option(
+        "-g",
+        "--groups",
+        action="store",
+        type="string",
+        dest="groups",
+        default="",
+        help="execute only tests of given comma-separated list of groups",
+    )
+    optparser.add_option(
+        "-r",
+        "--rerun",
+        action="store_true",
+        dest="rerun",
+        default=False,
+        help="execute commands for tests that failed last time",
+    )
+    optparser.add_option(
+        "-q",
+        "--quiet",
+        action="store_true",
+        dest="quiet",
+        default=False,
+        help="suppress information output other than about failed tests",
+    )
+    optparser.add_option(
+        "-x",
+        "--xml",
+        action="store",
+        type="string",
+        dest="xmlfile",
+        default="",
+        help=(
+            "write a report of test results in JUnit XML format to file; "
+            "if file exists, it is overwritten"
+        ),
+    )
+    optparser.add_option(
+        "-a",
+        "--alternative",
+        action="store",
+        type="string",
+        dest="alternatives",
+        default=None,
+        help="activate given alternative",
+    )
+    optparser.add_option(
+        "-S",
+        "--sphinx",
+        action="store_true",
+        dest="sphinx",
+        default=False,
+        help="indicates that we're running from inside Sphinx; for internal purposes",
+    )
+    optparser.add_option(
+        "-T",
+        "--update-times",
+        action="store_true",
+        dest="update_times",
+        default=False,
+        help="create a new timing baseline for tests being measured",
+    )
+    optparser.add_option(
+        "-R",
+        "--documentation",
+        action="store",
+        type="choice",
+        dest="doc",
+        choices=("rst", "md"),
+        metavar="format",
+        default=None,
+        help="Output documentation for tests, supported formats: rst, md",
+    )
+    optparser.add_option(
+        "-A",
+        "--show-all",
+        action="store_true",
+        default=False,
+        help=(
+            "For console output, show one-liners for passing/skipped tests "
+            "in addition to any failing ones"
+        ),
+    )
+    optparser.add_option(
+        "-z",
+        "--retries",
+        action="store",
+        dest="retries",
+        type="int",
+        default=0,
+        help="Retry failed tests this many times to determine if they are unstable",
+    )
+    optparser.add_option(
+        "--trace-file",
+        action="store",
+        dest="tracefile",
+        default="",
+        help="write Chrome tracing file to file; if file exists, it is overwritten",
+    )
+    optparser.add_option(
+        "-F",
+        "--abort-on-failure",
+        action="store_true",
+        dest="abort_on_failure",
+        help="terminate after first test failure",
+    )
+    optparser.add_option(
+        "-l",
+        "--list",
+        action="store_true",
+        dest="list",
+        default=False,
+        help="list available tests instead of executing them",
+    )
+    optparser.add_option(
+        "-s",
+        "--set",
+        action="append",
+        dest="defaults",
+        default=[],
+        help=(
+            "Override default key used in btest.cfg with another value. "
+            "Can be specified multiple times."
+        ),
+    )
+
+    optparser.set_defaults(mode="TEST")
+
+    (options, parsed_args) = optparser.parse_args()
+
+    # Update-interactive mode implies single-threaded operation
+    if options.mode == "UPDATE_INTERACTIVE" and options.threads > 1:
+        warning("ignoring requested parallelism in interactive-update mode")
+        options.threads = 1
+
+    if not os.path.exists(options.config):
+        error("configuration file '%s' not found" % options.config)
+
+    return options, parsed_args
+
+
 ### Main
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     # Python 3.8+ on macOS no longer uses "fork" as the default start-method
     # See https://github.com/zeek/btest/issues/26
     pyver_maj = sys.version_info[0]
     pyver_min = sys.version_info[1]
 
-    if (pyver_maj == 3 and pyver_min >= 8) or pyver_maj > 3:
-        multiprocessing.set_start_method('fork')
-
-optparser = optparse.OptionParser(usage="%prog [options] <directories>", version=VERSION)
-optparser.add_option("-U",
-                     "--update-baseline",
-                     action="store_const",
-                     dest="mode",
-                     const="UPDATE",
-                     help="create a new baseline from the tests' output")
-optparser.add_option("-u",
-                     "--update-interactive",
-                     action="store_const",
-                     dest="mode",
-                     const="UPDATE_INTERACTIVE",
-                     help="interactively asks whether to update baseline for a failed test")
-optparser.add_option("-d",
-                     "--diagnostics",
-                     action="store_true",
-                     dest="diag",
-                     default=False,
-                     help="show diagnostic output for failed tests")
-optparser.add_option("-D",
-                     "--diagnostics-all",
-                     action="store_true",
-                     dest="diagall",
-                     default=False,
-                     help="show diagnostic output for ALL tests")
-optparser.add_option(
-    "-f",
-    "--file-diagnostics",
-    action="store",
-    type="string",
-    dest="diagfile",
-    default="",
-    help="write diagnostic output for failed tests into file; if file exists, it is overwritten")
-optparser.add_option("-v",
-                     "--verbose",
-                     action="store_true",
-                     dest="verbose",
-                     default=False,
-                     help="show commands as they are executed")
-optparser.add_option("-w",
-                     "--wait",
-                     action="store_true",
-                     dest="wait",
-                     default=False,
-                     help="wait for <enter> after each failed (with -d) or all (with -D) tests")
-optparser.add_option("-b",
-                     "--brief",
-                     action="store_true",
-                     dest="brief",
-                     default=False,
-                     help="outputs only failed tests")
-optparser.add_option("-c",
-                     "--config",
-                     action="store",
-                     type="string",
-                     dest="config",
-                     default=ConfigDefault,
-                     help="configuration file")
-optparser.add_option("-t",
-                     "--tmp-keep",
-                     action="store_true",
-                     dest="tmps",
-                     default=False,
-                     help="do not delete tmp files created for running tests")
-optparser.add_option(
-    "-j",
-    "--jobs",
-    action="callback",
-    callback=jOption,
-    dest="threads",
-    default=1,
-    help="number of threads running tests in parallel; with no argument will use all CPUs")
-optparser.add_option("-g",
-                     "--groups",
-                     action="store",
-                     type="string",
-                     dest="groups",
-                     default="",
-                     help="execute only tests of given comma-separated list of groups")
-optparser.add_option("-r",
-                     "--rerun",
-                     action="store_true",
-                     dest="rerun",
-                     default=False,
-                     help="execute commands for tests that failed last time")
-optparser.add_option("-q",
-                     "--quiet",
-                     action="store_true",
-                     dest="quiet",
-                     default=False,
-                     help="suppress information output other than about failed tests")
-optparser.add_option(
-    "-x",
-    "--xml",
-    action="store",
-    type="string",
-    dest="xmlfile",
-    default="",
-    help=
-    "write a report of test results in JUnit XML format to file; if file exists, it is overwritten")
-optparser.add_option("-a",
-                     "--alternative",
-                     action="store",
-                     type="string",
-                     dest="alternatives",
-                     default=None,
-                     help="activate given alternative")
-optparser.add_option("-S",
-                     "--sphinx",
-                     action="store_true",
-                     dest="sphinx",
-                     default=False,
-                     help="indicates that we're running from inside Sphinx; for internal purposes")
-optparser.add_option("-T",
-                     "--update-times",
-                     action="store_true",
-                     dest="update_times",
-                     default=False,
-                     help="create a new timing baseline for tests being measured")
-optparser.add_option("-R",
-                     "--documentation",
-                     action="store",
-                     type="choice",
-                     dest="doc",
-                     choices=("rst", "md"),
-                     metavar="format",
-                     default=None,
-                     help="Output documentation for tests, supported formats: rst, md")
-optparser.add_option(
-    "-A",
-    "--show-all",
-    action="store_true",
-    default=False,
-    help=
-    "For console output, show one-liners for passing/skipped tests in addition to any failing ones")
-optparser.add_option("-z",
-                     "--retries",
-                     action="store",
-                     dest="retries",
-                     type="int",
-                     default=0,
-                     help="Retry failed tests this many times to determine if they are unstable")
-optparser.add_option("--trace-file",
-                     action="store",
-                     dest="tracefile",
-                     default="",
-                     help="write Chrome tracing file to file; if file exists, it is overwritten")
-optparser.add_option("-F",
-                     "--abort-on-failure",
-                     action="store_true",
-                     dest="abort_on_failure",
-                     help="terminate after first test failure")
-optparser.add_option("-l",
-                     "--list",
-                     action="store_true",
-                     dest="list",
-                     default=False,
-                     help="list available tests instead of executing them")
-
-optparser.set_defaults(mode="TEST")
-(Options, args) = optparser.parse_args()
-
-# Update-interactive mode implies single-threaded operation
-if Options.mode == "UPDATE_INTERACTIVE" and Options.threads > 1:
-    warning("ignoring requested parallelism in interactive-update mode")
-    Options.threads = 1
-
-if not os.path.exists(Options.config):
-    error("configuration file '%s' not found" % Options.config)
-
-# The defaults come from environment variables, plus a few additional items.
-defaults = {}
-# Changes to defaults should not change os.environ
-defaults.update(os.environ)
-defaults["default_path"] = os.environ["PATH"]
-
-dirname = os.path.dirname(Options.config)
-if not dirname:
-    dirname = os.getcwd()
-
-# If the BTEST_TEST_BASE envirnoment var is set, we'll use that as the testbase.
-# If not, we'll use the current directory.
-TestBase = os.path.abspath(os.environ.get("BTEST_TEST_BASE", dirname))
-defaults["testbase"] = TestBase
-defaults["baselinedir"] = os.path.abspath(
-    os.environ.get("BTEST_BASELINE_DIR", os.path.join(TestBase, "Baseline")))
-
-# Parse our config
-Config = getcfgparser(defaults)
-Config.read(Options.config)
-
-defaults["baselinedir"] = getOption("BaselineDir", defaults["baselinedir"])
-
-min_version = getOption("MinVersion", None)
-if min_version:
-    validate_version_requirement(min_version, VERSION)
-
-if Options.alternatives:
-    # Preprocess to split into list.
-    Options.alternatives = [alt.strip() for alt in Options.alternatives.split(",") if alt != "-"]
-
-    # Helper function that, if an option wasn't explicitly specified as an
-    # environment variable, checks if an alternative sets its through
-    # its own environment section. If so, we make that value our new default.
-    # If multiple alternatives set it, we pick the value from the first.
-    def get_env_from_alternative(env, opt, default, transform=None):
-        for tag in Options.alternatives:
-            value = getOption(env, None, section="environment-%s" % tag)
-            if value is not None:
-                if transform:
-                    value = transform(value)
-
-                defaults[opt] = value
-
-                # At this point, our defaults have changed, so we
-                # reread the configuration.
-                new_config = getcfgparser(defaults)
-                new_config.read(Options.config)
-                return new_config, value
-
-        return Config, default
-
-    (Config, TestBase) = get_env_from_alternative("BTEST_TEST_BASE", "testbase", TestBase,
-                                                  lambda x: os.path.abspath(x))
-    # Need to update BaselineDir - it may be interpolated from testbase.
-    defaults["baselinedir"] = getOption("BaselineDir", defaults["baselinedir"])
-    (Config, _) = get_env_from_alternative("BTEST_BASELINE_DIR", "baselinedir", None)
-
-os.chdir(TestBase)
-
-if Options.sphinx:
-    Options.quiet = True
+    if sys.platform == "win32":
+        # The "fork" method doesn't exist at all on Windows, so force over to
+        # "spawn" instead.
+        mp.set_start_method("spawn")
 
-if Options.quiet:
-    Options.brief = True
-
-# Determine output handlers to use.
-
-output_handlers = []
-
-if Options.verbose:
-    output_handlers += [Verbose(Options, )]
-
-elif Options.brief:
-    output_handlers += [Brief(Options, )]
-
-else:
-    if sys.stdout.isatty():
-        if Options.show_all:
-            output_handlers += [Console(Options, )]
-        else:
-            output_handlers += [CompactConsole(Options, )]
-    else:
-        output_handlers += [Standard(Options, )]
-
-if Options.diagall:
-    output_handlers += [Diag(Options, True, None)]
-
-elif Options.diag:
-    output_handlers += [Diag(Options, False, None)]
-
-if Options.diagfile:
-    try:
-        diagfile = open(Options.diagfile, "w", 1)
-        output_handlers += [Diag(Options, Options.diagall, diagfile)]
-
-    except IOError as e:
-        print("cannot open %s: %s" % (Options.diagfile, e), file=sys.stderr)
-
-if Options.sphinx:
-    output_handlers += [SphinxOutput(Options)]
-
-if Options.xmlfile:
-    try:
-        xmlfile = open(Options.xmlfile, "w", 1)
-        output_handlers += [XMLReport(Options, xmlfile)]
-
-    except IOError as e:
-        print("cannot open %s: %s" % (Options.xmlfile, e), file=sys.stderr)
-
-if Options.tracefile:
-    try:
-        tracefile = open(Options.tracefile, "w", 1)
-        output_handlers += [ChromeTracing(Options, tracefile)]
+        # Double-check that `bash.exe` exists and is executable, since it's
+        # required for pretty much anything here to work on Windows. Note we're
+        # doing this prior to parsing the config file because it's required for
+        # backtick-expansion there as well.
+        try:
+            subprocess.call(
+                ["bash.exe", "--version"],
+                stdout=subprocess.DEVNULL,
+                stderr=subprocess.DEVNULL,
+            )
+        except FileNotFoundError:
+            print(
+                "error: bash.exe is required to be in your PATH to run BTest.",
+                file=sys.stderr,
+            )
+            sys.exit(1)
 
-    except IOError as e:
-        print("cannot open %s: %s" % (Options.tracefile, e), file=sys.stderr)
+    elif (pyver_maj == 3 and pyver_min >= 8) or pyver_maj > 3:
+        mp.set_start_method("fork")
 
-output_handler = Forwarder(Options, output_handlers)
+    (Options, args) = parse_options()
 
-# Determine Timer to use.
+    # The defaults come from environment variables, plus a few additional items.
+    defaults = {}
+    # Changes to defaults should not change os.environ
+    defaults.update(os.environ)
+    defaults["default_path"] = os.environ["PATH"]
 
-Timer = None
+    dirname = os.path.dirname(Options.config)
+    if not dirname:
+        dirname = os.getcwd()
 
-if platform() == "Linux":
-    t = LinuxTimer()
-    if t.available():
-        Timer = t
+    # If the BTEST_TEST_BASE envirnoment var is set, we'll use that as the testbase.
+    # If not, we'll use the current directory.
+    TestBase = normalize_path(os.environ.get("BTEST_TEST_BASE", dirname))
+    defaults["testbase"] = TestBase
+    defaults["baselinedir"] = normalize_path(
+        os.environ.get("BTEST_BASELINE_DIR", os.path.join(TestBase, "Baseline"))
+    )
+    defaults["pathsep"] = os.pathsep
 
-if Options.update_times and not Timer:
-    warning("unable to create timing baseline because timer is not available")
+    # Parse our config
+    Config = getcfgparser(defaults)
+    Config.read(Options.config, encoding="utf-8")
 
-# Evaluate other command line options.
+    defaults["baselinedir"] = getOption("BaselineDir", defaults["baselinedir"])
 
-if Config.has_section("environment"):
-    for (name, value) in Config.itemsNoDefaults("environment"):
-        # Here we don't want to include items from defaults
-        os.environ[name.upper()] = value
+    min_version = getOption("MinVersion", None)
+    if min_version:
+        validate_version_requirement(min_version, VERSION)
+
+    if Options.alternatives:
+        # Preprocess to split into list. "-" refers to the default setup, as a
+        # shorthand for "default", to allow combination with select alternatives.
+        Options.alternatives = [alt.strip() for alt in Options.alternatives.split(",")]
+        Options.alternatives = [
+            Alternative.DEFAULT if alt == "-" else alt for alt in Options.alternatives
+        ]
+
+        # Helper function that, if an option wasn't explicitly specified as an
+        # environment variable, checks if an alternative sets its through
+        # its own environment section. If so, we make that value our new default.
+        # If multiple alternatives set it, we pick the value from the first.
+        def get_env_from_alternative(env, opt, default, transform=None):
+            for tag in Options.alternatives:
+                value = getOption(env, None, section="environment-%s" % tag)
+                if value is not None:
+                    if transform:
+                        value = transform(value)
+
+                    defaults[opt] = value
+
+                    # At this point, our defaults have changed, so we
+                    # reread the configuration.
+                    new_config = getcfgparser(defaults)
+                    new_config.read(Options.config)
+                    return new_config, value
+
+            return Config, default
+
+        (Config, TestBase) = get_env_from_alternative(
+            "BTEST_TEST_BASE",
+            "testbase",
+            TestBase,
+            lambda x: normalize_path(os.path.abspath(x)),
+        )
+        # Need to update BaselineDir - it may be interpolated from testbase.
+        defaults["baselinedir"] = normalize_path(
+            getOption("BaselineDir", defaults["baselinedir"])
+        )
+        (Config, _) = get_env_from_alternative(
+            "BTEST_BASELINE_DIR",
+            "baselinedir",
+            None,
+            transform=lambda x: normalize_path(x),
+        )
+
+    os.chdir(TestBase)
+
+    if Options.sphinx:
+        Options.quiet = True
+
+    if Options.quiet:
+        Options.brief = True
+
+    # Determine output handlers to use.
+
+    output_handler = create_output_handler(Options)
+
+    # Determine Timer to use.
+
+    Timer = None
+
+    if platform() == "Linux":
+        t = LinuxTimer()
+        if t.available():
+            Timer = t
+
+    if Options.update_times and not Timer:
+        warning("unable to create timing baseline because timer is not available")
+
+    # Evaluate other command line options.
+
+    if Config.has_section("environment"):
+        for name, value in Config.itemsNoDefaults("environment"):
+            # Here we don't want to include items from defaults
+            os.environ[name] = value
+
+    Alternatives = {}
+
+    if Options.alternatives:
+        for tag in Options.alternatives:
+            a = Alternative(tag)
 
-Alternatives = {}
+            try:
+                for name, value in Config.itemsNoDefaults("filter-%s" % tag):
+                    a.filters[name] = value
 
-if Options.alternatives:
-    for tag in Options.alternatives:
-        a = Alternative(tag)
+            except configparser.NoSectionError:
+                pass
 
-        try:
-            for (name, value) in Config.itemsNoDefaults("filter-%s" % tag):
-                a.filters[name] = value
+            try:
+                for name, value in Config.itemsNoDefaults("substitution-%s" % tag):
+                    a.substitutions[name] = value
 
-        except configparser.NoSectionError:
-            pass
+            except configparser.NoSectionError:
+                pass
 
-        try:
-            for (name, value) in Config.itemsNoDefaults("substitution-%s" % tag):
-                a.substitutions[name] = value
+            try:
+                for name, value in Config.itemsNoDefaults("environment-%s" % tag):
+                    a.envs[name] = value
 
-        except configparser.NoSectionError:
-            pass
+            except configparser.NoSectionError:
+                pass
 
-        try:
-            for (name, value) in Config.itemsNoDefaults("environment-%s" % tag):
-                a.envs[name] = value
+            if a.is_empty() and not a.is_default():
+                error('alternative "%s" is undefined' % tag)
+
+            Alternatives[tag] = a
+
+    CommandPrefix = getOption("CommandPrefix", "@TEST-")
+
+    RE_INPUT = re.compile(r"%INPUT")
+    RE_DIR = re.compile(r"%DIR")
+    RE_ENV = re.compile(r"\$\{(\w+)}")
+    RE_PART = re.compile(r"^(.*)#([0-9]+)$")
+    RE_IGNORE = re.compile(CommandPrefix + "IGNORE")
+    RE_START_NEXT_TEST = re.compile(CommandPrefix + "START-NEXT")
+    RE_START_FILE = re.compile(CommandPrefix + "START-FILE +([^\r\n ]*)")
+    RE_END_FILE = re.compile(CommandPrefix + "END-FILE")
+
+    # Commands as tuple (tag, regexp, more-than-one-is-ok, optional, group-main, group-add)
+    # pylint: disable=bad-whitespace
+    # fmt: off
+    RE_EXEC                = ("exec",            re.compile(CommandPrefix + "EXEC(-FAIL)?: *(.*)"), True, False, 2, 1) # noqa
+    RE_REQUIRES            = ("requires",        re.compile(CommandPrefix + "REQUIRES: *(.*)"), True, True, 1, -1) # noqa
+    RE_GROUP               = ("group",           re.compile(CommandPrefix + "GROUP: *(.*)"), True, True, 1, -1) # noqa
+    RE_SERIALIZE           = ("serialize",       re.compile(CommandPrefix + "SERIALIZE: *(.*)"), False, True, 1, -1) # noqa
+    RE_PORT                = ("port",            re.compile(CommandPrefix + "PORT: *(.*)"), True, True, 1, -1) # noqa
+    RE_INCLUDE_ALTERNATIVE = ("alternative",     re.compile(CommandPrefix + "ALTERNATIVE: *(.*)"), True, True, 1, -1) # noqa
+    RE_IGNORE_ALTERNATIVE  = ("not-alternative", re.compile(CommandPrefix + "NOT-ALTERNATIVE: *(.*)"), True, True, 1, -1) # noqa
+    RE_COPY_FILE           = ("copy-file",       re.compile(CommandPrefix + "COPY-FILE: *(.*)"), True, True, 1, -1) # noqa
+    RE_KNOWN_FAILURE       = ("known-failure",   re.compile(CommandPrefix + "KNOWN-FAILURE"), False, True, -1, -1) # noqa
+    RE_MEASURE_TIME        = ("measure-time",    re.compile(CommandPrefix + "MEASURE-TIME"), False, True, -1, -1) # noqa
+    RE_DOC                 = ("doc",             re.compile(CommandPrefix + "DOC: *(.*)"), True, True, 1, -1) # noqa
+    # fmt: on
+    # pylint: enable=bad-whitespace
+
+    Commands = (
+        RE_EXEC,
+        RE_REQUIRES,
+        RE_GROUP,
+        RE_SERIALIZE,
+        RE_PORT,
+        RE_INCLUDE_ALTERNATIVE,
+        RE_IGNORE_ALTERNATIVE,
+        RE_COPY_FILE,
+        RE_KNOWN_FAILURE,
+        RE_MEASURE_TIME,
+        RE_DOC,
+    )
+
+    StateFile = normalize_path(
+        getOption("StateFile", os.path.join(defaults["testbase"], ".btest.failed.dat"))
+    )
+    TmpDir = normalize_path(
+        getOption("TmpDir", os.path.join(defaults["testbase"], ".tmp"))
+    )
+    BaselineDirs = [
+        normalize_path(dir) for dir in defaults["baselinedir"].split(os.pathsep)
+    ]
+    BaselineTimingDir = normalize_path(
+        getOption("TimingBaselineDir", os.path.join(BaselineDirs[0], "_Timing"))
+    )
+
+    Initializer = getOption("Initializer", "")
+    Finalizer = getOption("Finalizer", "")
+    Teardown = getOption("Teardown", "")
+
+    PartInitializer = getOption("PartInitializer", "")
+    PartFinalizer = getOption("PartFinalizer", "")
+    PartTeardown = getOption("PartTeardown", "")
+
+    Config.configured_tests = []
+
+    testdirs = getOption("TestDirs", "").split()
+    if testdirs:
+        Config.configured_tests = findTests(testdirs, True)
+
+    if args:
+        tests = findTests(args)
 
-        except configparser.NoSectionError:
-            pass
+    else:
+        if Options.rerun:
+            (success, tests) = readStateFile()
 
-        Alternatives[tag] = a
-
-CommandPrefix = getOption("CommandPrefix", "@TEST-")
-
-RE_INPUT = re.compile(r"%INPUT")
-RE_DIR = re.compile(r"%DIR")
-RE_ENV = re.compile(r"\$\{(\w+)\}")
-RE_PART = re.compile(r"^(.*)#([0-9]+)$")
-RE_IGNORE = re.compile(CommandPrefix + "IGNORE")
-RE_START_NEXT_TEST = re.compile(CommandPrefix + "START-NEXT")
-RE_START_FILE = re.compile(CommandPrefix + "START-FILE +([^\r\n ]*)")
-RE_END_FILE = re.compile(CommandPrefix + "END-FILE")
-
-# Commands as tuple (tag, regexp, more-than-one-is-ok, optional, group-main, group-add)
-# pylint: disable=bad-whitespace
-# yapf: disable
-RE_EXEC                = ("exec",            re.compile(CommandPrefix + "EXEC(-FAIL)?: *(.*)"), True, False, 2, 1)
-RE_REQUIRES            = ("requires",        re.compile(CommandPrefix + "REQUIRES: *(.*)"), True, True, 1, -1)
-RE_GROUP               = ("group",           re.compile(CommandPrefix + "GROUP: *(.*)"), True, True, 1, -1)
-RE_SERIALIZE           = ("serialize",       re.compile(CommandPrefix + "SERIALIZE: *(.*)"), False, True, 1, -1)
-RE_PORT                = ("port",            re.compile(CommandPrefix + "PORT: *(.*)"), True, True, 1, -1)
-RE_INCLUDE_ALTERNATIVE = ("alternative",     re.compile(CommandPrefix + "ALTERNATIVE: *(.*)"), True, True, 1, -1)
-RE_IGNORE_ALTERNATIVE  = ("not-alternative", re.compile(CommandPrefix + "NOT-ALTERNATIVE: *(.*)"), True, True, 1, -1)
-RE_COPY_FILE           = ("copy-file",       re.compile(CommandPrefix + "COPY-FILE: *(.*)"), True, True, 1, -1)
-RE_KNOWN_FAILURE       = ("known-failure",   re.compile(CommandPrefix + "KNOWN-FAILURE"), False, True, -1, -1)
-RE_MEASURE_TIME        = ("measure-time",    re.compile(CommandPrefix + "MEASURE-TIME"), False, True, -1, -1)
-RE_DOC                 = ("doc",             re.compile(CommandPrefix + "DOC: *(.*)"), True, True, 1, -1)
-# yapf: enable
-# pylint: enable=bad-whitespace
-
-Commands = (RE_EXEC, RE_REQUIRES, RE_GROUP, RE_SERIALIZE, RE_PORT, RE_INCLUDE_ALTERNATIVE,
-            RE_IGNORE_ALTERNATIVE, RE_COPY_FILE, RE_KNOWN_FAILURE, RE_MEASURE_TIME, RE_DOC)
-
-StateFile = os.path.abspath(
-    getOption("StateFile", os.path.join(defaults["testbase"], ".btest.failed.dat")))
-TmpDir = os.path.abspath(getOption("TmpDir", os.path.join(defaults["testbase"], ".tmp")))
-BaselineDirs = [os.path.abspath(dir) for dir in defaults["baselinedir"].split(":")]
-BaselineTimingDir = os.path.abspath(
-    getOption("TimingBaselineDir", os.path.join(BaselineDirs[0], "_Timing")))
-
-Initializer = getOption("Initializer", "")
-Finalizer = getOption("Finalizer", "")
-Teardown = getOption("Teardown", "")
-
-PartInitializer = getOption("PartInitializer", "")
-PartFinalizer = getOption("PartFinalizer", "")
-PartTeardown = getOption("PartTeardown", "")
-
-Config.configured_tests = []
-
-testdirs = getOption("TestDirs", "").split()
-if testdirs:
-    Config.configured_tests = findTests(testdirs, True)
-
-if args:
-    tests = findTests(args)
+            if success:
+                if not tests:
+                    output("no tests failed last time")
+                    sys.exit(0)
 
-else:
-    if Options.rerun:
-        (success, tests) = readStateFile()
-
-        if success:
-            if not tests:
-                output("no tests failed last time")
-                sys.exit(0)
+            else:
+                warning("cannot read state file, executing all tests")
+                tests = Config.configured_tests
 
         else:
-            warning("cannot read state file, executing all tests")
             tests = Config.configured_tests
 
-    else:
-        tests = Config.configured_tests
-
-if Options.groups:
-    groups = Options.groups.split(",")
-    Options.groups = set([g for g in groups if not g.startswith("-")])
-    Options.no_groups = set([g[1:] for g in groups if g.startswith("-")])
-
-    def rightGroup(t):
-        if not t:
-            return True
+    if Options.groups:
+        groups = Options.groups.split(",")
+        Options.groups = {g for g in groups if not g.startswith("-")}
+        Options.no_groups = {g[1:] for g in groups if g.startswith("-")}
 
-        if t.groups & Options.groups:
-            return True
+        def rightGroup(t):
+            if not t:
+                return True
 
-        if "" in Options.no_groups:
-            if not t.groups:
+            if t.groups & Options.groups:
                 return True
 
-        elif Options.no_groups:
-            if t.groups & Options.no_groups:
-                return False
+            if "" in Options.no_groups:
+                if not t.groups:
+                    return True
 
-            return True
+            elif Options.no_groups:
+                if t.groups & Options.no_groups:
+                    return False
 
-        return False
+                return True
 
-    tests = [t for t in tests if rightGroup(t)]
+            return False
 
-if not tests:
-    output("no tests to execute")
-    sys.exit(0)
+        tests = [t for t in tests if rightGroup(t)]
 
-tests = mergeTestParts(tests)
+    if not tests:
+        output("no tests to execute")
+        sys.exit(0)
 
-if Options.doc:
-    outputDocumentation(tests, Options.doc)
-    sys.exit(0)
+    tests = mergeTestParts(tests)
 
-for d in BaselineDirs:
-    mkdir(d)
+    if Options.doc:
+        outputDocumentation(tests, Options.doc)
+        sys.exit(0)
 
-mkdir(TmpDir)
+    for d in BaselineDirs:
+        mkdir(d)
 
-# Building our own path to avoid "error: AF_UNIX path too long" on
-# some platforms. See BIT-862.
-sname = "btest-socket-%d" % os.getpid()
-addr = os.path.join(tempfile.gettempdir(), sname)
+    mkdir(TmpDir)
 
-# Check if the pathname is too long to fit in struct sockaddr_un (the
-# maximum length is system-dependent, so here we just use 100, which seems
-# a safe default choice).
-if len(addr) > 100:
-    # Try relative path to TmpDir (which would usually be ".tmp").
-    addr = os.path.join(os.path.relpath(TmpDir), sname)
+    if sys.platform == "win32":
+        # On win32 we have to use a named pipe so that python's multiprocessing
+        # chooses AF_PIPE as the family type.
+        addr = "\\\\.\\pipe\\btest-pipe-%s" % (os.getpid())
+    else:
+        # Building our own path to avoid "error: AF_UNIX path too long" on
+        # some platforms. See BIT-862.
+        sname = "btest-socket-%s" % (os.getpid())
+        addr = os.path.join(tempfile.gettempdir(), sname)
 
-    # If the path is still too long, then use the global tmp directory.
-    if len(addr) > 100:
-        addr = os.path.join("/tmp", sname)
+        # Check if the pathname is too long to fit in struct sockaddr_un (the
+        # maximum length is system-dependent, so here we just use 100, which seems
+        # a safe default choice).
+        if len(addr) > 100:
+            # Try relative path to TmpDir (which would usually be ".tmp").
+            addr = os.path.join(os.path.relpath(TmpDir), sname)
 
-mgr = TestManager(address=addr)
+            # If the path is still too long, then use the global tmp directory.
+            if len(addr) > 100:
+                addr = os.path.join("/tmp", sname)
 
-try:
-    if Options.list:
-        for test in sorted(tests):
-            if test.name:
-                print(test.name)
-        sys.exit(0)
-    else:
-        (succeeded, failed, skipped, unstable,
-         failed_expected) = mgr.run(copy.deepcopy(tests), output_handler)
-        total = succeeded + failed + skipped
-
-    output_handler.finished()
-
-# Ctrl-C can lead to broken pipe (e.g. FreeBSD), so include IOError here:
-except (Abort, KeyboardInterrupt, IOError) as exc:
-    output_handler.finished()
-    print(str(exc) or "Aborted with %s." % type(exc).__name__, file=sys.stderr)
-    sys.stderr.flush()
-    # Explicitly shut down sync manager to avoid leaking manager
-    # processes, particularly with --abort-on-failure:
-    mgr.shutdown()
-    os._exit(1)
-
-skip = (", %d skipped" % skipped) if skipped > 0 else ""
-unstablestr = (", %d unstable" % unstable) if unstable > 0 else ""
-failed_expectedstr = (" (with %d expected to fail)" %
-                      failed_expected) if failed_expected > 0 else ""
-
-if failed > 0:
-    if not Options.quiet:
-        output("%d of %d test%s failed%s%s%s" %
-               (failed, total, "s" if total > 1 else "", failed_expectedstr, skip, unstablestr))
-
-    if failed == failed_expected:
-        sys.exit(0)
-    else:
+    mgr = TestManager(address=addr)
+
+    try:
+        if Options.list:
+            for test in sorted(tests):
+                if test.name:
+                    print(test.name)
+            sys.exit(0)
+        else:
+            (succeeded, failed, skipped, unstable, failed_expected) = mgr.run(
+                copy.deepcopy(tests), output_handler
+            )
+            total = succeeded + failed + skipped
+
+        output_handler.finished()
+
+    # Ctrl-C can lead to broken pipe (e.g. FreeBSD), so include IOError here:
+    except (Abort, KeyboardInterrupt, OSError) as exc:
+        output_handler.finished()
+        print(str(exc) or "Aborted with %s." % type(exc).__name__, file=sys.stderr)
+        sys.stderr.flush()
+        # Explicitly shut down sync manager to avoid leaking manager
+        # processes, particularly with --abort-on-failure:
+        mgr.shutdown()
         sys.exit(1)
 
-elif skipped > 0 or unstable > 0:
-    if not Options.quiet:
-        output("%d test%s successful%s%s" %
-               (succeeded, "s" if succeeded != 1 else "", skip, unstablestr))
+    skip = (", %d skipped" % skipped) if skipped > 0 else ""
+    unstablestr = (", %d unstable" % unstable) if unstable > 0 else ""
+    failed_expectedstr = (
+        (" (with %d expected to fail)" % failed_expected) if failed_expected > 0 else ""
+    )
+
+    if failed > 0:
+        if not Options.quiet:
+            output(
+                "%d of %d test%s failed%s%s%s"
+                % (
+                    failed,
+                    total,
+                    "s" if total > 1 else "",
+                    failed_expectedstr,
+                    skip,
+                    unstablestr,
+                )
+            )
+
+        if failed == failed_expected:
+            sys.exit(0)
+        else:
+            sys.exit(1)
 
-    sys.exit(0)
+    elif skipped > 0 or unstable > 0:
+        if not Options.quiet:
+            output(
+                "%d test%s successful%s%s"
+                % (succeeded, "s" if succeeded != 1 else "", skip, unstablestr)
+            )
 
-else:
-    if not Options.quiet:
-        output("all %d tests successful" % total)
+        sys.exit(0)
 
-    sys.exit(0)
+    else:
+        if not Options.quiet:
+            output("all %d tests successful" % total)
+
+        sys.exit(0)
diff --git a/btest-diff b/btest-diff
index 47010f4..4e6af50 100755
--- a/btest-diff
+++ b/btest-diff
@@ -131,7 +131,11 @@ if [ "$#" -lt 1 ]; then
 fi
 
 # Split string with baseline directories into array.
-IFS=':' read -ra baseline_dirs <<<"$TEST_BASELINE"
+if [ "$(uname -s | cut -c 1-5)" == "MINGW" ]; then
+    IFS=';' read -ra baseline_dirs <<<"$TEST_BASELINE"
+else
+    IFS=':' read -ra baseline_dirs <<<"$TEST_BASELINE"
+fi
 
 input="$1"
 # shellcheck disable=SC2001
@@ -221,7 +225,7 @@ if [ -n "$baseline" ]; then
         if is_binary_mode; then
             diff -s "$@" "$canon_baseline" "$canon_output" >>$TEST_DIAGNOSTICS
         else
-            diff -au "$@" "$canon_baseline" "$canon_output" >>$TEST_DIAGNOSTICS
+            diff -au --strip-trailing-cr "$@" "$canon_baseline" "$canon_output" >>$TEST_DIAGNOSTICS
         fi
         result=$?
     fi
diff --git a/btest-setsid b/btest-setsid
index e46a685..8cd1bdb 100755
--- a/btest-setsid
+++ b/btest-setsid
@@ -5,11 +5,10 @@ import sys
 
 try:
     os.setsid()
-except:
+except Exception:
     pass
 
 prog = sys.argv[1]
-
 args = sys.argv[1:]
 
 os.execvp(prog, args)
diff --git a/debian/changelog b/debian/changelog
index 2fc6900..8367ded 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+btest (1.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 17 Apr 2023 16:47:00 -0000
+
 btest (0.72-1) unstable; urgency=medium
 
   * New upstream version 0.72
diff --git a/setup.cfg b/setup.cfg
index 8bfd5a1..d1c987d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,8 @@
+[flake8]
+max_line_length = 100
+ignore = E203,W503
+per-file-ignores = btest: E266
+
 [egg_info]
 tag_build = 
 tag_date = 0
diff --git a/setup.py b/setup.py
index 353c444..d99a6e4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,7 @@
 #! /usr/bin/env python
 
-from distutils.core import setup, Extension
+from setuptools import setup
+import sys
 
 # When making changes to the following list, remember to keep
 # CMakeLists.txt in sync.
@@ -21,26 +22,35 @@ scripts = [
 
 py_modules = ["btest-sphinx"]
 
+# We require the external multiprocess library on Windows due to pickling issues
+# with the standard one.
+if sys.platform == "win32":
+    install_requires = ["multiprocess"]
+else:
+    install_requires = []
+
 setup(
-    name='btest',
-    version="0.72",  # Filled in automatically.
-    description='A powerful system testing framework',
-    long_description='See https://github.com/zeek/btest',
-    author='Robin Sommer',
-    author_email='robin@icir.org',
-    url='https://github.com/zeek/btest',
+    name="btest",
+    version="1.0",  # Filled in automatically.
+    description="A powerful system testing framework",
+    long_description="See https://github.com/zeek/btest",
+    author="The Zeek Team",
+    author_email="info@zeek.org",
+    url="https://github.com/zeek/btest",
     scripts=scripts,
     package_dir={"": "sphinx"},
     py_modules=py_modules,
-    license='3-clause BSD License',
-    keywords='system tests testing framework baselines',
+    license="3-clause BSD License",
+    keywords="system tests testing framework baselines",
     classifiers=[
-        'Development Status :: 5 - Production/Stable',
-        'Environment :: Console',
-        'License :: OSI Approved :: BSD License',
-        'Operating System :: POSIX :: Linux',
-        'Operating System :: MacOS :: MacOS X',
-        'Programming Language :: Python :: 3',
-        'Topic :: Utilities',
+        "Development Status :: 5 - Production/Stable",
+        "Environment :: Console",
+        "License :: OSI Approved :: BSD License",
+        "Operating System :: POSIX :: Linux",
+        "Operating System :: MacOS :: MacOS X",
+        "Programming Language :: Python :: 3",
+        "Topic :: Utilities",
     ],
+    python_requires=">=3.7",
+    install_requires=install_requires,
 )
diff --git a/sphinx/btest-sphinx.py b/sphinx/btest-sphinx.py
index ae0303a..2973f2b 100644
--- a/sphinx/btest-sphinx.py
+++ b/sphinx/btest-sphinx.py
@@ -1,13 +1,12 @@
 import os
 import os.path
-import tempfile
 import subprocess
 import re
 
-from docutils import nodes, statemachine, utils
-from docutils.parsers.rst import directives, Directive, DirectiveError, Parser
-from docutils.transforms import TransformError, Transform
-from sphinx.util.console import bold, purple, darkgreen, red, term_width_line
+from docutils import nodes, utils
+from docutils.parsers.rst import directives, Directive, Parser
+from docutils.transforms import Transform
+from sphinx.util.console import darkgreen, red
 from sphinx.errors import SphinxError
 from sphinx.directives.code import LiteralInclude
 from sphinx.util import logging
@@ -44,7 +43,9 @@ def init(settings, reporter):
         raise SphinxError("error: btest_tests not set in config")
 
     if not os.path.exists(BTestBase):
-        raise SphinxError("error: btest_base directory '%s' does not exists" % BTestBase)
+        raise SphinxError(
+            "error: btest_base directory '%s' does not exists" % BTestBase
+        )
 
     joined = os.path.join(BTestBase, BTestTests)
 
@@ -68,7 +69,7 @@ def parsePartial(rawtext, settings):
     return document.children
 
 
-class Test(object):
+class Test:
     def __init__(self):
         self.has_run = False
 
@@ -85,7 +86,7 @@ class Test(object):
 
         try:
             subprocess.check_call("btest -S %s" % self.path, shell=True)
-        except (OSError, IOError, subprocess.CalledProcessError) as e:
+        except (OSError, subprocess.CalledProcessError) as e:
             # Equivalent to Directive.error(); we don't have an
             # directive object here and can't pass it in because
             # it doesn't pickle.
@@ -96,7 +97,6 @@ class Test(object):
 
 
 class BTestTransform(Transform):
-
     default_priority = 800
 
     def apply(self):
@@ -105,13 +105,13 @@ class BTestTransform(Transform):
 
         os.chdir(BTestBase)
 
-        if not test.tag in BTestTransform._run:
+        if test.tag not in BTestTransform._run:
             test.run()
             BTestTransform._run.add(test.tag)
 
         try:
             rawtext = open("%s#%d" % (test.rst_output, part)).read()
-        except IOError as e:
+        except OSError:
             rawtext = ""
 
         settings = self.document.settings
@@ -147,8 +147,7 @@ class BTest(Directive):
 
         tag = self.arguments[0]
 
-        if not tag in Tests:
-            import sys
+        if tag not in Tests:
             test = Test()
             test.tag = tag
             test.path = os.path.join(BTestTests, tag + ".btest")
@@ -181,7 +180,7 @@ class BTest(Directive):
 
 class BTestInclude(LiteralInclude):
     def __init__(self, *args, **kwargs):
-        super(BTestInclude, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
     def error(self, msg):
         self.state.document.settings.env.note_reread()
@@ -199,7 +198,9 @@ class BTestInclude(LiteralInclude):
 
         document = self.state.document
         if not document.settings.file_insertion_enabled:
-            return [document.reporter.warning('File insertion disabled', line=self.lineno)]
+            return [
+                document.reporter.warning("File insertion disabled", line=self.lineno)
+            ]
         env = document.settings.env
 
         expanded_arg = os.path.expandvars(self.arguments[0])
@@ -214,7 +215,8 @@ class BTestInclude(LiteralInclude):
         if ext in ExtMappings:
             self.options["language"] = ExtMappings[ext]
         else:
-            # Note that we always need to set a language, otherwise the lineos/emphasis don't seem to work.
+            # Note that we always need to set a language, otherwise the
+            # linenos/emphasis don't seem to work.
             self.options["language"] = "none"
 
         self.options["linenos"] = True
@@ -222,7 +224,7 @@ class BTestInclude(LiteralInclude):
         self.options["emphasize-lines"] = "1,1"
         self.options["style"] = "X"
 
-        retnode = super(BTestInclude, self).run()
+        retnode = super().run()
 
         os.chdir(BTestBase)
 
@@ -234,7 +236,7 @@ class BTestInclude(LiteralInclude):
         if tag.startswith("_"):
             tag = tag[1:]
 
-        test_path = ("include-" + tag + ".btest")
+        test_path = "include-" + tag + ".btest"
 
         if BTestTests:
             test_path = os.path.join(BTestTests, test_path)
@@ -265,14 +267,14 @@ class BTestInclude(LiteralInclude):
         return retnode
 
 
-directives.register_directive('btest', BTest)
-directives.register_directive('btest-include', BTestInclude)
+directives.register_directive("btest", BTest)
+directives.register_directive("btest-include", BTestInclude)
 
 
 def setup(app):
     global App
     App = app
 
-    app.add_config_value('btest_base', None, 'env')
-    app.add_config_value('btest_tests', None, 'env')
-    app.add_config_value('btest_tmp', None, 'env')
+    app.add_config_value("btest_base", None, "env")
+    app.add_config_value("btest_tests", None, "env")
+    app.add_config_value("btest_tmp", None, "env")
diff --git a/sphinx/btest.egg-info/PKG-INFO b/sphinx/btest.egg-info/PKG-INFO
index 20063d0..9f2a7a9 100644
--- a/sphinx/btest.egg-info/PKG-INFO
+++ b/sphinx/btest.egg-info/PKG-INFO
@@ -1,13 +1,12 @@
 Metadata-Version: 2.1
 Name: btest
-Version: 0.72
+Version: 1.0
 Summary: A powerful system testing framework
 Home-page: https://github.com/zeek/btest
-Author: Robin Sommer
-Author-email: robin@icir.org
+Author: The Zeek Team
+Author-email: info@zeek.org
 License: 3-clause BSD License
 Keywords: system tests testing framework baselines
-Platform: UNKNOWN
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Environment :: Console
 Classifier: License :: OSI Approved :: BSD License
@@ -15,7 +14,7 @@ Classifier: Operating System :: POSIX :: Linux
 Classifier: Operating System :: MacOS :: MacOS X
 Classifier: Programming Language :: Python :: 3
 Classifier: Topic :: Utilities
+Requires-Python: >=3.7
 License-File: COPYING
 
 See https://github.com/zeek/btest
-
diff --git a/sphinx/btest.egg-info/SOURCES.txt b/sphinx/btest.egg-info/SOURCES.txt
index 2e1e42f..c2e8ca3 100644
--- a/sphinx/btest.egg-info/SOURCES.txt
+++ b/sphinx/btest.egg-info/SOURCES.txt
@@ -1,6 +1,5 @@
 CHANGES
 COPYING
-MANIFEST
 MANIFEST.in
 Makefile
 README
@@ -14,6 +13,7 @@ btest-diff
 btest-progress
 btest-setsid
 btest.cfg.example
+setup.cfg
 setup.py
 Baseline/examples.t4/dots
 Baseline/examples.t5/output
@@ -54,10 +54,30 @@ sphinx/btest.egg-info/PKG-INFO
 sphinx/btest.egg-info/SOURCES.txt
 sphinx/btest.egg-info/dependency_links.txt
 sphinx/btest.egg-info/top_level.txt
+testing/.btest.failed.dat
 testing/.gitignore
 testing/Makefile
 testing/btest.cfg
 testing/btest.tests.cfg
+testing/.tmp/tests.unstable/.btest.failed.dat
+testing/.tmp/tests.unstable/.diag
+testing/.tmp/tests.unstable/.log
+testing/.tmp/tests.unstable/.stderr
+testing/.tmp/tests.unstable/.stdout
+testing/.tmp/tests.unstable/btest.cfg
+testing/.tmp/tests.unstable/output
+testing/.tmp/tests.unstable/persist
+testing/.tmp/tests.unstable/test1
+testing/.tmp/tests.unstable/unstable.test
+testing/.tmp/tests.unstable/.tmp/test1/.diag
+testing/.tmp/tests.unstable/.tmp/test1/.log
+testing/.tmp/tests.unstable/.tmp/test1/.stderr
+testing/.tmp/tests.unstable/.tmp/test1/.stdout
+testing/.tmp/tests.unstable/.tmp/test1/output
+testing/.tmp/tests.unstable/.tmp/test1/single-output1
+testing/.tmp/tests.unstable/.tmp/test1/single-output2
+testing/.tmp/tests.unstable/.tmp/test1/test1
+testing/.tmp/tests.unstable/Baseline/test1/output
 testing/Baseline/tests.abort-on-failure/output
 testing/Baseline/tests.abort-on-failure-with-only-known-fails/output
 testing/Baseline/tests.alternatives-environment/child-output
@@ -68,6 +88,7 @@ testing/Baseline/tests.alternatives-keywords/output
 testing/Baseline/tests.alternatives-substitution/child-output
 testing/Baseline/tests.alternatives-substitution/output
 testing/Baseline/tests.alternatives-testbase/output
+testing/Baseline/tests.alternatives-undefined/output
 testing/Baseline/tests.brief/out1
 testing/Baseline/tests.brief/out2
 testing/Baseline/tests.btest-cfg/abspath
@@ -86,6 +107,7 @@ testing/Baseline/tests.diff-max-lines/output2
 testing/Baseline/tests.doc/md
 testing/Baseline/tests.doc/rst
 testing/Baseline/tests.environment/output
+testing/Baseline/tests.environment-windows/output
 testing/Baseline/tests.exit-codes/out1
 testing/Baseline/tests.exit-codes/out2
 testing/Baseline/tests.groups/output
@@ -112,6 +134,7 @@ testing/Baseline/tests.quiet/out2
 testing/Baseline/tests.requires/output
 testing/Baseline/tests.requires-with-start-next/output
 testing/Baseline/tests.rerun/output
+testing/Baseline/tests.set-key/output
 testing/Baseline/tests.sphinx.rst-cmd/output
 testing/Baseline/tests.sphinx.run-sphinx/_build.text.index.txt
 testing/Baseline/tests.start-file/output
@@ -129,6 +152,8 @@ testing/Baseline/tests.threads/output.j5
 testing/Baseline/tests.tracing/output
 testing/Baseline/tests.unstable/output
 testing/Baseline/tests.unstable-dir/output
+testing/Baseline/tests.unstable-subtest/.stderr
+testing/Baseline/tests.unstable-subtest/diag
 testing/Baseline/tests.verbose/output
 testing/Baseline/tests.versioning/output
 testing/Baseline/tests.xml/output-j2.xml
@@ -141,8 +166,10 @@ testing/Files/local_alternative/tests/local-alternative-found.test
 testing/Files/local_alternative/tests/local-alternative-show-env.test
 testing/Files/local_alternative/tests/local-alternative-show-test-baseline.test
 testing/Files/local_alternative/tests/local-alternative-show-testbase.test
+testing/Scripts/convert-path-list.sh
 testing/Scripts/diff-remove-abspath
 testing/Scripts/dummy-script
+testing/Scripts/is-windows
 testing/Scripts/script-command
 testing/Scripts/strip-iso8601-date
 testing/Scripts/strip-test-base
@@ -159,6 +186,7 @@ testing/tests/alternatives-reread-config-baselinedir.test
 testing/tests/alternatives-reread-config.test
 testing/tests/alternatives-substitution.test
 testing/tests/alternatives-testbase.test
+testing/tests/alternatives-undefined.test
 testing/tests/baseline-dir-env.test
 testing/tests/basic-fail.test
 testing/tests/basic-succeed.test
@@ -180,6 +208,8 @@ testing/tests/diff-max-lines.test
 testing/tests/diff.test
 testing/tests/doc.test
 testing/tests/duplicate-selection.test
+testing/tests/env-var-casing.test
+testing/tests/environment-windows.test
 testing/tests/environment.test
 testing/tests/exit-codes.test
 testing/tests/finalizer.test
@@ -208,6 +238,7 @@ testing/tests/quiet.test
 testing/tests/requires-with-start-next.test
 testing/tests/requires.test
 testing/tests/rerun.test
+testing/tests/set-key.test
 testing/tests/start-file.test
 testing/tests/start-next-dir.test
 testing/tests/start-next-naming.test
@@ -221,6 +252,7 @@ testing/tests/threads.test
 testing/tests/tmps.test
 testing/tests/tracing.test
 testing/tests/unstable-dir.test
+testing/tests/unstable-subtest.test
 testing/tests/unstable.test
 testing/tests/verbose.test
 testing/tests/versioning.test
diff --git a/testing/.btest.failed.dat b/testing/.btest.failed.dat
new file mode 100644
index 0000000..e69de29
diff --git a/testing/.tmp/tests.unstable/.btest.failed.dat b/testing/.tmp/tests.unstable/.btest.failed.dat
new file mode 100644
index 0000000..a5bce3f
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.btest.failed.dat
@@ -0,0 +1 @@
+test1
diff --git a/testing/.tmp/tests.unstable/.diag b/testing/.tmp/tests.unstable/.diag
new file mode 100644
index 0000000..723d19b
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.diag
@@ -0,0 +1,8 @@
+== File ===============================
+test1 ... failed
+test1 ... failed on retry #1
+test1 ... failed on retry #2
+test1 ... ok on retry #3, unstable
+0 tests successful, 1 unstable
+== Diff ===============================
+=======================================
diff --git a/testing/.tmp/tests.unstable/.log b/testing/.tmp/tests.unstable/.log
new file mode 100644
index 0000000..8c1bd39
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.log
@@ -0,0 +1,3 @@
+test -f btest.cfg || cp /Users/tim/Desktop/projects/zeek-master/auxil/btest/testing/btest.tests.cfg btest.cfg; echo >/dev/null tests.unstable (expect success)
+btest -z 4 test1 >output 2>&1 (expect success)
+btest-diff output (expect success)
diff --git a/testing/.tmp/tests.unstable/.stderr b/testing/.tmp/tests.unstable/.stderr
new file mode 100644
index 0000000..e69de29
diff --git a/testing/.tmp/tests.unstable/.stdout b/testing/.tmp/tests.unstable/.stdout
new file mode 100644
index 0000000..e69de29
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/.diag b/testing/.tmp/tests.unstable/.tmp/test1/.diag
new file mode 100644
index 0000000..e0ae511
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/.diag
@@ -0,0 +1,11 @@
+== File ===============================
+ran
+more
+ran
+more
+ran
+more
+ran
+more
+== Diff ===============================
+=======================================
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/.log b/testing/.tmp/tests.unstable/.tmp/test1/.log
new file mode 100644
index 0000000..253445a
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/.log
@@ -0,0 +1,4 @@
+cat single-output1 >> ../../persist (expect success)
+cat single-output2 >> ../../persist (expect success)
+cat ../../persist > output (expect success)
+btest-diff output (expect success)
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/.stderr b/testing/.tmp/tests.unstable/.tmp/test1/.stderr
new file mode 100644
index 0000000..e69de29
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/.stdout b/testing/.tmp/tests.unstable/.tmp/test1/.stdout
new file mode 100644
index 0000000..e69de29
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/output b/testing/.tmp/tests.unstable/.tmp/test1/output
new file mode 100644
index 0000000..158b498
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/output
@@ -0,0 +1,8 @@
+ran
+more
+ran
+more
+ran
+more
+ran
+more
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/single-output1 b/testing/.tmp/tests.unstable/.tmp/test1/single-output1
new file mode 100644
index 0000000..817c028
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/single-output1
@@ -0,0 +1 @@
+ran
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/single-output2 b/testing/.tmp/tests.unstable/.tmp/test1/single-output2
new file mode 100644
index 0000000..ef49dd8
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/single-output2
@@ -0,0 +1 @@
+more
diff --git a/testing/.tmp/tests.unstable/.tmp/test1/test1 b/testing/.tmp/tests.unstable/.tmp/test1/test1
new file mode 100644
index 0000000..9ed5503
--- /dev/null
+++ b/testing/.tmp/tests.unstable/.tmp/test1/test1
@@ -0,0 +1,6 @@
+
+
+@TEST-EXEC: cat single-output1 >> ../../persist
+@TEST-EXEC: cat single-output2 >> ../../persist
+@TEST-EXEC: cat ../../persist > output
+@TEST-EXEC: btest-diff output
diff --git a/testing/.tmp/tests.unstable/Baseline/test1/output b/testing/.tmp/tests.unstable/Baseline/test1/output
new file mode 100644
index 0000000..158b498
--- /dev/null
+++ b/testing/.tmp/tests.unstable/Baseline/test1/output
@@ -0,0 +1,8 @@
+ran
+more
+ran
+more
+ran
+more
+ran
+more
diff --git a/testing/.tmp/tests.unstable/btest.cfg b/testing/.tmp/tests.unstable/btest.cfg
new file mode 100644
index 0000000..ea4be91
--- /dev/null
+++ b/testing/.tmp/tests.unstable/btest.cfg
@@ -0,0 +1,31 @@
+#
+# Configuration file used by individual tests.
+#
+# This is set so that all files will be created inside the current
+# sandbox.
+
+[btest]
+TmpDir      = `echo .tmp`
+BaselineDir = %(testbase)s/Baseline
+
+[environment]
+ORIGPATH=%(default_path)s
+ENV1=Foo
+ENV2=%(testbase)s
+ENV3=`expr 42`
+ENV4=`echo \(%(testbase)s=%(testbase)s\)`
+
+[environment-foo]
+FOO=BAR
+
+[filter-foo]
+cat=%(testbase)s/../../Scripts/test-filter
+
+[substitution-foo]
+printf=printf 'Hello, %%s'
+
+[environment-foo2]
+FOO2=`echo BAR2`
+
+[environment-local]
+BTEST_TEST_BASE=local_alternative
diff --git a/testing/.tmp/tests.unstable/output b/testing/.tmp/tests.unstable/output
new file mode 100644
index 0000000..b0379a4
--- /dev/null
+++ b/testing/.tmp/tests.unstable/output
@@ -0,0 +1,5 @@
+test1 ... failed
+test1 ... failed on retry #1
+test1 ... failed on retry #2
+test1 ... ok on retry #3, unstable
+0 tests successful, 1 unstable
diff --git a/testing/.tmp/tests.unstable/persist b/testing/.tmp/tests.unstable/persist
new file mode 100644
index 0000000..158b498
--- /dev/null
+++ b/testing/.tmp/tests.unstable/persist
@@ -0,0 +1,8 @@
+ran
+more
+ran
+more
+ran
+more
+ran
+more
diff --git a/testing/.tmp/tests.unstable/test1 b/testing/.tmp/tests.unstable/test1
new file mode 100644
index 0000000..99243ec
--- /dev/null
+++ b/testing/.tmp/tests.unstable/test1
@@ -0,0 +1,12 @@
+@TEST-START-FILE single-output1
+ran
+@TEST-END-FILE
+
+@TEST-START-FILE single-output2
+more
+@TEST-END-FILE
+
+@TEST-EXEC: cat single-output1 >> ../../persist
+@TEST-EXEC: cat single-output2 >> ../../persist
+@TEST-EXEC: cat ../../persist > output
+@TEST-EXEC: btest-diff output
diff --git a/testing/.tmp/tests.unstable/unstable.test b/testing/.tmp/tests.unstable/unstable.test
new file mode 100644
index 0000000..f2be8d3
--- /dev/null
+++ b/testing/.tmp/tests.unstable/unstable.test
@@ -0,0 +1,4 @@
+# %TEST-EXEC: btest -z 4 test1 >output 2>&1
+# %TEST-EXEC: btest-diff output
+
+
diff --git a/testing/Baseline/tests.alternatives-environment/child-output b/testing/Baseline/tests.alternatives-environment/child-output
index 839ecae..4b12b17 100644
--- a/testing/Baseline/tests.alternatives-environment/child-output
+++ b/testing/Baseline/tests.alternatives-environment/child-output
@@ -15,5 +15,8 @@ Foo: BAR
 Foo2: 
 -------------
 Foo: 
+Foo2: 
+-------------
+Foo: 
 Foo2: BAR2
 -------------
diff --git a/testing/Baseline/tests.alternatives-environment/output b/testing/Baseline/tests.alternatives-environment/output
index 3366bb0..1891806 100644
--- a/testing/Baseline/tests.alternatives-environment/output
+++ b/testing/Baseline/tests.alternatives-environment/output
@@ -7,5 +7,6 @@ alternatives-environment [foo] ... ok
 alternatives-environment [foo2] ... ok
 all 2 tests successful
 alternatives-environment [foo] ... ok
+alternatives-environment ... ok
 alternatives-environment [foo2] ... ok
-all 2 tests successful
+all 3 tests successful
diff --git a/testing/Baseline/tests.alternatives-keywords/output b/testing/Baseline/tests.alternatives-keywords/output
index ef23d0b..de86cfe 100644
--- a/testing/Baseline/tests.alternatives-keywords/output
+++ b/testing/Baseline/tests.alternatives-keywords/output
@@ -8,6 +8,3 @@ all 2 tests successful
 foo1 [foo] ... ok
 notdefault1 [foo] ... ok
 all 2 tests successful
-notdefault1 [notexist] ... ok
-notfoo1 [notexist] ... ok
-all 2 tests successful
diff --git a/testing/Baseline/tests.alternatives-undefined/output b/testing/Baseline/tests.alternatives-undefined/output
new file mode 100644
index 0000000..ee5df91
--- /dev/null
+++ b/testing/Baseline/tests.alternatives-undefined/output
@@ -0,0 +1,2 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+alternative "nonexistant" is undefined
diff --git a/testing/Baseline/tests.environment-windows/output b/testing/Baseline/tests.environment-windows/output
new file mode 100644
index 0000000..e58fc44
--- /dev/null
+++ b/testing/Baseline/tests.environment-windows/output
@@ -0,0 +1,25 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+Foo
+testbase is correct
+42
+macro expansion within backticks is correct
+default_path is correct
+<...>/.tmp/tests.environment-windows/.tmp/environment-windows/.diag
+TEST
+<...>/.tmp/tests.environment-windows/Baseline/environment-windows
+environment-windows
+<...>/.tmp/tests.environment-windows/.tmp/environment-windows/.verbose
+<...>/.tmp/tests.environment-windows
+1
+Foo
+testbase is correct
+42
+macro expansion within backticks is correct
+default_path is correct
+<...>/.tmp/tests.environment-windows/.tmp/environment-windows/.diag
+UPDATE
+<...>/.tmp/tests.environment-windows/Baseline/environment-windows
+environment-windows
+<...>/.tmp/tests.environment-windows/.tmp/environment-windows/.verbose
+<...>/.tmp/tests.environment-windows
+1
diff --git a/testing/Baseline/tests.set-key/output b/testing/Baseline/tests.set-key/output
new file mode 100644
index 0000000..5222ee0
--- /dev/null
+++ b/testing/Baseline/tests.set-key/output
@@ -0,0 +1,4 @@
+normal
+test
+test2
+equals=ok
diff --git a/testing/Baseline/tests.unstable-subtest/.stderr b/testing/Baseline/tests.unstable-subtest/.stderr
new file mode 100644
index 0000000..7f47880
--- /dev/null
+++ b/testing/Baseline/tests.unstable-subtest/.stderr
@@ -0,0 +1,6 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+test ... ok
+test-2 ... ok
+test-3 ... failed
+test-3 ... failed on retry #1
+1 of 3 tests failed
diff --git a/testing/Baseline/tests.unstable-subtest/diag b/testing/Baseline/tests.unstable-subtest/diag
new file mode 100644
index 0000000..f2fc1fb
--- /dev/null
+++ b/testing/Baseline/tests.unstable-subtest/diag
@@ -0,0 +1,8 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+== File ===============================
+ccc
+== Diff ===============================
+@@ -1 +1 @@
+-ddd
++ccc
+=======================================
diff --git a/testing/Scripts/convert-path-list.sh b/testing/Scripts/convert-path-list.sh
new file mode 100644
index 0000000..15dce13
--- /dev/null
+++ b/testing/Scripts/convert-path-list.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+# This script is used by tests.environment-windows to convert a semi-colon
+# separated list of Windows-style paths into a colon-separate list of
+# POSIX-style paths.
+
+new_list=""
+
+IFS=';' read -ra PARTS <<<"$1"
+for i in "${PARTS[@]}"; do
+    p=$(cygpath "${i}" | sed 's/\/$//')
+    new_list+=$p
+    new_list+=":"
+done
+
+echo ${new_list%?}
diff --git a/testing/Scripts/diff-remove-abspath b/testing/Scripts/diff-remove-abspath
index 361ad3f..93ca4ee 100755
--- a/testing/Scripts/diff-remove-abspath
+++ b/testing/Scripts/diff-remove-abspath
@@ -2,4 +2,4 @@
 #
 # Replace absolute paths with the basename.
 
-sed 's#/\([^/]\{1,\}/\)\{1,\}\([^/]\{1,\}\)#<...>/\2#g'
+sed 's#[a-zA-Z:]*/\([^/]\{1,\}/\)\{1,\}\([^/]\{1,\}\)#<...>/\2#g'
diff --git a/testing/Scripts/is-windows b/testing/Scripts/is-windows
new file mode 100644
index 0000000..d1b9a85
--- /dev/null
+++ b/testing/Scripts/is-windows
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+case "$OSTYPE" in
+    msys*)
+        exit 0
+        ;;
+    cygwin*)
+        exit 0
+        ;;
+    *)
+        exit 1
+        ;;
+esac
diff --git a/testing/Scripts/script-command b/testing/Scripts/script-command
index 591a3c6..f410ecb 100755
--- a/testing/Scripts/script-command
+++ b/testing/Scripts/script-command
@@ -1,10 +1,14 @@
-# This is a wrapper for the "script" command, which has different
-# options depending on the OS.
+# This is a wrapper for the "script" command, which has different options
+# depending on the OS. "script" can have side-effects on the current terminal
+# when invoked, breaking some carriage returns. Closing its stdin seems to
+# prevent that.
 
-if ! script -q -c ls /dev/null >/dev/null 2>&1; then
-    # FreeBSD and macOS
-    script -q /dev/null $@
-else
-    # Linux
-    script -qfc "$@" /dev/null
-fi
+true | {
+    if ! script -q -c ls /dev/null >/dev/null 2>&1; then
+        # FreeBSD and macOS
+        script -q /dev/null $@
+    else
+        # Linux
+        script -qfc "$@" /dev/null
+    fi
+}
diff --git a/testing/Scripts/strip-test-base b/testing/Scripts/strip-test-base
index 867e39d..7c023cc 100755
--- a/testing/Scripts/strip-test-base
+++ b/testing/Scripts/strip-test-base
@@ -3,4 +3,13 @@
 
 dir=$(dirname "$0")
 testbase=$(cd "$dir/.." && pwd)
+SCRIPTS="$(dirname -- "${BASH_SOURCE[0]}")"
+
+# shellcheck disable=SC2086
+# shellcheck disable=SC2157
+if [ ${SCRIPTS}/is-windows ]; then
+    # shellcheck disable=SC2001
+    testbase=$(echo "${testbase}" | sed 's#/\([a-zA-Z]\)/\(.*\)#\u\1:/\2#')
+fi
+
 sed "s#${testbase}#<...>#g"
diff --git a/testing/btest.cfg b/testing/btest.cfg
index b8607a6..f8f252d 100644
--- a/testing/btest.cfg
+++ b/testing/btest.cfg
@@ -11,7 +11,7 @@ CommandPrefix = %%TEST-
 Initializer = test -f btest.cfg || cp %(testbase)s/btest.tests.cfg btest.cfg; echo >/dev/null
 
 [environment]
-PATH=%(testbase)s/..:%(testbase)s/../sphinx:%(testbase)s/Scripts:%(default_path)s
+PATH=%(testbase)s/..%(pathsep)s%(testbase)s/../sphinx%(pathsep)s%(testbase)s/Scripts%(pathsep)s%(default_path)s
 SCRIPTS=%(testbase)s/Scripts
 TMPDIR=%(testbase)s/.tmp
 # BTEST_CFG=%(testbase)s/btest.tests.cfg
diff --git a/testing/btest.tests.cfg b/testing/btest.tests.cfg
index ea4be91..f4335e5 100644
--- a/testing/btest.tests.cfg
+++ b/testing/btest.tests.cfg
@@ -4,6 +4,9 @@
 # This is set so that all files will be created inside the current
 # sandbox.
 
+[DEFAULT]
+override=normal
+
 [btest]
 TmpDir      = `echo .tmp`
 BaselineDir = %(testbase)s/Baseline
@@ -14,6 +17,7 @@ ENV1=Foo
 ENV2=%(testbase)s
 ENV3=`expr 42`
 ENV4=`echo \(%(testbase)s=%(testbase)s\)`
+ENV5=%(override)s
 
 [environment-foo]
 FOO=BAR
diff --git a/testing/tests/alternatives-keywords.test b/testing/tests/alternatives-keywords.test
index aa79054..842002d 100644
--- a/testing/tests/alternatives-keywords.test
+++ b/testing/tests/alternatives-keywords.test
@@ -1,7 +1,6 @@
 # %TEST-EXEC: btest foo1 default1 notfoo1 notdefault1 >>output 2>&1
 # %TEST-EXEC: btest -a - foo1 default1 notfoo1 notdefault1 >>output 2>&1
 # %TEST-EXEC: btest -a foo foo1 default1 notfoo1 notdefault1 >>output 2>&1
-# %TEST-EXEC: btest -a notexist foo1 default1 notfoo1 notdefault1 >>output 2>&1
 # %TEST-EXEC: btest-diff output
 
 %TEST-START-FILE foo1
diff --git a/testing/tests/alternatives-undefined.test b/testing/tests/alternatives-undefined.test
new file mode 100644
index 0000000..0ee7c24
--- /dev/null
+++ b/testing/tests/alternatives-undefined.test
@@ -0,0 +1,6 @@
+# %TEST-DOC: This verifies that btest errors out when provided an undefined alternative.
+# %TEST-EXEC-FAIL: btest -a nonexistant %INPUT >output 2>&1
+# %TEST-EXEC: btest-diff output
+# %TEST-EXEC-FAIL: test -f child-output
+
+@TEST-EXEC: echo 'Hello world' >../../child-output
diff --git a/testing/tests/env-var-casing.test b/testing/tests/env-var-casing.test
new file mode 100644
index 0000000..ded6c74
--- /dev/null
+++ b/testing/tests/env-var-casing.test
@@ -0,0 +1,17 @@
+# %TEST-DOC: Validates that env vars are case-sensitive; this is a regression test for #75. Environment variables on Windows are always uppercase, due to legacy DOS requirements. This test will be skipped on that platform.
+#
+# %TEST-REQUIRES: ! ${SCRIPTS}/is-windows
+# %TEST-EXEC: http_proxy=aaa HTTP_PROXY=bbb btest -dv test
+
+# %TEST-START-FILE btest.cfg
+[environment]
+http_PROXY=ccc
+Http_Proxy=ddd
+# %TEST-END-FILE
+
+# %TEST-START-FILE test
+# @TEST-EXEC: env | grep http_proxy=aaa
+# @TEST-EXEC: env | grep HTTP_PROXY=bbb
+# @TEST-EXEC: env | grep http_PROXY=ccc
+# @TEST-EXEC: env | grep Http_Proxy=ddd
+# %TEST-END-FILE
diff --git a/testing/tests/environment-windows.test b/testing/tests/environment-windows.test
new file mode 100644
index 0000000..aaf8871
--- /dev/null
+++ b/testing/tests/environment-windows.test
@@ -0,0 +1,23 @@
+# %TEST-REQUIRES: ${SCRIPTS}/is-windows
+# %TEST-EXEC: btest -d %INPUT
+# %TEST-EXEC: btest -U %INPUT
+# %TEST-EXEC: btest-diff output
+
+@TEST-REQUIRES: test -n "${ENV2}"
+@TEST-EXEC-FAIL: test -z "${ENV2}"
+
+@TEST-EXEC: echo ${ENV1} >>../../output
+@TEST-EXEC: echo ${ENV2} >1
+@TEST-EXEC: set >>1
+@TEST-EXEC: test "${ENV2}" = `cd ../.. && pwd | cygpath -m -f -` && echo "testbase is correct" >>../../output
+@TEST-EXEC: echo ${ENV3} >>../../output
+@TEST-EXEC: test "${ENV4}" = "(${TEST_BASE}=${TEST_BASE})" && echo "macro expansion within backticks is correct" >>../../output
+@TEST-EXEC: test "`${SCRIPTS}/convert-path-list.sh \"${ORIGPATH}\"`" = "${PATH}" && echo "default_path is correct" >>../../output
+
+@TEST-EXEC: echo ${TEST_DIAGNOSTICS} | strip-test-base >>../../output
+@TEST-EXEC: echo ${TEST_MODE} >>../../output
+@TEST-EXEC: echo ${TEST_BASELINE} | strip-test-base >>../../output
+@TEST-EXEC: echo ${TEST_NAME} >>../../output
+@TEST-EXEC: echo ${TEST_VERBOSE} | strip-test-base >>../../output
+@TEST-EXEC: echo ${TEST_BASE} | strip-test-base >>../../output
+@TEST-EXEC: echo ${TEST_PART} >>../../output
diff --git a/testing/tests/environment.test b/testing/tests/environment.test
index 225463f..215eb8d 100644
--- a/testing/tests/environment.test
+++ b/testing/tests/environment.test
@@ -1,3 +1,4 @@
+# %TEST-REQUIRES: ! ${SCRIPTS}/is-windows
 # %TEST-EXEC: btest -d %INPUT
 # %TEST-EXEC: btest -U %INPUT
 # %TEST-EXEC: btest-diff output
diff --git a/testing/tests/multiple-baseline-dirs.test b/testing/tests/multiple-baseline-dirs.test
index d91661e..619d491 100644
--- a/testing/tests/multiple-baseline-dirs.test
+++ b/testing/tests/multiple-baseline-dirs.test
@@ -41,5 +41,5 @@
 
 %TEST-START-FILE btest.cfg
 [btest]
-BaselineDir = baseline1:baseline2:baseline3
+BaselineDir = baseline1%(pathsep)sbaseline2%(pathsep)sbaseline3
 %TEST-END-FILE
diff --git a/testing/tests/set-key.test b/testing/tests/set-key.test
new file mode 100644
index 0000000..69a9d59
--- /dev/null
+++ b/testing/tests/set-key.test
@@ -0,0 +1,7 @@
+# %TEST-EXEC: btest %INPUT
+# %TEST-EXEC: btest -s override=test %INPUT
+# %TEST-EXEC: btest --set=override=test2 %INPUT
+# %TEST-EXEC: btest -s override=equals=ok %INPUT
+# %TEST-EXEC: btest-diff output
+
+@TEST-EXEC: echo ${ENV5} >>../../output
diff --git a/testing/tests/sphinx/rst-cmd.sh b/testing/tests/sphinx/rst-cmd.sh
index fd5a462..d56c6a7 100755
--- a/testing/tests/sphinx/rst-cmd.sh
+++ b/testing/tests/sphinx/rst-cmd.sh
@@ -1,3 +1,4 @@
+# %TEST-REQUIRES: ! $SCRIPTS/is-windows
 # %TEST-EXEC: bash %INPUT
 
 %TEST-START-FILE file.txt
diff --git a/testing/tests/sphinx/run-sphinx b/testing/tests/sphinx/run-sphinx
index 765594f..f00eeb5 100644
--- a/testing/tests/sphinx/run-sphinx
+++ b/testing/tests/sphinx/run-sphinx
@@ -1,4 +1,5 @@
 # %TEST-REQUIRES: which sphinx-build
+# %TEST-REQUIRES: ! ${SCRIPTS}/is-windows
 #
 # %TEST-EXEC: cp -r %DIR/../../../examples/sphinx/* .
 # %TEST-EXEC: make clean && make text
diff --git a/testing/tests/tracing.test b/testing/tests/tracing.test
index 64c2ef8..19f3ea5 100644
--- a/testing/tests/tracing.test
+++ b/testing/tests/tracing.test
@@ -1,5 +1,5 @@
 # %TEST-EXEC-FAIL: btest -d --trace-file=trace.json t1 t2 t3
-# %TEST-EXEC: cat trace.json | python -c 'import json, sys; xs = json.load(sys.stdin); print(len(xs)); print(sorted([str(x) for x in xs[0].keys()]))' > output
+# %TEST-EXEC: cat trace.json | python3 -c 'import json, sys; xs = json.load(sys.stdin); print(len(xs)); print(sorted([str(x) for x in xs[0].keys()]))' > output
 # %TEST-EXEC: btest-diff output
 
 %TEST-START-FILE t1
diff --git a/testing/tests/unstable-subtest.test b/testing/tests/unstable-subtest.test
new file mode 100644
index 0000000..fe189e7
--- /dev/null
+++ b/testing/tests/unstable-subtest.test
@@ -0,0 +1,40 @@
+# This verifies that retried TEST-START-NEXT "subtests" run correctly.
+#
+# The following test contains a failure in its third (and last) subtest,
+# so the following will retry it once:
+# %TEST-EXEC-FAIL: btest -z 1 test
+#
+# The first and second subtests do not fail, so no output should remain.
+# (This used to be a bug: btest always retried the first subtest.)
+# %TEST-EXEC-FAIL: test -d .tmp/test
+# %TEST-EXEC-FAIL: test -d .tmp/test-2
+# %TEST-EXEC: test -d .tmp/test-3
+#
+# The retry's .diag for the third subtest shows the failure -- canonify it:
+# %TEST-EXEC: cat .tmp/test-3/.diag | grep -v '^---' | grep -v '^+++' >diag
+# %TEST-EXEC: btest-diff diag
+#
+# The toplevel .stderr traces executed subtests and their outcome:
+# %TEST-EXEC: btest-diff .stderr
+
+%TEST-START-FILE Baseline/test/output
+aaa
+%TEST-END-FILE
+
+%TEST-START-FILE Baseline/test-2/output
+bbb
+%TEST-END-FILE
+
+%TEST-START-FILE Baseline/test-3/output
+ddd
+%TEST-END-FILE
+
+%TEST-START-FILE test
+@TEST-EXEC: cat %INPUT | grep -v @ >output
+@TEST-EXEC: btest-diff output
+aaa
+@TEST-START-NEXT
+bbb
+@TEST-START-NEXT
+ccc
+%TEST-END-FILE
diff --git a/testing/tests/versioning.test b/testing/tests/versioning.test
index 599485c..b87e977 100644
--- a/testing/tests/versioning.test
+++ b/testing/tests/versioning.test
@@ -8,5 +8,5 @@
 %TEST-START-FILE btest.cfg
 [btest]
 TmpDir      = .tmp
-Minversion  = 99999.99
+MinVersion  = 99999.99
 %TEST-END-FILE

More details

Full run details