diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 9b6e6ebb86..7fc5efab4c 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -3,8 +3,10 @@ # # Unsloth Studio uninstaller for Windows PowerShell. # Stops running servers and removes install dir, launcher data, CLI shim, -# desktop and Start Menu shortcuts, the user PATH entry, and the PathBackup -# registry key. Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME +# desktop and Start Menu shortcuts, the user PATH entry, the PathBackup +# registry key, and WebView2 runtime data keyed by the app bundle id +# (EBWebView caches created at first desktop-app launch, not by install.ps1). +# Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME # at install time (read back from share\studio.conf). # # Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex @@ -398,6 +400,32 @@ function Uninstall-UnslothStudio { _RemovePath $defaultUnslothHome } + # WebView2/app runtime data keyed by the Tauri bundle id. Created at first + # desktop-app launch, not by install.ps1. LOCALAPPDATA holds the EBWebView + # profile (a leftover copy serves a stale frontend to the next install); + # APPDATA holds the app config dir. + _Step "Removing WebView caches and app data (ai.unsloth.studio)..." + $bundleId = "ai.unsloth.studio" + $webviewDataDirs = @() + if ($env:LOCALAPPDATA) { $webviewDataDirs += Join-Path $env:LOCALAPPDATA $bundleId } + if ($env:APPDATA) { $webviewDataDirs += Join-Path $env:APPDATA $bundleId } + # Stop the desktop app and any msedgewebview2.exe helper using these dirs + # first; WebView2 keeps open handles that make the delete fail. + try { Stop-Process -Name "unsloth-studio" -Force -ErrorAction SilentlyContinue } catch { } + try { + foreach ($proc in (Get-CimInstance Win32_Process -Filter "Name = 'msedgewebview2.exe'" -ErrorAction SilentlyContinue)) { + $cl = $proc.CommandLine + if (-not $cl) { continue } + foreach ($d in $webviewDataDirs) { + if ($cl -ilike "*$d*") { + try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { } + break + } + } + } + } catch { } + foreach ($d in $webviewDataDirs) { _RemovePath $d } + # ── Remove desktop and Start Menu shortcuts ── _Step "Removing desktop and Start Menu shortcuts..." try { diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 957d2b7af2..303b5225dc 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -4,7 +4,9 @@ # # Unsloth Studio uninstaller (macOS / Linux / WSL). # Stops running servers and removes install dir, launcher data, -# CLI shim, desktop shortcut, .app bundle, and Launch Services entry. +# CLI shim, desktop shortcut, .app bundle, Launch Services entry, and +# WebView runtime data keyed by the app bundle id (WebKit/webkit2gtk +# caches created at first app launch, not by install.sh). # Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME at # install time (read back from studio.conf). # @@ -80,6 +82,13 @@ $_roots_from_conf" pkill -KILL -f "$_pat" 2>/dev/null || true done done + + # Tauri desktop app (binary "unsloth-studio"): stop it so its WebView + # helpers can't hold or re-create the runtime caches removed below. + # -x is an exact name match, so the "unsloth" CLI shim is never touched. + pkill -TERM -x unsloth-studio 2>/dev/null || true + sleep 0.5 + pkill -KILL -x unsloth-studio 2>/dev/null || true } _remove_path() { @@ -261,6 +270,25 @@ case "$_os" in if [ -x "$_lsr" ]; then "$_lsr" -u "$HOME/Applications/Unsloth Studio.app" 2>/dev/null || true fi + # WebView/app runtime data keyed by the bundle id. Created by macOS + + # WKWebView at first app launch, not by install.sh, so it survives an + # uninstall and a leftover WebKit cache then serves a stale frontend + # to the next install. + _bid="ai.unsloth.studio" + echo "Removing WebView caches and app data ($_bid)..." + _remove_path "$HOME/Library/Caches/$_bid" + _remove_path "$HOME/Library/WebKit/$_bid" + _remove_path "$HOME/Library/Application Support/$_bid" + _remove_path "$HOME/Library/HTTPStorages/$_bid" + _remove_path "$HOME/Library/HTTPStorages/$_bid.binarycookies" + _remove_path "$HOME/Library/Cookies/$_bid.binarycookies" + _remove_path "$HOME/Library/Saved Application State/$_bid.savedState" + # Delete via defaults first: cfprefsd caches plists in memory and can + # re-write the file after a bare rm. + if command -v defaults >/dev/null 2>&1; then + defaults delete "$_bid" >/dev/null 2>&1 || true + fi + _remove_path "$HOME/Library/Preferences/$_bid.plist" ;; Linux) if [ "$_is_wsl" = "1" ]; then @@ -403,6 +431,15 @@ case "$_os" in echo " sudo rm -rf /opt/rocm /opt/rocm-* && sudo ldconfig" fi fi + # WebView/app runtime data keyed by the Tauri bundle id. Created by + # webkit2gtk at first desktop-app launch, not by install.sh; a + # leftover cache serves a stale frontend to the next install. + _bid="ai.unsloth.studio" + echo "Removing WebView caches and app data ($_bid)..." + _remove_path "${XDG_CACHE_HOME:-$HOME/.cache}/$_bid" + _remove_path "${XDG_DATA_HOME:-$HOME/.local/share}/$_bid" + _remove_path "${XDG_CONFIG_HOME:-$HOME/.config}/$_bid" + _remove_path "${XDG_STATE_HOME:-$HOME/.local/state}/$_bid" echo "Removing Linux .desktop entry..." _remove_path "$HOME/.local/share/applications/unsloth-studio.desktop" if command -v update-desktop-database >/dev/null 2>&1; then diff --git a/tests/run_all.sh b/tests/run_all.sh index eaa726f73c..bb62582c61 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -14,6 +14,7 @@ sh "$TESTS_DIR/sh/test_nvcc_meets_llama_minimum.sh" sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh" sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh" sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh" +sh "$TESTS_DIR/sh/test_uninstall_webview_data.sh" sh "$TESTS_DIR/sh/test_torch_flavor.sh" sh "$TESTS_DIR/sh/test_redact_install_output.sh" sh "$TESTS_DIR/sh/test_install_uv_override_space.sh" diff --git a/tests/sh/test_uninstall_webview_data.sh b/tests/sh/test_uninstall_webview_data.sh new file mode 100755 index 0000000000..099e1037cd --- /dev/null +++ b/tests/sh/test_uninstall_webview_data.sh @@ -0,0 +1,129 @@ +#!/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 +# Regression tests for WebView runtime-data cleanup in scripts/uninstall.sh. +# +# WKWebView (macOS) and webkit2gtk (Linux) create data keyed by the bundle id +# at first app launch, not at install time, so the uninstaller used to miss it +# and a leftover cache served a stale frontend to the next install. Runs the +# full script against a fixture HOME (pkill/defaults stubbed via PATH, OS +# branch picked by a stubbed uname, /proc/version WSL probe force-failed) and +# asserts bundle-id paths are removed while unrelated app data survives. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +UNINSTALL_SH="$SCRIPT_DIR/../../scripts/uninstall.sh" +BID="ai.unsloth.studio" +PASS=0 +FAIL=0 + +_TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$_TMP_ROOT"' EXIT + +assert_gone() { _l="$1"; if [ -e "$2" ]; then echo " FAIL: $_l (still present: $2)"; FAIL=$((FAIL+1)); else echo " PASS: $_l"; PASS=$((PASS+1)); fi; } +assert_present() { _l="$1"; if [ -e "$2" ]; then echo " PASS: $_l"; PASS=$((PASS+1)); else echo " FAIL: $_l (missing: $2)"; FAIL=$((FAIL+1)); fi; } + +# Stub out process kills and macOS pref/LaunchServices tools so the script can +# run against a fixture HOME without touching the real system. +STUB_BIN="$_TMP_ROOT/stubbin" +mkdir -p "$STUB_BIN" +for _tool in pkill defaults; do + printf '#!/bin/sh\nexit 0\n' > "$STUB_BIN/$_tool" + chmod +x "$STUB_BIN/$_tool" +done +# Force the non-WSL path: with uname stubbed to Linux on a WSL host, the +# script's `grep -qi microsoft /proc/version` probe would still fire and the +# real WSL cleanup would touch the host's /mnt/* shortcuts and /etc profile. +# Fail that one probe; delegate every other grep call to the real grep. +# REAL_GREP must be an absolute path: a bare "grep" (e.g. from an alias-shaped +# `command -v` result) would resolve back to this stub and self-exec forever. +REAL_GREP=$(command -v grep) +case "$REAL_GREP" in /*) ;; *) REAL_GREP=/usr/bin/grep ;; esac +cat > "$STUB_BIN/grep" < "$STUB_BIN/$_tool" + chmod +x "$STUB_BIN/$_tool" +done + +# run_uninstall : run the full script with a stubbed OS. +run_uninstall() { + printf '#!/bin/sh\necho %s\n' "$2" > "$STUB_BIN/uname" + chmod +x "$STUB_BIN/uname" + env -u UNSLOTH_STUDIO_HOME -u STUDIO_HOME \ + -u XDG_CACHE_HOME -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_STATE_HOME \ + HOME="$1" PATH="$STUB_BIN:$PATH" sh "$UNINSTALL_SH" >/dev/null 2>&1 +} + +# ── 1. macOS: every bundle-id-keyed ~/Library path is removed ── +H=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$H/Library/Caches/$BID/WebKit/NetworkCache" \ + "$H/Library/WebKit/$BID/WebsiteData/CacheStorage" \ + "$H/Library/Application Support/$BID" \ + "$H/Library/HTTPStorages/$BID" \ + "$H/Library/Saved Application State/$BID.savedState" \ + "$H/Library/Preferences" \ + "$H/Library/Cookies" \ + "$H/Library/Caches/com.other.app" +: > "$H/Library/HTTPStorages/$BID.binarycookies" +: > "$H/Library/Cookies/$BID.binarycookies" +: > "$H/Library/Preferences/$BID.plist" +: > "$H/Library/Caches/$BID/stale-frontend.js" +: > "$H/Library/Caches/com.other.app/keepme" +run_uninstall "$H" Darwin +assert_gone "macOS: Caches/$BID removed" "$H/Library/Caches/$BID" +assert_gone "macOS: WebKit/$BID removed" "$H/Library/WebKit/$BID" +assert_gone "macOS: Application Support/$BID removed" "$H/Library/Application Support/$BID" +assert_gone "macOS: HTTPStorages/$BID removed" "$H/Library/HTTPStorages/$BID" +assert_gone "macOS: HTTPStorages/$BID.binarycookies removed" "$H/Library/HTTPStorages/$BID.binarycookies" +assert_gone "macOS: Cookies/$BID.binarycookies removed" "$H/Library/Cookies/$BID.binarycookies" +assert_gone "macOS: Saved Application State removed" "$H/Library/Saved Application State/$BID.savedState" +assert_gone "macOS: Preferences/$BID.plist removed" "$H/Library/Preferences/$BID.plist" +assert_present "macOS: unrelated app cache kept" "$H/Library/Caches/com.other.app/keepme" + +# ── 2. Linux: bundle-id-keyed XDG default paths are removed ── +H=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$H/.cache/$BID" "$H/.local/share/$BID" "$H/.config/$BID" \ + "$H/.local/state/$BID" "$H/.cache/other.app" +run_uninstall "$H" Linux +assert_gone "linux: ~/.cache/$BID removed" "$H/.cache/$BID" +assert_gone "linux: ~/.local/share/$BID removed" "$H/.local/share/$BID" +assert_gone "linux: ~/.config/$BID removed" "$H/.config/$BID" +assert_gone "linux: ~/.local/state/$BID removed" "$H/.local/state/$BID" +assert_present "linux: unrelated app cache kept" "$H/.cache/other.app" + +# ── 3. Linux: XDG_*_HOME overrides are honored ── +H=$(mktemp -d -p "$_TMP_ROOT") +XDG=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$XDG/cache/$BID" "$XDG/data/$BID" "$XDG/config/$BID" "$XDG/state/$BID" +printf '#!/bin/sh\necho Linux\n' > "$STUB_BIN/uname" +chmod +x "$STUB_BIN/uname" +env -u UNSLOTH_STUDIO_HOME -u STUDIO_HOME \ + XDG_CACHE_HOME="$XDG/cache" XDG_DATA_HOME="$XDG/data" \ + XDG_CONFIG_HOME="$XDG/config" XDG_STATE_HOME="$XDG/state" \ + HOME="$H" PATH="$STUB_BIN:$PATH" sh "$UNINSTALL_SH" >/dev/null 2>&1 +assert_gone "linux: XDG_CACHE_HOME override honored" "$XDG/cache/$BID" +assert_gone "linux: XDG_DATA_HOME override honored" "$XDG/data/$BID" +assert_gone "linux: XDG_CONFIG_HOME override honored" "$XDG/config/$BID" +assert_gone "linux: XDG_STATE_HOME override honored" "$XDG/state/$BID" + +# ── 4. Nothing to remove is a clean no-op (fresh HOME, exit 0) ── +H=$(mktemp -d -p "$_TMP_ROOT") +if run_uninstall "$H" Darwin; then + echo " PASS: empty HOME -> no-op exit 0"; PASS=$((PASS+1)) +else + echo " FAIL: empty HOME -> nonzero exit"; FAIL=$((FAIL+1)) +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] diff --git a/tests/studio/playwright_ui_font_scale.py b/tests/studio/playwright_ui_font_scale.py index 7dbe36127c..89c7894929 100644 --- a/tests/studio/playwright_ui_font_scale.py +++ b/tests/studio/playwright_ui_font_scale.py @@ -38,7 +38,11 @@ def fail(m): raise AssertionError(f"[font-scale] FAIL: {m}") -def near(a, b, tol = 0.35): +def near( + a, + b, + tol = 0.35, +): return a is not None and b is not None and abs(a - b) <= tol @@ -86,9 +90,7 @@ def open_appearance(page): page.wait_for_timeout(700) if page.get_by_role("dialog").count() == 0: fail("settings dialog did not open") - page.get_by_role("dialog").get_by_role("button").filter( - has_text = "Appearance" - ).first.click() + page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click() page.wait_for_timeout(600) @@ -153,9 +155,7 @@ def main(): page.wait_for_timeout(400) step("overflowing select scrolls its Radix viewport") - page.get_by_role("dialog").get_by_role("button").filter( - has_text = "Voice" - ).first.click() + page.get_by_role("dialog").get_by_role("button").filter(has_text = "Voice").first.click() page.wait_for_timeout(600) page.set_viewport_size({"width": 1440, "height": 480}) page.locator("[aria-label='Dictation language']").click() @@ -194,9 +194,7 @@ def main(): page.wait_for_timeout(400) step("default restores exactly") - page.get_by_role("dialog").get_by_role("button").filter( - has_text = "Appearance" - ).first.click() + page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click() page.wait_for_timeout(500) set_input(page, "UI font size", DEFAULT) final = measure(page) diff --git a/tests/studio/test_ui_font_scale_contract.py b/tests/studio/test_ui_font_scale_contract.py index 62bf80e82b..393c60dd6b 100644 --- a/tests/studio/test_ui_font_scale_contract.py +++ b/tests/studio/test_ui_font_scale_contract.py @@ -15,9 +15,7 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[2] SRC = REPO / "studio/frontend/src" INDEX_CSS = (SRC / "index.css").read_text(encoding = "utf-8") -STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text( - encoding = "utf-8" -) +STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(encoding = "utf-8") SELECT = (SRC / "components/ui/select.tsx").read_text(encoding = "utf-8") # Raw numeric fontSize props are only allowed where a scaled stylesheet rule @@ -70,9 +68,7 @@ def test_ui_token_families_exist(): def test_explicit_code_font_size_is_never_multiplied(): - match = re.search( - r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS - ) + match = re.search(r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS) assert match is not None body = match.group(1) assert "var(--custom-code-font-size)" in body @@ -83,7 +79,9 @@ def test_radix_select_viewport_owns_the_scroll_state(): viewport = SELECT[SELECT.index("SelectPrimitive.Viewport") :] assert "overflow-y-auto" in viewport.split("")[0] # The rounded surface itself must not scroll (WebKit squares its corners). - content_cls = re.search(r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT) + content_cls = re.search( + r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT + ) assert content_cls is not None assert "overflow-hidden" in content_cls.group(1) assert "overflow-y-auto" not in content_cls.group(1)