unsloth/tests/sh/test_mac_intel_compat.sh
Daniel Han e83d4ae072
Windows installer: fix DiskPart UAC mid-install, drive-root cache, and spurious unsloth.exe rename warning (#6296)
* Windows installer: fix DiskPart UAC, drive-root cache, spurious rename warning, CPU-base messaging

amd-smi gate (DiskPart UAC mid-install): the AMD torch wheel ships hipInfo.exe
inside the venv, and the bitsandbytes fix prepends that venv Scripts dir to PATH.
shutil.which("hipinfo") then found it and flipped _amd_smi_allowed() to True, so
the post-install AMD probe fell through to `amd-smi list` (the venv hipInfo failed
to report gcnArchName, which is why the arch came from the GPU-name table) and
amd-smi elevated, popping the DiskPart UAC. Fix: a hipinfo resolved inside the
active venv (sys.prefix) is the torch-wheel binary, not a HIP SDK, and must not
open the gate. Mirrored in install_python_stack.py, install_llama_prebuilt.py, and
backend utils/hardware/amd.py (the runtime VRAM poller had the same latent prompt).

TORCHINDUCTOR_CACHE_DIR: move from C:\tc to <StudioHome>\TORCHINDUCTOR_CACHE_DIR so
the inductor/Triton cache lives under the user's Studio home, not the system drive
root. Long paths are already enabled above so deep inductor paths still fit.

unsloth.exe rename: skip the rename (and its "pip may fail with WinError 32"
warning) when SKIP_STUDIO_BASE=1. In the install.ps1 flow base packages are not
reinstalled, so unsloth.exe is never rewritten; the self-rename only failed because
setup runs via unsloth.exe (the running launcher holds its own file). The
'studio update' flow still attempts it.

CPU PyTorch messaging: clarify that the CPU base is temporary and setup replaces it
with GPU ROCm wheels, and print an explicit "GPU ROCm PyTorch installed" line after
the AMD wheels land, so the log makes clear the final install is GPU-accelerated.

Adds two regression tests covering the venv-internal vs external hipInfo gate.

Verified end-to-end on a Strix Halo box (Radeon 8060S / gfx1151): install.ps1
--local from this branch completed exit 0 with no DiskPart prompt, no rename
warning, the cache under the Studio home, and "GPU ROCm PyTorch installed
(gfx1151)"; Studio then booted and detected "ROCm (HIP 7.13.99004) -- AMD Radeon
8060S Graphics".

* Windows installer: drop the unreliable unsloth.exe rename and its WinError 32 warning

setup.ps1 used to rename the running unsloth.exe out of the way before the
base-package upgrade so pip could replace it. That rename never actually
worked: setup runs *via* unsloth.exe, so renaming our own running
uv-trampoline launcher failed with a sharing violation (WinError 32) and only
printed a scary 'could not rename unsloth.exe; pip may fail with WinError 32'
warning on every Windows install and update.

It also was not needed. pip tolerates a running/locked console-script .exe: it
moves the old one aside and writes the new one. The base upgrade routes through
pip on Windows, so the upgrade succeeds (or, in the install.ps1 flow with
SKIP_STUDIO_BASE=1, the base is not touched at all) and unsloth.exe is left
intact either way.

Removing the rename block and its failed-install restore block removes the
false warning for all Windows devices in both the install and update flows.

* Windows installer: gate venv-internal hipInfo.exe in PowerShell amd-smi probe; harden venv path checks

Follow-up to PR #6296.

- install.ps1 and setup.ps1: ignore the AMD torch wheel hipInfo.exe that lives
  inside the Studio venv when probing for a HIP SDK, so amd-smi no longer reopens
  the DiskPart UAC during install/update. Mirrors _path_inside_venv in the Python
  installers, which already do this.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: normcase the venv
  containment check (Windows paths are case-insensitive) and run the
  HIP_PATH/ROCM_PATH candidate through it too.
- setup.ps1: fall back to a short TORCHINDUCTOR cache dir when long paths are
  unavailable, and create the dir wildcard-safely.
- tests: isolate sys.prefix in the gate helper, add HIP_PATH/ROCM_PATH cases, and
  assert the PowerShell venv exclusion.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Windows installer: install ROCm PyTorch directly for a known AMD arch

When the GPU arch is known (name-inferred from the GPU-name table) but ROCm
could not be probe-verified (no HIP SDK, no amd-smi), the bootstrap installed
a CPU PyTorch base that setup.ps1 then force-reinstalled as ROCm. The
repo.amd.com wheels bundle their own runtime (no HIP SDK required), which
setup.ps1 already relies on, so the CPU base was a pure wasted download/install.

- Gate the ROCm index on a known arch, not only on probe-verified ROCm, so a
  mapped arch installs ROCm torch directly. Unmapped arches and no-GPU hosts
  still get CPU (unchanged).
- Fall back to a CPU base if the ROCm-index install fails, so a transient
  repo.amd.com outage does not abort the install (setup.ps1 retries ROCm).
- Correct the stale comment that claimed ROCm wheels need a confirmed HIP SDK.
- Add a regression test for the arch-based gate.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Windows installer: correct the unsloth.exe rename-removal comment

The comment claimed the base upgrade 'routes through pip on Windows' and that
pip 'moves the old unsloth.exe aside, then writes the new one'. That is not what
the code does. install_python_stack tries uv first; on a locked launcher uv
aborts and falls back to pip, but the pip fallback strips --upgrade-package and
base.txt lists only bare unsloth/unsloth-zoo, so pip finds them already
satisfied and no-ops. The running unsloth.exe is left intact at its current
version either way. Reword the comment to describe the real uv-first /
pip-fallback-no-op behavior. No functional change.

* Windows installer: close two gaps in the venv-internal hipinfo exclusion

Review follow-up. The amd-smi/DiskPart gate could still reopen in two cases:

- setup.ps1 ran the HIP probe long before $VenvDir is assigned, so without
  VIRTUAL_ENV (the `unsloth studio update` path) $venvRoots was empty and the
  venv-internal hipInfo.exe was not recognized. Seed the venv root from
  UNSLOTH_SETUP_PYTHON and the default Studio home too (both installers).
- The HIP_PATH/ROCM_PATH candidate was accepted without the venv filter, so an
  env var pointing into the venv (AMD wheel) still set $HipSdkInstalled. Run
  Test-HipinfoIsVenvInternal on the candidate as well (both installers).

Extend the PS gate test to assert both. Both .ps1 parse clean; install tests
pass (the venv-internal / HIP probe coverage at 359 passed).

* Windows installer: correct the CPU-base message for arches with no ROCm wheels

After gating the ROCm index on a known arch, a mapped arch sets $ROCmIndexUrl
and installs ROCm directly, so it no longer reaches the "temporary CPU base"
branch. That branch is now reached only by a name-inferred arch with no ROCm
wheels (e.g. RDNA2 gfx103X), where setup.ps1 does NOT install ROCm. The old
text ("setup replaces it with GPU ROCm wheels ... the final install IS
GPU-accelerated") was therefore always wrong there. Say plainly that PyTorch
stays on CPU for this GPU.

* Windows installer: seed the venv-internal hipInfo check from a custom Studio home

Test-HipinfoIsVenvInternal seeded the venv root from VIRTUAL_ENV, VenvDir, the
setup python, and the default %USERPROFILE% path only. A standalone
`unsloth studio update` with a custom UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias)
and none of those set would not recognize the venv hipInfo on PATH, reopening the
amd-smi/DiskPart gate. Seed the custom home too, in both installers, and assert
it in the gate test.

* Studio installer: resolve venv aliases and expand ~ in the hipInfo venv filter

Two review points on the amd-smi/DiskPart UAC gate:

1. _path_inside_venv compared os.path.abspath of sys.prefix and the hipInfo
   path, which does not resolve symlinks, junctions, or 8.3 short names. A venv
   reached through an aliased path then fails the check, so its bundled
   hipInfo.exe is mistaken for an external HIP SDK and amd-smi runs (the
   DiskPart prompt this fix exists to suppress). Switch to os.path.realpath in
   all three copies (amd.py, install_llama_prebuilt.py, install_python_stack.py).

2. setup.ps1's early venv-internal hipInfo probe seeded the venv root from a
   custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME) without expanding a
   leading ~, while the canonical resolver does. With a tilde form,
   [IO.Path]::GetFullPath kept the literal ~ relative to cwd, so the custom-home
   hipInfo escaped the filter and reopened the gate. Expand ~ in the probe the
   same way as the resolver.

