mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
test(reset): select a working Bash instead of an unusable WSL launcher (#671)
* test(reset): probe Bash variable expansion on Windows Use a bounded variable-expansion probe so a launcher that starts successfully but consumes $x is rejected. Rebase over #676 and keep its upstream-checkout guard alongside Bash discovery. Thanks to MadsLorentzen for diagnosing the launcher expansion failure and verifying the stronger probe. * changelog: place the #671 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+118
-3
No files matched your search
@@ -263,6 +263,14 @@ per-file diff commands.
|
||||
`MadsLorentzen/ai-job-search/1.0 (freehire-search)`. The host-check test now lists `github.com` and
|
||||
`gist.github.com` postings among the hosts that must stay unverified.
|
||||
|
||||
- **The `/reset` replay tests find a Bash that actually runs the script on Windows** (#671,
|
||||
`tests/_bash.py`, `tests/test_bash.py`, `tests/test_reset_command.py`, `tests/test_reset_documents.py`) -
|
||||
the tests replayed the documented `/reset` commands with whichever `bash` came first on PATH. On
|
||||
Windows that is often the WSL launcher, which expands variables before WSL's bash sees them, so the
|
||||
replayed script deleted nothing and two tests failed. Each candidate is now probed with
|
||||
`x=ok; test "$x" = ok`, Git for Windows' own Bash is tried when the launcher fails the probe, and the
|
||||
replay checks skip only when no usable shell is found.
|
||||
|
||||
### Security
|
||||
|
||||
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Find a usable Bash for tests that replay the documented reset commands."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
def find_bash():
|
||||
candidates = [shutil.which("bash")]
|
||||
if WINDOWS:
|
||||
# A Windows PATH may find the WSL launcher even without a usable WSL
|
||||
# installation. Git for Windows also ships Bash beside its cmd/ entry
|
||||
# points, but does not normally add that bin/ directory to PATH.
|
||||
git = shutil.which("git")
|
||||
if git:
|
||||
candidates.append(str(Path(git).resolve().parent.parent / "bin" / "bash.exe"))
|
||||
for candidate in dict.fromkeys(candidates):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[candidate, "-c", 'x=ok; test "$x" = ok'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if result.returncode == 0:
|
||||
return candidate
|
||||
return None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""A Bash launcher on PATH is not necessarily a usable shell (Windows WSL)."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from tests import _bash
|
||||
except ImportError:
|
||||
import _bash
|
||||
|
||||
|
||||
class BashDiscoveryTests(unittest.TestCase):
|
||||
def test_working_path_shell_is_used(self):
|
||||
with mock.patch.object(_bash, "WINDOWS", False), \
|
||||
mock.patch.object(_bash.shutil, "which", return_value="/bin/bash"), \
|
||||
mock.patch.object(_bash.subprocess, "run", return_value=mock.Mock(returncode=0)) as run:
|
||||
self.assertEqual(_bash.find_bash(), "/bin/bash")
|
||||
self.assertEqual(run.call_args.args[0], ["/bin/bash", "-c", 'x=ok; test "$x" = ok'])
|
||||
|
||||
def test_launcher_that_expands_variables_falls_back_to_git_bash(self):
|
||||
git = Path(__file__).resolve().parent / "Git" / "cmd" / "git.exe"
|
||||
fallback = str(git.parent.parent / "bin" / "bash.exe")
|
||||
|
||||
def probe(args, **kwargs):
|
||||
script = args[2]
|
||||
if args[0] == "wsl-bash":
|
||||
# The launcher can exit successfully, but expands $x before
|
||||
# Bash assigns it, so the reset command cannot work.
|
||||
script = script.replace("$x", "")
|
||||
return subprocess.CompletedProcess(args, 0 if script in (
|
||||
"exit 0", 'x=ok; test "$x" = ok',
|
||||
) else 1)
|
||||
|
||||
with mock.patch.object(_bash, "WINDOWS", True), \
|
||||
mock.patch.object(_bash.shutil, "which", side_effect=["wsl-bash", str(git)]), \
|
||||
mock.patch.object(_bash.subprocess, "run", side_effect=probe):
|
||||
self.assertEqual(_bash.find_bash(), fallback)
|
||||
|
||||
def test_missing_shell_needs_no_probe(self):
|
||||
with mock.patch.object(_bash, "WINDOWS", False), \
|
||||
mock.patch.object(_bash.shutil, "which", return_value=None), \
|
||||
mock.patch.object(_bash.subprocess, "run") as run:
|
||||
self.assertIsNone(_bash.find_bash())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_unusable_path_shell_falls_back_to_git_bash_on_windows(self):
|
||||
git = Path(__file__).resolve().parent / "Git" / "cmd" / "git.exe"
|
||||
fallback = str(git.parent.parent / "bin" / "bash.exe")
|
||||
with mock.patch.object(_bash, "WINDOWS", True), \
|
||||
mock.patch.object(_bash.shutil, "which", side_effect=["wsl-bash", str(git)]), \
|
||||
mock.patch.object(_bash.subprocess, "run", side_effect=[
|
||||
mock.Mock(returncode=1), mock.Mock(returncode=0),
|
||||
]) as run:
|
||||
self.assertEqual(_bash.find_bash(), fallback)
|
||||
self.assertEqual([call.args[0][0] for call in run.call_args_list], ["wsl-bash", fallback])
|
||||
|
||||
def test_failed_or_hanging_shell_is_unavailable(self):
|
||||
for error in (OSError("cannot start"), subprocess.TimeoutExpired("bash", 5)):
|
||||
with self.subTest(error=type(error).__name__), \
|
||||
mock.patch.object(_bash, "WINDOWS", False), \
|
||||
mock.patch.object(_bash.shutil, "which", return_value="bash"), \
|
||||
mock.patch.object(_bash.subprocess, "run", side_effect=error):
|
||||
self.assertIsNone(_bash.find_bash())
|
||||
|
||||
def test_nonzero_exit_without_a_fallback_is_unavailable(self):
|
||||
with mock.patch.object(_bash, "WINDOWS", False), \
|
||||
mock.patch.object(_bash.shutil, "which", return_value="bash"), \
|
||||
mock.patch.object(_bash.subprocess, "run", return_value=mock.Mock(returncode=1)):
|
||||
self.assertIsNone(_bash.find_bash())
|
||||
@@ -28,21 +28,22 @@ tree, the profile files from /setup Step 3's own headings - so a new drop
|
||||
folder or a new /setup target fails this test until /reset covers it.
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
try:
|
||||
from tests._bash import find_bash
|
||||
from tests._upstream import is_upstream_checkout
|
||||
except ImportError: # `python -m unittest discover -s tests` imports test modules top-level
|
||||
from _bash import find_bash
|
||||
from _upstream import is_upstream_checkout
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
RESET = REPO / ".claude" / "commands" / "reset.md"
|
||||
SETUP = REPO / ".claude" / "commands" / "setup.md"
|
||||
BASH = shutil.which("bash")
|
||||
BASH = find_bash()
|
||||
|
||||
|
||||
def tracked_document_subfolders():
|
||||
|
||||
@@ -8,9 +8,14 @@ import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
try:
|
||||
from tests._bash import find_bash
|
||||
except ImportError: # unittest discover -s tests imports modules top-level
|
||||
from _bash import find_bash
|
||||
|
||||
COMMAND = Path(__file__).resolve().parent.parent / ".claude/commands/reset.md"
|
||||
FOLDERS = ("cv", "linkedin", "diplomas", "references", "projects", "postings", "applications")
|
||||
BASH = shutil.which("bash")
|
||||
BASH = find_bash()
|
||||
|
||||
|
||||
def symlinks_available():
|
||||
|
||||
Reference in new issue
Block a user