unsloth/tests/python/test_windows_setup_output_encoding.py
Daniel Han 6c124b6dd8
Run the repo CPU test suite on all four runner cores (#9019)
* Fix a 500 on dictation Unload before any backend is resident

stt_unload passes expected_model positionally:

    _, unload_stt = _stt_lifecycle()
    failed = await asyncio.to_thread(unload_stt, engines, model)

_stt_lifecycle() returns the orchestrator's unload_stt_model when a backend is
resident and stt_registry.unload when one is not. Only the first takes
expected_model positionally; on the registry it sits behind a `*`:

    def unload(engines = None, *, wait = True, expected_model = None)
    def unload_stt_model(self, engines = None, expected_model = None)

So with nothing loaded yet, which is what a fresh process is, Unload raises
TypeError and the route answers 500. Reproduced outside pytest:

    peek_inference_backend() fresh process: None
    stt_unload -> TypeError: unload() takes from 0 to 1 positional arguments
                             but 2 were given

Pass it by keyword, which both callables accept.

Found while profiling CI, not by the tests, and the tests are why: this file's
two unload tests passed only because an earlier test in the full suite had left
a backend resident. Standalone they failed, and in their own file they failed;
they went green only inside the full serial run. That order dependence hid a
live bug. The new test drives the no-backend path directly, so neither the
signature nor the call site can drift back.

    before: 2 failed, 53 passed   (file alone)
    after:  56 passed

* Run the repo CPU test suite on all four runner cores

Backend CI is the most expensive workflow in the repo at 126.7 runner-minutes
per push. Install is cached at ~1.2 minutes, so almost all of it is pytest.
ubuntu-latest has 4 vCPUs and this suite is CPU-bound and GPU-free, so it was
running on one core of four.

On a real runner the step goes 17.75 minutes to 7.67, and the failure set is
unchanged: the same single pre-existing failure, serial and parallel, on the
same tree in the same staging repo. Locally, on the dependency shape the
workflow installs, 806.1s to 219.7s with an identical 2 failed / 8066 passed /
178 skipped / 40 subtests result.

The three small pytest steps in this job stay serial: the hardware-spoof step
exists because those files mutate hardware.py module globals, and all three
already measure under 0.1 minutes.

The four-interpreter matrix is deliberately left alone. It is the bigger prize
at ~79 runner-minutes, but measured under -n 4 on a real runner it fails tests
serial does not, in more than one way and not the same way twice, so it needs
its order dependencies found and fixed first rather than a flag.

* Pin the two unload signatures against the route's one call site

The bug fixed in the previous commit exists because _stt_lifecycle returns two
different callables and the route has a single call site:

  def unload(engines = None, *, wait = True, expected_model = None)   # stt_registry
  def unload_stt_model(self, engines = None, expected_model = None)   # orchestrator

A call that suits one is a TypeError on the other, and which one runs depends on
whether a backend happens to be resident, so the broken half only appears on a
fresh process. That will recur the next time either signature is edited.

This binds BOTH real signatures against the arguments the route actually passes.
It does not demand they be identical, only that one call site can serve both.

Checked against two mutations:

  registry grows a keyword-only param  -> passes (benign, and it should)
  registry drops expected_model        -> fails, "got an unexpected keyword
                                          argument 'expected_model'"

Paired with the test beside it: that one covers the call site (route -> registry
on a cold process), this one covers the two callees staying compatible.

57 passed.

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

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

* Give each node harness run its own script file

Reported on this PR, and reproduced before changing anything. Every _run in
test_chat_preset_builtin_invariants.py wrote the same TEMP/run.mts and then
executed "node run.mts" from TEMP. Under pytest-xdist's default load
distribution two of this file's nine tests can land on different workers, so one
worker executes the script the other just wrote over the top of it.

Pinned to four cores, running the file at -n 4:

  before: 5 failed / 6 failed / 5 failed / 5 failed / 6 failed   (of 9)
  after:  9 passed, six runs out of six

Serial is unchanged at 9 passed either way, which is why the full-suite runs
that back this PR did not show it: at ~8000 tests across four workers these nine
rarely collide, so it is a latent flake rather than a reliable failure. That is
worse, not better, and enabling -n 4 is what would have started rolling the dice.

A unique NAME rather than a per-call directory, which is what
tests/studio/_node_harness.py::run_harness does for every other node harness
here. Those scripts reach the frontend sources by a relative path counted from
TEMP, so an extra directory level breaks every import with ERR_MODULE_NOT_FOUND
(tried it: 9 failed). register.mjs and loader.mjs stay shared because their
contents are fixed, so a concurrent rewrite writes identical bytes.

Swept the other five fixed TEMP roots under tests/studio: all of them already
allocate per call through tempfile.mkdtemp, directly or via run_harness. This
file was the only one rolling its own.

* Shorten the harness comment

* Stop two more shared-state races the parallel repo job would expose

Both reported on this PR, both real, both confirmed by reading the code.

1. tests/studio/test_chat_preset_builtin_invariants.py still rewrote the shared
   register.mjs and loader.mjs on every _run. Only run.mts became unique in the
   previous commit. write_text truncates before it writes, so a worker rewriting
   one of those while another worker's node process is importing it can hand that
   process an empty or partial module. The contents are constant, which is why I
   wrongly called a concurrent rewrite harmless: identical bytes still arrive
   after a truncation. They are now written through a temp file and os.replace,
   so every reader sees one whole version.

2. tests/python/test_no_torch_filtering.py::TestRealRequirementsFiltering had an
   autouse fixture that snapshotted the requirements directory and, at teardown,
   deleted every filtered file that had appeared since. Those files land in the
   REAL requirements directory, not tmp_path, so under xdist one test's teardown
   removes a file another worker is still reading. It now records the paths it
   creates and removes only those.

  test_chat_preset_builtin_invariants.py:  9 passed serial, 9 passed on four -n 4 runs
  test_no_torch_filtering.py:             65 passed serial, 65 passed on four -n 4 runs
  no filtered files left behind

* Run the event-loop latency tests outside the parallel invocation

Reported on this PR. tests/studio/load_freeze asserts upper bounds on real
elapsed time -- a /health burst under 250 ms while a 600 ms blocking probe runs,
and a 100-request burst under 350 ms -- and those bounds ARE the contract, so
they cannot be loosened without the tests ceasing to test anything. A pytest
worker descheduled by the other three inflates them.

Measured before changing anything, under 3x CPU oversubscription (8 spinners
pinned to the same 4 cores): 8 ms against the 250 ms bound, 38 ms against the
350 ms one, 3 runs, no failures. So the margin is wide on this box. It is not
wide enough to leave alone: a runner core is several times slower, which puts
the 350 ms bound within reach of an unlucky schedule. The directory is now
ignored from the -n 4 run and runs in its own serial step, 20s against the
~10 minutes -n 4 saves on this job.

The ignore and the step are two edits held together by nothing, and losing the
step is silent -- the job stays green while 23 tests stop running. New guard
tests/studio/test_backend_ci_parallel_isolation.py fails if an isolated path is
missing from the parallel run's ignores, or ignored with no serial step running
it, for load_freeze and for the three hardware-spoof files that were already in
this shape. Deleting either half of the load_freeze change fails it; a third
test pins the command scan so it cannot pass by matching nothing.

  load_freeze:            23 passed in 20.14s serial
  new guard:               9 passed, and 1 failed under each of the two mutations
  test_ci_shell_suite_coverage.py + test_xpu_spoof_pipeline.py: 58 passed

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

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

* Raise the matrix hang limit past the suite it was cancelling

Not a review item; found while triaging why every PR in this stack carries a red
check. The Backend CI matrix limit was 30 minutes, set when the step measured
14.1 to 15.0. It now measures 22.5 to 29.2, and it is cancelling: across the six
open PRs on this stack, five had exactly one leg cancelled at 30m9s while its
siblings passed at 25 to 29 minutes. Which leg loses is luck, so the red says
nothing about the PR, and a cancelled leg burns the full 30 minutes for no
signal.

45, which is what the Windows UI job already uses. This is a hang guard, not a
budget, and it costs nothing until a job needs it. The real fix is running this
matrix on all four cores, worth ~79 runner-minutes a push, and it stays blocked
on the order dependencies this stack is working through one at a time.

* Give each PowerShell probe its own script path

Reported on this PR, real, and reproduced directly. _run_capturing_bytes wrote to
tests/python/_{stem}_probe_{shape}.ps1, a name several tests share, so under
xdist one case unlinks the script another has written before that one's pwsh
child opens it. pwsh is installed on ubuntu-latest, so these do not skip there.

Reverting to the shared name and running -n 4 four times: 1 failed each time.
With a uuid in the name:

  35 passed, 8 skipped serial
  35 passed, 8 skipped on each of four -n 4 runs
  no probe scripts left behind

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

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

* Stop the pin scanner reading a generated filtered file as a second pin source

Reported on this PR and reproduced. test_no_torch_filtering.py exercises
install_python_stack._filter_requirements, which writes
`.{stem}-filtered-XXXX.txt` BESIDE the source on purpose, so relative -r/-c
includes still resolve. test_diffusers_pin.py::test_only_the_pin_file_names_diffusers
scans REQ_ROOT.rglob("*.txt"), so under xdist it can see one worker's copy of
diffusers-pin.txt and report it as a second source of the pin.

Calling _filter_requirements on diffusers-pin.txt and then running that test
fails exactly as described. It is not only an xdist problem: the function passes
delete=False, so the file also survives a real install on any machine.

The scan now skips dotfiles. That matches what it means -- no CHECKED-IN
requirements file other than the pin may name diffusers -- rather than narrowing
it: a real second source still fails it (verified with a probe file), and the
production write path is untouched, since writing beside the source is the
behaviour that makes includes resolve.

  5 passed with a filtered file present, 1 failed with a real second source

* Exempt only the generated filter files, not every dotfile

Reported on this PR and right: a checked-in hidden requirements file such as
.constraints.txt is a real place the pin could be overridden from, and the
blanket dotfile skip took it out of the scan.

Matched by the shape _filter_requirements actually writes now: a dot, the source
stem, "-filtered-", and NamedTemporaryFile's 8-character suffix.

  generated filtered file present:      5 passed
  checked-in .constraints.txt naming diffusers: 1 failed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-16 21:46:01 -07:00

799 lines
35 KiB
Python
Raw Permalink Blame History

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""The Windows desktop setup log must be UTF-8 and must print each step once.
"Getting things ready..." used to produce::
?? Unsloth Studio Setup
????????????????????????????????????????????????????
gpu
none (chat-only / GGUF)
gpu none (chat-only / GGUF)
Encoding: 5.1 encodes redirected output with the OEM code page while the desktop
app decodes the pipe as UTF-8 (``from_utf8_lossy``, src-tauri/src/install.rs).
U+1F9A5 has no OEM form so PowerShell writes one ``?`` per UTF-16 surrogate;
U+2500 has one, so it becomes a bare 0xC4 and arrives as U+FFFD.
Duplication: ``step``/``substep`` wrote through Write-Host *and* a console-handle
mirror, and the CLI spawns setup.ps1 as ``-Command "& '...' *>&1"``, which merges
the Information stream into stdout.
Splitting: ``step`` built one line from two Write-Host calls with -NoNewline,
which a redirected consumer splits at the record boundary.
Sink: fixing ``step``/``substep`` left every other line on Write-Host, which
5.1's console host writes through its own console-attached writer rather than
the UTF-8 one bound to ``[Console]::Out``. The banner and the footer are not
steps, so they never entered the sink #8083 built. Both entry scripts now
funnel through ``Write-StudioLine``, and Write-Host survives only inside
helpers that have already ruled out the redirected sink.
No console: the transcode above needs a console to transcode against. Where
``CREATE_NO_WINDOW`` really leaves the child without one, which is the state
install.rs's own comment assumes, Write-Host has no screen buffer to query and
throws instead, taking the whole script down under ``-ErrorActionPreference
Stop``. The banner is then not mangled, it is absent. That is what
``test_banner_survives_a_console_less_spawn`` measures, and it is the only case
here that separates this fix from what shipped before it.
The byte-level tests assert on raw bytes; decoding first would hide the exact
regression being guarded.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
import uuid
from functools import lru_cache
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
INSTALL_PS1 = REPO_ROOT / "install.ps1"
EXTRACTOR = REPO_ROOT / "tests" / "studio_setup_ps1" / "Get-FunctionSource.ps1"
SLOTH = "\U0001f9a5"
RULE_CHAR = ""
REPLACEMENT = "<EFBFBD>"
# The desktop app spawns Windows PowerShell 5.1; pwsh stands in elsewhere. The
# OEM-code-page bug only reproduces on 5.1, which the Windows runner covers.
_PWSH = shutil.which("powershell") if sys.platform == "win32" else shutil.which("pwsh")
pwsh_only = pytest.mark.skipif(_PWSH is None, reason = "PowerShell is unavailable")
def _harness(redirected_probe: bool) -> str:
"""Emit a known banner + steps using the real helpers.
Extracted from setup.ps1 rather than restated, so a change in shape fails
here instead of drifting.
"""
sink = "$true" if redirected_probe else "[Console]::IsOutputRedirected"
return f"""
$ErrorActionPreference = 'Stop'
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {{ [Console]::OutputEncoding = $_UnslothUtf8NoBom }} catch {{ }}
$OutputEncoding = $_UnslothUtf8NoBom
. '{EXTRACTOR.as_posix()}'
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep')) {{
$src = Get-FunctionSource -Path '{SETUP_PS1.as_posix()}' -Name $fn
if (-not $src) {{ throw "missing $fn" }}
. ([scriptblock]::Create($src))
}}
$script:StudioVtOk = $false
$script:StudioStdoutRedirected = {sink}
$Rule = [string]::new([char]0x2500, 52)
$Sloth = [char]::ConvertFromUtf32(0x1F9A5)
if ($script:StudioStdoutRedirected) {{
[Console]::Out.WriteLine(" $Sloth Unsloth Studio Setup")
[Console]::Out.WriteLine(" $Rule")
[Console]::Out.Flush()
}} else {{
Write-Host (" " + $Sloth + " Unsloth Studio Setup")
Write-Host (" " + $Rule)
}}
step "gpu" "none (chat-only / GGUF)"
step "long paths" "enabled"
substep "installing OXC validator runtime..."
"""
def _section(source: str, title: str) -> str:
"""The statements under a ``# <title>`` box header, up to the blank line.
Sliced out of setup.ps1 rather than restated, so the banner and the footer
are exercised as written. A rewrite that drops them back onto Write-Host
fails here.
"""
match = re.search(rf"(?m)^# {re.escape(title)}\n#[^\n]*\n", source)
assert match, f"no '{title}' section header in {SETUP_PS1.name}"
body = source[match.end() :]
return body[: body.index("\n\n")]
def _banner_footer_harness() -> str:
"""Print setup.ps1's real banner and footer with the redirected sink on."""
source = SETUP_PS1.read_text(encoding = "utf-8")
return f"""
$ErrorActionPreference = 'Stop'
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {{ [Console]::OutputEncoding = $_UnslothUtf8NoBom }} catch {{ }}
$OutputEncoding = $_UnslothUtf8NoBom
. '{EXTRACTOR.as_posix()}'
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioLine', 'Write-StudioStdoutMirror', 'step', 'substep')) {{
$src = Get-FunctionSource -Path '{SETUP_PS1.as_posix()}' -Name $fn
if (-not $src) {{ throw "missing $fn" }}
. ([scriptblock]::Create($src))
}}
# No console handle under CREATE_NO_WINDOW, so the real run never takes the
# ANSI branch; pin it here instead of depending on the test host.
$script:StudioVtOk = $false
$script:StudioStdoutRedirected = $true
$script:LlamaCppDegraded = $false
$env:SKIP_STUDIO_BASE = '1'
$Rule = [string]::new([char]0x2500, 52)
{_section(source, "Banner")}
{_section(source, "Footer")}
"""
def _run_capturing_bytes(
script: str,
use_command_shape: bool,
stem: str = "setup_output",
) -> bytes:
"""Run through a real pipe, in both launch shapes the product uses.
``-File`` is how the desktop app spawns the installer; ``-Command ... *>&1``
is how the CLI spawns setup for ``unsloth studio update``. Piped stdout is
required to reproduce, and is captured as bytes, never decoded here.
"""
# Unique per call. The name used to be (stem, shape), which several tests
# share, so under pytest-xdist one case could unlink the script after another
# had written it and before its pwsh child opened it. pwsh is installed on
# ubuntu-latest, so these do not skip there and would race for real.
tmp = (
REPO_ROOT
/ "tests"
/ "python"
/ f"_{stem}_probe_{int(use_command_shape)}_{uuid.uuid4().hex}.ps1"
)
tmp.write_text(script, encoding = "utf-8")
try:
base = [_PWSH, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"]
if use_command_shape:
literal = str(tmp).replace("'", "''")
argv = base + ["-Command", f"& '{literal}' *>&1"]
else:
argv = base + ["-File", str(tmp)]
proc = subprocess.run(argv, stdout = subprocess.PIPE, stderr = subprocess.PIPE, timeout = 180)
assert proc.returncode == 0, proc.stderr.decode("utf-8", errors = "replace")
return proc.stdout
finally:
tmp.unlink(missing_ok = True)
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_setup_output_is_valid_utf8(use_command_shape: bool) -> None:
"""Strict decode. Lossy decoding here would hide the exact regression."""
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8") # strict on purpose; UnicodeDecodeError is the failure
assert REPLACEMENT not in text, "output contains U+FFFD (OEM bytes decoded as UTF-8)"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_glyphs_survive_the_pipe(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8")
assert text.count(SLOTH) == 1, "sloth emoji lost or duplicated"
assert "??" not in text, "emoji was transcoded to '?' by a non-UTF-8 code page"
assert RULE_CHAR * 52 in text, "the 52-char rule did not survive intact"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_every_step_appears_exactly_once(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8")
for sentinel in ("none (chat-only / GGUF)", "enabled", "installing OXC validator runtime..."):
assert (
text.count(sentinel) == 1
), f"{sentinel!r} appeared {text.count(sentinel)} times, expected 1"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_step_label_and_value_stay_on_one_line(use_command_shape: bool) -> None:
"""The `gpu` / newline / `none (chat-only / GGUF)` split."""
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
lines = raw.decode("utf-8").splitlines()
matches = [line for line in lines if "gpu" in line]
assert len(matches) == 1, f"expected one gpu line, got {matches!r}"
assert matches[0] == " gpu none (chat-only / GGUF)"
# The banner and the footer are the two blocks a user actually reads in the
# desktop setup log, and neither goes through step/substep, so they need their
# own byte-level coverage.
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_and_footer_are_valid_utf8(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_banner_footer_harness(), use_command_shape, stem = "banner_footer")
text = raw.decode("utf-8") # strict on purpose
assert REPLACEMENT not in text, "banner/footer contain U+FFFD (OEM bytes decoded as UTF-8)"
assert "??" not in text, "emoji was transcoded to '?' by a non-UTF-8 code page"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_and_footer_print_once_each(use_command_shape: bool) -> None:
"""One sink, so no line survives twice even when *>&1 merges the streams."""
raw = _run_capturing_bytes(_banner_footer_harness(), use_command_shape, stem = "banner_footer")
text = raw.decode("utf-8")
assert text.count(SLOTH) == 1, "sloth emoji lost or duplicated"
assert text.count(f" {SLOTH} Unsloth Studio Setup") == 1
assert text.count(" Unsloth Studio Setup Complete") == 1
# One rule under the banner, two around the footer.
assert text.count(" " + RULE_CHAR * 52) == 3, "the 52-char rule did not survive intact"
# Source contracts. These run everywhere, including the Linux backend CI job,
# so a regression is caught without waiting for a Windows runner.
def _strip_comments(source: str) -> str:
return re.sub(r"(?m)#.*$", "", source)
def _mask_literals(source: str) -> str:
"""Blank comments and string literals, keeping every offset in place.
A regex over the raw text would trip over the launcher script install.ps1
builds in a here-string (it has its own Write-Host and no helper to call)
and over the commented-out block in setup.ps1.
"""
out = list(source)
index, size = 0, len(source)
def blank(start: int, stop: int) -> None:
for k in range(start, stop):
if out[k] != "\n":
out[k] = " "
while index < size:
char = source[index]
if source.startswith("<#", index):
stop = source.find("#>", index + 2)
stop = size if stop < 0 else stop + 2
elif char == "@" and index + 1 < size and source[index + 1] in "\"'":
terminator = "\n" + source[index + 1] + "@"
stop = source.find(terminator, index + 2)
stop = size if stop < 0 else stop + len(terminator)
elif char == "#" and (index == 0 or source[index - 1] in " \t\r\n(){};,|=&"):
stop = source.find("\n", index)
stop = size if stop < 0 else stop
elif char in "\"'":
stop = index + 1
while stop < size:
if char == '"' and source[stop] == "`":
stop += 2
continue
if source[stop] == char:
if stop + 1 < size and source[stop + 1] == char:
stop += 2
continue
stop += 1
break
stop += 1
else:
index += 1
continue
blank(index, stop)
index = stop
return "".join(out)
def _close_brace(masked: str, open_offset: int) -> int:
"""Offset of the `}` closing the `{` at `open_offset`, over masked source."""
depth = 0
for offset in range(open_offset, len(masked)):
if masked[offset] == "{":
depth += 1
elif masked[offset] == "}":
depth -= 1
if depth == 0:
return offset
raise AssertionError("unbalanced braces")
def _function_span(masked: str, name: str) -> tuple[int, int]:
"""Offsets of `function <name> { ... }`, brace-matched over masked source."""
match = _function_match(masked, name)
assert match, f"no function {name}"
return match.start(), _close_brace(masked, masked.index("{", match.end())) + 1
def _function_match(masked: str, name: str) -> re.Match[str] | None:
return re.search(r"(?im)^[ \t]*function\s+" + re.escape(name) + r"\b", masked)
# Write-Host may only appear inside a helper that has already ruled out the
# redirected sink: Write-StudioLine itself, and setup.ps1's step/substep, which
# return through the console mirror before reaching their interactive branch.
WRITE_HOST_ALLOW_LIST = {
SETUP_PS1: ("Write-StudioLine", "step", "substep"),
INSTALL_PS1: ("Write-StudioLine",),
}
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_set_the_utf8_invariant(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
assert "[Console]::OutputEncoding = $_UnslothUtf8NoBom" in source
assert "$env:PYTHONUTF8 = '1'" in source
assert "$env:PYTHONIOENCODING = 'utf-8'" in source
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_have_no_bom(path: Path) -> None:
"""5.1 parses BOM-less scripts as ANSI, so the fix stays ASCII-only.
A BOM would be a far wider packaging change: these get concatenated and
streamed through `irm | iex`.
"""
assert not path.read_bytes().startswith(b"\xef\xbb\xbf")
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_step_helper_emits_one_record(path: Path) -> None:
"""-NoNewline splits a logical line once a redirected consumer sees records."""
source = _strip_comments(path.read_text(encoding = "utf-8"))
match = re.search(r"(?m)^\s*function step\b", source)
assert match, f"no step function in {path.name}"
body = source[match.start() : match.start() + 2000]
assert "-NoNewline" not in body
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_write_host_only_survives_inside_gated_helpers(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
masked = _mask_literals(source)
spans = [_function_span(masked, name) for name in WRITE_HOST_ALLOW_LIST[path]]
lines = source.splitlines()
stray = [
f" {path.name}:{source.count(chr(10), 0, m.start()) + 1}: "
f"{lines[source.count(chr(10), 0, m.start())].strip()}"
for m in re.finditer(r"\bWrite-Host\b", masked)
if not any(lo <= m.start() < hi for lo, hi in spans)
]
assert not stray, (
"Write-Host is written by 5.1's console host, not by the UTF-8 writer bound to "
"[Console]::Out, so under CREATE_NO_WINDOW the desktop app renders these lines as "
"U+FFFD. Call Write-StudioLine instead (same arguments, same colors when "
"interactive):\n" + "\n".join(stray)
)
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_every_allow_listed_helper_rules_out_the_redirected_sink(path: Path) -> None:
"""The allow-list is only safe while each entry still checks the sink."""
masked = _mask_literals(path.read_text(encoding = "utf-8"))
for name in WRITE_HOST_ALLOW_LIST[path]:
lo, hi = _function_span(masked, name)
assert "$script:StudioStdoutRedirected" in masked[lo:hi], (
f"{name} in {path.name} reaches Write-Host without testing "
"$script:StudioStdoutRedirected; drop it from the allow-list or gate it"
)
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_the_sink_helper_is_defined_before_the_first_line_it_prints(path: Path) -> None:
"""PowerShell resolves functions at call time, but not before their line runs."""
masked = _mask_literals(path.read_text(encoding = "utf-8"))
definition = masked.index("function Write-StudioLine")
first_call = min(m.start() for m in re.finditer(r"\bWrite-StudioLine\b", masked))
assert first_call == definition + len(
"function "
), f"{path.name} calls Write-StudioLine before defining it"
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_the_sink_helper_writes_through_the_console_handle(path: Path) -> None:
masked = _mask_literals(path.read_text(encoding = "utf-8"))
lo, hi = _function_span(masked, "Write-StudioLine")
body = path.read_text(encoding = "utf-8")[lo:hi]
assert "[Console]::Out.WriteLine($Message)" in body
# Tauri reads line by line, so a buffered line is a line the user never sees.
assert "[Console]::Out.Flush()" in body
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_resolve_the_redirect_sink_once(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
assert "$script:StudioStdoutRedirected = [Console]::IsOutputRedirected" in source
def test_refresh_environment_cannot_clobber_the_python_encoding_vars() -> None:
"""Refresh-Environment reloads the registry repeatedly through a long run.
Without the guard a registry PYTHONUTF8=0 reloads over ours and every later
Python child goes back to mojibake.
"""
source = SETUP_PS1.read_text(encoding = "utf-8")
assert "$key -eq 'PYTHONUTF8' -or $key -eq 'PYTHONIOENCODING'" in source
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_bind_a_utf8_writer_when_there_is_no_console(path: Path) -> None:
"""The setter needs a console handle, and the desktop spawns us without one.
It drops the cached writer BEFORE throwing, assigning OutputEncoding only
after, so Console.Out would rebuild on the old code page and redirected
step/substep, whose only sink it is, would stay locale-encoded.
"""
source = path.read_text(encoding = "utf-8")
assert "[Console]::OpenStandardOutput()" in source
assert "[Console]::SetOut(" in source
# stderr is decoded identically and the failure text is built from it.
assert "[Console]::OpenStandardError()" in source
assert "[Console]::SetError(" in source
def test_managed_cli_command_uses_the_utf8_switch_not_just_env() -> None:
"""The managed CLI children must force UTF-8 on the command line.
Every Windows spawn of the CLI now goes through build_managed_cli_command in
process.rs, so that is where the switch has to be; update.rs only calls it.
The env vars alone are not enough: a caller that already exports
PYTHONIOENCODING wins over the ones set beside the spawn, and the Rust
readers decode as UTF-8 regardless. -X utf8 is not overridable that way.
https://docs.python.org/3/using/cmdline.html#cmdoption-X
"""
source = (REPO_ROOT / "studio" / "src-tauri" / "src" / "process.rs").read_text(encoding = "utf-8")
assert re.search(
r'"-X"\s*,\s*"utf8"', source
), "the managed CLI child needs -X utf8, not just the env vars"
@pytest.mark.parametrize(
"rust_file",
["install.rs", "update.rs", "process.rs"],
)
def test_rust_windows_spawns_force_utf8(rust_file: str) -> None:
"""The Rust readers decode as UTF-8, so their Windows children must emit it."""
source = (REPO_ROOT / "studio" / "src-tauri" / "src" / rust_file).read_text(encoding = "utf-8")
assert 'cmd.env("PYTHONUTF8", "1");' in source, f"{rust_file} does not force PYTHONUTF8"
assert (
'cmd.env("PYTHONIOENCODING", "utf-8");' in source
), f"{rust_file} does not force PYTHONIOENCODING"
# The console-less spawn. Windows only, and last in the file because it reuses
# the literal masking above to brace-match the blocks it lifts.
#
# A GitHub runner hands a CREATE_NO_WINDOW child a console anyway
# (GetConsoleOutputCP 437, GetConsoleWindow 0), so the UTF-8 setter in the
# preamble succeeds there and every version of these scripts emits a clean
# banner. The cases above therefore cannot tell this fix from what preceded it.
# Calling FreeConsole() in the child first puts it in the state install.rs's own
# comment assumes CREATE_NO_WINDOW produces, and there the two diverge hard:
# without the sink, Write-Host throws `HostException: GetConsoleScreenBufferInfo,
# The Win32 internal error "The handle is invalid" 0x6`, the script dies with
# exit 1 and 2 bytes of stdout, and the user's setup log holds a PowerShell
# stack trace where the banner should be.
#
# Everything the probe prints is sliced out of the script under test; only the
# FreeConsole prologue and the stderr diagnostics are harness. Restating the
# banner here would only assert that this file can print a sloth.
CREATE_NO_WINDOW = 0x08000000
# studio/src-tauri/src/install.rs::powershell_launch_args, minus the -File the
# runner appends. Not Bypass: RemoteSigned is what the shipped spawn uses.
TAURI_FLAGS = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "RemoteSigned"]
# install.rs::powershell_exe. Resolved absolutely for the same reason it is
# there, and not fallen back to a bare `powershell.exe`: pwsh 7 is UTF-8 by
# default and would pass this without exercising anything.
_WINDOWS_POWERSHELL = (
Path(os.environ.get("SystemRoot", r"C:\Windows"))
/ r"System32\WindowsPowerShell\v1.0\powershell.exe"
if sys.platform == "win32"
else None
)
windows_only = pytest.mark.skipif(
sys.platform != "win32", reason = "the console-less spawn is a Win32 state"
)
powershell_51_only = pytest.mark.skipif(
_WINDOWS_POWERSHELL is None or not _WINDOWS_POWERSHELL.is_file(),
reason = "Windows PowerShell 5.1 is unavailable",
)
# Documented kernel32 calls and nothing else, so the probe reaches the target
# state without the scripts under test knowing they are being tested.
_FREE_CONSOLE = """Add-Type -Namespace Force -Name Native -MemberDefinition @'
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
'@
$null = [Force.Native]::FreeConsole()"""
# The first line of the preamble, and the anchor the slice starts from.
_UTF8_ENCODER = "$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false"
def _dedent(block: str) -> str:
"""Strip the common indent; install.ps1 defines all of this inside a block."""
indents = [len(line) - len(line.lstrip()) for line in block.split("\n") if line.strip()]
cut = min(indents) if indents else 0
return "\n".join(line[cut:] if line.strip() else "" for line in block.split("\n"))
def _slice_preamble(source: str) -> str:
"""The UTF-8 output invariant, from the encoder to the PYTHONIOENCODING line."""
head = source.rindex("\n", 0, source.index(_UTF8_ENCODER)) + 1
tail = source.index("\n", source.index("$env:PYTHONIOENCODING = 'utf-8'")) + 1
return _dedent(source[head:tail])
def _slice_optional(source: str, pattern: str) -> str | None:
"""None is an answer: main's install.ps1 has neither probe nor sink helper."""
match = re.search(pattern, source)
return _dedent(match.group(0)) if match else None
def _slice_if_chain(source: str, masked: str, start: int) -> str:
"""A whole `if {} else {}`; brace-matching alone drops the non-ANSI branch.
The redirected run is exactly the one that takes that branch.
"""
end = _close_brace(masked, masked.index("{", start))
chained = r"[ \t\r\n]*(?:elseif[ \t]*\(.*?\)|else)[ \t\r\n]*\{"
while True:
tail = re.match(chained, masked[end + 1 :], re.S)
if not tail:
return source[start : end + 1]
end = _close_brace(masked, end + tail.end())
def _slice_banner(source: str, masked: str) -> str:
"""The blank line, the VT/plain branch and the trailing blank, verbatim."""
match = re.search(
r'(?m)^[ \t]*(?:Write-Host|Write-StudioLine) ""\n'
r"[ \t]*if \(\$script:StudioVtOk -and -not \$env:NO_COLOR\) \{",
source,
)
assert match, "no banner block"
block = _slice_if_chain(source, masked, match.end() - 1)
end = match.end() - 1 + len(block)
trailing = re.match(r'\n[ \t]*(?:Write-Host|Write-StudioLine) ""(?=\n)', source[end:])
return _dedent(source[match.start() : end + (trailing.end() if trailing else 0)])
def _console_less_probe(path: Path) -> str:
"""Assemble a probe out of the script's own preamble, helpers and banner."""
source = path.read_text(encoding = "utf-8")
masked = _mask_literals(source)
# Sliced too: it is what turns the Write-Host throw into a dead script
# rather than a skipped line, so restating it would be assuming the result.
eap = _slice_optional(source, r'(?m)^[ \t]*\$ErrorActionPreference = "Stop"')
assert eap, f"{path.name} no longer stops on error before the banner"
parts = [eap, _FREE_CONSOLE, "", _slice_preamble(source), ""]
redirect_probe = _slice_optional(
source,
r"(?m)^[ \t]*\$script:StudioStdoutRedirected = \$false\n"
r"[ \t]*try \{ \$script:StudioStdoutRedirected = \[Console\]::IsOutputRedirected \} catch \{ \}",
)
parts += [redirect_probe or "$script:StudioStdoutRedirected = $false", ""]
for name in ("Write-StudioLine", "Enable-StudioVirtualTerminal", "Get-StudioAnsi"):
if _function_match(masked, name):
lo, hi = _function_span(masked, name)
parts += [_dedent(source[lo:hi]), ""]
parts += ["$script:StudioVtOk = Enable-StudioVirtualTerminal", ""]
for pattern in (
r"(?m)^[ \t]*\$Rule = \[string\]::new\(\[char\]0x2500, 52\)",
r"(?m)^[ \t]*\$Sloth = \[char\]::ConvertFromUtf32\(0x1F9A5\)",
):
# setup.ps1 inlines the sloth in the banner; install.ps1 binds it first.
assignment = _slice_optional(source, pattern)
if assignment:
parts.append(assignment)
parts += ["", _slice_banner(source, masked), ""]
# On stderr, which the app reads on a separate reader, so stdout stays
# exactly the byte stream the log panel is built from.
parts += [
'[Console]::Error.WriteLine("psversion=" + $PSVersionTable.PSVersion.ToString())',
'[Console]::Error.WriteLine("console_outputencoding_codepage=" + [Console]::OutputEncoding.CodePage)',
'[Console]::Error.WriteLine("output_redirected=" + [Console]::IsOutputRedirected)',
'[Console]::Error.WriteLine("studio_stdout_redirected=" + $script:StudioStdoutRedirected)',
'[Console]::Error.WriteLine("studio_vt_ok=" + $script:StudioVtOk)',
]
assembled = "\n".join(parts) + "\n"
# 5.1 parses a BOM-less file as ANSI, which is why both scripts are ASCII-only.
assembled.encode("ascii")
return assembled
@lru_cache(maxsize = None)
def _run_console_less(path: Path, source: str | None = None) -> tuple[int, bytes, str]:
"""Spawn the probe the way install.rs spawns the installer, and read bytes.
`source` is for the VT parity case, which runs this file's own function beside the one it
replaced. A str keeps the lru_cache above workable; a dict would not hash.
"""
with tempfile.TemporaryDirectory() as workdir:
# A file written here has no Zone.Identifier, so RemoteSigned admits it.
probe = Path(workdir) / f"{path.stem}_console_less_probe.ps1"
text = _console_less_probe(path) if source is None else source
probe.write_bytes(text.replace("\n", "\r\n").encode("ascii"))
proc = subprocess.run(
[str(_WINDOWS_POWERSHELL), *TAURI_FLAGS, "-File", str(probe)],
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
creationflags = CREATE_NO_WINDOW,
timeout = 180,
)
return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors = "replace")
def _decode_like_install_rs(raw: bytes) -> str:
"""install.rs: read_until(b'\\n') -> trim_line_endings -> from_utf8_lossy.
One record per `install-progress` event, so this is what the log panel
renders. Python's 'replace' emits one U+FFFD per maximal subpart, the rule
Rust's from_utf8_lossy uses.
"""
records = raw.split(b"\n")
if records and records[-1] == b"":
records.pop() # read_until returning Ok(0) at EOF, not an empty line
return "\n".join(r.rstrip(b"\r\n").decode("utf-8", errors = "replace") for r in records)
def _explain(path: Path, code: int, raw: bytes, err: str) -> str:
tail = "\n".join(line for line in err.splitlines() if line.strip())[-1200:]
return (
f"\n{path.name} under a console-less CREATE_NO_WINDOW spawn: exit {code}, "
f"{len(raw)} stdout bytes.\nstderr:\n{tail}\n"
)
# A floor, not the 191 and 207 bytes these banners currently produce. The point
# is only to outrun a dead script: without the sink the run aborts having
# written 2 bytes, and every "nothing is wrong with this stream" assertion below
# holds trivially over 2 bytes. Two of the three cases here are phrased that way
# because that is the regression they guard, so they need this floor underneath
# them or they pass on the very code they exist to reject. Pinning the exact
# count instead would make editing the banner wording a test failure.
_MIN_BANNER_BYTES = 64
def _banner_or_explain(path: Path) -> tuple[str, str]:
"""Run the probe, insist the banner actually arrived, and decode it.
Every console-less case starts here. `raw` being truthy is not enough: the
aborted run is truthy too.
"""
code, raw, err = _run_console_less(path)
detail = _explain(path, code, raw, err)
assert code == 0, (
"the banner block aborted the script instead of printing. Write-Host needs a "
"console screen buffer, and CREATE_NO_WINDOW is documented not to give the "
"child one, so it throws and -ErrorActionPreference Stop takes the run down. "
"The desktop setup log gets a PowerShell stack trace and no banner at all. "
"Route the line through Write-StudioLine." + detail
)
assert len(raw) >= _MIN_BANNER_BYTES, (
f"only {len(raw)} stdout bytes, under the {_MIN_BANNER_BYTES}-byte floor: the "
"banner was LOST, not mangled. Exiting 0 having printed nothing is the same "
"empty setup log to the user as throwing." + detail
)
return _decode_like_install_rs(raw), detail
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_banner_survives_a_console_less_spawn(path: Path) -> None:
"""Without the sink this exits 1 with 2 bytes: the banner never arrives."""
_banner_or_explain(path)
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_console_less_banner_is_valid_utf8(path: Path) -> None:
"""Lossy first: it names how bad the stream is before the strict decode."""
lossy, detail = _banner_or_explain(path)
assert REPLACEMENT not in lossy, (
"the desktop app decodes this pipe as UTF-8 and got bytes that are not, so the "
"log shows U+FFFD. With no console the UTF-8 setter throws, and only the "
"writers bound in its catch branch keep the stream UTF-8." + detail
)
# Strict on purpose; UnicodeDecodeError is the failure. Reruns the cached
# bytes rather than trusting the lossy pass above to have caught everything.
_run_console_less(path)[1].decode("utf-8")
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_console_less_banner_keeps_its_glyphs(path: Path) -> None:
text, detail = _banner_or_explain(path)
assert SLOTH in text, "the sloth did not reach stdout" + detail
assert "??" not in text, "the sloth was transcoded to '?' by a non-UTF-8 code page" + detail
assert text.count(RULE_CHAR * 52) == 1, (
f"expected one 52-char U+2500 rule, found {text.count(RULE_CHAR * 52)}" + detail
)
# Sliced back out to rebuild the function this replaced, so parity is measured against the real
# predecessor. The comments go with the guard: they do not execute, but leaving them behind
# would make the reconstruction something this test invented rather than the merge-base function.
_VT_FAST_PATH = re.compile(
r"(?m)^[ \t]*# A redirected stdout is not a console.*?\n"
r"(?:^[ \t]*#.*\n)*"
r"^[ \t]*if \(\$script:StudioStdoutRedirected\) \{ return \$false \}\n"
)
def _probe_without_the_vt_fast_path(path: Path) -> str:
probe = _console_less_probe(path)
stripped, count = _VT_FAST_PATH.subn("", probe, count = 1)
assert count == 1, (
f"{path.name}: the VT fast path is not in the sliced probe in the shape this test "
f"removes, so nothing was being compared. Update _VT_FAST_PATH."
)
return stripped
def _vt_verdict(err: str) -> str:
for line in err.splitlines():
if line.startswith("studio_vt_ok="):
return line.split("=", 1)[1].strip()
raise AssertionError(f"the probe printed no studio_vt_ok line:\n{err}")
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_vt_fast_path_decides_exactly_as_the_compile_did(path: Path) -> None:
"""Skipping csc.exe must not change one byte the user sees.
This probe is the changed branch, not a bystander: install.rs spawns with a pipe, so
`$script:StudioStdoutRedirected` is true here and the early return is what runs. The
reconstructed predecessor reaches Add-Type instead, and has to land on the same verdict.
"""
new_code, new_raw, new_err = _run_console_less(path)
old_code, old_raw, old_err = _run_console_less(
path, source = _probe_without_the_vt_fast_path(path)
)
assert new_code == old_code == 0, (
f"probe exit codes {new_code} (with the fast path) and {old_code} (without)"
f"{_explain(path, new_code, new_raw, new_err)}"
)
assert _vt_verdict(new_err) == _vt_verdict(old_err) == "False", (
f"a redirected stream cannot render VT: the fast path returned "
f"{_vt_verdict(new_err)} where the compile returned {_vt_verdict(old_err)}"
)
assert new_raw == old_raw, (
"the banner bytes moved. Same verdict in, same bytes out is the whole contract of "
"this change" + _explain(path, new_code, new_raw, new_err)
)