tests/studio/install/test_pr5940_followups.py: 30 passed (adds a symlink
realpath case and a setup.ps1 tilde-expansion guard).

* Studio installer: mirror the hipInfo venv filter and ROCm wheel pins into install.ps1

Follow-up review on the same install.ps1 paths:

1. install.ps1's venv-internal hipInfo probe (Test-HipinfoIsVenvInternal)
   seeded the venv root from a custom Studio home without expanding a leading
   ~, unlike the canonical resolver and setup.ps1. A tilde form left
   [IO.Path]::GetFullPath with the literal ~ (relative to cwd), so the
   custom-home hipInfo escaped the filter and reopened the amd-smi/DiskPart
   gate. Expand ~ in the probe, matching the setup.ps1 fix.

2. The AMD ROCm path installed torchvision/torchaudio bare while pinning torch
   to below 2.12. AMD's per-arch index publishes the companions independently
   and may ship torchvision 0.27 (for torch 2.12) before removing 0.26, so a
   bare resolve can pick an ABI-incompatible set and fall back to CPU. Add
   torchvision/torchaudio floor maps and pass the pinned specs, mirroring
   setup.ps1 and install_python_stack.py.

3. The ROCm-to-CPU fallback torch install used Invoke-InstallCommand (no
   retry), the only torch step in the file without it. Switch to
   Invoke-InstallCommandRetry so the recovery path survives a transient index
   failure.

tests/studio/install/test_pr5940_followups.py: 33 passed (parametrized tilde
check over both installers, a torch/companion floor-map parity test, and a
CPU-fallback retry guard).

* Studio installer: scan all PATH hipinfo so the venv copy can't shadow a real HIP SDK

The amd-smi HIP-SDK probe used shutil.which("hipinfo") / Get-Command hipinfo,
which return only the first hit on PATH. The AMD torch wheel ships hipInfo.exe
inside the venv and the bnb fix (plus the Studio backend) prepend the venv
Scripts dir to PATH, so that venv-internal copy lands first. When a real HIP SDK
hipinfo sits later on PATH with HIP_PATH/ROCM_PATH unset, the first-hit probe
stopped at the venv copy, treated it as "not a HIP SDK", and closed the amd-smi
gate -- AMD users in that PATH-only SDK setup lost amd-smi telemetry and could
fall back to CPU. Scan every PATH entry and keep the first hipinfo that is not
venv-internal; only the venv copy is ignored, so the UAC/DiskPart suppression is
unchanged.

Applied to all three Python copies (install_llama_prebuilt.py,
install_python_stack.py, backend/utils/hardware/amd.py) via a new
_external_hipinfo_on_path helper, and both PowerShell callers (install.ps1,
setup.ps1) now use Get-Command hipinfo -All filtered by Test-HipinfoIsVenvInternal.

tests/studio/install/test_pr5940_followups.py: 36 passed (real-PATH scan tests, a
shadow-regression test for the exact venv-first ordering, and a parity check that
every Python copy uses the scanning helper).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio uninstallers: fix leftovers (false "removed", shared icon, llama lock)

Auditing a dual native+WSL uninstall on a real device surfaced three leftovers:

1. uninstall.ps1 removed the data dir (which holds unsloth.ico) before the
   shortcuts that reference that icon, so Explorer's icon cache briefly held it
   open. Remove-Item -Recurse reported success yet left the locked file, and the
   dir was never re-attempted, so it orphaned with a false "removed" log.
   _RemovePath now verifies the path is actually gone (retrying transient locks)
   and reports honestly, and the data dir is re-swept after the shortcuts go.

2. install.sh writes a shared unsloth.ico to %LOCALAPPDATA%\Unsloth Studio for
   the WSL shortcut, but uninstall.sh never removed it, orphaning the icon (and
   dir) after a WSL uninstall. uninstall.sh now drops that icon and the dir when
   empty, in both the powershell.exe and drvfs-fallback paths.

3. ~/.unsloth/.llama.cpp.install.lock was never removed, so the rmdir of
   ~/.unsloth failed and the dir lingered. Both uninstallers now remove the lock.

Verified by running both uninstallers on a real dual install: device fully clean
(no install dirs, shortcuts, PATH/registry entries, shared icon, or lock left).

* install.sh: auto-route Strix Halo WSL to an existing Ubuntu 24.04

ROCm-on-WSL is the GPU runtime for Strix Halo and only targets Ubuntu
24.04. When the installer runs in a newer default distro (e.g. 26.04) it
cannot enable the GPU and silently falls back to CPU. If a 24.04 distro
already exists, re-run the install there and stop in the current one so the
GPU path is taken without the user having to know about the distro
requirement.

