diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index bb39610..b4d5f39 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -307,12 +307,13 @@ jobs: - name: The bug-report collector works in the distribution # tools/bugreport.py is the one part of the stack that must run when # nothing else does, so it is exercised as shipped: from the export, - # on the runner's plain python3, in the instance the step above set up. + # through the tools/bugreport launcher the instructions name, which picks + # the runner's plain python3, in the instance the step above set up. # The unit tests cover what it collects; this covers that it arrives. run: | set -eu cd "$INSTANCE_DIR" - python3 tools/bugreport.py --no-trace + tools/bugreport --no-trace bundle=$(ls -d reports/bugreport-*/ | head -n 1) test -f "$bundle/MANIFEST.md" test -f "$bundle/environment.json" @@ -321,7 +322,7 @@ jobs: # The same collector with --pseudonymise: the mapping lies beside the # bundle, not in it or its zip, and this container's hostname is gone. rm -rf reports/bugreport-* - python3 tools/bugreport.py --no-trace --pseudonymise + tools/bugreport --no-trace --pseudonymise bundle=$(ls -d reports/bugreport-*/ | head -n 1) bundle=${bundle%/} test -f "$bundle.pseudonyms.json" diff --git a/CHANGES.md b/CHANGES.md index 4c53ac4..8c4e9f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 8.0.0-beta.21 - 2026-10-02 - INSTALL.md an die Installationsinstruktionen gekoppelt: Voraussetzungen generiert, Setup-Fragen geprüft +## 8.0.0-beta.22 - 2026-10-02 - tools/bugreport: Starter für den Bugreport-Sammler, überspringt die Store-Aliase (#166) **Author:** Torben Nehmer @@ -96,6 +96,7 @@ concern - readable here, never shipped as something to parse. - trace-hook.ps1: Copilot hooks no longer open Windows' choose-an-app dialog - Windows-Portabilität: Pfadtrenner, Zeilenenden, Encoding und Locks - INSTALL.md an die Installationsinstruktionen gekoppelt: Voraussetzungen generiert, Setup-Fragen geprüft +- tools/bugreport: Starter für den Bugreport-Sammler, überspringt die Store-Aliase (#166) **Low impact** - version bump no longer points at version release in its output @@ -133,6 +134,30 @@ concern - readable here, never shipped as something to parse. - preflight.ps1: the asset-mode error helper is Exit-Asset, so PSScriptAnalyzer passes +### `tools/bugreport`: the collector finds its own Python (#166) + +The bug-report collector is the one part of the stack that has to run when nothing else does, +and it was started as `python3 tools/bugreport.py`. On the Windows target that name is the +Microsoft Store's alias in Git Bash, and in PowerShell `python` can be one too - so the report +failed exactly where it was needed. + +It now has a launcher pair after the pattern of `tools/wikitool`: `tools/bugreport` (sh, for +Linux, macOS and Git Bash) and `tools/bugreport.ps1`, which PowerShell resolves the same string +to first. They look for a Python the way the preflight does - `python3`, `python` on `PATH`; on +Windows `python`, `py -3`, `python3`, with the registry's `PATH` added under PowerShell - and then +try the venv's. Each candidate is probed for 3.8 or newer before it runs anything, and nothing +under `WindowsApps` is ever started. One that is too old, or that the machine refuses (a venv +`python.exe` blocked by Defender), is passed over. Finding none, the launcher exits 1 and says +why, naming any Python it found too old and the direct call with a full path as the way out. +Neither launcher assumes the preflight, `.wikitool-tools.json` or the venv, and the PowerShell +one keeps to what Windows PowerShell 5.1 understands, so a report never fails on the shell's +version first. + +`instructions/bug-report.md`, `INSTALL.md` § Troubleshooting, the stage-2 line the collector +prints and the CI step that runs it in the distribution all use `tools/bugreport`. The shell test +for shipped instructions now refuses a bare `python3`/`python`/`py` call in a command block. +Starting `bugreport.py` directly still works. + ### INSTALL.md held to the installation instructions (#154) The installation procedure has one source, the instructions under `instructions/`; `INSTALL.md` diff --git a/INSTALL.md b/INSTALL.md index aebb1cf..7aa4843 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -490,9 +490,11 @@ tools/wikitool instructions verify anfasst, um an der Verweigerung vorbeizukommen, öffnet ein Gate aus eigenem Antrieb. - **Setup oder Update schlägt fehl und ich will es melden** - den Agenten [instructions/bug-report.md](instructions/bug-report.md) ausführen lassen, oder direkt - `python3 tools/bugreport.py`. Das Skript braucht nur Python 3.8 oder neuer, läuft auch ohne - funktionierendes `wikitool` und schreibt ein Bündel nach `reports/bugreport-/` - samt Zip. Geheimnisse werden entfernt, und aus allem, was das Skript selbst erzeugt, bleiben + `tools/bugreport` aufrufen (aus Bash, Git Bash und PowerShell gleich). Der Aufruf sucht sich + selbst ein Python 3.8 oder neuer und überspringt dabei die Store-Aliase, auf die `python3` + unter Windows zeigt. Er braucht weder den Preflight noch ein funktionierendes `wikitool`, und + findet er kein Python, sagt er, warum. Das Skript schreibt ein Bündel nach + `reports/bugreport-/` samt Zip. Geheimnisse werden entfernt, und aus allem, was das Skript selbst erzeugt, bleiben Seitentitel draußen (`--titles` nimmt sie mit). Der Sitzungs-Trace ist standardmäßig dabei (`--no-trace` lässt ihn weg) und kann wie Chronologie und Transkripte Seiteninhalt und Titel enthalten; das Bündel enthält außerdem Maschinen-, Benutzer- und Pfadnamen. Mit `--pseudonymise` diff --git a/VERSION b/VERSION index 8fe62bc..79b4dcb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.21 +8.0.0-beta.22 diff --git a/instructions/bug-report.md b/instructions/bug-report.md index 0350d73..00e9464 100644 --- a/instructions/bug-report.md +++ b/instructions/bug-report.md @@ -1,7 +1,7 @@ --- type: types/instruction.md name: bug-report -description: How to collect a bug-report bundle when the stack misbehaves on this machine - run tools/bugreport.py, write a fact-only chronology, tell the user what the bundle contains, and stop short of sending it anywhere. +description: How to collect a bug-report bundle when the stack misbehaves on this machine - run tools/bugreport, write a fact-only chronology, tell the user what the bundle contains, and stop short of sending it anywhere. manual: true --- # Collect a bug report @@ -32,7 +32,9 @@ the pointers at the failure decision points of [setup-instance.md](setup-instanc `tools/bugreport.py` is a standalone script. It uses the standard library only and imports nothing from the stack, so it still runs when `wikitool` does not start - no venv, a broken package, a Python -that is too old for the stack. It writes `reports/bugreport-/` and a zip beside it, in +that is too old for the stack. Its launcher `tools/bugreport` assumes no more than that: it looks for +a Python 3.8 or later on `PATH` itself, skipping the Microsoft Store's aliases, and needs neither +the preflight nor `.wikitool-tools.json`. It writes `reports/bugreport-/` and a zip beside it, in four layers: | Layer | Files | Holds | @@ -94,16 +96,20 @@ session. The bundle does not leave that place because of it. [template](#chronology-template) below. Facts only - no diagnosis. If the session's own history is too long to reconstruct, say what is missing instead of filling the gap. -3. **Run the collector**, with whichever Python 3.8 or later the machine has: +3. **Run the collector** through its launcher, which finds a Python 3.8 or later itself: ```bash - python3 tools/bugreport.py --chronology reports/chronology.md + tools/bugreport --chronology reports/chronology.md ``` Add `--no-trace` if the user declined the trace, `--titles` if titles may stay, `--transcript ` (repeatable) for a transcript the user pointed at, `--session ` to take a - trace other than the caller's, `--pseudonymise` if the user chose it (stage 1). `python3` may be `python` or `py -3` on Windows. If no Python starts - at all, that is the report: give the user the exact error text, verbatim. + trace other than the caller's, `--pseudonymise` if the user chose it (stage 1). Do not call a Python yourself: on Windows, + `python3` - and in PowerShell also `python` - can be the Microsoft Store's alias, which the + launcher skips. If the launcher finds no Python, it exits 1 and says why; that is the report - + give the user its text verbatim. If the user then names a working Python by its full path, run + the collector with it directly (` tools/bugreport.py` and the same + options). 4. **Stage 2, only if the bundle was pseudonymised.** Read, in the bundle: `CHRONOLOGY.md` and `MANIFEST.md` completely; the review list `reports/bugreport-.review.txt` completely; the @@ -112,7 +118,7 @@ session. The bundle does not leave that place because of it. domain, or project - one per line in `reports/candidates.txt`; `#` starts a comment. Then run: ```bash - python3 tools/bugreport.py --bundle reports/bugreport- --candidates reports/candidates.txt + tools/bugreport --bundle reports/bugreport- --candidates reports/candidates.txt ``` The script applies the candidates with the machinery of stage 1, reports which it did not apply diff --git a/tools/README.md b/tools/README.md index 1b0e2e5..a814bf8 100644 --- a/tools/README.md +++ b/tools/README.md @@ -76,6 +76,9 @@ tools/ prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor` trace-hook what the harness hooks call: trace_ingest.py under the venv's Python trace-hook.ps1 the same for PowerShell, which resolves `./tools/trace-hook` to this file first - without it Windows asks which app opens the sh script + bugreport entry point for the bug-report collector (POSIX sh): finds a Python 3.8+ on PATH itself, skipping the Microsoft Store aliases, and runs bugreport.py - no preflight needed + bugreport.ps1 the same for PowerShell, which resolves `tools/bugreport` to this file first; keeps to what Windows PowerShell 5.1 understands + bugreport.py the bug-report collector itself - see below chemenu/ cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from @@ -119,6 +122,13 @@ survive. The mapping, the review list and the candidate file stay beside the bun `dist export` ships it with the rest of `tools/`; its tests (`tests/test_bugreport.py`) run it on a bare interpreter (`-I -S`) to keep that promise. +It is started through `tools/bugreport` (`bugreport.ps1` under PowerShell), which assumes no more +than the collector does: no preflight, no `.wikitool-tools.json`, no venv. It looks for the +interpreter the way the preflight does - `python3`, `python` on `PATH`; on Windows `python`, +`py -3`, `python3` - then tries the venv's, probes each for 3.8 or newer, and never starts one under +`WindowsApps`, where `python3` in Git Bash is the Microsoft Store's alias. Finding none, it exits 1 +and says why. + **Two consumers, one core.** The CLI is not the only caller any more. The cores (`search/service.py`, `lint_core.py`, `types_core.py`, `catalog.py`) hold what decides an answer and import no `typer` and no `rich`; the modules under `commands/` turn diff --git a/tools/bugreport b/tools/bugreport new file mode 100755 index 0000000..4e24740 --- /dev/null +++ b/tools/bugreport @@ -0,0 +1,129 @@ +#!/bin/sh +# Entry point for the bug-report collector, so agents and people start it through one +# stable path on every platform: +# +# tools/bugreport [options] +# +# This is the POSIX half - Linux, macOS and Git Bash on Windows. PowerShell resolves +# the same string to tools/bugreport.ps1 first. +# +# tools/bugreport.py is the one part of the stack that has to run when nothing else +# does, so this script assumes nothing the preflight sets up: no .wikitool-tools.json, +# no venv. It looks for a Python itself, the way the preflight does - on Windows +# python, py -3, python3; elsewhere python3, python; every match on PATH in PATH +# order - and only then tries the venv's. It never starts anything under WindowsApps: +# there, python.exe and python3.exe are the Microsoft Store's aliases, and in Git Bash +# `python3` is exactly that. Every candidate is probed first, so one that is too old or +# that the machine refuses to run (a venv python.exe blocked by Defender) is passed over +# rather than ending the report. +# +# Without any Python 3.8 or newer it says so and exits 1, never 42: the collector opens +# no gate. +# +# CHEMENU_PREFLIGHT_PLATFORM stands in for the platform, as it does for the preflight; +# the test suite is its only user. +set -u +DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 1 +MIN=3.8 +# Exit 3 marks "a Python, but too old" apart from "did not start at all". +PROBE='import sys; sys.exit(0 if sys.version_info >= (3, 8) else 3)' + +platform=${CHEMENU_PREFLIGHT_PLATFORM:-} +if [ -z "$platform" ]; then + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) platform=windows ;; + *) platform=other ;; + esac +fi + +if [ "$platform" = windows ]; then + order="python: py:-3 python3:" + looked="python, py -3, python3" +else + order="python3: python:" + looked="python3, python" +fi + +is_store_alias() { + case "$1" in *[Ww]indows[Aa]pps/*|*[Ww]indows[Aa]pps\\*) return 0 ;; esac + return 1 +} + +# Every executable called on PATH, in PATH order, store aliases dropped. +on_path() { + set -f + old_ifs=$IFS + IFS=: + for d in $PATH; do + [ -n "$d" ] || d=. + for f in "$d/$1" "$d/$1.exe"; do + if [ -f "$f" ] && [ -x "$f" ] && ! is_store_alias "$f"; then + printf '%s\n' "$f" + fi + done + done + IFS=$old_ifs + set +f +} + +PY="" EXTRA="" TOO_OLD="" + +probe() { # [extra argument, e.g. -3 for py] -> sets PY/EXTRA on success + "$@" -c "$PROBE" >/dev/null 2>&1 + status=$? + if [ "$status" -eq 0 ]; then + PY=$1 EXTRA=${2:-} + return 0 + fi + if [ "$status" -eq 3 ]; then + TOO_OLD="$TOO_OLD + $1" + fi + return 1 +} + +for candidate in $order; do + name=${candidate%%:*} + extra=${candidate#*:} + found=$(on_path "$name") + [ -n "$found" ] || continue + # A here-document, not a pipe: `break 2` has to leave the outer loop of this + # shell, and a path may contain spaces ("Program Files"). + while IFS= read -r path; do + if [ -n "$extra" ]; then + probe "$path" "$extra" && break 2 + else + probe "$path" && break 2 + fi + done < tools/bugreport.py " +} >&2 +exit 1 diff --git a/tools/bugreport.ps1 b/tools/bugreport.ps1 new file mode 100644 index 0000000..7fcef13 --- /dev/null +++ b/tools/bugreport.ps1 @@ -0,0 +1,151 @@ +# Entry point for the bug-report collector under PowerShell, so agents and people start it +# through the same string on every platform: +# +# tools/bugreport [options] +# +# PowerShell resolves that to this file first. Without it, `tools/bugreport` is the sh +# launcher next to it, which PowerShell does not run. The POSIX half - Linux, macOS and +# Git Bash - is tools/bugreport. +# +# Same rules as the sh twin: tools/bugreport.py has to run when nothing else does, so +# nothing the preflight sets up is assumed. The Python is looked for the way the preflight +# looks - on Windows python, py -3, python3; elsewhere python3, python; every match on PATH +# in PATH order, on Windows with the registry's PATH added for a session that started +# before the install - and only then in the venv. Nothing under WindowsApps is ever started: +# python.exe and python3.exe there are the Microsoft Store's aliases. Every candidate is +# probed first, so one that is too old or refused (a venv python.exe blocked by Defender) is +# passed over rather than ending the report. Without any Python 3.8 or newer it says so and +# exits 1, never 42: the collector opens no gate. +# +# There is no `#Requires -Version 7`: someone who needs a bug report should not fail at the +# shell's version first, so this file keeps to what Windows PowerShell 5.1 understands too. +# +# CHEMENU_PREFLIGHT_PLATFORM stands in for the platform, as it does for the preflight; the +# test suite is its only user. + +$ErrorActionPreference = 'Stop' +$PSNativeCommandArgumentPassing = 'Standard' + +$Dir = $PSScriptRoot +$Collector = Join-Path $Dir 'bugreport.py' +$Min = '3.8' +# Exit 3 marks "a Python, but too old" apart from "did not start at all". +$Probe = 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 3)' + +$Platform = $env:CHEMENU_PREFLIGHT_PLATFORM +if (-not $Platform) { + if ($env:OS -eq 'Windows_NT') { $Platform = 'windows' } else { $Platform = 'other' } +} + +if ($Platform -eq 'windows') { + $Order = @(@('python', @()), @('py', @('-3')), @('python3', @())) + $Looked = 'python, py -3, python3' +} else { + $Order = @(@('python3', @()), @('python', @())) + $Looked = 'python3, python' +} + +function Test-StoreAlias { + param([string]$Path) + return $Path -match '(?i)[\\/]windowsapps[\\/]' +} + +# PATH as this process has it, plus - on Windows - what the registry holds that a +# long-running session has not picked up yet. +function Get-SearchDirectory { + $directories = New-Object System.Collections.Generic.List[string] + $sources = @($env:PATH) + if ($env:OS -eq 'Windows_NT') { + foreach ($scope in 'Machine', 'User') { + $sources += [Environment]::GetEnvironmentVariable('Path', $scope) + } + } + foreach ($source in $sources) { + if (-not $source) { continue } + foreach ($entry in $source.Split([IO.Path]::PathSeparator)) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim()) + if ($expanded -and -not $directories.Contains($expanded)) { + $directories.Add($expanded) + } + } + } + return $directories +} + +# Every file called on PATH, in PATH order, store aliases dropped. +function Find-OnPath { + param([string]$Name) + $found = @() + foreach ($directory in (Get-SearchDirectory)) { + foreach ($file in @($Name, "$Name.exe")) { + $candidate = Join-Path $directory $file + if ((Test-Path -LiteralPath $candidate -PathType Leaf) -and -not (Test-StoreAlias $candidate)) { + $found += $candidate + } + } + } + return $found +} + +$script:TooOld = @() + +# 0 when this Python is 3.8 or newer; anything else when it is not, or did not start. +function Test-Python { + param([string]$Path, [string[]]$Extra) + try { + & $Path @Extra -c $Probe *> $null + $status = $LASTEXITCODE + } catch { + return $false + } + if ($status -eq 3) { + $script:TooOld += $Path + } + return $status -eq 0 +} + +$Python = '' +$PythonExtra = @() +foreach ($candidate in $Order) { + foreach ($path in (Find-OnPath $candidate[0])) { + if (Test-Python $path $candidate[1]) { + $Python = $path + $PythonExtra = $candidate[1] + break + } + } + if ($Python) { break } +} + +if (-not $Python) { + foreach ($venv in @((Join-Path $Dir '.venv/Scripts/python.exe'), (Join-Path $Dir '.venv/bin/python'))) { + if ((Test-Path -LiteralPath $venv -PathType Leaf) -and (Test-Python $venv @())) { + $Python = $venv + break + } + } +} + +if ($Python) { + & $Python @PythonExtra $Collector @args + exit $LASTEXITCODE +} + +$message = @( + "No Python $Min or newer could be started, so the bug-report collector did not run." + "Looked for $Looked on PATH, then the one in tools/.venv. Microsoft Store aliases" + 'under WindowsApps are skipped on purpose - they do not run Python.' +) +if ($script:TooOld.Count -gt 0) { + $message += "Found, but older than ${Min}:" + $message += ($script:TooOld | ForEach-Object { " $_" }) +} +$message += @( + '' + "Install Python $Min or newer, or start the collector directly with the full path" + 'of one that works:' + '' + ' tools/bugreport.py ' +) +[Console]::Error.WriteLine(($message -join [Environment]::NewLine)) +exit 1 diff --git a/tools/bugreport.py b/tools/bugreport.py index 39b943c..eb36042 100644 --- a/tools/bugreport.py +++ b/tools/bugreport.py @@ -6,10 +6,15 @@ Runs on the base Python with the standard library only, and imports nothing from - no venv, a broken package, a Python that is too old. Syntax stays at Python 3.8 so that even an old interpreter can still produce a report. - python tools/bugreport.py [--chronology FILE] [--transcript FILE]... - [--session ID | --no-trace] [--titles] - [--pseudonymise] [--root DIR] [--out DIR] - python tools/bugreport.py --bundle DIR --candidates FILE + tools/bugreport [--chronology FILE] [--transcript FILE]... + [--session ID | --no-trace] [--titles] + [--pseudonymise] [--root DIR] [--out DIR] + tools/bugreport --bundle DIR --candidates FILE + +`tools/bugreport` (and `tools/bugreport.ps1` under PowerShell) finds a Python 3.8 +or newer and runs this file with it, skipping the Microsoft Store's aliases that +`python3` resolves to on Windows. Started directly - ` tools/bugreport.py` +- it works the same, with whichever interpreter was named. The bundle is `/bugreport-/` plus a zip beside it, in four layers: the environment, the stack, what `wikitool` prints (if it starts), and @@ -1188,7 +1193,7 @@ def make_zip(bundle_dir: Path, archive: Path) -> None: def parse_args(argv): parser = argparse.ArgumentParser( - prog="bugreport.py", + prog="tools/bugreport", description="Collect a bug-report bundle. Removes secrets, keeps page titles out " "unless --titles is given, uploads nothing.", ) @@ -1330,7 +1335,7 @@ def _collect(args, root: Path, out: Path, stamp: str, bundle_dir: Path) -> int: print("Mapping: %s" % _sibling(bundle_dir, ".pseudonyms.json")) print("Review: %s" % _sibling(bundle_dir, ".review.txt")) print("Both hold originals and stay on this machine: never share them.") - print("Stage 2: python3 tools/bugreport.py --bundle %s --candidates " % bundle_dir) + print("Stage 2: tools/bugreport --bundle %s --candidates " % bundle_dir) print() print(STAGE1_NOTICE if pz is not None else PRIVACY_NOTICE) return 0 diff --git a/tools/chemenu/tests/test_bugreport_launcher.py b/tools/chemenu/tests/test_bugreport_launcher.py new file mode 100644 index 0000000..3bed986 --- /dev/null +++ b/tools/chemenu/tests/test_bugreport_launcher.py @@ -0,0 +1,184 @@ +"""`tools/bugreport` and `tools/bugreport.ps1`, the collector's launchers (Gitea #166). + +The collector has to run when nothing else does, and on Windows `python3` - in PowerShell +also `python` - can be the Microsoft Store's alias. So the launchers find a Python 3.8+ +themselves, never start anything under WindowsApps, and assume nothing the preflight sets +up. These tests drive both against the stub machine from `test_preflight.py`: a `PATH` the +test builds, stub Pythons in it, and no `.wikitool-tools.json` anywhere. + +A stub Python answers the launcher's probe (`-c ...`) with 0, or 3 for "too old", and +otherwise prints the arguments it was started with - which is how a test sees that the +collector was run, and with what. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from chemenu.tests.test_preflight import SHELLS, TOOLS, Machine + +PWSH = shutil.which("pwsh") + +# Answers the probe as a current Python, otherwise echoes its arguments. +PYTHON = "#!/bin/sh\n[ \"$1\" = -c ] && exit 0\nprintf '%s\\n' \"$@\"\n" +# The same for the `py` launcher, which needs -3 in front of the probe. +PY_LAUNCHER = "#!/bin/sh\n[ \"$1\" = -3 ] && [ \"$2\" = -c ] && exit 0\nprintf '%s\\n' \"$@\"\n" +TOO_OLD = "#!/bin/sh\n[ \"$1\" = -c ] && exit 3\necho 'collector run by a Python that is too old'\n" +REFUSED = "#!/bin/sh\necho 'Access is denied.' >&2\nexit 1\n" + + +@pytest.fixture +def machine(tmp_path: Path) -> Machine: + machine = Machine(tmp_path.resolve()) + for name in ("bugreport", "bugreport.ps1", "bugreport.py"): + shutil.copy2(TOOLS / name, machine.tools / name) + return machine + + +def _env(machine: Machine) -> dict[str, str]: + return { + "PATH": os.pathsep.join(str(d) for d in machine.path_dirs), + "HOME": str(machine.base), + **machine.extra_env, + } + + +def _sh(machine: Machine, *args: str, shell: str) -> subprocess.CompletedProcess: + return subprocess.run([shell, str(machine.tools / "bugreport"), *args], + capture_output=True, text=True, env=_env(machine), timeout=30) + + +def _pwsh(machine: Machine, *args: str) -> subprocess.CompletedProcess: + env = {**_env(machine), "DOTNET_CLI_TELEMETRY_OPTOUT": "1", "POWERSHELL_TELEMETRY_OPTOUT": "1"} + return subprocess.run( + [PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / "bugreport.ps1"), *args], + capture_output=True, text=True, env=env, timeout=120, + ) + + +def _runners(): + runners = [pytest.param(lambda m, *a, shell=shell: _sh(m, *a, shell=shell), id=Path(shell).name) + for shell in SHELLS] + runners.append(pytest.param( + _pwsh, id="pwsh", marks=pytest.mark.skipif(PWSH is None, reason="PowerShell 7 (pwsh) is not installed"), + )) + return runners + + +@pytest.fixture(params=_runners()) +def launch(request): + return request.param + + +def _store_alias(machine: Machine, name: str) -> Path: + """A Store alias first on PATH that leaves a marker if anything ever starts it.""" + marker = machine.base / f"{name}-alias-was-run" + apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps" + machine.stub(name, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps) + machine.path_dirs.insert(0, apps) + return marker + + +def test_runs_the_collector_with_every_argument(machine, launch): + machine.stub("python3", PYTHON) + result = launch(machine, "--chronology", "reports/a b.md", "--no-trace") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + str(machine.tools / "bugreport.py"), "--chronology", "reports/a b.md", "--no-trace", + ] + + +def test_needs_nothing_the_preflight_sets_up(machine, launch): + machine.stub("python3", PYTHON) + assert launch(machine, "--no-trace").returncode == 0 + assert not (machine.root / ".wikitool-tools.json").exists() + assert not (machine.tools / ".venv").exists() + + +@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")]) +def test_a_store_alias_is_never_started(machine, launch, platform, alias): + machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = platform + marker = _store_alias(machine, alias) + machine.stub(alias, PYTHON) + result = launch(machine, "--no-trace") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines()[0] == str(machine.tools / "bugreport.py") + assert not marker.exists() + + +def test_on_windows_python_comes_before_python3(machine, launch): + """In Git Bash on the Windows target, `python` is the real one and `python3` the alias + (T4) - the order alone must not depend on the alias being recognised.""" + machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows" + machine.stub("python3", "#!/bin/sh\necho 'wrong python'\n") + machine.stub("python", PYTHON) + result = launch(machine, "--no-trace") + assert result.stdout.splitlines() == [str(machine.tools / "bugreport.py"), "--no-trace"] + + +def test_on_windows_the_py_launcher_gets_minus_3(machine, launch): + machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows" + machine.stub("py", PY_LAUNCHER) + result = launch(machine, "--no-trace") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["-3", str(machine.tools / "bugreport.py"), "--no-trace"] + + +def test_a_python_too_old_is_passed_over(machine, launch): + old = machine.stub("python3", TOO_OLD) + machine.stub("python", PYTHON) + result = launch(machine, "--no-trace") + assert result.returncode == 0, result.stderr + assert "too old" not in result.stdout + assert str(old) not in result.stdout + + +def test_a_python_that_refuses_to_start_is_passed_over(machine, launch): + machine.stub("python3", REFUSED) + machine.stub("python", PYTHON) + result = launch(machine, "--no-trace") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines()[0] == str(machine.tools / "bugreport.py") + + +@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")]) +def test_the_venv_python_is_the_last_resort(machine, launch, layout): + machine.stub(layout[1], PYTHON, machine.tools / ".venv" / layout[0]) + result = launch(machine, "--no-trace") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines()[0] == str(machine.tools / "bugreport.py") + + +def test_without_a_python_it_says_why_and_exits_1(machine, launch): + old = machine.stub("python3", TOO_OLD) + marker = _store_alias(machine, "python") + result = launch(machine, "--no-trace") + assert result.returncode == 1 + assert "No Python 3.8 or newer" in result.stderr + assert "WindowsApps" in result.stderr + assert str(old) in result.stderr + assert " tools/bugreport.py" in result.stderr + assert not marker.exists() + + +def test_the_collectors_exit_code_passes_through(machine, launch): + machine.stub("python3", "#!/bin/sh\n[ \"$1\" = -c ] && exit 0\nexit 1\n") + assert launch(machine, "--bundle", "b", "--candidates", "c").returncode == 1 + + +def test_both_launchers_ship_and_the_sh_one_is_executable(): + assert (TOOLS / "bugreport").is_file() and (TOOLS / "bugreport.ps1").is_file() + assert os.access(TOOLS / "bugreport", os.X_OK) + + +def test_the_real_collector_runs_through_the_launcher(tmp_path): + """End to end on this machine's own Python: the launcher finds it and the collector's + usage comes back, naming the launcher as the program.""" + result = subprocess.run([str(TOOLS / "bugreport"), "--help"], capture_output=True, text=True, + timeout=60, cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("usage: tools/bugreport") diff --git a/tools/chemenu/tests/test_instructions_shell.py b/tools/chemenu/tests/test_instructions_shell.py index e9a62e0..0c919d4 100644 --- a/tools/chemenu/tests/test_instructions_shell.py +++ b/tools/chemenu/tests/test_instructions_shell.py @@ -35,6 +35,10 @@ FORBIDDEN = ( ("sha256sum", re.compile(r"\bsha256sum\b")), ("curl", re.compile(r"\bcurl\b")), ("tar", re.compile(r"(^|[;&|]\s*)tar\s")), + # Which name starts a real Python differs per shell and platform: on Windows `python3` - in + # PowerShell also `python` - can be the Microsoft Store's alias. A command goes through a + # launcher that finds the interpreter itself (`tools/wikitool`, `tools/bugreport`). + ("bare interpreter", re.compile(r"(^|[;&|]\s*)(python3?|py)(\.exe)?\s")), ) # The two sanctioned exceptions, each one line per shell (instructions/CONTRACT.md): the session diff --git a/tools/chemenu/tests/test_portability.py b/tools/chemenu/tests/test_portability.py index 29d046d..f48f8c2 100644 --- a/tools/chemenu/tests/test_portability.py +++ b/tools/chemenu/tests/test_portability.py @@ -396,6 +396,7 @@ def test_output_into_a_pipe_is_utf8_even_under_a_cp1252_locale(): @pytest.mark.parametrize("path, eol", [ ("tools/wikitool", "lf"), ("tools/trace-hook", "lf"), + ("tools/bugreport", "lf"), ("tools/preflight.sh", "lf"), ("AGENTS.md", "lf"), ])