unsloth/tests/studio/playwright_ui_font_scale.py
Long Yixing e143e1ce33
feat(studio): Mac-aware training controls for MLX (optimizers, LoftQ, packing) (#7358)
* feat(studio): offer MLX-supported optimizers on Apple Silicon

The training form's optimizer dropdown only listed CUDA/bitsandbytes optimizers (adamw_8bit, paged variants, torch fused). On Apple Silicon the MLX trainer supports a different set (adamw, adam, lion, muon, sgd, adafactor) and remaps every bitsandbytes/torch name to plain AdamW, so the dropdown misrepresented what actually runs.

Offer the MLX optimizer list when the device is a Mac, and derive the displayed value so the control is never blank: the shared CUDA default and the other bitsandbytes/torch options render as AdamW (exactly how the MLX backend normalizes them), while any other value is shown as-is so an unrecognized or non-canonical imported optimizer is never mislabeled. Non-Mac behavior is unchanged. The run-summary optimizer label now resolves from both lists.

* feat(studio): show an MLX-appropriate optimizer tooltip on Apple Silicon

The optimizer tooltip described "8-bit variants" and recommended "Fused" for vision models, neither of which is offered when training runs on MLX. On Apple Silicon, show a tooltip that matches the MLX optimizer set and notes that Lion typically needs a lower learning rate than AdamW.

Copy-only: no change to the selected optimizer or the learning rate, and the non-Mac tooltip is unchanged. The new string is added to the English locale; other locales fall back to English until translated, matching how new keys are handled elsewhere.

* fix(studio): label Mac CUDA-alias optimizers as AdamW in the run summary

On Apple Silicon the run-configuration summary looked up the stored optimizer name directly, so a run that kept a CUDA/bitsandbytes default such as adamw_8bit was labeled "AdamW 8-bit" even though the picker shows "AdamW" and the MLX backend runs plain AdamW. Mirror the training form's derivation so those aliases are labeled AdamW in the summary too.

Display-only: no change to the stored or submitted optimizer, and non-Mac summaries are unchanged.

* feat(studio): disable LoftQ and sequence packing on Apple Silicon

Neither LoftQ nor sequence packing is supported on MLX — the backend rejects LoftQ and the trainer silently forces packing off — yet the training form still offered both on Apple Silicon.

Disable the LoftQ LoRA-init option (greyed and unclickable, with an inline "Not supported on Apple Silicon" note) and the "Enable packing" checkbox (greyed, with a tooltip explaining why), matching how the unsupported "Enable streaming" control is presented. Clearing effects reset a stale loftq/packing value to its default on Mac so the disabled controls never submit it. Non-Mac behavior is unchanged.

* [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-07-23 02:14:20 -07:00

213 lines
7.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""UI font size scaling regression (Settings > Appearance).
Drives the real appearance controls and asserts the typography-scale
contract: text and line heights scale by size/16, the root font size and
layout geometry never move, an explicit Code font size stays fixed, and an
overflowing Radix select scrolls its viewport by keyboard and wheel.
Runs against an already-booted, already-bootstrapped Unsloth:
BASE_URL=http://127.0.0.1:18894 STUDIO_PW=... python tests/studio/playwright_ui_font_scale.py
"""
import os
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _playwright_robust import wait_for_health # noqa: E402
BASE = os.environ["BASE_URL"]
PW = os.environ["STUDIO_PW"]
ART = Path(os.environ.get("PW_ART_DIR", "logs/playwright_fontscale"))
ART.mkdir(parents = True, exist_ok = True)
SIZES = (12, 20)
DEFAULT = 16
def step(s):
print(f"[font-scale] STEP {s}", flush = True)
def fail(m):
raise AssertionError(f"[font-scale] FAIL: {m}")
def near(
a,
b,
tol = 0.35,
):
return a is not None and b is not None and abs(a - b) <= tol
MEASURE_JS = """
() => {
const fs = (el) => (el ? parseFloat(getComputedStyle(el).fontSize) : null);
const lh = (el) => (el ? parseFloat(getComputedStyle(el).lineHeight) : null);
const byText = (txt) =>
[...document.querySelectorAll("span, h2, label, p")].find(
(e) => e.textContent.trim() === txt,
);
const nav = byText("New chat");
const sidebar =
document.querySelector("[data-slot='sidebar-container']") ??
document.querySelector("aside") ??
document.querySelector("nav");
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
uiAttr: document.documentElement.getAttribute("data-ui-font-size"),
navFont: fs(nav),
navLine: lh(nav),
sidebarW: sidebar ? sidebar.getBoundingClientRect().width : null,
};
}
"""
def measure(page):
return page.evaluate(MEASURE_JS)
def set_input(page, label, value):
field = page.locator(f"input[aria-label='{label}']")
field.scroll_into_view_if_needed()
field.fill(str(value))
page.keyboard.press("Enter")
page.wait_for_timeout(600)
def open_appearance(page):
page.keyboard.press("Control+,")
page.wait_for_timeout(700)
if page.get_by_role("dialog").count() == 0:
page.keyboard.press("Meta+,")
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.wait_for_timeout(600)
def main():
wait_for_health(BASE)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport = {"width": 1440, "height": 900})
page.goto(BASE, wait_until = "networkidle")
pw_field = page.locator("input[type='password']")
if pw_field.count():
pw_field.first.fill(PW)
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500)
step("baseline at the default size")
open_appearance(page)
set_input(page, "UI font size", DEFAULT)
base = measure(page)
if base["root"] != 16:
fail(f"root font size not 16 at default: {base['root']}")
if base["navFont"] is None or base["sidebarW"] is None:
fail(f"baseline samples missing: {base}")
for size in SIZES:
step(f"UI font size {size}")
set_input(page, "UI font size", size)
m = measure(page)
ratio = size / DEFAULT
if m["root"] != 16:
fail(f"root font size moved at {size}: {m['root']}")
if m["uiAttr"] != str(size):
fail(f"data-ui-font-size wrong at {size}: {m['uiAttr']}")
if not near(m["navFont"], base["navFont"] * ratio):
fail(f"nav font at {size}: {base['navFont']} -> {m['navFont']}")
if not near(m["navLine"], base["navLine"] * ratio):
fail(f"nav line-height at {size}: {base['navLine']} -> {m['navLine']}")
if not near(m["sidebarW"], base["sidebarW"], 0.75):
fail(f"sidebar width moved at {size}: {base['sidebarW']} -> {m['sidebarW']}")
page.screenshot(path = str(ART / f"scale-{size}.png"))
step("explicit Code font size stays fixed under UI 20")
set_input(page, "Code font size", 13)
res = page.evaluate(
"""
() => {
const pre = document.createElement("pre");
pre.textContent = "sample";
document.body.appendChild(pre);
const size = getComputedStyle(pre).fontSize;
pre.remove();
return size;
}
"""
)
if res != "13px":
fail(f"explicit code font size scaled: {res}")
code_field = page.locator("input[aria-label='Code font size']")
code_field.fill("")
page.keyboard.press("Enter")
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.wait_for_timeout(600)
page.set_viewport_size({"width": 1440, "height": 480})
page.locator("[aria-label='Dictation language']").click()
page.wait_for_timeout(700)
state = page.evaluate(
"""
() => {
const vp = document.querySelector("[data-radix-select-viewport]");
return vp
? { scrollable: vp.scrollHeight > vp.clientHeight, top: vp.scrollTop }
: null;
}
"""
)
if not state or not state["scrollable"]:
fail(f"select viewport not scrollable: {state}")
for _ in range(6):
page.keyboard.press("ArrowDown")
page.wait_for_timeout(100)
kb_top = page.evaluate(
"() => document.querySelector('[data-radix-select-viewport]').scrollTop"
)
if not kb_top > 0:
fail(f"keyboard did not scroll the select viewport: {kb_top}")
vp_box = page.locator("[data-radix-select-viewport]").bounding_box()
page.mouse.move(vp_box["x"] + vp_box["width"] / 2, vp_box["y"] + 40)
page.mouse.wheel(0, -400)
page.wait_for_timeout(300)
wheel_top = page.evaluate(
"() => document.querySelector('[data-radix-select-viewport]').scrollTop"
)
if not wheel_top < kb_top:
fail(f"wheel did not scroll the select viewport: {kb_top} -> {wheel_top}")
page.keyboard.press("Escape")
page.set_viewport_size({"width": 1440, "height": 900})
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.wait_for_timeout(500)
set_input(page, "UI font size", DEFAULT)
final = measure(page)
for key in ("root", "navFont", "navLine", "sidebarW"):
if not near(final[key], base[key], 0.35):
fail(f"default drifted for {key}: {base[key]} -> {final[key]}")
if final["uiAttr"] is not None:
fail(f"data-ui-font-size present at default: {final['uiAttr']}")
page.screenshot(path = str(ART / "restored-default.png"))
browser.close()
print("[font-scale] PASS", flush = True)
if __name__ == "__main__":
main()