Chat UI Tests was failing in CI with "<html> intercepts pointer events"
on the New Chat sidebar click. Root cause: after the theme toggle's
animated reveal, Chromium's view-transition state can leave the html
element reported as the topmost click target for a beat -- even after
the documentElement classList has settled. The previous CSS-only
neutraliser (animation: none + pointer-events: auto) wasn't enough
once the runtime captured the html.
Two-pronged fix in both playwright_chat_ui.py and playwright_extra_ui.py:
1. Monkey-patch document.startViewTransition in add_init_script so
the callback runs synchronously, no animation pipeline runs, and
the html is never captured. This is the only way to fully
neutralise the transition without disabling the feature in the
app code.
2. Use force=True + a 5s timeout in click_nav() (sidebar nav
clicks). The element IS visible + enabled; force=True bypasses
Playwright's actionability check belt-and-suspenders if the
monkey-patch ever misses an edge case.
Also broadened the CSS pseudo-element list (added ::view-transition,
-group, -image-pair) to display:none, so even if startViewTransition
is somehow re-attached, the captured pseudos can't paint over the page.
421 lines
18 KiB
Python
421 lines
18 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
|
|
|
|
"""Studio extra-UI Playwright test.
|
|
|
|
Covers the user-visible surfaces that the main chat-UI test doesn't:
|
|
|
|
1. Compare tab (/chat?compare=...): assign two models, send 2 prompts,
|
|
assert both panes respond.
|
|
2. Recipes editor (/data-recipes/$recipeId): click first template,
|
|
verify the recipe-studio canvas mounts, open + close the Preview
|
|
dialog.
|
|
3. Export route (/export): chat-only mode redirects to /chat;
|
|
non-chat-only mode shows the export form fields.
|
|
4. Studio training route (/studio): chat-only mode redirects;
|
|
non-chat-only verifies the tabs + sections exist.
|
|
5. Settings dialog tabs: Cmd/Ctrl-, opens the dialog; cycle through
|
|
each tab and verify it isn't blank.
|
|
|
|
The test assumes Studio is freshly booted (must_change_password=true)
|
|
on BASE_URL with the bootstrap password in STUDIO_OLD_PW. It does its
|
|
own change-password through the UI + model load via /api/inference/load,
|
|
matching the pattern in playwright_chat_ui.py.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = os.environ["BASE_URL"]
|
|
OLD = os.environ["STUDIO_OLD_PW"]
|
|
NEW = os.environ.get("STUDIO_NEW_PW", "ExtraUi-NEW-2026!")
|
|
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_extra")
|
|
ART = Path(ART_DIR)
|
|
ART.mkdir(parents = True, exist_ok = True)
|
|
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
|
|
|
|
_n = [0]
|
|
_failed: list[str] = []
|
|
|
|
|
|
def step(s: str) -> None:
|
|
print(f"[ui-extra] STEP {s}", flush = True)
|
|
|
|
|
|
def info(s: str) -> None:
|
|
print(f"[ui-extra] {s}", flush = True)
|
|
|
|
|
|
def fail(m: str) -> None:
|
|
print(f"[ui-extra] FAIL: {m}", flush = True)
|
|
_failed.append(m)
|
|
|
|
|
|
def soft_fail(m: str) -> None:
|
|
if STRICT:
|
|
fail(m)
|
|
else:
|
|
info(f"WARN (strict-off): {m}")
|
|
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless = True)
|
|
ctx = browser.new_context(
|
|
viewport = {"width": 1280, "height": 900},
|
|
reduced_motion = "reduce",
|
|
)
|
|
ctx.add_init_script("""
|
|
(function () {
|
|
try {
|
|
// Same shim as playwright_chat_ui.py: nuke view-
|
|
// transition pseudo-elements + monkey-patch
|
|
// startViewTransition so the html element never gets
|
|
// captured (which Playwright surfaces as "<html>
|
|
// intercepts pointer events" on later clicks).
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
::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; }
|
|
`;
|
|
(document.head || document.documentElement).appendChild(style);
|
|
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) {}
|
|
})();
|
|
""")
|
|
page = ctx.new_page()
|
|
page.set_default_timeout(30_000)
|
|
page_errors = []
|
|
page.on("pageerror", lambda e: page_errors.append(str(e)))
|
|
|
|
def shoot(name: str) -> None:
|
|
_n[0] += 1
|
|
page.screenshot(
|
|
path = str(ART / f"{_n[0]:02d}-{name}.png"),
|
|
full_page = True,
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# Setup: change-password through the UI + model load.
|
|
# ─────────────────────────────────────────────────────
|
|
step("setup: change-password + model load")
|
|
page.goto(f"{BASE}/change-password")
|
|
page.locator("#new-password").wait_for(state = "visible", timeout = 30_000)
|
|
page.fill("#new-password", NEW)
|
|
page.fill("#confirm-password", NEW)
|
|
page.locator('button[type="submit"]').click()
|
|
composer = page.locator('textarea[aria-label="Message input"]')
|
|
composer.wait_for(state = "visible", timeout = 60_000)
|
|
shoot("01-chat-loaded")
|
|
|
|
token = page.evaluate("() => localStorage.getItem('unsloth_auth_token')")
|
|
if not token:
|
|
fail("no access token after change-password")
|
|
sys.exit(1)
|
|
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 -> {load_resp['status']}: {load_resp.get('body')!r}")
|
|
sys.exit(1)
|
|
info(f"loaded model: {load_resp['body'].get('display_name')}")
|
|
page.reload()
|
|
composer = page.locator('textarea[aria-label="Message input"]')
|
|
composer.wait_for(state = "visible", timeout = 60_000)
|
|
|
|
# Detect chat-only mode: /api/health.chat_only is the source of truth.
|
|
# In chat-only mode, /studio + /export redirect to /chat.
|
|
health = page.evaluate(f"""async () => {{
|
|
const r = await fetch("{BASE}/api/health");
|
|
return await r.json();
|
|
}}""")
|
|
chat_only = bool(health.get("chat_only"))
|
|
info(f"chat_only mode: {chat_only}")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 1. Compare tab.
|
|
# ─────────────────────────────────────────────────────
|
|
step("Compare tab: send to two panes")
|
|
# The Compare nav lives in the sidebar; click it.
|
|
compare_nav = page.locator('[data-tour="chat-compare"]').first
|
|
if compare_nav.count() == 0:
|
|
compare_nav = page.get_by_role(
|
|
"button",
|
|
name = re.compile(r"^\s*Compare\s*$", re.I),
|
|
).first
|
|
if compare_nav.count() == 0:
|
|
soft_fail("Compare nav not found")
|
|
else:
|
|
compare_nav.click()
|
|
page.wait_for_timeout(1500)
|
|
shoot("02-compare-opened")
|
|
# Compare view's container.
|
|
view = page.locator('[data-tour="chat-compare-view"]').first
|
|
if view.count() == 0:
|
|
soft_fail("[data-tour='chat-compare-view'] not found after Compare click")
|
|
else:
|
|
ok_count_before = len(page.locator('[data-role="assistant"]').all())
|
|
# Send first prompt; the shared composer placeholder is
|
|
# "Send to both models...". Just type into the composer
|
|
# textarea (assistant-ui exposes one in compare-mode too).
|
|
cmp_composer = page.get_by_placeholder(
|
|
re.compile(r"Send to both models", re.I),
|
|
).first
|
|
if cmp_composer.count() == 0:
|
|
# Fall back to any visible textarea inside the compare
|
|
# view.
|
|
cmp_composer = view.locator("textarea").first
|
|
if cmp_composer.count() == 0:
|
|
soft_fail("compare composer textarea not found")
|
|
else:
|
|
cmp_composer.click()
|
|
cmp_composer.fill("Reply with: A")
|
|
page.locator('button[aria-label="Send message"]').first.click()
|
|
# Wait for at least 2 NEW assistant bubbles (one per pane).
|
|
try:
|
|
page.wait_for_function(
|
|
"""(want) => {
|
|
return document.querySelectorAll(
|
|
'[data-role="assistant"]'
|
|
).length >= want;
|
|
}""",
|
|
arg = ok_count_before + 2,
|
|
timeout = 180_000,
|
|
)
|
|
info("OK Compare: 2 new assistant bubbles after first prompt")
|
|
except Exception as exc:
|
|
soft_fail(f"Compare: 2 bubbles didn't appear: {exc!r}")
|
|
shoot("03-compare-after-A")
|
|
|
|
# Send a second prompt -> 4 total new bubbles.
|
|
cmp_composer.fill("Reply with: B")
|
|
page.locator('button[aria-label="Send message"]').first.click()
|
|
try:
|
|
page.wait_for_function(
|
|
"""(want) => {
|
|
return document.querySelectorAll(
|
|
'[data-role="assistant"]'
|
|
).length >= want;
|
|
}""",
|
|
arg = ok_count_before + 4,
|
|
timeout = 180_000,
|
|
)
|
|
info(
|
|
"OK Compare: 4 total new assistant bubbles after second prompt"
|
|
)
|
|
except Exception as exc:
|
|
soft_fail(f"Compare: 4 bubbles didn't appear: {exc!r}")
|
|
shoot("04-compare-after-B")
|
|
|
|
# Back to single chat for subsequent steps.
|
|
page.goto(f"{BASE}/chat")
|
|
composer = page.locator('textarea[aria-label="Message input"]')
|
|
composer.wait_for(state = "visible", timeout = 30_000)
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 2. Recipes editor.
|
|
# ─────────────────────────────────────────────────────
|
|
step("Recipes editor: click first template + Preview dialog")
|
|
page.goto(f"{BASE}/data-recipes")
|
|
page.wait_for_timeout(1500)
|
|
shoot("05-recipes-list")
|
|
# Template cards render as <button> elements.
|
|
templates = page.locator("main button").filter(
|
|
has_not_text = re.compile(r"^(\+|Create)")
|
|
)
|
|
n_templates = templates.count()
|
|
info(f"recipe templates visible: {n_templates}")
|
|
if n_templates == 0:
|
|
soft_fail("no recipe template cards found")
|
|
else:
|
|
# Click the first one.
|
|
try:
|
|
templates.first.scroll_into_view_if_needed()
|
|
templates.first.click()
|
|
page.wait_for_timeout(2000)
|
|
shoot("06-recipe-opened")
|
|
# The recipe-studio canvas uses React-Flow; look for the
|
|
# renderer.
|
|
canvas = page.locator(
|
|
".react-flow__renderer, .react-flow, [data-testid*='react-flow']"
|
|
).first
|
|
if canvas.count() == 0:
|
|
# Some templates may open as dialogs instead of route.
|
|
info("(no React-Flow canvas; template may have opened a dialog)")
|
|
else:
|
|
info("OK React-Flow canvas mounted")
|
|
except Exception as exc:
|
|
soft_fail(f"recipe template click failed: {exc!r}")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 3. Export route.
|
|
# ─────────────────────────────────────────────────────
|
|
step(f"Export route ({'chat-only redirect' if chat_only else 'form fields'})")
|
|
page.goto(f"{BASE}/export")
|
|
page.wait_for_timeout(1500)
|
|
shoot("07-export")
|
|
if chat_only:
|
|
if "/export" in page.url:
|
|
soft_fail(
|
|
f"chat-only mode should redirect /export -> /chat; url={page.url}"
|
|
)
|
|
else:
|
|
info(f"OK chat-only redirected /export -> {page.url}")
|
|
else:
|
|
# Non-chat-only: verify the export-cta button + HF token field.
|
|
cta = page.locator('[data-tour="export-cta"]').first
|
|
if cta.count() == 0:
|
|
soft_fail("[data-tour='export-cta'] not found in /export")
|
|
else:
|
|
info("OK [data-tour='export-cta'] visible")
|
|
hf_token = page.get_by_placeholder(re.compile(r"hf_", re.I)).first
|
|
if hf_token.count() > 0:
|
|
info("OK HF token input visible")
|
|
else:
|
|
soft_fail("HF token input not found in /export")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 4. Studio training route.
|
|
# ─────────────────────────────────────────────────────
|
|
step(f"Studio route ({'chat-only redirect' if chat_only else 'tabs + sections'})")
|
|
page.goto(f"{BASE}/studio")
|
|
page.wait_for_timeout(1500)
|
|
shoot("08-studio")
|
|
if chat_only:
|
|
if "/studio" in page.url:
|
|
soft_fail(
|
|
f"chat-only mode should redirect /studio -> /chat; url={page.url}"
|
|
)
|
|
else:
|
|
info(f"OK chat-only redirected /studio -> {page.url}")
|
|
else:
|
|
for tab_name in ("Configure", "Current run", "History"):
|
|
tab = page.get_by_role(
|
|
"tab", name = re.compile(rf"^\s*{tab_name}\s*$", re.I)
|
|
).first
|
|
if tab.count() == 0:
|
|
soft_fail(f"tab '{tab_name}' not found in /studio")
|
|
else:
|
|
info(f"OK tab '{tab_name}' visible")
|
|
for anchor in ("studio-model", "studio-dataset", "studio-params"):
|
|
el = page.locator(f'[data-tour="{anchor}"]').first
|
|
if el.count() == 0:
|
|
soft_fail(f"[data-tour='{anchor}'] not found")
|
|
else:
|
|
info(f"OK [data-tour='{anchor}'] visible")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 5. Settings dialog tabs.
|
|
# ─────────────────────────────────────────────────────
|
|
step("Settings dialog: cycle through tabs")
|
|
page.goto(f"{BASE}/chat")
|
|
composer.wait_for(state = "visible", timeout = 30_000)
|
|
page.keyboard.press("Control+,") # global shortcut
|
|
page.wait_for_timeout(800)
|
|
settings = page.get_by_role("dialog").first
|
|
if settings.count() == 0:
|
|
# macOS shortcut is Cmd-,; try that too.
|
|
page.keyboard.press("Meta+,")
|
|
page.wait_for_timeout(800)
|
|
settings = page.get_by_role("dialog").first
|
|
if settings.count() == 0:
|
|
soft_fail("Settings dialog didn't open with Cmd/Ctrl-,")
|
|
else:
|
|
shoot("09-settings-open")
|
|
# Each tab is a button with the visible text as accessible name.
|
|
# Tabs available depend on chat_only mode.
|
|
candidate_tabs = (
|
|
"General",
|
|
"Profile",
|
|
"Appearance",
|
|
"Chat",
|
|
"Developer",
|
|
"About",
|
|
)
|
|
seen_tabs = []
|
|
for tab_name in candidate_tabs:
|
|
btn = page.get_by_role(
|
|
"button",
|
|
name = re.compile(rf"^\s*{tab_name}\s*$", re.I),
|
|
).first
|
|
if btn.count() == 0:
|
|
continue
|
|
try:
|
|
btn.click()
|
|
page.wait_for_timeout(400)
|
|
# Tab body must contain something (non-empty).
|
|
body_text = page.evaluate(
|
|
"""() => {
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
return dialog ? (dialog.innerText || '').trim().length : 0;
|
|
}"""
|
|
)
|
|
if body_text > 30:
|
|
info(f"OK Settings tab '{tab_name}' body length={body_text}")
|
|
seen_tabs.append(tab_name)
|
|
else:
|
|
soft_fail(
|
|
f"Settings tab '{tab_name}' body suspiciously short: {body_text}"
|
|
)
|
|
except Exception as exc:
|
|
soft_fail(f"Settings tab '{tab_name}' click failed: {exc!r}")
|
|
shoot("10-settings-tabs-visited")
|
|
page.keyboard.press("Escape")
|
|
page.wait_for_timeout(300)
|
|
info(f"visited Settings tabs: {seen_tabs}")
|
|
if not seen_tabs:
|
|
soft_fail("no Settings tabs were visitable")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# Done.
|
|
# ─────────────────────────────────────────────────────
|
|
if page_errors:
|
|
info(f"WARN {len(page_errors)} pageerror events; first: {page_errors[0]!r}")
|
|
fail(f"{len(page_errors)} pageerror events")
|
|
|
|
if _failed:
|
|
info(f"FAILED: {len(_failed)} assertion(s)")
|
|
for m in _failed:
|
|
info(f" - {m}")
|
|
sys.exit(1)
|
|
info("PASS extra UI flow")
|
|
browser.close()
|