unsloth/tests/studio/playwright_chat_ui.py
Daniel Han 1b92a8bb72 ci(mac): make Playwright screenshots best-effort + 90s timeout
Run 25494399543 / job 74810247593 progressed past the change-password
flow + composer-mount + default_models[0] check (so commits d35bf6a
and fdf7f94's Chromium fixes are working) but then crashed on
`shoot('03b-default-model-button')` with:

  playwright._impl._errors.TimeoutError:
    Page.screenshot: Timeout 30000ms exceeded.
  Call log:
    - taking page screenshot
    - waiting for fonts to load...
    - fonts loaded

Page.screenshot waits for the page's webfonts to be resolved before
snapshotting. On macos-14 free runners under --single-process
Chromium, font loading for the Studio chat page (Inter / Geist Mono)
crowds the 30s default. Two changes:

1. Bump screenshot timeout to 90_000ms.
2. Wrap shoot() in try/except. Screenshots are diagnostic artifacts
   uploaded for human triage; a failure to capture one should never
   fail the test. The actual UI assertions live in step()/info()/
   wait_for() calls, which are unaffected.

Adds animations='disabled' for deterministic captures (frozen CSS
transitions). Both playwright_chat_ui.py and playwright_extra_ui.py
get the same treatment.
2026-05-07 12:10:21 +00:00

1235 lines
56 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
"""Comprehensive Studio chat UI test, run locally + in CI.
Covers:
1. /change-password through the UI (no API pre-rotate).
2. Model loaded by the time chat opens (the chat page's runtime
adapter pings /api/models/list; we trigger /api/inference/load
via page.evaluate so we don't need the password out-of-band).
3. Five chat turns, each deterministic (temperature handled at the
server level via Studio's default; we only assert non-empty).
4. Regenerate the last turn from the assistant action bar.
5. Composer toggle buttons: Thinking / Web search / Code execution
-- assert aria-label flips state on click.
6. Configuration sheet: open, drive Temperature slider via keyboard,
close.
7. Theme toggle through the account menu, multiple cycles, with a
deterministic computed-background-color check on
`document.documentElement` and `document.body`.
8. Sidebar nav: New Chat, Compare, Search, Recipes (URL changes).
9. Recents (history) cards: click an existing chat thread.
10. API tab via account menu -> Developer / api-keys.
11. Image attachment UI (upload widget reachable; vision response
not asserted because gemma-3-270m is text-only).
12. Reload + verify session JWT survives.
13. /api/health remains healthy.
14. Negative-auth post-UI-rotation: old=401, new=200.
15. Terminal-driven password rotation via subprocess(curl) to
/api/auth/change-password (NEW -> NEW2). Confirms refresh
tokens get revoked and that an out-of-band password change
(i.e. another tab / CLI / curl) invalidates the old creds.
16. Shutdown via the account menu's Shutdown menuitem + the
AlertDialog's "Stop server" action; wait for /api/health to
become unreachable (server process exited).
17. No uncaught page errors.
"""
import json
import os
import re
import socket
import subprocess
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
from playwright.sync_api import expect, sync_playwright
BASE = os.environ["BASE_URL"]
OLD = os.environ["STUDIO_OLD_PW"]
NEW = os.environ["STUDIO_NEW_PW"]
NEW2 = os.environ.get("STUDIO_NEW2_PW", NEW + "X9!")
GGUF_REPO = os.environ.get("GGUF_REPO", "unsloth/gemma-3-270m-it-GGUF")
GGUF_VARIANT = os.environ.get("GGUF_VARIANT", "UD-Q4_K_XL")
ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright")
ART = Path(ART_DIR)
ART.mkdir(parents = True, exist_ok = True)
# Strict mode -- when on (default in CI), the test fails loudly if any
# expected button / nav / dialog is missing instead of logging a WARN
# and continuing. Locally we leave it off so the test still runs against
# a partial Studio install.
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
# Per-turn assistant-bubble wait. The free macos-14 runner (3 vCPU /
# 7 GB / no GPU) is ~3-5x slower at gemma-3-270m CPU inference than the
# free ubuntu-latest runner; "Say the word 'tree'" has been observed to
# hit the 180 s default exactly. STUDIO_UI_TURN_TIMEOUT_MS lets the Mac
# CI bump this without hard-coding a Mac branch in the test.
TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000"))
_n = [0]
def step(s):
print(f"[ui] STEP {s}", flush = True)
def info(s):
print(f"[ui] {s}", flush = True)
def fail(m):
raise AssertionError(f"[ui] FAIL: {m}")
def soft_fail(m):
"""Hard fail in STRICT mode, info-warn otherwise.
Use for "this button should exist but didn't" assertions where
a missing element is a regression in CI but acceptable when
running against a partial Studio locally.
"""
if STRICT:
fail(m)
info(f"WARN (strict-off): {m}")
def login_via_api(pw):
req = urllib.request.Request(
f"{BASE}/api/auth/login",
data = json.dumps({"username": "unsloth", "password": pw}).encode(),
method = "POST",
headers = {"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout = 10) as r:
return r.status
except urllib.error.HTTPError as exc:
return exc.code
def parse_rgb(s):
m = re.search(r"rgba?\((\d+),\s*(\d+),\s*(\d+)", s or "")
return tuple(int(x) for x in m.groups()) if m else None
with sync_playwright() as p:
# Chromium stability args for macos-14 free runners. Without these
# Chromium browser process dies in the first few seconds (during
# change-password page load) and the driver Node process can't
# parse the truncated stdout JSON-RPC line, throwing
# 'SyntaxError: Unexpected end of JSON input' in pipeTransport.js
# — see runs 25491698868 / 25489049059. --disable-dev-shm-usage and
# --no-sandbox are the standard set; --disable-gpu forces software
# rendering on the headless runner; --single-process keeps the
# renderer in the same process as the browser, eliminating the
# browser↔renderer IPC pipe that was the actual crash site.
_CHROMIUM_STABILITY_ARGS = [
"--disable-dev-shm-usage",
"--no-sandbox",
"--disable-gpu",
"--single-process",
]
browser = p.chromium.launch(
headless = True,
args = _CHROMIUM_STABILITY_ARGS,
)
ctx = browser.new_context(
viewport = {"width": 1280, "height": 900},
# Reduces motion so the theme toggle's view-transition
# animation doesn't briefly intercept pointer events
# (the running CSS view-transition leaves the html in a
# state where Playwright's actionability check fails).
reduced_motion = "reduce",
)
# Hard-disable CSS view-transitions in case the app doesn't
# honour prefers-reduced-motion. We inject a tiny stylesheet
# that nukes the ::view-transition pseudo-elements + restores
# pointer events on the html element. Equivalent to the user
# having browser-level reduced-motion.
ctx.add_init_script("""
(function () {
try {
// Nuke view-transition pseudo-elements completely +
// monkey-patch document.startViewTransition so the
// theme toggle's animated reveal never runs in CI.
// Without this, the post-transition html element keeps
// intercepting pointer events on later clicks (sidebar
// nav, account menu, etc.) -- Playwright surfaces this
// as "<html> intercepts pointer events".
const css = `
::view-transition,
::view-transition-group(*),
::view-transition-image-pair(*),
::view-transition-old(*),
::view-transition-new(*) {
display: none !important;
animation: none !important;
opacity: 0 !important;
}
html, body { pointer-events: auto !important; }
`;
const style = document.createElement("style");
style.id = "playwright-no-view-transition";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
// Replace startViewTransition with an immediate-call
// shim so the callback runs synchronously without the
// animation pipeline ever capturing the html element.
if (typeof document.startViewTransition === "function") {
document.startViewTransition = function (cb) {
try { if (cb) cb(); } catch (e) {}
return {
ready: Promise.resolve(),
finished: Promise.resolve(),
updateCallbackDone: Promise.resolve(),
skipTransition: () => {},
};
};
}
} catch (e) { /* noop */ }
})();
""")
page = ctx.new_page()
page.set_default_timeout(30_000)
page_errors = []
page.on("pageerror", lambda e: page_errors.append(str(e)))
console_errors = []
page.on(
"console",
lambda m: console_errors.append(m.text) if m.type == "error" else None,
)
# Per-turn HTTP-status capture: if a /v1/chat/completions request
# 4xx-rejects mid-test the symptom is a hung wait_for_function and
# a "FAIL: 1 non-benign pageerror events" line; this listener
# surfaces the underlying status codes so a flake is debuggable
# straight from the CI log without artifact spelunking.
chat_completions_responses: list[tuple[int, str]] = []
page.on(
"response",
lambda r: (
chat_completions_responses.append((r.status, r.url))
if "/v1/chat/completions" in r.url
else None
),
)
def shoot(name):
# Screenshots are diagnostic artifacts only -- never fail the
# test on a screenshot timeout. Page.screenshot waits for
# webfonts to fully load before snapshotting; on macos-14 free
# runners with --single-process Chromium, font loading on the
# Studio chat page (Inter / Geist Mono) regularly crowds the
# 30s default and crashes Page.screenshot. Bump the timeout
# AND wrap in try/except so the test progresses even if the
# screenshot can't be captured. animations='disabled' freezes
# any in-flight CSS transitions for a deterministic snap.
_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}")
# ─────────────────────────────────────────────────────
# 1. Change-password through the UI ("Setup your account").
# The bootstrap state injects window.__UNSLOTH_BOOTSTRAP__
# so the current-password is pre-seeded; we only enter the
# new password twice and submit. Match the workflow rename
# from "tool calling tests" pattern: this *is* the user's
# first-run experience.
# ─────────────────────────────────────────────────────
step("change-password through UI (Setup your account)")
page.goto(f"{BASE}/change-password")
page.locator("#new-password").wait_for(state = "visible", timeout = 30_000)
shoot("01-change-password-initial")
page.fill("#new-password", NEW)
page.fill("#confirm-password", NEW)
shoot("02-change-password-filled")
page.locator('button[type="submit"]').click()
# ─────────────────────────────────────────────────────
# 2. Chat surface mounts, default model surface is visible.
# ─────────────────────────────────────────────────────
step("wait for composer to mount")
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
shoot("03-chat-loaded")
# Pull the auth token now -- /api/models/list and
# /api/inference/load both require a bearer. The frontend
# stores it under "unsloth_auth_token" (auth/session.ts).
token = page.evaluate(
"() => localStorage.getItem('unsloth_auth_token')",
)
if not token:
# Fall back: exchange the refresh token via /api/auth/refresh.
refresh_token = page.evaluate(
"() => localStorage.getItem('unsloth_auth_refresh_token')",
)
if refresh_token:
refresh = page.evaluate(
f"""async (rt) => {{
const r = await fetch("{BASE}/api/auth/refresh", {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{refresh_token: rt}}),
}});
return await r.json();
}}""",
refresh_token,
)
token = refresh.get("access_token")
if not token:
fail("could not obtain auth token after change-password")
# Verify the chat page's default model surface comes from
# backend/core/inference/defaults.py:DEFAULT_MODELS_GGUF[0],
# which is the canonical "what the user sees if nothing has
# been loaded yet" entry. A regression that reorders that
# list or hides the default would break the first-launch UX,
# which is what this assertion guards.
step("default_models[0] matches DEFAULT_MODELS_GGUF[0]")
EXPECTED_DEFAULT = os.environ.get(
"EXPECTED_DEFAULT_MODEL",
"unsloth/gemma-4-E2B-it-GGUF",
)
defaults = page.evaluate(
f"""async (token) => {{
const r = await fetch("{BASE}/api/models/list", {{
headers: {{ "Authorization": "Bearer " + token }},
}});
return await r.json();
}}""",
token,
)
if not defaults.get("default_models"):
fail(f"/api/models/list returned no default_models: {defaults}")
if defaults["default_models"][0] != EXPECTED_DEFAULT:
fail(
f"default_models[0]={defaults['default_models'][0]!r}, "
f"expected {EXPECTED_DEFAULT!r}; defaults.py drift?"
)
info(f"OK default_models[0] = {EXPECTED_DEFAULT}")
# The model selector button text on the chat page should say
# the default model's display name even before a model is
# loaded. The model-selector renders the current model name
# (or "Select model" if no current); for a fresh chat it
# should surface the default.
selector_btn = page.locator(
'button:has-text("Select model"), '
'button:has-text("gemma"), '
'button:has-text("Qwen"), '
'button:has-text("Llama")'
).first
if selector_btn.count() > 0:
sel_text = (selector_btn.text_content() or "").strip()
info(f"model selector button text: {sel_text!r}")
shoot("03b-default-model-button")
# ─────────────────────────────────────────────────────
# 3. Trigger model load via the page's session cookies.
# Equivalent to the user clicking a model in the picker;
# we just call the same endpoint the picker would.
# ─────────────────────────────────────────────────────
step("load GGUF via /api/inference/load (uses session cookie)")
# Token already fetched above; reuse it for the load call.
load_resp = page.evaluate(f"""async () => {{
const r = await fetch("{BASE}/api/inference/load", {{
method: "POST",
headers: {{
"Authorization": "Bearer {token}",
"Content-Type": "application/json",
}},
body: JSON.stringify({{
model_path: "{GGUF_REPO}",
gguf_variant: "{GGUF_VARIANT}",
is_lora: false,
max_seq_length: 2048,
}}),
}});
return {{status: r.status, body: await r.json()}};
}}""")
if load_resp["status"] != 200:
fail(
f"/api/inference/load returned {load_resp['status']}: {load_resp.get('body')!r}"
)
info(f"loaded model: {load_resp['body'].get('display_name')}")
# Studio caches the per-context model state in zustand; reload
# to make the chat composer pick up the loaded model.
page.reload()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
# ─────────────────────────────────────────────────────
# 3b. Model picker search bar -- click the model selector,
# type into the search box, verify filtering. We don't
# actually select a different model (that would trigger a
# multi-GB download); we just exercise the typeahead so a
# regression in the picker mount / debounced HF search would
# surface here.
# ─────────────────────────────────────────────────────
step("model picker: open + drive search bar")
# Stable selector first: [data-tour="chat-model-selector"] is the
# guided-tour anchor on the model picker button (app-sidebar.tsx).
# If the tour anchor moves the tour breaks, so this selector is at
# least as stable as anything else in the codebase.
picker_btn = page.locator('[data-tour="chat-model-selector"]').first
if picker_btn.count() == 0:
# Fall back to text-based locators for older Studio builds.
picker_btn = page.locator(
'button:has-text("gemma-3-270m"), '
'button:has-text("Gemma 3"), '
'button:has-text("Select model")'
).first
if picker_btn.count() == 0:
soft_fail("model picker button not found")
else:
picker_btn.click()
page.wait_for_timeout(500)
shoot("03c-model-picker-open")
search = page.get_by_placeholder(
re.compile(r"Search.*models?", re.I),
).first
if search.count() == 0:
soft_fail("model picker search input not found")
else:
# Type "qwen" -> capture popover text. Type "llama" -> capture
# again. The two text snapshots must DIFFER, proving the
# typeahead actually filters the list (a regression that
# rendered the picker but ignored input would silently pass
# the old version of this test).
def picker_visible_text():
return page.evaluate("""() => {
const el = document.querySelector(
'[role="dialog"], [role="listbox"], [role="menu"]'
);
return el ? (el.innerText || '').trim() : '';
}""")
search.fill("qwen")
page.wait_for_timeout(800)
qwen_text = picker_visible_text()
shoot("03d-model-picker-search-qwen")
search.fill("")
page.wait_for_timeout(300)
search.fill("llama")
page.wait_for_timeout(800)
llama_text = picker_visible_text()
shoot("03e-model-picker-search-llama")
if qwen_text and llama_text and qwen_text == llama_text:
soft_fail(
"model picker text was identical for qwen + llama "
"queries -- typeahead may not be filtering"
)
else:
info("OK search bar filtered (qwen text != llama text)")
# Close picker without changing selection.
page.keyboard.press("Escape")
page.wait_for_timeout(300)
# ─────────────────────────────────────────────────────
# 4. Five chat turns, all non-empty.
# ─────────────────────────────────────────────────────
prompts = [
"Reply with exactly: hello",
"What is 1+1? Reply with the digit only.",
"Reply with exactly: world",
"Reply with exactly: tree",
"What is 2+2? Reply with the digit only.",
]
def _bubble_count():
"""Total number of [data-role='assistant'] elements (empty or not)."""
return page.evaluate("""() => {
return document.querySelectorAll('[data-role="assistant"]').length;
}""")
def send_and_wait(prompt, idx):
# 1. Wait until the previous turn has fully stopped: Send
# button is attached AND Stop button is detached. The
# assistant-ui composer hot-swaps these inside a single
# DOM slot; relying on Stop's detached state alone is
# racy (the slot can briefly show neither during
# transition).
page.wait_for_selector(
'button[aria-label="Send message"]',
state = "attached",
timeout = TURN_TIMEOUT_MS,
)
try:
page.wait_for_selector(
'button[aria-label="Stop generating"]',
state = "detached",
timeout = 5_000,
)
except Exception:
# Stop button still hanging on -- that's the prior turn
# mid-stream. Wait it out at the full per-turn budget.
page.wait_for_selector(
'button[aria-label="Stop generating"]',
state = "detached",
timeout = TURN_TIMEOUT_MS,
)
# 2. Snapshot total bubble count BEFORE send. We then wait
# for total count to grow by exactly 1 (proves the new
# placeholder rendered) and for the Stop button to come
# + go (proves the new turn ran end-to-end). We do NOT
# require the new bubble's text to be non-empty: an
# empty assistant response is a legitimate model output,
# not a test failure. The earlier "non-empty count >=
# baseline + 1" predicate broke when any prior turn
# streamed empty (which gemma-3-270m DOES on simple
# prompts at temperature 0), because that empty bubble
# became permanently "stuck" below the moving threshold.
bubbles_before = _bubble_count()
composer.click()
composer.fill(prompt)
page.locator('button[aria-label="Send message"]').click()
# 3. Wait for the new placeholder bubble to render. This
# confirms the click was actionable AND the request
# issued (assistant-ui only mounts the placeholder once
# the runtime accepts the message).
page.wait_for_function(
"""(want) => {
return document.querySelectorAll(
'[data-role="assistant"]'
).length >= want;
}""",
arg = bubbles_before + 1,
timeout = TURN_TIMEOUT_MS,
)
# 4. Wait for streaming to FINISH for this specific turn.
# We wait for Stop button to APPEAR (proves streaming
# started) with a short budget; if it never appears,
# that's fine -- gemma-3-270m can finish before the
# Stop button paints. Either way we then wait for it
# to be detached at the full per-turn budget.
try:
page.wait_for_selector(
'button[aria-label="Stop generating"]',
state = "attached",
timeout = 3_000,
)
except Exception:
pass
try:
page.wait_for_selector(
'button[aria-label="Stop generating"]',
state = "detached",
timeout = TURN_TIMEOUT_MS,
)
except Exception:
shoot(f"04-turn-{idx}-still-streaming")
raise
for i, p_ in enumerate(prompts, start = 1):
step(f"turn {i}: {p_!r}")
send_and_wait(p_, i)
shoot("04-after-five-turns")
texts = page.evaluate("""() => Array.from(document.querySelectorAll('[data-role="assistant"]'))
.map(e => (e.innerText || '').trim())""")
if len(texts) < len(prompts):
fail(f"expected >= {len(prompts)} assistant bubbles, got {len(texts)}")
info(f"five turn lengths = {[len(t) for t in texts[:5]]}")
# Surface /v1/chat/completions HTTP status distribution so a flake
# is debuggable from the CI log directly. A 4xx during a chat
# turn is almost always the upstream cause of a hung
# wait_for_function on a downstream turn.
if chat_completions_responses:
statuses = [code for code, _ in chat_completions_responses]
bad = [code for code in statuses if code >= 400]
info(
f"/v1/chat/completions: {len(statuses)} request(s); "
f"statuses={statuses}; 4xx/5xx={len(bad)}"
)
# ─────────────────────────────────────────────────────
# 5. Regenerate the last assistant turn.
# ─────────────────────────────────────────────────────
step("regenerate last assistant turn")
last_assistant = page.locator('[data-role="assistant"]').last
last_assistant.hover()
page.wait_for_timeout(400)
regen_btn = page.get_by_role(
"button",
name = re.compile(r"(reload|regenerate)", re.I),
).first
if regen_btn.count() > 0:
regen_btn.click()
try:
page.wait_for_selector(
'button[aria-label="Stop generating"]',
state = "detached",
timeout = 90_000,
)
except Exception:
pass
shoot("05-after-regenerate")
info("regenerate completed")
else:
# Don't strict-fail on regenerate -- the assistant-ui
# ActionBarPrimitive.Reload doesn't expose a stable
# aria-label, so the test depends on tooltip text matching
# which is tied to the icon set. Soft-skip until we add a
# data-testid in the action bar (TODO).
info("WARN regenerate button not visible (known-fragile locator, skipped)")
# ─────────────────────────────────────────────────────
# 6. Add two more turns AFTER regenerate.
# ─────────────────────────────────────────────────────
extra = ["Reply with: yes", "Reply with: no"]
for j, p_ in enumerate(extra, start = 1):
step(f"extra turn {j}: {p_!r}")
before_count = len(page.locator('[data-role="assistant"]').all())
send_and_wait(p_, before_count + 1)
shoot("06-after-extra-turns")
# ─────────────────────────────────────────────────────
# 7. Composer toggle buttons. Each renders with an
# aria-label that flips between "Disable X" / "Enable X"
# depending on its current state (shared-composer.tsx).
# ─────────────────────────────────────────────────────
step("composer toggle buttons (Thinking / Web search / Code execution)")
for feature in ("thinking", "web search", "code execution"):
# Look for either "Disable X" or "Enable X" -- whichever
# is currently rendered.
toggle = page.locator(
f'button[aria-label="Disable {feature}"], '
f'button[aria-label="Enable {feature}"]'
).first
if toggle.count() == 0:
info(f"toggle '{feature}' not present on this layout")
continue
# Skip if the model doesn't support this capability (the
# button is rendered disabled). gemma-3-270m, for instance,
# has no reasoning so "Disable thinking" is permanent-disabled.
if toggle.is_disabled():
info(f"toggle '{feature}' is disabled for this model -- skip")
continue
before = toggle.get_attribute("aria-label") or ""
toggle.click()
page.wait_for_timeout(200)
after = (
page.locator(
f'button[aria-label="Disable {feature}"], '
f'button[aria-label="Enable {feature}"]'
).first.get_attribute("aria-label")
or ""
)
if before == after:
info(f"WARN '{feature}' aria-label did not flip ({before!r})")
else:
info(f"OK '{feature}': {before!r} -> {after!r}")
# Flip back so test state is unchanged.
try:
page.locator(
f'button[aria-label="Disable {feature}"], '
f'button[aria-label="Enable {feature}"]'
).first.click()
except Exception:
pass
page.wait_for_timeout(200)
shoot("07-toggles-cycled")
# ─────────────────────────────────────────────────────
# 8. Configuration sheet: open, find Temperature slider,
# press Home (→ 0), close.
# ─────────────────────────────────────────────────────
cfg_open = page.locator('button[aria-label="Open configuration"]').first
if cfg_open.count() > 0:
step("Configuration sheet: drive Temperature + Top P + extras")
cfg_open.click()
page.wait_for_timeout(500)
shoot("08-config-open")
# ParamSlider uses Radix UI Slider. Each slider gets a
# role="slider" attribute. Walk every slider in the sheet
# by index, focus it, send Home (-> min) so the test
# state is fully deterministic. Whatever the labels are
# ("Temperature", "Top P", "Min P", "Repetition penalty",
# max_tokens etc.), we drive them all to min so a
# regression that locks a slider returns errors here.
sliders = page.locator('[role="slider"]')
n_sliders = sliders.count()
info(f"configuration sheet exposes {n_sliders} slider(s)")
for idx in range(n_sliders):
try:
s = sliders.nth(idx)
s.scroll_into_view_if_needed()
s.focus()
page.keyboard.press("Home") # -> min
page.wait_for_timeout(80)
except Exception as exc:
info(f" slider[{idx}] focus/Home failed: {exc!r}")
shoot("09-config-all-min")
# Then drive Temperature specifically to 0.0 to make the
# downstream chat deterministic. Temperature is the *first*
# slider in the sheet (configuration-sheet.tsx renders it
# first); Home already pinned it to 0.
info("Temperature set to slider min (0.0) for determinism")
# Close.
close_btn = page.locator('button[aria-label="Close configuration"]').first
if close_btn.count() > 0:
close_btn.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
# ─────────────────────────────────────────────────────
# 9. Theme toggle -- multiple cycles + deterministic
# computed-background-color check. The light theme
# uses near-white (>240); dark uses near-black (<40).
# ─────────────────────────────────────────────────────
acct = page.locator('button[aria-label$=" account menu"]').first
if acct.count() > 0:
step("theme toggle x3 with computed-color assertion")
observed = []
for cycle in range(3):
# Wait for any prior dropdown to fully detach. The Radix
# Account-menu sets data-state="open" while the view-
# transition is mid-flight; clicking it again before that
# clears would no-op silently and the for-loop bailed
# after cycle 1 in earlier runs.
try:
page.wait_for_function(
"""() => !document.querySelector('[role="menu"]')""",
timeout = 3_000,
)
except Exception:
pass
page.wait_for_timeout(150)
try:
acct.click(force = True)
except Exception as exc:
soft_fail(
f"theme cycle {cycle + 1}: account-menu click failed " f"({exc!r})"
)
break
# Wait for the dropdown menu to actually render before
# querying its items.
try:
page.wait_for_selector('[role="menu"]', timeout = 3_000)
except Exception:
soft_fail(f"theme cycle {cycle + 1}: account menu didn't open")
break
theme_item = page.get_by_role(
"menuitem",
name = re.compile(r"^(Light Mode|Dark Mode)$", re.I),
).first
if theme_item.count() == 0:
page.keyboard.press("Escape")
soft_fail(f"theme cycle {cycle + 1}: theme menuitem missing")
break
try:
theme_item.click(force = True)
except Exception as exc:
page.keyboard.press("Escape")
soft_fail(
f"theme cycle {cycle + 1}: theme menuitem click failed "
f"({exc!r})"
)
break
# Settle. The ".dark" class on <html> is the ground
# truth (theme-store toggles only that class); the
# ".light" sibling is steady-state from next-themes
# so don't gate on it.
page.wait_for_timeout(700)
bg = page.evaluate("""() => {
const root = document.documentElement;
return {
cls: root.className,
isDark: root.classList.contains('dark'),
bg: getComputedStyle(document.body).backgroundColor,
rbg: getComputedStyle(root).backgroundColor,
};
}""")
observed.append(bg)
shoot(f"10-theme-cycle-{cycle + 1}")
info(f" cycle {cycle + 1}: dark={bg['isDark']} body bg={bg['bg']!r}")
# Sanity check: across cycles we should observe both a
# light state (body bg roughly near-white) and a dark state
# (body bg near-black). If we only saw one polarity the
# toggle didn't flip.
rgbs = [parse_rgb(o["bg"]) for o in observed if parse_rgb(o["bg"])]
light_seen = any(min(r) > 220 for r in rgbs)
dark_seen = any(max(r) < 60 for r in rgbs)
if len(observed) < 3:
soft_fail(f"theme toggle ran only {len(observed)} cycle(s), expected 3")
# Don't strict-fail on "both polarities observed" -- the
# CI runner's prefers-color-scheme + Studio's "system" default
# can collapse to a single polarity even after a successful
# toggle (the .dark classlist toggles correctly, but the
# resolved theme can stay constant). Surface as info; the
# 3-cycle loop completion above is the real invariant.
if light_seen and dark_seen:
info("OK light + dark computed background colors observed")
else:
info(
f"WARN observed only one polarity across {len(rgbs)} "
f"cycles: light_seen={light_seen}, dark_seen={dark_seen} "
"(toggle may not flip on this runner's color-scheme)"
)
# ─────────────────────────────────────────────────────
# 10. Sidebar nav: New Chat, Compare, Search, Recipes.
# ─────────────────────────────────────────────────────
def click_nav(label, expected_url_pat = None):
# Resolve the sidebar nav button. The plain
# get_by_role("button", name=...) lookup works on Linux
# Chromium because the accessible-name algorithm there picks
# up `tooltip={label}` from SidebarMenuButton, but on macOS
# Chromium the tooltip-derived name is sometimes empty when
# the sidebar collapses to icon-only mode. Fall back through
# progressively more permissive locators so the test stays
# green on both platforms.
candidates = [
page.get_by_role(
"button", name = re.compile(rf"^\s*{label}\s*$", re.I)
).first,
page.locator(f'button:has-text("{label}")').first,
page.locator(f'a:has-text("{label}")').first,
page.locator(f'[data-sidebar="menu-button"]:has-text("{label}")').first,
]
btn = None
for c in candidates:
if c.count() > 0:
btn = c
break
if btn is None:
soft_fail(f"nav '{label}' not found")
return False
# force=True bypasses Playwright's actionability check. The
# button IS visible + enabled, but the post-theme-toggle view-
# transition can leave <html> reported as the topmost element
# for a beat (we already neutralise startViewTransition via
# add_init_script; this is belt-and-suspenders).
try:
btn.click(force = True, timeout = 5_000)
except Exception as exc:
soft_fail(f"nav '{label}' click failed: {exc!r}")
return False
page.wait_for_timeout(800)
if expected_url_pat and not re.search(expected_url_pat, page.url):
soft_fail(
f"clicking '{label}' didn't change url to /{expected_url_pat}; "
f"current: {page.url}"
)
return False
return True
step("sidebar nav: New Chat -> Compare -> Search -> Recipes")
click_nav("New Chat", r"/chat")
shoot("11-new-chat")
click_nav("Compare", r"/chat\?") # /chat?compare=...
shoot("12-compare")
# Search opens a dialog (not a route change).
search_btn = page.get_by_role("button", name = re.compile(r"^search$", re.I)).first
if search_btn.count() > 0:
search_btn.click()
page.wait_for_timeout(500)
shoot("13-search-dialog")
page.keyboard.press("Escape")
page.wait_for_timeout(300)
click_nav("Recipes", r"/data-recipes")
shoot("14-recipes")
# Back to chat for subsequent steps.
page.goto(f"{BASE}/chat")
composer.wait_for(state = "visible", timeout = 30_000)
# ─────────────────────────────────────────────────────
# 11. API / Developer tab via account menu -> opens the
# Settings dialog with the api-keys tab. Verify we can see
# the Create API Key form (or existing keys table); regressions
# that hide the api-keys management UI surface here.
# ─────────────────────────────────────────────────────
if acct.count() > 0:
step("Developer (API) tab via account menu")
acct.click()
page.wait_for_timeout(400)
dev = page.get_by_role(
"menuitem", name = re.compile(r"developer|api", re.I)
).first
if dev.count() > 0:
dev.click()
page.wait_for_timeout(800)
shoot("15-developer-tab")
# Look for the create-key affordance.
create_btn = page.get_by_role(
"button",
name = re.compile(r"create.*key|generate.*key|add.*key|new key", re.I),
).first
if create_btn.count() > 0:
info("OK 'create API key' affordance visible")
# Look for the api-keys list section title.
keys_section = page.get_by_text(
re.compile(r"api keys|developer", re.I),
).first
if keys_section.count() > 0:
info(
f"OK API tab text: {(keys_section.text_content() or '').strip()[:80]!r}"
)
# Close dialog with Escape.
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
page.keyboard.press("Escape")
# ─────────────────────────────────────────────────────
# 11b. Recipes tab: verify cards render + we can click one.
# The Recipes route renders a grid of preset cards; a
# regression that breaks the loader would render zero cards
# or crash the route.
# ─────────────────────────────────────────────────────
step("Recipes tab: cards render + click first card")
page.goto(f"{BASE}/data-recipes")
page.wait_for_timeout(1500)
# Recipe cards are rendered as <a> or button elements; count
# all clickable headings under main + screenshot.
headings = page.locator(
"main h2, main h3, [data-recipe], a[href*='/data-recipes/']"
)
n_cards = headings.count()
info(f"Recipes route headings/cards: {n_cards}")
shoot("15b-recipes-cards")
if n_cards > 0:
# Try clicking the first one to confirm it navigates / opens.
try:
headings.first.scroll_into_view_if_needed()
headings.first.click()
page.wait_for_timeout(1200)
shoot("15c-recipes-first-card")
info("OK clicked first recipe card")
except Exception as exc:
info(f"WARN click first recipe failed: {exc!r}")
# Back to chat.
page.goto(f"{BASE}/chat")
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 30_000)
# ─────────────────────────────────────────────────────
# 11c. Recents: the chat sidebar lists previous threads. We
# already created several turns above (which gets persisted
# as a thread). Find the sidebar's recents region and click
# the most-recent entry. This catches regressions in the
# thread-history loader / route param plumbing.
# ─────────────────────────────────────────────────────
step("Recents: click previous chat in sidebar")
# The sidebar lists threads as buttons / links. Match anything
# under the sidebar with non-empty text other than the nav
# entries we already verified (New Chat / Compare / Search /
# Recipes / Account).
EXCLUDE = re.compile(
r"^(New Chat|Compare|Search|Recipes|Settings|Account|"
r"Light Mode|Dark Mode|Developer|Help|Shutdown)$",
re.I,
)
candidates = page.locator(
"aside a, aside button, [data-sidebar='sidebar'] a, "
"[data-sidebar='sidebar'] button"
)
count_c = candidates.count()
clicked_recent = False
# We sent the prompts ["Reply with exactly: hello", "What is 1+1?",
# "Reply with exactly: world", ...] above. The thread title that
# gets persisted is typically a snippet of the first user message
# (Studio summarises after a few turns). We accept either a literal
# word from one of our prompts OR a short Studio-summary heuristic.
PROMPT_KEYWORDS = ("hello", "world", "tree", "yes", "1+1", "2+2")
for i in range(count_c):
try:
t = (candidates.nth(i).text_content() or "").strip()
if not t or EXCLUDE.match(t):
continue
# Heuristic: thread titles are typically the first
# user message snippet or a short summary.
if 3 <= len(t) <= 80:
candidates.nth(i).scroll_into_view_if_needed()
candidates.nth(i).click()
page.wait_for_timeout(1500)
shoot("15d-recent-clicked")
info(f"OK clicked recent entry: {t[:60]!r}")
clicked_recent = True
# Strict check: after clicking the Recents entry, the
# thread we land on must include at least one of our
# prompts in its rendered messages. Otherwise we
# navigated to a thread that wasn't ours.
turns_text = page.evaluate("""() => {
const els = document.querySelectorAll(
'[data-role="user"], [data-role="assistant"]'
);
return Array.from(els).map(e => (e.innerText || '')
.toLowerCase()).join(' ');
}""")
if any(k in turns_text for k in PROMPT_KEYWORDS):
info("OK landed on a thread that includes our prompts")
else:
soft_fail(
"Recents-clicked thread doesn't contain any of our "
f"sent prompts; turns_text={turns_text[:120]!r}"
)
break
except Exception:
continue
if not clicked_recent:
soft_fail("no Recents entry was clickable")
# Back to chat.
page.goto(f"{BASE}/chat")
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 30_000)
# ─────────────────────────────────────────────────────
# 12. Image attachment UI (upload widget reachable). The
# current model is text-only so we don't assert a vision
# response -- just that the attachment button is there
# and the file input accepts a PNG. CI's gemma-4-E2B
# job covers the actual vision path.
# ─────────────────────────────────────────────────────
step("attachment widget reachable")
attach = page.locator('button[aria-label="Add Attachment"]').first
if attach.count() > 0:
# Just hover -- triggering the file picker mid-test
# would block on a native dialog. Verifying the
# button is reachable is enough.
attach.hover()
page.wait_for_timeout(200)
shoot("16-attachment-hover")
# ─────────────────────────────────────────────────────
# 13. Reload + verify session JWT survives.
# ─────────────────────────────────────────────────────
step("reload + session survives")
page.reload()
composer.wait_for(state = "visible", timeout = 60_000)
if "/login" in page.url:
fail(f"unexpected redirect to /login after reload: {page.url}")
shoot("17-after-reload")
# ─────────────────────────────────────────────────────
# 14. /api/health stays healthy throughout.
# ─────────────────────────────────────────────────────
health = page.evaluate(f"""async () => {{
const r = await fetch("{BASE}/api/health");
return {{status: r.status, body: await r.text()}};
}}""")
if health["status"] != 200:
fail(f"/api/health returned {health['status']}")
# ─────────────────────────────────────────────────────
# 15. Negative-auth post-UI-rotation.
# ─────────────────────────────────────────────────────
step("post-rotation auth check (after UI change-password)")
if (s_old := login_via_api(OLD)) != 401:
fail(f"old bootstrap pw should be 401, got {s_old}")
if (s_new := login_via_api(NEW)) != 200:
fail(f"rotated pw should be 200, got {s_new}")
info("OK old=401, new=200")
# ─────────────────────────────────────────────────────
# 16. Out-of-band ("terminal") password rotation.
# POST /api/auth/change-password from a real subprocess(curl)
# invocation -- this is the same surface a sysadmin / another
# tab / a desktop helper would use, and the security promise
# is: rotating the password from "the terminal" must invalidate
# the previous credentials. The endpoint also revokes refresh
# tokens server-side (auth.py:152), so /api/auth/refresh from
# the still-open browser context must fail too.
# ─────────────────────────────────────────────────────
step("rotate password via subprocess(curl) -- the 'terminal' path")
# Get a fresh access token by logging in via the API rather than
# reusing whatever's in localStorage; this matches what an admin
# would actually do from a shell.
login_proc = subprocess.run(
[
"curl",
"-fsS",
"-X",
"POST",
f"{BASE}/api/auth/login",
"-H",
"Content-Type: application/json",
"-d",
json.dumps({"username": "unsloth", "password": NEW}),
],
capture_output = True,
text = True,
timeout = 15,
)
if login_proc.returncode != 0:
fail(f"curl login failed: {login_proc.stderr!r}")
login_body = json.loads(login_proc.stdout)
cli_token = login_body.get("access_token")
if not cli_token:
fail(f"curl login returned no access_token: {login_body!r}")
info("CLI obtained an access token")
change_proc = subprocess.run(
[
"curl",
"-fsS",
"-X",
"POST",
f"{BASE}/api/auth/change-password",
"-H",
"Content-Type: application/json",
"-H",
f"Authorization: Bearer {cli_token}",
"-d",
json.dumps({"current_password": NEW, "new_password": NEW2}),
],
capture_output = True,
text = True,
timeout = 15,
)
if change_proc.returncode != 0:
fail(
f"curl change-password failed: rc={change_proc.returncode} "
f"stderr={change_proc.stderr!r} stdout={change_proc.stdout!r}"
)
info("CLI rotated password NEW -> NEW2 successfully")
# NEW must now be 401, NEW2 must be 200.
if (s_new1 := login_via_api(NEW)) != 401:
fail(f"after CLI rotation, NEW pw should be 401, got {s_new1}")
if (s_new2 := login_via_api(NEW2)) != 200:
fail(f"after CLI rotation, NEW2 pw should be 200, got {s_new2}")
info("OK after CLI rotation: NEW=401, NEW2=200 -- old studio creds dead")
# The browser still has the pre-rotation access token. Refresh
# tokens were revoked server-side by /change-password (auth.py),
# so /api/auth/refresh from the browser context must now fail.
refresh_after = page.evaluate(f"""async () => {{
const r = await fetch("{BASE}/api/auth/refresh", {{
method: "POST",
credentials: "include",
}});
return {{status: r.status}};
}}""")
if refresh_after["status"] == 200:
fail(f"/api/auth/refresh should fail after CLI rotation; got 200")
info(
f"OK browser /api/auth/refresh now {refresh_after['status']} "
"(refresh token revoked) -- old studio session can no longer renew"
)
# ─────────────────────────────────────────────────────
# 17. Shutdown button via the account menu.
# The Shutdown menuitem opens an AlertDialog ("Stop Unsloth
# Studio?") whose primary action is "Stop server"; clicking
# it POSTs /api/shutdown and then replaces document.body with
# the "Unsloth Studio has stopped" placeholder. /api/health
# should become unreachable shortly after.
# ─────────────────────────────────────────────────────
step("Shutdown via account menu")
# Re-login through the UI with NEW2 so the browser has a valid
# access token for the /api/shutdown call (the previous one
# was invalidated by the CLI rotation above).
page.goto(f"{BASE}/login")
pw_field = page.locator("#password")
pw_field.wait_for(state = "visible", timeout = 30_000)
pw_field.fill(NEW2)
page.locator('button[type="submit"]').click()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
shoot("18-relogin-with-NEW2")
acct_btn = page.locator('button[aria-label$=" account menu"]').first
if acct_btn.count() == 0:
fail("account menu button missing -- can't reach Shutdown")
acct_btn.click()
page.wait_for_timeout(400)
shutdown_item = page.get_by_role(
"menuitem",
name = re.compile(r"^\s*Shutdown\s*$", re.I),
).first
if shutdown_item.count() == 0:
fail("Shutdown menuitem not in account menu")
shutdown_item.click()
shoot("19-shutdown-dialog")
stop_btn = page.get_by_role(
"button",
name = re.compile(r"^\s*Stop server\s*$", re.I),
).first
stop_btn.wait_for(state = "visible", timeout = 5_000)
stop_btn.click()
# Wait for the post-shutdown placeholder body. The component
# replaces document.body.innerHTML with text containing
# "Unsloth Studio has stopped." once /api/shutdown returns ok.
try:
page.wait_for_function(
"""() => /Unsloth Studio has stopped/.test(document.body.innerText)""",
timeout = 15_000,
)
shoot("20-shutdown-placeholder")
info("OK 'Unsloth Studio has stopped' placeholder rendered")
except Exception as exc:
info(f"WARN shutdown placeholder didn't render: {exc!r}")
# Now /api/health must become unreachable (process exited or is
# at least not listening). Poll for up to 15 s.
host = re.sub(r"^https?://", "", BASE).split(":")[0]
port = int(re.search(r":(\d+)", BASE).group(1)) if ":" in BASE else 80
deadline = time.time() + 15
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout = 1):
pass
time.sleep(0.5)
except (ConnectionRefusedError, OSError):
info("OK port closed -- server process is gone")
break
else:
# Connection still works -> shutdown didn't take effect.
try:
r = urllib.request.urlopen(f"{BASE}/api/health", timeout = 2)
fail(f"server still up after Shutdown click; /api/health={r.status}")
except urllib.error.URLError as exc:
info(f"OK /api/health unreachable: {exc!r}")
# Some pageerrors are benign in this test:
# - "Request failed (422)": the OpenAI-compatible chat-completions
# endpoint rejects rapid-fire/malformed requests with 422. The
# surfaced error is a network-layer bubble-up, NOT a JS bug,
# and the per-turn flow already validates message-by-message
# correctness. Filtering these here keeps the pageerror gate
# focused on actual frontend regressions (TypeError, ReferenceError,
# null deref, etc.).
# - "Failed to fetch" / "NetworkError" after the Shutdown click:
# the server is intentionally dead by then; any in-flight
# fetch fails by design.
BENIGN_PATTERNS = (
"Request failed (422)",
"Failed to fetch",
"NetworkError",
"Load failed",
"At least one non-system message is required",
)
real_errors = [
e for e in page_errors if not any(pat in e for pat in BENIGN_PATTERNS)
]
if page_errors:
info(
f"WARN page errors: {len(page_errors)} total "
f"({len(real_errors)} non-benign); first: {page_errors[0]!r}"
)
if real_errors:
fail(f"{len(real_errors)} non-benign pageerror events")
info(f"console.error events: {len(console_errors)}")
info("PASS comprehensive UI flow")
browser.close()