unsloth/tests/studio/playwright_model_config.py
Souravrajvi0 dffea2af27
fix(studio): honor run settings on initial model load (#7346) (#7351)
* fix(studio): honor run settings on initial model load

When loading a model from the gear-icon run-settings page, Context Length
and KV Cache Dtype were ignored if the user clicked Load before blurring
the context field, or before React flushed staged config into the store.

- Add NumericValueInput.commit() to flush a focused draft on Load
- Pass effectiveLoadConfig from model-config-page to onRun
- Prefer selection.config in performLoad for all load knobs
- Preserve meta.forceReload from the config-page reload path

Fixes #7346

* fix(studio): flush NumericValueInput draft when Load blurs first

Clicking Load blurs the context field before handleRun runs, so commit()
returned the stale value prop. Keep draft in a ref and parse it even when
the input is no longer focused.

* fix(studio): preserve Auto context when Load is clicked without edits

NumericValueInput.commit() now returns null unless the user actually
changed the field, so GGUF Load/Save no longer pins the displayed native
context into customContextLength when Auto was left untouched.

* fix(studio): clear NumericValueInput dirty state after blur commit

After a normal blur commit, reset dirtyRef so a later Load cannot replay a
stale draftRef when the user changed context via Reset or the slider.

* test(studio): pin NumericValueInput Auto/dirty contracts for #7346

Lock Codex P1/P2: commit returns null unless dirty, blur clears dirtyRef,
and handleRun only promotes a non-null committed context.

* fix(studio): keep same-click context draft after blur (#7346)

Blur can commit and clear dirtyRef before Load's onClick; stash that
committed value for one imperative commit() so typed context is not lost.

* chore: refresh PR head for #7351

* fix(studio): handle context commit edge cases

* chore: refresh PR head

* test(studio): guard invalid context drafts

* style(studio): format context draft guard

* test(studio): exercise same-click model config loads

* fix(studio): drop stale blur pin when the typed context equals the shown value

NumericValueInput cached every blur commit in lastBlurCommittedRef, even when
the draft equalled the current value and no onChange was dispatched. Because the
displayed value never changed, the useEffect([value]) clear never fired, so a
later Reset or external edit that leaves the shown value unchanged could not drop
the cache and the next commit() replayed it into an override that Reset had
removed. Only cache the blur result when it actually dispatched onChange
(final !== value); when final === value the parent is already current and there
is nothing to bridge. Add a Playwright regression that re-types the shown context
and asserts no override is stored.

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

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

* Studio: commit every same-click numeric draft before staging the load config

The run-settings Load/Reload button flushed only the GGUF Context Length draft
imperatively before building the load config. Max Seq Length (non-GGUF), GPU
Layers and MoE Layers on CPU (GGUF) are the same NumericValueInput and stage
their typed value only on blur, so editing one and clicking Load in the same
gesture staged the load from a still-stale parent config and dropped the value
the user just typed.

Wire an imperative commit handle through those inputs too and fold every
committed draft into the effective config, recomputing the non-GGUF load-time
max sequence length from the committed draft.

* fix(studio): recompute fixed-layer context pin and drop stale blur cache on every render

Two run-settings edge cases on the model-config page:

1) pinFixedLayerContext was computed from the render-time config, before a
   same-click GPU Layers draft is committed in handleRun. Typing a positive
   fixed-layer value on an auto-fit GGUF and clicking Reload therefore built
   the runtime config with customContextLength: null, so a later fresh load
   sent the native context with fixed layers (the OOM the pin exists to
   avoid). Recompute the pin from the committed effectiveConfig.

2) NumericValueInput cleared its blur bridge only on a value change. A real
   edit (final !== value) that Reset then reverts to the same shown number
   nets value back unchanged, so the effect never re-ran and the stale pin
   survived into the next Load/Save, replaying the override Reset removed.
   The bridge is only valid across the single synchronous same-click gesture
   that set it, so clear it on every settled render instead.