Runs before venv creation so the wrong distro is left untouched, guards
against re-route loops via UNSLOTH_WSL_REROUTED, leaves a working ROCm
distro alone (librocdxg present), and skips the GGUF-only / opt-out /
non-Strix cases. When no 24.04 distro exists we keep today's behaviour:
continue to CPU and print the `wsl --install Ubuntu-24.04` guidance, never
auto-downloading a distro.

Adds tests/sh/test_strixhalo_wsl_reroute.sh (hermetic: extracts the
function, rewrites its paths to fixtures, mocks wsl.exe) covering the full
decision matrix, wired into tests/run_all.sh.

* uninstall.ps1: keep shared unsloth.ico for a surviving WSL shortcut

A dual native+WSL install shares %LOCALAPPDATA%\Unsloth Studio\unsloth.ico:
install.sh points the WSL shortcut's icon there while the native install owns the
dir. The native uninstaller removed the whole dir unconditionally, so uninstalling
native while keeping WSL left the WSL shortcut with a blank icon. The old code only
avoided this when Explorer happened to hold the icon open, which is unreliable; on a
real dual install the dir was deleted and the WSL shortcut went blank.

_RemoveDataDirKeepingWslIcon now scans the Start Menu + Desktop for a surviving
"Unsloth Studio (WSL ...).lnk" and, if found, removes everything in the data dir
except unsloth.ico (keeping the dir) instead of deleting it; with no WSL shortcut it
removes the dir as before. uninstall.sh still drops the icon and the empty dir when
WSL itself is uninstalled, so every uninstall order ends clean.

Adds tests/studio/test_uninstall_dual_install_icon.ps1 (AST-extracts the helper and
runs it against a temp dir with controlled shortcut dirs) covering the dual,
native-only, empty, and missing-dir cases, wired into the windows-inference smoke
workflow. Verified on a real dual install: native uninstall now keeps unsloth.ico
and the WSL shortcut's icon stays intact.

* installer: condense AMD/ROCm code comments (no behavior change)

Tighten the comments added for the Strix Halo native+WSL installer work so
they are shorter and clearer without losing intent: the venv-internal hipInfo
amd-smi gate, the ROCm torch/companion floor maps, the WSL 24.04 reroute, and
the dual-install uninstall icon handling. Comment-only; code paths unchanged.
107 insertions, 166 deletions across 11 files.

* install.sh: run the Strix Halo WSL reroute before any STUDIO_HOME write

The reroute fired after mkdir -p "$STUDIO_HOME" and the legacy-venv migration,
so rerouting 26.04 -> 24.04 left an empty ~/.unsloth/studio stub in the origin
distro (and ran venv migration in the distro about to be abandoned). Move the
reroute ahead of the venv section so the origin distro is left untouched, matching
the function's own comment. Behavior is identical on every non-reroute path.

* installer: fix ROCm CPU-fallback, hipinfo gate edge cases, uninstall icon, WSL 22.04

- install.ps1: clear $ROCmIndexUrl/$ROCmTorchFloor after the CPU fallback so the
  flavor-repair block does not retry the failed ROCm index and abort the install;
  pin the ROCm companion specs ($visionSpec/$audioSpec) in the repair path too.
- install.ps1 + setup.ps1: skip a bare drive root in Test-HipinfoIsVenvInternal so a
  non-venv UNSLOTH_SETUP_PYTHON does not match the whole drive; iterate
  HIP_PATH/HIP_PATH_57/ROCM_PATH and take the first non-venv hipinfo.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: strip surrounding
  quotes from PATH entries before probing for hipinfo.
- install.sh: pipefail the WSL reroute curl|sh; do not reroute supported Ubuntu 22.04.
- uninstall.sh: keep the shared unsloth.ico while any Unsloth shortcut (native or
  another WSL distro) still references it, in both the powershell and drvfs paths.
- tests: regression coverage for all of the above.

* installer: forward reroute options, guard ROCm bootstrap, harden hipinfo gate

- install.sh: forward the caller's --package/--python/--verbose/--tauri and a custom
  UNSLOTH_STUDIO_HOME into the WSL reroute (was a bare default install); bail on
  --local; run the reroute BEFORE dependency/uv install so the origin distro is left
  untouched; set UNSLOTH_SKIP_ROCM_WSL_SETUP after a failed reroute so the later
  ROCm-on-WSL bootstrap does not install into the unsupported origin distro.
- install.ps1 + setup.ps1: Get-Command hipinfo -CommandType Application so only real
  executables match (not an alias/function named hipinfo).
- uninstall.ps1: guard $env:APPDATA when building the default shortcut search dirs.
- tests: cover option forwarding, --local bail, the bootstrap guard, and the gate change.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* installer: guard origin ROCm bootstrap on every CPU-only fallback; harden ~ expansion

WSL reroute: the no-wsl.exe, no-24.04-target and --local fallbacks all tell the
user the install continues CPU-only, but only the failed-reroute branch set
UNSLOTH_SKIP_ROCM_WSL_SETUP=1. The later _maybe_bootstrap_rocm_wsl gate keys off
that flag, so the other three branches could still install ROCm into the
unsupported origin distro (e.g. 26.04). Set the skip guard on all of them.

Forward UNSLOTH_ROCM_WSL_AUTO into the reroute so a Tauri/consented GPU bootstrap
carries through to the rerouted 24.04 child instead of dropping to the prompt path.

install.ps1/setup.ps1: guard the venv-probe ~ expansion on a non-empty
$env:USERPROFILE so Join-Path does not throw on a profile-less service account.

Tests: add no-wsl.exe and UNSLOTH_ROCM_WSL_AUTO reroute cases, the USERPROFILE
guard assertion, and route shell-test fixtures through a single trap-cleaned root.

* installer: pin + soften Windows ROCm Python repair, reroute to 22.04, harden gates

install_python_stack.py: the Windows AMD ROCm repair in _ensure_rocm_torch()
installed bare torch/torchvision/torchaudio via the fatal pip_install -- the same
asymmetry already fixed on the PowerShell side. A transient repo.amd.com failure
could abort the whole install even after install.ps1/setup.ps1 fell back to CPU.
Pin companions per-arch (gfx120X/Strix -> the rocm7.2 trio, mirroring the PS floor
maps) and make the retry nonfatal: keep the existing build and let the user re-run
update to retry ROCm, so the chain install.ps1 -> setup.ps1 -> stack stays CPU-safe.

install.sh: reroute now targets an installed Ubuntu 24.04 OR 22.04 (24.04 preferred);
both are AMD-supported for ROCm-on-WSL, matching the leave-alone set, so a box with
only 22.04 reaches the GPU instead of staying CPU-only.

