From 364c6a3d030200f70e385fa6a94dd8f43a2df422 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:03:41 +0200 Subject: [PATCH 1/3] testing: pin stdin closed while recording, so a golden is not a claim about a console `_run_subprocess` never set `stdin`, so a recorded case inherited the recorder's. For any CLI with an interactive path -- grub drops into a REPL when the query is omitted, `cw.confirm` asks a question -- that made the *recording itself* depend on where it was run: recorder's stdin recorded case ---------------- ------------------------------------------- a terminal returncode None, "cw.testing: timed out" /dev/null (CI, pytest) returncode 0, "prompt> read: " Both are wrong in the same way: neither is a fact about the CLI. The practical damage is a golden recorded in CI that fails when a developer replays it locally, for a reason that has nothing to do with the command line -- which is exactly the class of failure RECORDING_ENV exists to remove ("to remove a source of variation between two machines"). stdin belongs in that list, and it was missing. `capture()` gets the same treatment on descriptor 0, saved and restored beside 1 and 2. The module argues that using one mechanism for both sides of a diff is what makes the diff mean something; that only holds if the two recording mechanisms agree about stdin too. Found while migrating `grub`, whose headline behaviour is the no-query REPL and which therefore could not be characterized at all from a terminal. The regression test records the same case twice under two different stdins -- an open pipe nobody writes to, then /dev/null -- and asserts the two recordings are equal. Verified it fails without the fix (returncode None + timeout vs 0 + EOF). Note the more obvious test, "a stdin-reading command records EOF", passes either way under pytest, because pytest already hands fd 0 a devnull; only the two-stdin comparison catches this. 874 passed. Parity gate: 8 shapes / 137 cases identical. Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- cw/testing.py | 26 +++++++++++++-- tests/test_testing.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/cw/testing.py b/cw/testing.py index c95a7d7..57f1b18 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -55,8 +55,8 @@ Recorded text is stored newline-normalised (``\\r\\n`` and ``\\r`` both become ``\\n``) and every comparison normalises both sides, so a golden recorded on a Mac asserts cleanly on a Windows runner. The subprocess environment pins ``COLUMNS``, ``PYTHONUTF8``, -``PYTHONIOENCODING``, ``PYTHONHASHSEED`` and ``TERM`` so the bytes are reproducible rather -than merely comparable. :func:`read_cases` reads a JSON-list form as well as a ``shlex`` +``PYTHONIOENCODING``, ``PYTHONHASHSEED`` and ``TERM``, and ``stdin`` is pinned closed, so +the bytes are reproducible rather than merely comparable. :func:`read_cases` reads a JSON-list form as well as a ``shlex`` line, because ``shlex`` is POSIX-only and a Windows user must never need it. And :func:`parity` spawns no subprocess at all -- it runs in-process against shipped fixtures -- so the ``.exe`` console-script shim and the cp1252 console never enter the picture. @@ -437,8 +437,14 @@ def capture(call) -> dict: _flush(sys.stdout, sys.stderr) with tempfile.TemporaryFile() as out_file, tempfile.TemporaryFile() as err_file: saved_fds = (os.dup(1), os.dup(2)) + # Descriptor 0 is pinned closed for the same reason the subprocess path pins it -- + # see _run_subprocess. A command with an interactive path must read EOF here, not + # whatever console the recorder happens to be attached to. + saved_stdin_fd = os.dup(0) + null_fd = os.open(os.devnull, os.O_RDONLY) trailer = "" try: + os.dup2(null_fd, 0) os.dup2(out_file.fileno(), 1) os.dup2(err_file.fileno(), 2) sys.stdout = _stream_on_fd(1) @@ -465,6 +471,9 @@ def capture(call) -> dict: for saved, fd in zip(saved_fds, (1, 2)): os.dup2(saved, fd) os.close(saved) + os.dup2(saved_stdin_fd, 0) + os.close(saved_stdin_fd) + os.close(null_fd) out_file.seek(0) err_file.seek(0) stdout = out_file.read().decode("utf-8", "replace") @@ -516,7 +525,17 @@ def _as_command(prog) -> list: def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: - """One case, run as a real subprocess. The heart of the standalone half.""" + """One case, run as a real subprocess. The heart of the standalone half. + + ``stdin`` is :data:`subprocess.DEVNULL`, not the recorder's own. A CLI with an + interactive path -- a REPL entered when a query is omitted, a ``confirm()`` prompt -- + reads stdin, and inheriting the recorder's makes the *recording itself* depend on where + it was run: at a terminal the child blocks on a prompt nobody answers and the case is + recorded as a timeout, while under CI or pytest the same case sees an immediate EOF and + records the real behaviour. A golden is a claim about a CLI, so it must not be a claim + about the console that recorded it; pinning stdin closed is the same measure as pinning + ``COLUMNS``, and it belongs for the same reason. + """ try: done = subprocess.run( command + list(argv), @@ -527,6 +546,7 @@ def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: env=env, cwd=cwd, timeout=timeout, + stdin=subprocess.DEVNULL, ) except subprocess.TimeoutExpired: return { diff --git a/tests/test_testing.py b/tests/test_testing.py index 2c3b8a3..6779a75 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -9,6 +9,7 @@ import io import json import os +import pathlib import subprocess import sys import textwrap @@ -403,6 +404,78 @@ def test_argcomplete_is_removed_so_it_cannot_hijack_a_recording(self): os.environ.pop("_ARGCOMPLETE", None) +class TestPinnedStdin: + """A recording must describe the CLI, not the console that recorded it. + + A CLI with an interactive path -- grub drops into a REPL when the query is omitted, + `cw.confirm` asks a question -- reads stdin. If the recorded child inherits the + recorder's stdin, the *same case* records two different facts: at a terminal the child + blocks on a prompt nobody will answer and the case is recorded as a timeout, while + under CI or pytest it sees an immediate EOF and records the real behaviour. Then a + golden recorded in CI fails when a developer replays it locally, for a reason that has + nothing to do with the CLI. + """ + + READS_STDIN = ( + 'import sys\n' + 'try:\n' + ' line = input("prompt> ")\n' + 'except EOFError:\n' + ' line = ""\n' + 'print("read:", line)\n' + ) + + @pytest.fixture + def reads_stdin(self, tmp_path): + path = tmp_path / "reads_stdin.py" + path.write_text(self.READS_STDIN, encoding="utf-8") + return [sys.executable, str(path)] + + def test_a_command_that_reads_stdin_records_eof_not_a_timeout(self, reads_stdin): + golden = testing.characterize(reads_stdin, [[]], timeout=10) + case = golden["cases"][0] + assert case["returncode"] == 0 + assert "" in case["stdout"] + + def test_the_recording_does_not_depend_on_the_recorder_s_own_stdin( + self, reads_stdin, tmp_path + ): + """Record the same case twice, under two different stdins, and compare.""" + driver = tmp_path / "driver.py" + driver.write_text( + "import json, sys\n" + f"sys.path.insert(0, {str(pathlib.Path(testing.__file__).parent.parent)!r})\n" + "from cw import testing\n" + f"golden = testing.characterize({reads_stdin!r}, [[]], timeout=10)\n" + "print(json.dumps(golden['cases'][0]))\n", + encoding="utf-8", + ) + + def record_with(stdin): + done = subprocess.run( + [sys.executable, str(driver)], + stdin=stdin, + capture_output=True, + text=True, + timeout=60, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + # An open pipe nobody writes to is what a terminal looks like to the child. + read_fd, write_fd = os.pipe() + try: + from_terminal = record_with(read_fd) + finally: + os.close(read_fd) + os.close(write_fd) + with open(os.devnull, "rb") as devnull: + from_devnull = record_with(devnull) + + assert from_terminal == from_devnull + assert from_terminal["returncode"] == 0 + + class TestExitStatus: """`capture` reproduces what the interpreter does with a `SystemExit`.""" From 1e43fc2a926bc3b5a46f5ac241da2b614f76e35e Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:09:57 +0200 Subject: [PATCH 2/3] testing: scrub Windows' .exe out of recorded text, so a Mac golden asserts on the runner The module promises that "a golden recorded on a Mac asserts cleanly on a Windows runner", and it did not. `argparse` takes its `prog` from `basename(sys.argv[0])`, and a console script is installed as `opsward.exe` on Windows, so the same CLI at the same commit reports usage: opsward [-h] {diagnose,...} on a Mac usage: opsward.EXE [-h] {diagnose,...} on the runner and every case that prints a usage line or an error prefix differs. In the migration that found this, that was 22 of 31 cases -- a wall of red saying nothing about the CLI. The docstring's Windows section actually named the `.exe` console-script shim as a reason `parity` avoids subprocesses. `characterize` and `replay` DO spawn subprocesses, and had no defence against the very thing that paragraph named. `scrub_exe_suffix` rewrites only the *program's own* stem, so a CLI that talks about some other `.exe` is untouched; the match is case-insensitive because the extension surfaces as `.EXE` on some Windows configurations and `.exe` on others, which would otherwise make two Windows runners disagree with each other. It is applied in `_run_subprocess`, the single choke point all three subprocess paths go through, at record time as well as replay time -- the same treatment, and for the same stated reason, as `scrub_addresses`. Two hazards worth keeping: the replacement is a function, not a template string, and the stem is split on both separators. A Windows path reaches this on a POSIX host through a golden's recorded `prog`, where `os.path.basename` does not split on a backslash and `re.sub` reads the leftover `\p` as a bad escape. The doctest found that on the first run. The test installs one toy CLI under two names, `toy` and `toy.EXE`, records from the first and replays against the second -- an exact simulation rather than a mock. Verified it fails without the fix. 877 passed. Parity gate: 8 shapes / 137 cases identical. No POSIX-recorded golden changes, since there is no `.exe` in one. Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- cw/testing.py | 56 +++++++++++++++++++++++++++++++++++++++---- tests/test_testing.py | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/cw/testing.py b/cw/testing.py index 57f1b18..18496ac 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -57,9 +57,13 @@ Windows runner. The subprocess environment pins ``COLUMNS``, ``PYTHONUTF8``, ``PYTHONIOENCODING``, ``PYTHONHASHSEED`` and ``TERM``, and ``stdin`` is pinned closed, so the bytes are reproducible rather than merely comparable. :func:`read_cases` reads a JSON-list form as well as a ``shlex`` -line, because ``shlex`` is POSIX-only and a Windows user must never need it. And -:func:`parity` spawns no subprocess at all -- it runs in-process against shipped fixtures -- -so the ``.exe`` console-script shim and the cp1252 console never enter the picture. +line, because ``shlex`` is POSIX-only and a Windows user must never need it. The ``.exe`` +a console script is installed as on Windows is scrubbed out of recorded text by +:func:`scrub_exe_suffix`, because ``argparse`` takes its ``prog`` from +``basename(sys.argv[0])`` and would otherwise report ``usage: opsward.EXE`` on the runner +and ``usage: opsward`` everywhere else. And :func:`parity` spawns no subprocess at all -- +it runs in-process against shipped fixtures -- so the console-script shim and the cp1252 +console never enter the picture there either. >>> normalise_text('a\\r\\nb\\r\\n') 'a\\nb\\n' @@ -92,6 +96,8 @@ "pinned_env", "read_cases", "replay", + "scrub_addresses", + "scrub_exe_suffix", ] #: Bumped when the golden JSON schema changes incompatibly. A golden that does not carry @@ -266,6 +272,45 @@ def scrub_addresses(text: str) -> str: return _ADDRESS.sub("0xADDR", text) if text else text +def scrub_exe_suffix(text: str, program: str) -> str: + """Strip Windows' ``.exe`` off the program name a CLI prints about itself. + + ``argparse`` derives ``prog`` from ``os.path.basename(sys.argv[0])``, and on Windows a + console script is installed as ``opsward.exe``. So the *same* CLI, at the same commit, + reports ``usage: opsward ...`` on a Mac and ``usage: opsward.EXE ...`` on a Windows + runner -- and every case that prints a usage line or an error prefix differs. That is a + fact about packaging, not about the command line, and this module promises that "a + golden recorded on a Mac asserts cleanly on a Windows runner". + + Only the *program's own* stem is rewritten, so a CLI that talks about some other + ``.exe`` is left alone. The match is case-insensitive because the shim's extension is + reported as ``.EXE`` on some Windows configurations and ``.exe`` on others -- which + would otherwise make two Windows runners disagree with each other. + + Both path separators are handled, and the replacement is a function rather than a + template string, because a Windows path reaches this on a POSIX host -- through a + golden's recorded ``prog`` -- where ``os.path.basename`` does not split on a backslash + and ``re.sub`` would read the remaining ``\\p`` as a bad escape. + + >>> scrub_exe_suffix('usage: opsward.EXE [-h]', r'C:\\Scripts\\opsward.exe') + 'usage: opsward [-h]' + >>> scrub_exe_suffix('usage: opsward [-h]', '/usr/local/bin/opsward') + 'usage: opsward [-h]' + >>> scrub_exe_suffix('run setup.exe first', '/usr/local/bin/opsward') + 'run setup.exe first' + """ + if not text or not program: + return text + stem = re.split(r"[\\/]", program)[-1] + if stem[-4:].lower() == ".exe": + stem = stem[:-4] + if not stem: + return text + return re.sub( + re.escape(stem) + r"\.exe\b", lambda _match: stem, text, flags=re.IGNORECASE + ) + + def _usage_of(stdout: str, stderr: str) -> str: """The normalised ``usage:`` line, from wherever the CLI put it. @@ -554,10 +599,11 @@ def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: "stdout": "", "stderr": f"cw.testing: timed out after {timeout}s\n", } + program = command[0] if command else "" return { "returncode": done.returncode, - "stdout": normalise_text(done.stdout), - "stderr": normalise_text(done.stderr), + "stdout": scrub_exe_suffix(normalise_text(done.stdout), program), + "stderr": scrub_exe_suffix(normalise_text(done.stderr), program), } diff --git a/tests/test_testing.py b/tests/test_testing.py index 6779a75..13034d6 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -476,6 +476,51 @@ def record_with(stdin): assert from_terminal["returncode"] == 0 +class TestWindowsConsoleScriptShim: + """A golden recorded on a Mac must assert on a Windows runner -- as promised. + + `argparse` takes its `prog` from `basename(sys.argv[0])`, and a console script is + installed as `opsward.exe` on Windows. Without scrubbing, the same CLI at the same + commit reports `usage: opsward ...` on a Mac and `usage: opsward.EXE ...` on the + runner, and every case that prints a usage line or an error prefix differs -- 22 of + 31 in the case that found this. + + The simulation is exact rather than mocked: the same toy CLI is installed under two + names, one with the Windows extension, and the POSIX-recorded golden is replayed + against the `.exe` one. + """ + + DERIVES_PROG = ( + "import argparse, sys\n" + "parser = argparse.ArgumentParser(description='A toy.')\n" + "parser.add_argument('name')\n" + "parser.parse_args()\n" + ) + + @pytest.fixture + def two_names(self, tmp_path): + """The same CLI as `toy` and as `toy.EXE`, both letting argparse derive prog.""" + posix = tmp_path / "toy" + posix.write_text("#!" + sys.executable + "\n" + self.DERIVES_PROG, encoding="utf-8") + posix.chmod(0o755) + windows = tmp_path / "toy.EXE" + windows.write_text(posix.read_text(encoding="utf-8"), encoding="utf-8") + windows.chmod(0o755) + return str(posix), str(windows) + + def test_the_exe_suffix_does_not_make_a_recording_os_specific(self, two_names): + posix, windows = two_names + cases = [["--help"], []] + golden = testing.characterize([posix], cases, timeout=30) + assert "toy.EXE" not in json.dumps(golden) + # Replaying the POSIX-recorded golden against the .exe shim must be clean. + testing.assert_replay(golden, prog=[windows], strict_help=True) + + def test_only_the_program_s_own_exe_is_scrubbed(self): + assert testing.scrub_exe_suffix("run setup.exe", "/bin/toy") == "run setup.exe" + assert testing.scrub_exe_suffix("toy.exe ran", "/bin/toy") == "toy ran" + + class TestExitStatus: """`capture` reproduces what the interpreter does with a `SystemExit`.""" From b906b514d02367d366274d5e8548ceb99a27e7b5 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:24:09 +0200 Subject: [PATCH 3/3] testing: make the .exe scrub work when the program is not the command's first word Two fixes to the previous commit, both surfaced by its own CI. 1. The regression test was POSIX-only. It wrote a shebang and chmod +x, which the Windows runner rejected with `OSError: [WinError 193] %1 is not a valid Win32 application` -- exactly the sort of assumption a class named for Windows should not be making. Both toys now run as `[sys.executable, path]`. 2. That fix exposed a real narrowness in `scrub_exe_suffix`: it derived the stem from `command[0]` alone, so `['python', 'toy.exe']` scrubbed nothing, because the program argparse names is the *second* word. It now considers every part of the command. A part that is not the program yields a stem appearing nowhere in the text, so considering it costs nothing -- and the test is now stronger than the one that failed, since it covers the harder shape rather than the easy one. 878 passed. Parity gate: 8 shapes / 137 cases identical. Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr --- cw/testing.py | 37 +++++++++++++++++++++++++------------ tests/test_testing.py | 41 ++++++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/cw/testing.py b/cw/testing.py index 18496ac..2e6f9e3 100644 --- a/cw/testing.py +++ b/cw/testing.py @@ -272,7 +272,7 @@ def scrub_addresses(text: str) -> str: return _ADDRESS.sub("0xADDR", text) if text else text -def scrub_exe_suffix(text: str, program: str) -> str: +def scrub_exe_suffix(text: str, program) -> str: """Strip Windows' ``.exe`` off the program name a CLI prints about itself. ``argparse`` derives ``prog`` from ``os.path.basename(sys.argv[0])``, and on Windows a @@ -287,6 +287,12 @@ def scrub_exe_suffix(text: str, program: str) -> str: reported as ``.EXE`` on some Windows configurations and ``.exe`` on others -- which would otherwise make two Windows runners disagree with each other. + ``program`` is the **whole command**, not just its first word, because the console + script is not always the first word: a caller may pass ``['python', 'toy.exe']`` as + readily as ``['opsward.exe']``, and ``argparse`` names whichever of them landed in + ``sys.argv[0]``. Every part is considered; a part that is not the program yields a stem + that appears nowhere in the text, so considering it costs nothing. + Both path separators are handled, and the replacement is a function rather than a template string, because a Windows path reaches this on a POSIX host -- through a golden's recorded ``prog`` -- where ``os.path.basename`` does not split on a backslash @@ -294,6 +300,8 @@ def scrub_exe_suffix(text: str, program: str) -> str: >>> scrub_exe_suffix('usage: opsward.EXE [-h]', r'C:\\Scripts\\opsward.exe') 'usage: opsward [-h]' + >>> scrub_exe_suffix('usage: toy.EXE [-h]', ['/usr/bin/python', '/tmp/toy.exe']) + 'usage: toy [-h]' >>> scrub_exe_suffix('usage: opsward [-h]', '/usr/local/bin/opsward') 'usage: opsward [-h]' >>> scrub_exe_suffix('run setup.exe first', '/usr/local/bin/opsward') @@ -301,14 +309,20 @@ def scrub_exe_suffix(text: str, program: str) -> str: """ if not text or not program: return text - stem = re.split(r"[\\/]", program)[-1] - if stem[-4:].lower() == ".exe": - stem = stem[:-4] - if not stem: - return text - return re.sub( - re.escape(stem) + r"\.exe\b", lambda _match: stem, text, flags=re.IGNORECASE - ) + parts = [program] if isinstance(program, str) else list(program) + for part in parts: + stem = re.split(r"[\\/]", str(part))[-1] + if stem[-4:].lower() == ".exe": + stem = stem[:-4] + if not stem: + continue + text = re.sub( + re.escape(stem) + r"\.exe\b", + lambda _match, matched=stem: matched, + text, + flags=re.IGNORECASE, + ) + return text def _usage_of(stdout: str, stderr: str) -> str: @@ -599,11 +613,10 @@ def _run_subprocess(command, argv, *, env, cwd, timeout) -> dict: "stdout": "", "stderr": f"cw.testing: timed out after {timeout}s\n", } - program = command[0] if command else "" return { "returncode": done.returncode, - "stdout": scrub_exe_suffix(normalise_text(done.stdout), program), - "stderr": scrub_exe_suffix(normalise_text(done.stderr), program), + "stdout": scrub_exe_suffix(normalise_text(done.stdout), command), + "stderr": scrub_exe_suffix(normalise_text(done.stderr), command), } diff --git a/tests/test_testing.py b/tests/test_testing.py index 13034d6..8854ac8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -485,13 +485,20 @@ class TestWindowsConsoleScriptShim: runner, and every case that prints a usage line or an error prefix differs -- 22 of 31 in the case that found this. - The simulation is exact rather than mocked: the same toy CLI is installed under two - names, one with the Windows extension, and the POSIX-recorded golden is replayed - against the `.exe` one. + The simulation is exact rather than mocked: the same toy CLI is written under two + names, one carrying the Windows extension, and the golden recorded from the plain one + is replayed against the `.exe` one. + + Both are run as `[sys.executable, path]` rather than as executables in their own + right. A first version wrote a shebang and `chmod +x`, which is exactly the sort of + POSIX assumption this class exists to catch -- it failed on the Windows runner with + `OSError: [WinError 193] %1 is not a valid Win32 application`. It also made the test + weaker than it looks: with the interpreter in front, the console script is *not* the + command's first word, so this now covers the harder shape too. """ DERIVES_PROG = ( - "import argparse, sys\n" + "import argparse\n" "parser = argparse.ArgumentParser(description='A toy.')\n" "parser.add_argument('name')\n" "parser.parse_args()\n" @@ -500,26 +507,30 @@ class TestWindowsConsoleScriptShim: @pytest.fixture def two_names(self, tmp_path): """The same CLI as `toy` and as `toy.EXE`, both letting argparse derive prog.""" - posix = tmp_path / "toy" - posix.write_text("#!" + sys.executable + "\n" + self.DERIVES_PROG, encoding="utf-8") - posix.chmod(0o755) + plain = tmp_path / "toy" + plain.write_text(self.DERIVES_PROG, encoding="utf-8") windows = tmp_path / "toy.EXE" - windows.write_text(posix.read_text(encoding="utf-8"), encoding="utf-8") - windows.chmod(0o755) - return str(posix), str(windows) + windows.write_text(self.DERIVES_PROG, encoding="utf-8") + return ( + [sys.executable, str(plain)], + [sys.executable, str(windows)], + ) def test_the_exe_suffix_does_not_make_a_recording_os_specific(self, two_names): - posix, windows = two_names - cases = [["--help"], []] - golden = testing.characterize([posix], cases, timeout=30) + plain, windows = two_names + golden = testing.characterize(plain, [["--help"], []], timeout=30) assert "toy.EXE" not in json.dumps(golden) - # Replaying the POSIX-recorded golden against the .exe shim must be clean. - testing.assert_replay(golden, prog=[windows], strict_help=True) + # Replaying the plain-name golden against the .exe shim must be clean. + testing.assert_replay(golden, prog=windows, strict_help=True) def test_only_the_program_s_own_exe_is_scrubbed(self): assert testing.scrub_exe_suffix("run setup.exe", "/bin/toy") == "run setup.exe" assert testing.scrub_exe_suffix("toy.exe ran", "/bin/toy") == "toy ran" + def test_the_program_need_not_be_the_command_s_first_word(self): + command = ["/usr/bin/python", "/tmp/toy.exe"] + assert testing.scrub_exe_suffix("usage: toy.EXE [-h]", command) == "usage: toy [-h]" + class TestExitStatus: """`capture` reproduces what the interpreter does with a `SystemExit`."""