Add source-contract regressions for both.

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

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

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-24 23:27:47 -07:00

791 lines
34 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
"""Model-picker per-model-config Playwright regression test (GPU-free, CPU gemma).
Guards, end to end against the real frontend, the exact regressions that got the
predecessor PR reverted:
- Context Length persists: set a distinctive per-model Context Length + tick
"Remember for this model" + Load; the value reaches the /api/inference/load
request (max_seq_length) AND lands in localStorage (unsloth_model_configs),
and survives a full browser reload (HARD).
- Reset clears: after customizing, Reset must clear the stored override, never
pin the context to a fixed number (the "Reset pins context" regression) (HARD).
- Hidden infra models absent: the RAG embedder (bge-small-en-v1.5) and the
llama.cpp validation probe (stories260K) never appear in the picker. The
probe GGUF is primed into the HF cache by the CI job, so "absent" proves
"hidden", not "not downloaded" (HARD).
- Legacy migration is idempotent: a pre-feature unsloth_load_settings store
migrates once into the versioned unsloth_model_configs map with the value
preserved, and a second reload with a fresh legacy seed present does not
re-migrate, duplicate, or clobber (gates under STUDIO_UI_STRICT via soft_fail).
- Advanced settings persist: KV cache dtype / tensor-parallel toggled under
Advanced + Remember land in unsloth_model_configs (best-effort).
Runs as a plain script (not via pytest), mirroring tests/studio/playwright_extra_ui.py:
accumulate failures in `_failed`, exit non-zero if any HARD gate failed. With
STUDIO_UI_STRICT=1 (as CI sets), soft_fail also gates; genuinely-optional checks
use runtime_warn so they never flake the merge gate.
"""
import json
import re
import sys
import os
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
# Run as a plain script (not via pytest), so prepend the dir to sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _playwright_robust import ( # noqa: E402
chromium_launch_args,
click_and_wait_for_response,
evaluate_fetch,
install_view_transition_killer,
install_wall_clock_watchdog,
is_benign_page_error,
recover_or_replace_page,
robust_evaluate,
wait_for_health,
)
BASE = os.environ["BASE_URL"]
NEW = os.environ.get("STUDIO_NEW_PW", "ModelCfg-NEW-2026!")
# Attach mode: log into an already-provisioned Studio with an existing password
# instead of the first-boot change-password dance. CI leaves STUDIO_LOGIN_PW unset
# to exercise the real change-password flow; local runs can set it to skip re-provisioning.
LOGIN_PW = os.environ.get("STUDIO_LOGIN_PW")
LOGIN_USER = os.environ.get("STUDIO_LOGIN_USER", "unsloth")
GGUF_REPO = os.environ.get("GGUF_REPO", "unsloth/gemma-3-270m-it-GGUF")
GGUF_VARIANT = os.environ.get("GGUF_VARIANT", "UD-Q4_K_XL")
# Substring of the On Device picker row for the loaded model.
MODEL_HINT = os.environ.get("STUDIO_MODEL_HINT", "gemma-3-270m")
# A distinctive valid (>=128, multiple of 128, below the model's 32768 ceiling)
# Context Length, clearly not a default, so persistence is unambiguous.
DISTINCT_CTX = int(os.environ.get("STUDIO_DISTINCT_CTX", "4096"))
ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_modelcfg")
ART = Path(ART_DIR)
ART.mkdir(parents = True, exist_ok = True)
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
PLAYWRIGHT_BROWSER = os.environ.get("STUDIO_PLAYWRIGHT_BROWSER", "chromium").lower()
PLAYWRIGHT_CHANNEL = os.environ.get("STUDIO_PLAYWRIGHT_CHANNEL") or None
TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000"))
WALL_TIMEOUT_S = float(os.environ.get("STUDIO_UI_WALL_TIMEOUT_S", "720"))
FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_FETCH_TIMEOUT_MS", "30000"))
LOAD_FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_LOAD_TIMEOUT_MS", "180000"))
_n = [0]
_failed: list[str] = []
def step(s: str) -> None:
print(f"[ui-modelcfg] STEP {s}", flush = True)
def info(s: str) -> None:
print(f"[ui-modelcfg] {s}", flush = True)
def fail(m: str) -> None:
print(f"[ui-modelcfg] FAIL: {m}", flush = True)
_failed.append(m)
def soft_fail(m: str) -> None:
if STRICT:
fail(m)
else:
info(f"WARN (strict-off): {m}")
def runtime_warn(m: str) -> None:
"""Warn about a genuinely-optional check that STRICT does not gate."""
info(f"WARN (runtime): {m}")
def _count(loc) -> int:
try:
return loc.count()
except Exception:
return 0
def _as_int(value) -> int | None:
"""Parse an input value to int, tolerating commas/whitespace. Comparisons
must be numeric, never substring: '40960' (a model's native default) would
spuriously "contain" '4096'."""
if value is None:
return None
try:
return int(str(value).replace(",", "").strip())
except Exception:
return None
def _login_token_via_api(base: str, user: str, pw: str) -> str:
"""POST /api/auth/login -> access_token (attach-mode helper, stdlib only)."""
import urllib.request
req = urllib.request.Request(
f"{base}/api/auth/login",
data = json.dumps({"username": user, "password": pw}).encode(),
headers = {"Content-Type": "application/json"},
method = "POST",
)
with urllib.request.urlopen(req, timeout = 15) as r:
return json.loads(r.read().decode())["access_token"]
with sync_playwright() as p:
_watchdog = install_wall_clock_watchdog(
WALL_TIMEOUT_S,
label = "ui-modelcfg",
info = info,
)
# Health pre-flight: bash-side health wait can pass before the auth DB migrates.
wait_for_health(BASE, timeout = 30.0, info = info)
if PLAYWRIGHT_BROWSER not in ("chromium", "firefox", "webkit"):
fail(f"unsupported STUDIO_PLAYWRIGHT_BROWSER={PLAYWRIGHT_BROWSER!r}")
sys.exit(1)
browser_type = getattr(p, PLAYWRIGHT_BROWSER)
launch_kwargs = {"headless": True}
if PLAYWRIGHT_BROWSER == "chromium":
launch_kwargs["args"] = chromium_launch_args()
if PLAYWRIGHT_CHANNEL:
launch_kwargs["channel"] = PLAYWRIGHT_CHANNEL
elif PLAYWRIGHT_CHANNEL:
fail("STUDIO_PLAYWRIGHT_CHANNEL requires chromium")
sys.exit(1)
browser = browser_type.launch(**launch_kwargs)
ctx = browser.new_context(
viewport = {"width": 1280, "height": 900},
reduced_motion = "reduce",
)
install_view_transition_killer(ctx)
page = ctx.new_page()
page.set_default_timeout(60_000)
page_errors = []
def _on_pageerror(e):
msg = str(e)
if is_benign_page_error(msg):
info(f"WARN ignoring benign pageerror: {msg!r}")
return
page_errors.append(msg)
page.on("pageerror", _on_pageerror)
# Record every /api/inference/load POST payload so the persistence gate can
# assert max_seq_length.
load_posts: list[str] = []
def _on_request(req):
try:
if req.method == "POST" and "/api/inference/load" in req.url:
load_posts.append(req.post_data or "")
except Exception:
pass
page.on("request", _on_request)
def shoot(name: str) -> None:
_n[0] += 1
try:
page.screenshot(
path = str(ART / f"{_n[0]:02d}-{name}.png"),
full_page = True,
timeout = 90_000,
animations = "disabled",
)
except Exception as _shoot_err:
info(f"WARN: screenshot {name} failed: {_shoot_err}")
def read_configs() -> dict:
"""Return the parsed unsloth_model_configs map (or {} if absent/invalid)."""
raw = robust_evaluate(page, "() => localStorage.getItem('unsloth_model_configs')")
if not raw:
return {}
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def config_entries(cfg: dict) -> list[dict]:
"""The per-model entries (dict values) of the stored map, schema-tolerant."""
return [v for v in cfg.values() if isinstance(v, dict)]
# ─────────────────────────────────────────────────────
# Setup: authenticate + model load.
# ─────────────────────────────────────────────────────
if LOGIN_PW:
# Attach mode: log in via the API and seed the token before navigation,
# skipping the first-boot change-password dance.
step("setup: API login + token seed (attach to running Studio)")
_tok = _login_token_via_api(BASE, LOGIN_USER, LOGIN_PW)
ctx.add_init_script(
f"try{{localStorage.setItem('unsloth_auth_token', {json.dumps(_tok)});}}"
f"catch(e){{}}"
)
page.goto(BASE, wait_until = "domcontentloaded", timeout = 60_000)
else:
step("setup: change-password")
# 3-attempt retry: the form can re-render mid-fill on slow runners and
# detach the password fields; each retry re-navigates with a fresh page.
form_err: Exception | None = None
for _form_attempt in range(3):
try:
page.goto(f"{BASE}/change-password", wait_until = "domcontentloaded", timeout = 60_000)
try:
page.wait_for_load_state("networkidle", timeout = 30_000)
except Exception:
pass
pw_field = page.locator("#new-password")
pw_field.wait_for(state = "visible", timeout = 60_000)
pw_field.fill(NEW, timeout = 60_000)
page.fill("#confirm-password", NEW, timeout = 60_000)
status, _ = click_and_wait_for_response(
page,
url_substr = "/api/auth/change-password",
method = "POST",
do_click = lambda: page.locator('button[type="submit"]').click(),
timeout_ms = 30_000,
info = lambda m: print(f"[ui-modelcfg] {m}", flush = True),
)
if status is not None and status >= 400:
raise AssertionError(
f"change-password POST returned {status}; page_errors={page_errors[:1]!r}"
)
form_err = None
break
except Exception as e:
form_err = e
try:
cur_url = page.url
except Exception:
cur_url = "<page closed>"
print(
f"[ui-modelcfg] change-password attempt {_form_attempt + 1} failed: "
f"{type(e).__name__}: {str(e)[:200]}; page.url={cur_url}; "
f"page_errors={len(page_errors)}",
flush = True,
)
if _form_attempt < 2:
if "ERR_NO_BUFFER_SPACE" in str(e):
backoff_s = 5 if _form_attempt == 0 else 15
time.sleep(backoff_s)
page = recover_or_replace_page(
page,
ctx,
default_timeout_ms = 60_000,
info = lambda m: print(f"[ui-modelcfg] recovery: {m}", flush = True),
)
page.on("request", _on_request)
if form_err is not None:
raise form_err
try:
page.wait_for_load_state("networkidle", timeout = 30_000)
except Exception:
pass
composer = page.locator('textarea[aria-label="Message input"]')
last_err: Exception | None = None
for _attempt in range(2):
try:
composer.wait_for(state = "visible", timeout = 60_000)
last_err = None
break
except Exception as e:
last_err = e
try:
shoot(f"00-composer-wait-attempt-{_attempt + 1}-fail")
except Exception:
pass
if _attempt == 0:
page = recover_or_replace_page(
page,
ctx,
default_timeout_ms = 60_000,
goto_url = BASE,
settle_networkidle = True,
info = lambda m: print(f"[ui-modelcfg] recovery: {m}", flush = True),
)
page.on("request", _on_request)
composer = page.locator('textarea[aria-label="Message input"]')
if last_err is not None:
raise last_err
shoot("01-chat-loaded")
token = robust_evaluate(page, "() => localStorage.getItem('unsloth_auth_token')")
if not token:
fail("no access token after auth setup")
sys.exit(1)
# Load the tiny GGUF so it is a live "On Device" model in the picker.
load_resp = evaluate_fetch(
page,
f"{BASE}/api/inference/load",
method = "POST",
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
body = {
"model_path": GGUF_REPO,
"gguf_variant": GGUF_VARIANT,
"is_lora": False,
"max_seq_length": 2048,
},
timeout_ms = LOAD_FETCH_TIMEOUT_MS,
)
if load_resp.get("error"):
fail(f"/api/inference/load wedged: {load_resp['error']!r}")
sys.exit(1)
if load_resp["status"] != 200:
fail(f"/api/inference/load -> {load_resp['status']}: {load_resp.get('body')!r}")
sys.exit(1)
info(f"loaded model: {(load_resp['body'] or {}).get('display_name')}")
page.reload()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
load_posts.clear() # drop the setup load; keep only UI-driven loads below.
# ─────────────────────────────────────────────────────
# Picker helpers (proven selectors).
# ─────────────────────────────────────────────────────
POPOVER = '[data-tour="chat-model-selector-popover"]'
TRIGGER = '[data-tour="chat-model-selector"]'
def open_picker():
popover = page.locator(POPOVER).first
if _count(popover) == 0 or not popover.is_visible():
page.locator(TRIGGER).first.click()
page.wait_for_timeout(900)
popover = page.locator(POPOVER).first
popover.wait_for(state = "visible", timeout = 30_000)
return popover
def close_picker():
try:
page.keyboard.press("Escape")
page.wait_for_timeout(400)
except Exception:
pass
def select_on_device_row(popover, hint):
od = page.get_by_role("tab", name = "On Device").first
if _count(od):
od.click()
page.wait_for_timeout(700)
row = popover.locator("[data-model-picker-option]", has_text = hint).first
if _count(row) == 0:
# Fall back to search filtering.
search = popover.locator("[data-model-picker-search-input]").first
if _count(search):
search.click()
search.fill(hint)
page.wait_for_timeout(700)
row = popover.locator("[data-model-picker-option]", has_text = hint).first
if _count(row) == 0:
return None
row.click()
page.wait_for_timeout(800)
return row
def open_config(popover, hint):
if select_on_device_row(popover, hint) is None:
return None
gear = popover.locator('button[aria-label^="Inference settings for"]').first
if _count(gear) == 0:
return None
gear.click()
page.wait_for_timeout(800)
return popover
def context_input(popover):
for role in ("textbox", "spinbutton"):
loc = popover.get_by_role(role, name = "Context Length").first
if _count(loc):
return loc
loc = popover.locator('input[aria-label="Context Length"]').first
return loc if _count(loc) else None
def primary_button(popover):
for name in ("Load model", "Reload model", "Save settings", "Forget settings"):
b = popover.get_by_role("button", name = name).first
if _count(b):
return b
return None
# ─────────────────────────────────────────────────────
# 1. Hidden infra models absent from the picker (HARD).
# ─────────────────────────────────────────────────────
step("hidden infra models absent from picker")
popover = open_picker()
shoot("02-picker-open")
needles = ["bge-small-en-v1.5", "stories260"]
tabs = ["Recommended", "On Device", "Connected"]
hidden_ok = True
for needle in needles:
for tab_name in tabs:
tab = page.get_by_role("tab", name = tab_name).first
if _count(tab) == 0:
continue
try:
tab.click()
page.wait_for_timeout(400)
except Exception:
continue
search = popover.locator("[data-model-picker-search-input]").first
if _count(search):
search.click()
search.fill(needle)
page.wait_for_timeout(600)
hit = popover.locator(
"[data-model-picker-option]",
has_text = re.compile(re.escape(needle), re.I),
)
c = _count(hit)
if c > 0:
hidden_ok = False
fail(f"infra model {needle!r} visible in picker '{tab_name}' tab ({c} rows)")
if _count(search):
search.fill("")
page.wait_for_timeout(300)
if hidden_ok:
info("OK hidden: bge-small-en-v1.5 + stories260K absent from every picker tab")
shoot("03-hidden-check")
close_picker()
# ─────────────────────────────────────────────────────
# 2. Context Length persists (load + request + reload) (HARD).
# ─────────────────────────────────────────────────────
step(f"context length {DISTINCT_CTX} persists")
popover = open_picker()
if open_config(popover, MODEL_HINT) is None:
fail(f"could not open run-settings for a model matching {MODEL_HINT!r}")
else:
shoot("04-config-open")
ctx_in = context_input(popover)
if ctx_in is None:
fail("Context Length input not found in run-settings")
else:
default_ctx = ctx_in.input_value()
info(f"default Context Length shown: {default_ctx!r}")
remember = popover.get_by_label("Remember for this model").first
if _count(remember):
try:
remember.check()
except Exception:
remember.click()
else:
fail("'Remember for this model' checkbox not found")
ctx_in.click()
ctx_in.fill(str(DISTINCT_CTX))
page.wait_for_timeout(300)
shoot("05-ctx-set")
btn = primary_button(popover)
if btn is None:
fail("primary Load/Save button not found in run-settings")
else:
# Keep the input focused. The button click must commit the draft
# and use it in the same load request.
btn.click()
page.wait_for_timeout(2500)
shoot("06-after-load")
# (a) localStorage stored the distinctive context.
cfg = read_configs()
entries = config_entries(cfg)
got_ls = any(e.get("customContextLength") == DISTINCT_CTX for e in entries)
if got_ls:
info(f"OK persist(localStorage): customContextLength={DISTINCT_CTX} stored")
else:
fail(
"context not stored in unsloth_model_configs "
f"(entries={json.dumps(entries)[:400]})"
)
# (b) the load request carried max_seq_length == distinctive value.
got_req = False
for body in load_posts:
try:
payload = json.loads(body) if body else {}
except Exception:
payload = {}
if payload.get("max_seq_length") == DISTINCT_CTX:
got_req = True
break
if got_req:
info(f"OK persist(request): /api/inference/load max_seq_length={DISTINCT_CTX}")
else:
# The UI may debounce the load; localStorage is the primary
# proof, so only warn if the request was missed.
runtime_warn(
"no /api/inference/load carried "
f"max_seq_length={DISTINCT_CTX}; posts={load_posts!r}"
)
# (c) survives a full browser reload.
close_picker()
page.reload()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
popover = open_picker()
if open_config(popover, MODEL_HINT) is None:
fail("could not reopen run-settings after reload")
else:
ctx_in = context_input(popover)
val = ctx_in.input_value() if ctx_in else None
if _as_int(val) == DISTINCT_CTX:
info(f"OK persist(reload): Context Length still {val!r} after reload")
else:
fail(f"Context Length did not persist across reload (got {val!r})")
shoot("07-after-reload")
# ─────────────────────────────────────────────────────
# 3. Reset clears the override (never pins context) (HARD).
# ─────────────────────────────────────────────────────
step("reset clears the per-model override")
# (popover + config still open from the reload check.)
reset_btn = popover.get_by_role("button", name = "Reset").first
if _count(reset_btn) == 0:
fail("Reset button not found in run-settings")
else:
try:
reset_btn.click()
page.wait_for_timeout(500)
except Exception as e:
fail(f"Reset click failed: {e}")
# The input after Reset is informational only: a live-loaded model can still
# echo its context even with the stored override gone. The regression we
# guard ("Reset PINS the override") lives in localStorage, asserted below.
ctx_in = context_input(popover)
after_reset = ctx_in.input_value() if ctx_in else None
info(f"reset: Context Length input now shows {after_reset!r}")
# Commit the reset so the stored override is dropped, then assert storage.
btn = primary_button(popover)
if btn is not None and btn.is_enabled():
btn.click()
page.wait_for_timeout(1500)
cfg = read_configs()
pinned = any(
_as_int(e.get("customContextLength")) == DISTINCT_CTX for e in config_entries(cfg)
)
if pinned:
fail("Reset left the distinctive context pinned in unsloth_model_configs")
else:
info("OK reset: distinctive context cleared from unsloth_model_configs")
shoot("08-after-reset")
# ─────────────────────────────────────────────────────
# 3b. Re-typing the value already shown must not pin an override (HARD).
# Entering the currently displayed native/default context commits no
# onChange (the value is unchanged), so the cached blur value must not be
# replayed into a stored override on Load. Otherwise re-typing the shown
# number, or doing so before a Reset, recreates a phantom context pin.
# ─────────────────────────────────────────────────────
step("re-typing the shown context does not pin an override")
ctx_in = context_input(popover)
native_default = _as_int(ctx_in.input_value()) if ctx_in else None
if ctx_in is None or native_default is None:
info("skip re-type-shown: Context Length input has no numeric default")
else:
remember = popover.get_by_label("Remember for this model").first
if _count(remember):
try:
remember.check()
except Exception:
remember.click()
ctx_in.click()
ctx_in.fill(str(native_default))
page.wait_for_timeout(200)
btn = primary_button(popover)
if btn is not None and btn.is_enabled():
# Same-click Load: the button click must commit the draft, but a draft
# equal to the shown value carries no override.
btn.click()
page.wait_for_timeout(1500)
cfg = read_configs()
pinned = any(
_as_int(e.get("customContextLength")) == native_default for e in config_entries(cfg)
)
if pinned:
fail(
"re-typing the shown context pinned it as an override "
f"(customContextLength={native_default})"
)
else:
info("OK re-type-shown: shown context not stored as an override")
shoot("08b-after-retype-shown")
close_picker()
# ─────────────────────────────────────────────────────
# 4. Advanced settings persist (best-effort, never gates).
# ─────────────────────────────────────────────────────
step("advanced (KV cache dtype / tensor parallel) persists")
try:
popover = open_picker()
if open_config(popover, MODEL_HINT) is not None:
adv = popover.get_by_role("switch", name = re.compile("advanced settings", re.I)).first
if _count(adv):
try:
adv.check()
except Exception:
adv.click()
page.wait_for_timeout(500)
# The Tensor Parallelism Radix Switch has no aria-label, so target the
# first switch after the "Tensor Parallelism" text.
tp = popover.locator(
'xpath=.//span[contains(text(),"Tensor Parallelism")]'
'/following::*[@role="switch"][1]'
).first
toggled = False
if _count(tp):
try:
tp.click()
toggled = True
except Exception:
pass
remember = popover.get_by_label("Remember for this model").first
if _count(remember):
try:
remember.check()
except Exception:
remember.click()
btn = primary_button(popover)
if btn is not None and btn.is_enabled():
btn.click()
page.wait_for_timeout(1500)
cfg = read_configs()
has_adv = any(
e.get("tensorParallel") or e.get("kvCacheDtype") for e in config_entries(cfg)
)
if toggled and has_adv:
info("OK advanced: tensorParallel/kvCacheDtype persisted")
else:
runtime_warn(
f"advanced persistence not observed (toggled={toggled}, "
f"entries={json.dumps(config_entries(cfg))[:300]})"
)
else:
runtime_warn("could not open run-settings for the advanced-persist check")
close_picker()
except Exception as e:
runtime_warn(f"advanced-persist check errored: {e}")
# ─────────────────────────────────────────────────────
# 5. Legacy migration is idempotent (gates in CI via soft_fail).
# Seed a pre-feature unsloth_load_settings store, confirm it migrates once
# with the value preserved, then reload with a fresh legacy seed and confirm
# the migration does not re-run, duplicate, or clobber. Re-running on every
# reload was the regression that reverted the predecessor PR.
# ─────────────────────────────────────────────────────
step("legacy unsloth_load_settings migrates once and stays idempotent")
try:
legacy_key = f"{GGUF_REPO}::{GGUF_VARIANT}"
legacy = {
legacy_key: {
"contextLength": DISTINCT_CTX,
"kvCacheDtype": "q8_0",
"tensorParallel": True,
}
}
robust_evaluate(
page,
"(seed) => {"
" localStorage.setItem('unsloth_load_settings', JSON.stringify(seed));"
" localStorage.removeItem('unsloth_model_configs');"
" localStorage.removeItem('unsloth_model_configs_migrated');"
" return true;"
"}",
arg = legacy,
)
page.reload()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
# Opening the picker config forces the store to read (which migrates).
popover = open_picker()
open_config(popover, MODEL_HINT)
page.wait_for_timeout(800)
cfg_first = read_configs()
migrated_ctx = any(
e.get("customContextLength") == DISTINCT_CTX for e in config_entries(cfg_first)
)
if migrated_ctx:
info(f"OK migration: legacy context {DISTINCT_CTX} preserved after migrating")
else:
soft_fail(
f"legacy context {DISTINCT_CTX} not migrated into unsloth_model_configs "
f"(got {json.dumps(cfg_first)[:400]})"
)
flag_first = robust_evaluate(
page, "() => localStorage.getItem('unsloth_model_configs_migrated')"
)
if flag_first != "1":
soft_fail(f"migration flag not set after migrating (got {flag_first!r})")
shoot("09-after-migration")
close_picker()
# Idempotency: a second reload with a DIFFERENT legacy entry must not re-run
# the migration (the persistent flag blocks it), so the new key must not leak
# in, nothing duplicates, and the migrated value is untouched.
if migrated_ctx:
probe_key = "unsloth/__idem_probe__::Q4_K_M"
robust_evaluate(
page,
"(seed) => {"
" localStorage.setItem('unsloth_load_settings', JSON.stringify(seed));"
" return true;"
"}",
arg = {probe_key: {"contextLength": DISTINCT_CTX + 2048, "tensorParallel": True}},
)
page.reload()
composer.wait_for(state = "visible", timeout = 60_000)
popover = open_picker()
open_config(popover, MODEL_HINT)
page.wait_for_timeout(800)
cfg_second = read_configs()
keys_first = set(cfg_first.keys())
keys_second = set(cfg_second.keys())
new_keys = keys_second - keys_first
still_has_ctx = any(
e.get("customContextLength") == DISTINCT_CTX for e in config_entries(cfg_second)
)
if new_keys:
soft_fail(
"legacy migration re-ran on a second reload (persistent flag "
f"ignored): new keys {sorted(new_keys)}"
)
elif keys_second != keys_first:
soft_fail(
"legacy migration dropped entries on a second reload: "
f"{sorted(keys_first)} -> {sorted(keys_second)}"
)
elif not still_has_ctx:
soft_fail("legacy migration clobbered the migrated context on a second reload")
else:
info(
"OK migration idempotent: second reload did not re-migrate, duplicate, or clobber"
)
shoot("10-after-second-reload")
close_picker()
except Exception as e:
soft_fail(f"migration idempotency check errored: {e}")
# ─────────────────────────────────────────────────────
if page_errors:
fail(f"page errors during run: {page_errors[:3]!r}")
browser.close()
if _failed:
print(f"[ui-modelcfg] RESULT: FAIL ({len(_failed)} issue(s))", flush = True)
for m in _failed:
print(f"[ui-modelcfg] - {m}", flush = True)
sys.exit(1)
print("[ui-modelcfg] RESULT: PASS", flush = True)
sys.exit(0)