install.ps1/setup.ps1: a bare ~ for UNSLOTH_STUDIO_HOME left an empty Join-Path child
(PS 5.1 throws); fall back to USERPROFILE directly and only join a real remainder.

_path_inside_venv (amd.py + both installers): guard a root-dir sys.prefix so commonpath
can't classify every path on the drive as venv-internal (defensive; venv never at root).

uninstall.sh: guard an empty LOCALAPPDATA in the PS-interop icon cleanup (mirror APPDATA).

Tests: add 22.04-target reroute cases, Windows ROCm pin+nonfatal coverage (text +
behavioral), root-dir guard coverage, and bare-~/LOCALAPPDATA guard assertions.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install.sh: match WSL reroute target by exact distro name, not substring

The 24.04/22.04 reroute target was chosen with grep -F (substring), so a custom
distro such as 'Ubuntu-24.04-test' (with no exact Ubuntu-24.04) was picked as the
target; the later 'wsl -d Ubuntu-24.04' then fails and the Strix Halo install stays
CPU-only. Match whole lines (grep -ixF) and reuse the matched name so only a real
Ubuntu-24.04/22.04 is targeted. Adds substring-rejection + exact-vs-custom tests.

* install.sh: keep the WSL reroute target to Ubuntu 24.04 (helper-supported only)

The ROCm-on-WSL bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any
VERSION_ID other than 24.04 and pins the noble repo, so treating 22.04 as
GPU-supported let the parent report a successful reroute while the child fell
back to CPU. Drop 22.04 from the supported set and the reroute target list;
24.04 stays the sole target (keeping the exact whole-line distro match). An
already-working ROCm on any other version is still left alone by the librocdxg
check above.

tests: reroute 22.04 cases updated to the 24.04-only behavior; make the
"no wsl.exe" case hermetic so a real host wsl.exe can't leak in on dev boxes;
stop the tauri exit-order check from mis-flagging the reroute helper's
[ "$TAURI_MODE" = true ] && ... --tauri one-liner.

* installer: tighten comment wording across the Strix Halo install/uninstall paths

Condense the verbose multi-line comment blocks (amd-smi hipinfo gate, ROCm
torch install + CPU fallback, WSL reroute, uninstall icon-keep) into fewer,
clearer lines. Comments and a few docstrings only; no code, logic, or
behavior change. Verified with bash -n, the PowerShell parser, and ast.parse,
and the installer test suite still passes.

* add AGPL-3.0 SPDX headers to the .sh/.ps1 scripts missing them

Every shell and PowerShell script under the Studio/installer surface now
carries the standard SPDX-License-Identifier: AGPL-3.0-only + copyright
header (after the shebang where present): the installer (install.sh,
install.ps1), build.sh, the .github and src-tauri scripts, the installer
test suite, and the moe kernel test. Header-only, line endings preserved;
bash -n, the PowerShell parser, and the installer tests all pass.

* installer: drop the duplicate AGPL header from install.sh and install.ps1

Both already carry an SPDX-License-Identifier: AGPL-3.0-only header below
their usage comment block; the prior header pass added a second one at the
top because it only scanned the first few lines. Remove the duplicate so each
file keeps a single original header.

* installer: force-reinstall CPU fallback torch; propagate Tauri NEED_SUDO from reroute

install.ps1/setup.ps1: when the AMD ROCm wheel install fails and we fall back to a
CPU base, force-reinstall the torch/vision/audio triplet. A failed ROCm install can
leave an unpinned ROCm torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still
satisfies the CPU torch>=2.4,<2.11.0 range, so without --force-reinstall uv keeps the
ROCm build and only swaps the companions -- a mismatched venv the flavor-repair block
won't fix. setup.ps1 scopes the forced reinstall to the ROCm-fallback path
() so the genuine CPU-only install stays fast.

install.sh: the Strix Halo WSL reroute treated every nonzero child exit as a reroute
failure and fell back to CPU. In --tauri mode the child uses exit 2 ([TAURI:NEED_SUDO])
to ask the desktop app to elevate for the target distro; capture the child's exit code
and propagate exit 2 in Tauri mode (the child already printed the NEED_SUDO line)
instead of masking it. CLI mode still falls back to CPU on a generic failure.

Tests: reroute Tauri exit-2 propagation (and non-Tauri CPU-fallback) cases;
run_func now preserves the child exit code; force-reinstall assertions for both
PowerShell installers.

Note: codex's _rr_q apostrophe finding is a false positive -- the helper already
emits POSIX-correct 'O'\''Brien' and round-trips under both sh and bash.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* setup.ps1: fix $cpuForce array collapse in the ROCm->CPU torch fallback

An if-expression assignment ($cpuForce = if ($ROCmCpuFallback) { @("--force-reinstall") })
collapses the single-element array to a scalar string, so @cpuForce splatting enumerated
it character-by-character into broken single-letter args (- - f o r c e ...), which made
uv/pip reject the install and aborted the whole Studio setup on the AMD ROCm->CPU fallback
path. Build $cpuForce as a real array assigned outside the if-expression so the splat passes
a single --force-reinstall arg. Genuine CPU-only installs stay fast (empty array, no flag).
Test now asserts the array-build form and rejects the if-expression form.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* uninstall: remove the isolated Node.js runtime (~/.unsloth/node)

The isolated Node.js runtime (install_node_prebuilt.py, added with the managed-Node
change) installs to ~/.unsloth/node in default mode -- a sibling of studio, so deleting
<studio> leaves it behind (~200MB orphaned after uninstall). Both uninstallers already
remove the other default-mode siblings (llama.cpp/.cache/.staging); add node alongside
them. uninstall.ps1 also adds it to the handle-lock sweep so a held node.exe can't block
the delete. Env/custom mode nests node under the custom root, removed with that root.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-22 03:09:08 -07:00

644 lines
22 KiB
Bash

#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# End-to-end sandbox tests for Mac Intel compatibility and UNSLOTH_NO_TORCH propagation.
# Tests version_ge, arch detection (existing), plus E2E venv creation, torch skip
# via a mock uv shim, and UNSLOTH_NO_TORCH env propagation in install.sh.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
PASS=0
FAIL=0
assert_eq() {
_label="$1"; _expected="$2"; _actual="$3"
if [ "$_actual" = "$_expected" ]; then
echo " PASS: $_label"
PASS=$((PASS + 1))
else
echo " FAIL: $_label (expected '$_expected', got '$_actual')"
FAIL=$((FAIL + 1))
fi
}
assert_contains() {
_label="$1"; _haystack="$2"; _needle="$3"
if echo "$_haystack" | grep -qF "$_needle"; then
echo " PASS: $_label"
PASS=$((PASS + 1))
else
echo " FAIL: $_label (expected to find '$_needle')"
FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
_label="$1"; _haystack="$2"; _needle="$3"
if echo "$_haystack" | grep -qF "$_needle"; then
echo " FAIL: $_label (found '$_needle' but should not)"
FAIL=$((FAIL + 1))
else
echo " PASS: $_label"
PASS=$((PASS + 1))
fi
}
# ── Extract version_ge function from install.sh ──
_VGE_FILE=$(mktemp)
sed -n '/^version_ge()/,/^}/p' "$INSTALL_SH" > "$_VGE_FILE"
echo "=== version_ge ==="
# Basic comparisons
_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13' '3.12' && echo pass || echo fail")
assert_eq "3.13 >= 3.12" "pass" "$_result"
_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.12' '3.13' && echo pass || echo fail")
assert_eq "3.12 >= 3.13" "fail" "$_result"
_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13' '3.13' && echo pass || echo fail")
assert_eq "3.13 >= 3.13 (equal)" "pass" "$_result"
# Patch versions
_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13.8' '3.13' && echo pass || echo fail")
assert_eq "3.13.8 >= 3.13 (patch > implicit 0)" "pass" "$_result"
_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.12.0' '3.13.0' && echo pass || echo fail")
assert_eq "3.12.0 >= 3.13.0 (minor less)" "fail" "$_result"
# UV_MIN_VERSION edge cases
_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.7.14' '0.7.14' && echo pass || echo fail")
assert_eq "0.7.14 >= 0.7.14 (exact UV_MIN_VERSION)" "pass" "$_result"
_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.7.13' '0.7.14' && echo pass || echo fail")
assert_eq "0.7.13 >= 0.7.14 (below minimum)" "fail" "$_result"
_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.11.1' '0.7.14' && echo pass || echo fail")
assert_eq "0.11.1 >= 0.7.14 (well above)" "pass" "$_result"
# Major jump
_result=$(bash -c ". '$_VGE_FILE'; version_ge '1.0' '0.99.99' && echo pass || echo fail")
assert_eq "1.0 >= 0.99.99 (major jump)" "pass" "$_result"
rm -f "$_VGE_FILE"
echo ""
echo "=== Architecture detection + PYTHON_VERSION ==="
# Self-contained arch detection snippet matching install.sh logic
_ARCH_SNIPPET=$(mktemp)
cat > "$_ARCH_SNIPPET" << 'SNIPPET'
OS="linux"
if [ "$(uname)" = "Darwin" ]; then
OS="macos"
fi
_ARCH=$(uname -m)
MAC_INTEL=false
if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then
MAC_INTEL=true
fi
_USER_PYTHON=""
if [ -n "$_USER_PYTHON" ]; then
PYTHON_VERSION="$_USER_PYTHON"
elif [ "$MAC_INTEL" = true ]; then
PYTHON_VERSION="3.12"
else
PYTHON_VERSION="3.13"
fi
echo "$OS $MAC_INTEL $PYTHON_VERSION"
SNIPPET
# Test: Darwin x86_64 -> macos true 3.12
_result=$(bash -c '
uname() {
case "$1" in
-m) echo "x86_64" ;;
*) echo "Darwin" ;;
esac
}
export -f uname
'"source '$_ARCH_SNIPPET'")
assert_eq "Darwin x86_64 -> macos true 3.12" "macos true 3.12" "$_result"
# Test: Darwin arm64 -> macos false 3.13
_result=$(bash -c '
uname() {
case "$1" in
-m) echo "arm64" ;;
*) echo "Darwin" ;;
esac
}
export -f uname
'"source '$_ARCH_SNIPPET'")
assert_eq "Darwin arm64 -> macos false 3.13" "macos false 3.13" "$_result"
# Test: Linux x86_64 -> linux false 3.13
_result=$(bash -c '
uname() {
case "$1" in
-m) echo "x86_64" ;;
*) echo "Linux" ;;
esac
}
export -f uname
'"source '$_ARCH_SNIPPET'")
assert_eq "Linux x86_64 -> linux false 3.13" "linux false 3.13" "$_result"
# Test: Linux aarch64 -> linux false 3.13
_result=$(bash -c '
uname() {
case "$1" in
-m) echo "aarch64" ;;
*) echo "Linux" ;;
esac
}
export -f uname
'"source '$_ARCH_SNIPPET'")
assert_eq "Linux aarch64 -> linux false 3.13" "linux false 3.13" "$_result"
rm -f "$_ARCH_SNIPPET"
echo ""
echo "=== get_torch_index_url on Darwin ==="
# Extract get_torch_index_url and replace hardcoded nvidia-smi path
_FUNC_FILE=$(mktemp)
_FAKE_SMI_DIR=$(mktemp -d)
sed -n '/^get_torch_index_url()/,/^}/p' "$INSTALL_SH" \
| sed "s|/usr/bin/nvidia-smi|$_FAKE_SMI_DIR/nvidia-smi-absent|g" \
> "$_FUNC_FILE"
# Build a minimal tools directory
_TOOLS_DIR=$(mktemp -d)
for _cmd in grep sed head sh bash cat; do
_real=$(command -v "$_cmd" 2>/dev/null || true)
[ -n "$_real" ] && ln -sf "$_real" "$_TOOLS_DIR/$_cmd"
done
# Create a mock uname that returns Darwin
_MOCK_UNAME_DIR=$(mktemp -d)
cat > "$_MOCK_UNAME_DIR/uname" << 'MOCK_UNAME'
#!/bin/sh
case "$1" in
-s) echo "Darwin" ;;
-m) echo "arm64" ;;
*) echo "Darwin" ;;
esac
MOCK_UNAME
chmod +x "$_MOCK_UNAME_DIR/uname"
# Mock nvidia-smi that returns CUDA version (to prove macOS ignores it)
_GPU_DIR=$(mktemp -d)
cat > "$_GPU_DIR/nvidia-smi" << 'MOCK_SMI'
#!/bin/sh
cat <<'SMI_OUT'
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.6 |
+-----------------------------------------------------------------------------------------+
SMI_OUT
MOCK_SMI
chmod +x "$_GPU_DIR/nvidia-smi"
# Test: Darwin always returns cpu (even with nvidia-smi present)
_result=$(PATH="$_GPU_DIR:$_MOCK_UNAME_DIR:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null)
assert_eq "Darwin -> cpu (even with nvidia-smi)" "https://download.pytorch.org/whl/cpu" "$_result"
# Test: Darwin without nvidia-smi also returns cpu
_result=$(PATH="$_MOCK_UNAME_DIR:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null)
assert_eq "Darwin -> cpu (no nvidia-smi)" "https://download.pytorch.org/whl/cpu" "$_result"
# Test: Darwin + UNSLOTH_PYTORCH_MIRROR produces mirror/cpu
_result=$(UNSLOTH_PYTORCH_MIRROR="https://mirror.example.com/whl" PATH="$_MOCK_UNAME_DIR:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null)
assert_eq "Darwin + mirror env -> mirror/cpu" "https://mirror.example.com/whl/cpu" "$_result"
rm -f "$_FUNC_FILE"
rm -rf "$_FAKE_SMI_DIR" "$_TOOLS_DIR" "$_MOCK_UNAME_DIR" "$_GPU_DIR"
echo ""
echo "=== UNSLOTH_NO_TORCH propagation ==="
# Verify UNSLOTH_NO_TORCH is passed to setup.sh in BOTH the --local and non-local branches.
_local_count=$(grep -c 'UNSLOTH_NO_TORCH=' "$INSTALL_SH" | head -1)
if [ "$_local_count" -ge 2 ]; then
echo " PASS: UNSLOTH_NO_TORCH appears in >= 2 setup.sh invocations ($_local_count found)"
PASS=$((PASS + 1))
else
echo " FAIL: UNSLOTH_NO_TORCH should appear in >= 2 setup.sh invocations (found $_local_count)"
FAIL=$((FAIL + 1))
fi
# Verify the value passed is "$SKIP_TORCH" (the unified variable, not MAC_INTEL)
_skip_torch_count=$(grep 'UNSLOTH_NO_TORCH="\$SKIP_TORCH"' "$INSTALL_SH" | wc -l)
if [ "$_skip_torch_count" -ge 2 ]; then
echo " PASS: UNSLOTH_NO_TORCH=\"\$SKIP_TORCH\" in both branches ($_skip_torch_count found)"
PASS=$((PASS + 1))
else
echo " FAIL: UNSLOTH_NO_TORCH=\"\$SKIP_TORCH\" should appear in >= 2 branches (found $_skip_torch_count)"
FAIL=$((FAIL + 1))
fi
# Verify MAC_INTEL is set to true when Intel Mac is detected
_mac_intel_set=$(grep -c 'MAC_INTEL=true' "$INSTALL_SH")
if [ "$_mac_intel_set" -ge 1 ]; then
echo " PASS: MAC_INTEL=true is set in install.sh"
PASS=$((PASS + 1))
else
echo " FAIL: MAC_INTEL=true not found in install.sh"
FAIL=$((FAIL + 1))
fi
# Verify SKIP_TORCH unified variable exists
if grep -q 'SKIP_TORCH=true' "$INSTALL_SH"; then
echo " PASS: SKIP_TORCH=true assignment found"
PASS=$((PASS + 1))
else
echo " FAIL: SKIP_TORCH=true not found in install.sh"
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== E2E: venv creation at Python 3.12 (simulated Intel Mac) ==="
# Actually create a uv venv at Python 3.12 to verify the path works
if command -v uv >/dev/null 2>&1; then
_VENV_DIR=$(mktemp -d)
_uv_result=$(uv venv "$_VENV_DIR/test_venv" --python 3.12 2>&1) && _uv_rc=0 || _uv_rc=$?
if [ "$_uv_rc" -eq 0 ]; then
echo " PASS: uv venv created at Python 3.12"
PASS=$((PASS + 1))
# Verify Python version inside the venv
_py_ver=$("$_VENV_DIR/test_venv/bin/python" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
assert_eq "venv Python is 3.12" "3.12" "$_py_ver"
# Verify torch is NOT available (fresh venv has no torch)
if "$_VENV_DIR/test_venv/bin/python" -c "import torch" 2>/dev/null; then
echo " FAIL: torch should NOT be importable in fresh 3.12 venv"
FAIL=$((FAIL + 1))
else
echo " PASS: torch not importable in fresh 3.12 venv (expected for Intel Mac)"
PASS=$((PASS + 1))
fi
else
echo " SKIP: Could not create Python 3.12 venv (python 3.12 not available)"
fi
rm -rf "$_VENV_DIR"
else
echo " SKIP: uv not available, cannot test venv creation"
fi
echo ""
echo "=== E2E: torch install skipped when SKIP_TORCH=true (mock uv shim) ==="
# Create a mock uv that logs all calls instead of running them
_MOCK_UV_DIR=$(mktemp -d)
_UV_LOG="$_MOCK_UV_DIR/uv_calls.log"
touch "$_UV_LOG"
cat > "$_MOCK_UV_DIR/uv" << MOCK_UV_EOF
#!/bin/sh
echo "UV_CALL: \$*" >> "$_UV_LOG"
MOCK_UV_EOF
chmod +x "$_MOCK_UV_DIR/uv"
# Simulates the torch install decision from install.sh using SKIP_TORCH
_TORCH_BLOCK=$(mktemp)
cat > "$_TORCH_BLOCK" << 'TORCH_EOF'
# Simulates the torch install decision from install.sh
TORCH_INDEX_URL="https://download.pytorch.org/whl/cpu"
_VENV_PY="/fake/python"
if [ "$SKIP_TORCH" = true ]; then
echo "==> Skipping PyTorch (--no-torch or Intel Mac x86_64)."
else
echo "==> Installing PyTorch ($TORCH_INDEX_URL)..."
uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \
--index-url "$TORCH_INDEX_URL"
fi
TORCH_EOF
# Test: SKIP_TORCH=true -> torch install should be SKIPPED (no uv calls)
> "$_UV_LOG" # clear log
_torch_output=$(SKIP_TORCH=true PATH="$_MOCK_UV_DIR:$PATH" bash "$_TORCH_BLOCK" 2>&1)
assert_contains "SKIP_TORCH=true prints skip message" "$_torch_output" "Skipping PyTorch"
if [ -s "$_UV_LOG" ]; then
echo " FAIL: uv was called when SKIP_TORCH=true (should be skipped)"
echo " Log: $(cat "$_UV_LOG")"
FAIL=$((FAIL + 1))
else
echo " PASS: no uv pip install torch when SKIP_TORCH=true"
PASS=$((PASS + 1))
fi
# Test: SKIP_TORCH=false -> torch install should EXECUTE (uv called with torch)
> "$_UV_LOG" # clear log
_torch_output=$(SKIP_TORCH=false PATH="$_MOCK_UV_DIR:$PATH" bash "$_TORCH_BLOCK" 2>&1)
assert_contains "SKIP_TORCH=false prints install message" "$_torch_output" "Installing PyTorch"
if grep -q "torch" "$_UV_LOG"; then
echo " PASS: uv pip install torch called when SKIP_TORCH=false"
PASS=$((PASS + 1))
else
echo " FAIL: uv pip install torch NOT called when SKIP_TORCH=false"
FAIL=$((FAIL + 1))
fi
rm -f "$_TORCH_BLOCK"
rm -rf "$_MOCK_UV_DIR"
echo ""
echo "=== E2E: UNSLOTH_NO_TORCH env propagation (dynamic test) ==="
# Simulates the setup.sh invocation using SKIP_TORCH
_ENV_BLOCK=$(mktemp)
cat > "$_ENV_BLOCK" << 'ENV_EOF'
# Simulates the setup.sh invocation block from install.sh
PACKAGE_NAME="unsloth"
_REPO_ROOT="/fake/repo"
SETUP_SH="/fake/setup.sh"
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
SKIP_STUDIO_BASE=1 \
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
STUDIO_LOCAL_INSTALL=1 \
STUDIO_LOCAL_REPO="$_REPO_ROOT" \
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
env | grep "^UNSLOTH_NO_TORCH="
else
SKIP_STUDIO_BASE=1 \
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
env | grep "^UNSLOTH_NO_TORCH="
fi
ENV_EOF
# Test: SKIP_TORCH=true -> UNSLOTH_NO_TORCH=true in env
_env_result=$(SKIP_TORCH=true STUDIO_LOCAL_INSTALL=false bash "$_ENV_BLOCK" 2>&1)
assert_eq "non-local: UNSLOTH_NO_TORCH=true when SKIP_TORCH=true" "UNSLOTH_NO_TORCH=true" "$_env_result"
# Test: SKIP_TORCH=false -> UNSLOTH_NO_TORCH=false in env
_env_result=$(SKIP_TORCH=false STUDIO_LOCAL_INSTALL=false bash "$_ENV_BLOCK" 2>&1)
assert_eq "non-local: UNSLOTH_NO_TORCH=false when SKIP_TORCH=false" "UNSLOTH_NO_TORCH=false" "$_env_result"
# Test: local install path also propagates
_env_result=$(SKIP_TORCH=true STUDIO_LOCAL_INSTALL=true bash "$_ENV_BLOCK" 2>&1)
assert_eq "local: UNSLOTH_NO_TORCH=true when SKIP_TORCH=true" "UNSLOTH_NO_TORCH=true" "$_env_result"
_env_result=$(SKIP_TORCH=false STUDIO_LOCAL_INSTALL=true bash "$_ENV_BLOCK" 2>&1)
assert_eq "local: UNSLOTH_NO_TORCH=false when SKIP_TORCH=false" "UNSLOTH_NO_TORCH=false" "$_env_result"
rm -f "$_ENV_BLOCK"
echo ""
echo "=== --python override flag ==="
# Test: flag parsing extracts version correctly
_PARSE_BLOCK=$(mktemp)
cat > "$_PARSE_BLOCK" << 'PARSE_EOF'
_USER_PYTHON=""
_next_is_python=false
_next_is_package=false
STUDIO_LOCAL_INSTALL=false
PACKAGE_NAME="unsloth"
for arg in "$@"; do
if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg"; _next_is_package=false; continue; fi
if [ "$_next_is_python" = true ]; then _USER_PYTHON="$arg"; _next_is_python=false; continue; fi
case "$arg" in
--local) STUDIO_LOCAL_INSTALL=true ;;
--package) _next_is_package=true ;;
--python) _next_is_python=true ;;
esac
done
if [ "$_next_is_python" = true ]; then echo "ERROR"; exit 1; fi
echo "$_USER_PYTHON"
PARSE_EOF
_result=$(bash "$_PARSE_BLOCK" --python 3.12)
assert_eq "--python 3.12 parsed" "3.12" "$_result"
_result=$(bash "$_PARSE_BLOCK" --local --python 3.11)
assert_eq "--local --python 3.11 parsed" "3.11" "$_result"
_result=$(bash "$_PARSE_BLOCK" --python 3.12 --local --package foo)
assert_eq "--python with --local --package" "3.12" "$_result"
_result=$(bash "$_PARSE_BLOCK" 2>&1) # no --python
assert_eq "no --python -> empty" "" "$_result"
_rc=0
bash "$_PARSE_BLOCK" --python >/dev/null 2>&1 || _rc=$?
assert_eq "--python without arg -> error" "1" "$_rc"
rm -f "$_PARSE_BLOCK"
# Test: --python overrides auto-detected version in PYTHON_VERSION resolution
_RESOLVE_BLOCK=$(mktemp)
cat > "$_RESOLVE_BLOCK" << 'RESOLVE_EOF'
_USER_PYTHON="$1"
MAC_INTEL="$2"
if [ -n "$_USER_PYTHON" ]; then
PYTHON_VERSION="$_USER_PYTHON"
elif [ "$MAC_INTEL" = true ]; then
PYTHON_VERSION="3.12"
else
PYTHON_VERSION="3.13"
fi
echo "$PYTHON_VERSION"
RESOLVE_EOF
_result=$(bash "$_RESOLVE_BLOCK" "3.11" "true")
assert_eq "--python 3.11 overrides Intel Mac 3.12" "3.11" "$_result"
_result=$(bash "$_RESOLVE_BLOCK" "3.12" "false")
assert_eq "--python 3.12 overrides default 3.13" "3.12" "$_result"
_result=$(bash "$_RESOLVE_BLOCK" "" "true")
assert_eq "no override -> Intel Mac gets 3.12" "3.12" "$_result"
_result=$(bash "$_RESOLVE_BLOCK" "" "false")
assert_eq "no override -> non-Intel gets 3.13" "3.13" "$_result"
rm -f "$_RESOLVE_BLOCK"
# Test: --python flag exists in install.sh
if grep -q '\-\-python)' "$INSTALL_SH"; then
echo " PASS: --python case exists in install.sh"
PASS=$((PASS + 1))
else
echo " FAIL: --python case not found in install.sh"
FAIL=$((FAIL + 1))
fi
# Test: _USER_PYTHON guards exist for stale-venv and 3.13.8 checks
_user_py_guards=$(grep -c '_USER_PYTHON' "$INSTALL_SH")
if [ "$_user_py_guards" -ge 4 ]; then
echo " PASS: _USER_PYTHON referenced >= 4 times in install.sh (flag + resolution + guards)"
PASS=$((PASS + 1))
else
echo " FAIL: _USER_PYTHON should appear >= 4 times (found $_user_py_guards)"
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== --no-torch flag parsing ==="
# Test: --no-torch sets _NO_TORCH_FLAG=true
_FLAG_SNIPPET=$(mktemp)
cat > "$_FLAG_SNIPPET" << 'SNIPPET'
_NO_TORCH_FLAG=false
_next_is_package=false
STUDIO_LOCAL_INSTALL=false
PACKAGE_NAME="unsloth"
for arg in "$@"; do
if [ "$_next_is_package" = true ]; then
PACKAGE_NAME="$arg"
_next_is_package=false
continue
fi
case "$arg" in
--local) STUDIO_LOCAL_INSTALL=true ;;
--package) _next_is_package=true ;;
--no-torch) _NO_TORCH_FLAG=true ;;
esac
done
echo "$_NO_TORCH_FLAG"
SNIPPET
_result=$(bash "$_FLAG_SNIPPET" --no-torch)
assert_eq "--no-torch sets flag to true" "true" "$_result"
_result=$(bash "$_FLAG_SNIPPET")
assert_eq "no flags -> flag is false" "false" "$_result"
_result=$(bash "$_FLAG_SNIPPET" --local --no-torch)
assert_eq "--local --no-torch both work" "true" "$_result"
_result=$(bash "$_FLAG_SNIPPET" --no-torch --package custom-pkg)
assert_eq "--no-torch with --package works" "true" "$_result"
rm -f "$_FLAG_SNIPPET"
echo ""
echo "=== SKIP_TORCH unification ==="
# Test: SKIP_TORCH is set to true when --no-torch flag is set (even without MAC_INTEL)
_SKIP_SNIPPET=$(mktemp)
cat > "$_SKIP_SNIPPET" << 'SNIPPET'
MAC_INTEL=false
_NO_TORCH_FLAG=$1
SKIP_TORCH=false
if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
SKIP_TORCH=true
fi
echo "$SKIP_TORCH"
SNIPPET
_result=$(bash "$_SKIP_SNIPPET" true)
assert_eq "--no-torch flag alone sets SKIP_TORCH=true" "true" "$_result"
_result=$(bash "$_SKIP_SNIPPET" false)
assert_eq "no flag, no MAC_INTEL -> SKIP_TORCH=false" "false" "$_result"
# Test: MAC_INTEL=true alone also sets SKIP_TORCH=true
_SKIP_SNIPPET2=$(mktemp)
cat > "$_SKIP_SNIPPET2" << 'SNIPPET'
MAC_INTEL=true
_NO_TORCH_FLAG=false
SKIP_TORCH=false
if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
SKIP_TORCH=true
fi
echo "$SKIP_TORCH"
SNIPPET
_result=$(bash "$_SKIP_SNIPPET2")
assert_eq "MAC_INTEL=true alone sets SKIP_TORCH=true" "true" "$_result"
rm -f "$_SKIP_SNIPPET" "$_SKIP_SNIPPET2"
echo ""
echo "=== --no-torch flag in install.sh ==="
if grep -q '\-\-no-torch' "$INSTALL_SH"; then
echo " PASS: --no-torch appears in install.sh"
PASS=$((PASS + 1))
else
echo " FAIL: --no-torch not found in install.sh"
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== Apple Silicon x86_64 (Rosetta) venv rebuild ==="
# Extract the real guard block from install.sh so we exercise the shipped logic
# (comment header down to its column-0 closing fi).
_GUARD_FILE=$(mktemp)
awk '/Guard against two independent Apple Silicon venv problems/{f=1} f{print} f&&/^fi$/{exit}' \
"$INSTALL_SH" > "$_GUARD_FILE"
if [ ! -s "$_GUARD_FILE" ]; then
echo " FAIL: could not extract Apple Silicon venv guard from install.sh"
FAIL=$((FAIL + 1))
else
# Runner: stub uv (via run_install_cmd) + a fake venv python, source the
# guard, then print "<final_arch> <final_ver> | <recreate_selectors>".
# The stub maps a uv arm64 selector to the interpreter uv would produce:
# cpython-3.12-* -> arm64 3.12.7, cpython-3.13-* -> arm64 $REBUILD_313_VERSION.
_RUNNER=$(mktemp)
cat > "$_RUNNER" << 'RUNNER_EOF'
GUARD="$1"; VENV_DIR="$2"
make_python() { # dir machine version
mkdir -p "$1/bin"
printf '#!/usr/bin/env bash\necho "%s %s"\n' "$2" "$3" > "$1/bin/python"
chmod +x "$1/bin/python"
}
RECREATE_LOG=$(mktemp); : > "$RECREATE_LOG"
run_install_cmd() {
shift # drop the human label
if [ "$1" = "uv" ] && [ "$2" = "venv" ]; then
dir="$3"; sel=""; shift 3
while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done
echo "$sel" >> "$RECREATE_LOG"
case "$sel" in
*3.12-macos-aarch64*) make_python "$dir" arm64 "3.12.7" ;;
*3.13-macos-aarch64*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;;
*) make_python "$dir" arm64 "$sel" ;;
esac
fi
}
[ "$INIT_ARCH" != none ] && make_python "$VENV_DIR" "$INIT_ARCH" "$INIT_VER"
PYTHON_VERSION="3.13"
. "$GUARD" >&2 # guard's user-facing echoes go to stderr; keep stdout clean
final="none"; [ -x "$VENV_DIR/bin/python" ] && final="$("$VENV_DIR/bin/python" -c x)"
printf '%s | %s\n' "$final" "$(paste -sd, "$RECREATE_LOG" 2>/dev/null)"
rm -f "$RECREATE_LOG"
RUNNER_EOF
_run_guard() { # _USER_PYTHON OS _ARCH INIT_ARCH INIT_VER REBUILD_313_VERSION
_vd=$(mktemp -d)
env _USER_PYTHON="$1" OS="$2" _ARCH="$3" INIT_ARCH="$4" INIT_VER="$5" \
REBUILD_313_VERSION="$6" bash "$_RUNNER" "$_GUARD_FILE" "$_vd/venv"
rm -rf "$_vd"
}
assert_eq "clean arm64 venv left untouched" \
"arm64 3.13.3 | " "$(_run_guard '' macos arm64 arm64 3.13.3 '')"
assert_eq "x86_64 venv rebuilt as arm64" \
"arm64 3.13.3 | cpython-3.13-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 venv that lands on 3.13.8 is rebuilt then downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.13-macos-aarch64-none,cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 3.13.8)"
assert_eq "arm64 3.13.8 venv downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 arm64 3.13.8 '')"
assert_eq "--python override skips the guard entirely" \
"x86_64 3.13.3 | " "$(_run_guard 3.11 macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 host (Intel/Rosetta shell) is a no-op here" \
"x86_64 3.13.3 | " "$(_run_guard '' macos x86_64 x86_64 3.13.3 '')"
rm -f "$_RUNNER"
fi
rm -f "$_GUARD_FILE"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1