* studio: redesign chat composer Reworks the new-chat composer and the compare composer into a single rounded pill surface with a softer, lighter look. - New welcome screen with a time-of-day sloth mascot and a lighter heading. - One rounded composer surface with a soft drop shadow. The input grows inline as you type and collapses back to a single row when cleared. - Tools and attachments live in a single plus menu; the thinking control is a compact pill with a reasoning-effort submenu. - Inlined glyphs for the thinking, send, and dictate controls, kept in sync across the main and compare composers. - Toast notifications match the composer surface: no border line, the same drop shadow, and the same dark surface color, with a ring-less close button. - Dark mode: the side-menu shadow blends into the background, hovered menu rows read clearly, and their roundness matches light mode. - Composer styles use dedicated unsloth- prefixed classes so compare mode keeps its own stacked layout. * studio: sync compare-composer reasoning state and harden compare id - Compare composer: keep "Preserve thinking" consistent with reasoning, matching the main composer. Enabling it now turns reasoning on, and disabling reasoning (the None option or the Thinking toggle) turns it off, so the invalid "preserve on while thinking off" state can't occur. - Guard crypto.randomUUID in the Compare action. It is undefined in non-secure contexts (HTTP over a LAN IP) and would throw; fall back to a timestamped random id, matching createNavigationNonce. * studio: reflect pre-selected Search/Code tools when no model is loaded The Search and Code pills only lit up when the tool was usable right now (a model loaded and capable), so a tool turned on from the + menu showed as off in the pill while the menu showed it on. toolsEnabled is persisted and takes effect once a capable model loads, so the pill should reflect it. The pills now disable only when a loaded model lacks the capability, and otherwise reflect the selected state. Applied to the main and compare composers. * Studio: link MCP Servers heading to its PR and fix composer pill cursors Make the "MCP Servers" heading in the chat Configuration sheet link to the MCP PR, keeping the chevron as the toggle. The label and chevron are rendered as siblings so we don't nest an <a> inside a <button>. Also add cursor-pointer to the composer pills and the thinking pill so hovering a clickable pill shows the hand cursor instead of the default arrow. * Studio: refine chat composer and add compare-mode parity - Composer expands to two rows only once the input wraps to a second line, not on the first keystroke. Re-measure the autosize textarea on the width swap so expanding no longer leaves a stray blank row. - Light-mode composer shadow now matches Gemini's soft elevation. - Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG) and add Code above MCP. Active Web search/Code items use medium weight. - Compare mode: the plus side menu, Search/Code toggles, and a Compare exit pill now match single chat, with the thinking control on the right. - Projects menu entries link to their tracking PR (#5725). - Add cursor-pointer to the composer plus button. * studio: refine composer controls and chat search shadow - Active tool pills show an x on hover to signal click-to-disable - Plus button rotates into an x when the tools menu opens - Composer surface uses a 32px radius and a taller single-line height - Even, ChatGPT-style spacing between the plus and tool pills in both the single and compare composers - Send and mic circles resized and spaced, with the arrow centered in the circle - Chat search box gets a borderless, soft Gemini-style shadow * studio: size the pill hover x to match the icon it replaces Cross-engine checks (Chromium, Firefox, WebKit) flagged the active-pill hover x as a fixed 14px, so it popped smaller than the 19px Code icon. Fill the glyph slot instead so the x tracks whatever icon it covers. * studio: do not persist Kimi search/thinking mutual-exclusion in single composer The single-chat composer flipped the other control off when toggling search or thinking on Kimi, but without { persist: false }, so it overwrote the user's saved preference. Match shared-composer and keep the side effect session-only. * studio: pointer cursor on model selector trigger and menu items Add scoped marker classes so the model picker trigger and every clickable element in its menu (tabs, model rows, delete, eject) show a pointer cursor; disabled items stay not-allowed. * studio: pass baseUrl when resolving reasoning caps in single composer The docked composer omitted baseUrl, so a custom Gemini OpenAI-compat gateway still advertised the native thinking ladder the backend cannot honor. Pass selectedExternalProvider.baseUrl like the compare composer so the resolver hides it. * studio: grey side-menu hover, green pill hover, thinking hover x - Plus side-menu items hover grey in light mode, not the green accent - Thinking pill hovers green like the Search and Code pills - The plain Thinking toggle shows an x on hover when active, matching Search and Code; the effort dropdown trigger keeps its bulb * studio: make the pill hover x a uniform size The x filled the icon slot, so the wider Code chevron gave a bigger x than Search and Compare. Pin it to a fixed 15px, centered, so every pill's x matches. * studio: broaden chat attachments, fix active hover color, gemini shadow - Accept svg, source code and many text/config files as drag-and-drop or picked attachments, matched by extension since their MIME is unreliable; html keeps its own adapter - Active (green) side-menu items keep their text and icon color on hover instead of switching to the accent color - Composer surface uses Gemini's soft centered shadow 0 0 20px rgba(0,0,0,0.04) * studio: keep the thinking pill full height when icon-only The inactive thinking pill has no label, so its flex row collapsed to the icon height and the hover box looked short. Reserve one text line (min-height: 1lh + padding) so it matches the Search and Code pills. * studio: refine composer menu, drop overlay and greetings - Open the MCP servers dialog directly from the composer plus menu - Redesign the drag-and-drop affordance Gemini style, drop the badge and border, make the whole chat page a drop target - Swap in Hugeicons for the RAG, attachment chip and new project icons - Add time-based randomized welcome greetings, each matched to a fitting sloth * studio: rename artifacts toggle to Canvas and make it opt-in - Label the toggle Canvas everywhere, matching the plus menu - Stop greying out the Canvas menu item; it toggles like the other items - Only show the Canvas pill in the composer row once it is turned on, since it is less central than Search and Code * studio: wire Canvas and MCP composer toggles, even out the pill row - Open the MCP servers dialog from the menu, or toggle MCP on/off once a server is enabled - Force MCP off when no server is enabled, so the toggle stays honest - Show Canvas and MCP as opt-in pills that appear in the order they were toggled on - Expand the composer and light up the pill when Canvas or MCP is on, like Search and Code - Keep Compare directly after Code in the compare composer - Use the same Code icon on both composers and give every pill an even icon slot * studio: tidy composer toggle row and fix MCP enable/disable lifecycle - Enable MCP automatically after a server is configured via the toggle flow - Force MCP off everywhere once the last enabled server is removed - Collapse the pill labels to icons only when more than 4 pills show, keeping Compare labelled - Order Compare first in compare mode, before Search and Code - Use the same Code icon and an even 19px icon slot across both composers - Match the compare composer surface padding and send button inset to normal chat * studio: revert compare composer padding change that cramped the input Matching the surface padding to normal chat clipped the textarea text and left a white strip on top. Restore the compare composer's own padding, which gives proper top spacing. The send button inset fix stays. * studio: center welcome greeting and soften composer scrollbar Center the sloth and title together over the composer instead of shifting the row left, which left the greeting sitting off to the side. Keep the composer textarea scroll thumb faint by default and only darken it when the thumb is hovered or dragged, so a tall draft no longer shows a heavy dark rail. * studio: match composer plus-menu tool gating to the pills The new plus-menu tool entries did not carry the gating the visible pills already enforce, so the menu and pills could disagree about a loaded model's capabilities. - Web search and Code menu items now disable when a loaded model lacks the capability, while still allowing preselection with no model loaded. - Enabling Web search from the menu on a Kimi model now flips thinking off as a session-only change, since Kimi forbids search and thinking together. This matches the Search pill. - Added an Images menu item, shown only for image-generation models and disabled until a model loads, so a short prompt has an entry point. Applied to both the single-chat and compare composers. * studio: round the active-pill hover x and even out pill padding The hover x sat bare and the trailing label was tighter to the pill edge than the leading icon, so the pill looked lopsided. - Give the hover x a soft circular background that fills the icon slot, matching the ChatGPT-style toggle and the icon it replaces. - Add a little more trailing padding so the label and the leading icon have even breathing room, and keep icon-only compact pills symmetric. * studio: nudge the thinking bulb icon up by 0.5px Bump the thinking lightbulb from 15px to 15.5px in the single-chat and compare composers so it sits a touch larger next to the other controls. * studio: drop the hover x circle on icon-only pills When pills collapse to icon-only, the circle around the hover x is too cramped in the small chip, so show a bare x there and keep the circle only on the full-width labelled pills. * studio: space the compare send button like normal chat In compare mode the Thinking control sat right against the send button. Match the normal composer's control spacing (gap-1.5 plus a send margin) so Thinking has the same breathing room before send. The send button keeps its 14px inset, so its position is unchanged. * studio: make collapsed pill hover a circle, not a wide pill Icon-only pills were wider than tall, so their rounded-full hover highlight read as a fat rounded rectangle. Make the compact button a square and center the glyph so the hover (and the x it reveals) sits in a clean circle. * studio: fix compare pane drops and audio picker lifetime - Skip the page-level drop handler when the composer is hidden, so files dropped on a compare pane are not swallowed by a hidden composer; the shared compare composer keeps handling drops through its own dropzone. - Build the audio file input on document.body instead of inside the plus menu, so the menu closing on select no longer unmounts the input before the OS picker returns and drops the file. * studio/chat: stop projects list from white-screening on older backends The projects list API returned data.projects directly, so a backend that omits the field handed back undefined. useChatProjects cached that value, then the next mount read undefined.length and crashed the whole chat page. Default the projects and threads list APIs to an empty array and keep the hook null-safe so a bad response can never poison the cache. * studio/chat: align MCP dropdown with the + menu and add a chevron Reuse the + menu surface (unsloth-plus-menu) for the MCP dropdown: rounded corners, narrower width, neutral grey hover, and enabled rows shown as green text with a right-aligned check instead of the emerald underlay. Add a chevron to the MCP pill so it reads as openable, matching the Thinking pill. * studio/chat: make MCP an opt-in pill and fix its dropdown placement - MCP is back in the + menu as a toggle. The pill now only shows in the composer when MCP is on, matching Canvas, instead of always sitting there. - The dropdown follows the composer side like the + menu (opens down in the welcome composer, up when docked) rather than always opening upward. - Drop the dropdown caret when pills collapse so the icon is not squished. - Stop force-syncing mcpEnabledForChat to the server count; the + menu owns it. * studio/chat: MCP expands the composer, drop sidebar Compare, tidy scrollbars - Toggling MCP now expands the composer and shows the tool pills, the same as Canvas, instead of leaving the row collapsed. - Remove the Compare item from the sidebar now that it lives in the + menu, and point the compare tour step at the side-by-side view instead of the old button. - Both sidebars only show their scrollbar on hover, and run settings reserves the scrollbar gutter so the close button no longer shifts when it appears. * studio/chat: tighten toggle gap, fix run-settings close button, collapsed Train - Reduce the composer toggle gap by 2px (gap-1 to gap-0.5) in both composers. - Move the run settings header out of the scroll area so the close button keeps its position whether or not the scrollbar shows, and sits flush with the topbar open button again instead of shifting left. - Surface Train as an icon in the collapsed sidebar (it already has a labelled section when expanded). * studio/chat: tighten Thinking pill X padding, create projects inline - The Thinking pill used px-2.5, so the hover X sat further in than the left pills. Match their pl-2 so the X lines up. - The + menu New project now opens a create dialog and jumps straight to the new project, instead of routing to the projects list. Shared by both composers via a small NewProjectDialog. * studio/chat: soften account menu, hover scrollbars, show collapsed chevrons - Account menu drops its border ring for the composer's soft shadow and opens centered over its trigger. - Settings and search reuse the hover-only scrollbar via a shared hover-scrollbar class, matching the sidebars. - Train and Recents keep their chevron visible while collapsed so it is clear they can be expanded. * studio/chat: roomier, more rounded account menu Widen the account menu, add more left and right padding on the rows, bump the row height and text a touch, and round the corners more, closer to the GPT account menu. * studio/chat: trim account menu width and nudge it up 2px Pull the account menu in slightly on the left and right (narrower box, a touch less row padding) and lift it 2px higher above the trigger. * studio/settings: drop outline ring, circular close hover, pointer cursors - Remove the settings dialog outline ring, keeping just the soft shadow. - The close button hover is now a circle instead of a rounded rectangle. - Every clickable control in the settings dialog uses a pointer cursor. * studio/chat: bump MCP pill icon to 14.5px Nudge the MCP icon up 0.5px so it sits even with the other pill glyphs. * studio/chat: bump MCP pill icon to 15px Nudge the MCP icon up another 0.5px. * studio/settings: add a Settings title above the tabs Put a Settings heading at the top of the sidebar so the tabs sit below it, matching the Claude settings layout. Hidden on mobile where the nav is a row. * studio/settings: rounder tab hover, bigger title, less-round search dialog * studio/sidebar: round nav row hover boxes 2px more (10px to 12px) * studio: drop settings dark shadow + divider, add tab left padding, tune hover roundness * studio/model-selector: roomier padding, borderless box, rounder hover rows; settings divider light-only * studio/search: match chat box shadow (soft light, none dark) * studio/sidebar: borderless chat context menus, rename submenu to Projects with folder-export icon * studio/model-selector: match light corner radius in dark, drop dark shadow, more visible dark hover * studio/sidebar: chat context menu matches + side menu styling; relabel submenu Move to project * studio: borderless message export menu (no dark shadow), match dark corner radius to light on export menu and settings * studio/sidebar: open chat options menu GPT-style (down-right) and widen so Move to project fits one line * studio/chat: message export menu uses the chatbox shadow in light mode * studio/sidebar: narrow chat options menu slightly (w-60 to w-56) * studio: unify all download icons to Hugeicons download-01; round profile button hover 1px more * studio/run-settings: bump header to 16px * studio/sidebar: trim chat options menu width slightly (w-56 to 216px) * studio/sidebar: trim chat options menu width to w-52 * studio/profile: camera-01 Hugeicons glyph and chatbox shadow on avatar button * studio: match dark-mode corner radius to light globally (single --radius token) * studio/recipes: borderless New Recipe menu with chatbox shadow in light, none in dark * studio: borderless dropdowns globally, chatbox shadow in light, none in dark * studio: extend borderless + chatbox/none shadow to select, combobox and popover overlays * studio/mcp: nudge MCP dropdown radius to 20px so its wider box reads as round as the + menu * studio: restore dark dropdown shadow to avoid same-color merge; greet name ~1/3 of lines; bigger sloth + more gap * studio/train: active tab is a borderless pill (no underline), roomier padding, more tab gap and bottom spacing * studio/chat: nudge welcome up ~5px (still vh-based) and trim sloth image to 44px * studio/train: active tab pill is white with chatbox shadow in light, taller padding * studio/chat: welcome offset to calc(30vh - 10px) * studio/chat: welcome offset to 28vh (drop the -10px) * studio/chat: tighten sloth-to-text gap by 1px (16px to 15px) * studio/train: revert light active pill to grey fill, drop white bg + shadow * studio: app-wide hand cursor on every clickable control (disabled excluded) * studio/chat: welcome offset to 26vh * studio/chat: welcome offset to 28vh * studio/chat: harden project and thread list guards against non-array payloads * studio/sidebar: give the profile row more height and breathing room * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: reduce Train and Recents section label size slightly * studio/sidebar: trim the profile row top and bottom padding a touch more * studio/sidebar: enlarge the profile hover area top and bottom * studio/sidebar: increase profile hover roundness by 1px * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: trim the profile row top and bottom padding slightly * studio/chat: cache composer line metrics so wrap detection runs once, not per keystroke * studio/chat: restore the prior view when exiting compare opened from the + menu * studio/tests: drive Compare from the composer + menu after it moved out of the sidebar * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio/tests: open Compare from the composer + menu in the extra UI suite too * studio: fix chat dictation microphone access * studio: snappier plus-to-x spin and steady composer expand gap Speed up the composer plus icon morph from 480ms to 300ms. Add row-gap on the expanded composer line so the space between the text and the controls row stays the same whether the box expanded from wrapped text or from a toggle being on. The gap sits on the line, not the input, so the placeholder max-height clamp never crops it. * studio: only show composer tool pills once a model is loaded Persisted Search/Code/Canvas/MCP toggles were surfacing the composer pill row on a fresh page load before any model was selected, so an empty composer looked different from the clean just-ejected state. Gate the composerExpanded tool checks on modelLoaded so a model-less composer stays collapsed, while saved preferences still apply the moment a model loads. * studio: hide RAG composer menu item temporarily Hide the placeholder RAG entry from the composer plus menu in both single chat and compare until the feature is ready, and drop the now-unused DatabaseIcon import. * studio: let composer tools pre-select before a model loads Selecting Web search, Code, Canvas or MCP from the + menu with no model loaded did nothing visible: the toggle turned on but the composer never expanded, so the pill stayed hidden. Drop the model-loaded gate from the expand check so an active tool always surfaces its pill. Align MCP with the Search/Code pattern too: grey it out only when a loaded model lacks tool support, so MCP stays toggleable and the pill stays clickable before a model is loaded instead of looking disabled. --------- Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
627 lines
27 KiB
Python
627 lines
27 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
|
|
|
|
# Shared robustness helpers live next to this script. Tests run as
|
|
# plain `python tests/studio/playwright_extra_ui.py` (not via pytest /
|
|
# import), so prepend the dir to sys.path before importing.
|
|
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,
|
|
wait_for_health,
|
|
)
|
|
|
|
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"
|
|
# Mirrors playwright_chat_ui.py. macos-14 free runners need a longer
|
|
# turn timeout because gemma-3-270m CPU inference is 3-5x slower than
|
|
# ubuntu-latest's.
|
|
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-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}")
|
|
|
|
|
|
def runtime_warn(m: str) -> None:
|
|
"""Warn about a runtime-coupled assertion that depends on a real
|
|
model loaded into the Compare panes. STRICT mode gates selector
|
|
presence (those MUST hold) but not Compare-pane streaming, which
|
|
is still flaky when no explicit pane model is set.
|
|
"""
|
|
info(f"WARN (runtime): {m}")
|
|
|
|
|
|
with sync_playwright() as p:
|
|
_watchdog = install_wall_clock_watchdog(
|
|
WALL_TIMEOUT_S,
|
|
label = "ui-extra",
|
|
info = info,
|
|
)
|
|
# Health pre-flight (best-effort). Same rationale as in
|
|
# playwright_chat_ui.py: bash-side health wait can succeed before
|
|
# the auth DB has finished migrating on macos-14 free runners.
|
|
wait_for_health(BASE, timeout = 30.0, info = info)
|
|
# Chromium launch args: see `tests/studio/_playwright_robust.py`.
|
|
# Bundles macos-14 stability + new throttling-kill flags shared
|
|
# with playwright_chat_ui.py.
|
|
browser = p.chromium.launch(
|
|
headless = True,
|
|
args = chromium_launch_args(),
|
|
)
|
|
ctx = browser.new_context(
|
|
viewport = {"width": 1280, "height": 900},
|
|
reduced_motion = "reduce",
|
|
)
|
|
install_view_transition_killer(ctx)
|
|
page = ctx.new_page()
|
|
# See playwright_chat_ui.py -- 60s default for macos-14 free
|
|
# runner with --single-process Chromium. The extra-UI script is
|
|
# the SECOND Studio boot of the job, so the runner is even
|
|
# warmer (slower disk cache, contended Chromium state).
|
|
page.set_default_timeout(60_000)
|
|
page_errors = []
|
|
|
|
# Filter out known-benign React errors that fire when the Compare
|
|
# flow's second prompt races the first prompt's SSE stream, or when
|
|
# /export's lazy-loaded sections haven't finished mounting before
|
|
# the error boundary trips. Both are timing artefacts on slow CI
|
|
# runners (macos-14 free), not Studio bugs. The base list lives in
|
|
# `_playwright_robust.BENIGN_PAGE_ERROR_PATTERNS` so the chat_ui
|
|
# test shares it.
|
|
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)
|
|
|
|
def shoot(name: str) -> None:
|
|
# See playwright_chat_ui.py:shoot -- screenshots are diagnostic,
|
|
# never fail the test on a font-load timeout under
|
|
# --single-process Chromium on macos-14 free runners.
|
|
_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}")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# Setup: change-password through the UI + model load.
|
|
# ─────────────────────────────────────────────────────
|
|
step("setup: change-password + model load")
|
|
# 3-attempt retry mirrors playwright_chat_ui.py: form re-renders
|
|
# mid-fill on macos-14 free runners detach #new-password OR
|
|
# #confirm-password between locator and fill, hitting 60s timeouts.
|
|
# Each retry re-navigates with a fresh page if the old one died.
|
|
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)
|
|
# Click submit AND wait for the POST response together --
|
|
# surfaces a server-side reject (or net::ERR_NO_BUFFER_SPACE
|
|
# buffer-fail on macos-14) immediately rather than discovering
|
|
# it 60s later via a downstream composer.wait_for. Same shape
|
|
# as playwright_chat_ui.py's change-password block.
|
|
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-extra] {m}", flush = True),
|
|
)
|
|
if status is not None and status >= 400:
|
|
raise AssertionError(
|
|
f"change-password POST returned {status}; "
|
|
f"see 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"[extra-ui] change-password form 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:
|
|
# ERR_NO_BUFFER_SPACE needs the OS to recover socket
|
|
# buffers; immediate retry just re-fails. Back off
|
|
# 5s then 15s before next attempt.
|
|
if "ERR_NO_BUFFER_SPACE" in str(e):
|
|
backoff_s = 5 if _form_attempt == 0 else 15
|
|
print(
|
|
f"[extra-ui] ENOBUFS detected; sleeping {backoff_s}s "
|
|
f"before retry to let OS recover socket buffers...",
|
|
flush = True,
|
|
)
|
|
time.sleep(backoff_s)
|
|
page = recover_or_replace_page(
|
|
page,
|
|
ctx,
|
|
default_timeout_ms = 60_000,
|
|
info = lambda m: print(f"[extra-ui] recovery: {m}", flush = True),
|
|
)
|
|
if form_err is not None:
|
|
raise form_err
|
|
# Same defense-in-depth as playwright_chat_ui.py: settle network,
|
|
# then wait_for with one recovery cycle. The post-submit React
|
|
# re-render can either leave the composer suspending or crash the
|
|
# renderer outright under --single-process Chromium on macos-14.
|
|
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:
|
|
cur_url = page.url
|
|
except Exception:
|
|
cur_url = "<page closed>"
|
|
print(
|
|
f"[extra-ui] composer.wait_for attempt {_attempt + 1} failed: "
|
|
f"{type(e).__name__}: {str(e)[:200]}; page.url={cur_url}; "
|
|
f"page_errors={len(page_errors)}",
|
|
flush = True,
|
|
)
|
|
try:
|
|
shoot(f"01-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"[extra-ui] recovery: {m}", flush = True),
|
|
)
|
|
composer = page.locator('textarea[aria-label="Message input"]')
|
|
if last_err is not None:
|
|
raise last_err
|
|
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 = 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)
|
|
|
|
# Detect chat-only mode: /api/health.chat_only is the source of truth.
|
|
# In chat-only mode, /studio + /export redirect to /chat.
|
|
health_resp = evaluate_fetch(
|
|
page,
|
|
f"{BASE}/api/health",
|
|
timeout_ms = FETCH_TIMEOUT_MS,
|
|
)
|
|
if health_resp.get("error"):
|
|
fail(f"/api/health wedged: {health_resp['error']!r}")
|
|
sys.exit(1)
|
|
health = health_resp.get("body") or {}
|
|
chat_only = bool(health.get("chat_only"))
|
|
info(f"chat_only mode: {chat_only}")
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 1. Compare tab.
|
|
# ─────────────────────────────────────────────────────
|
|
step("Compare tab: send to two panes")
|
|
# Compare moved into the composer + menu (Tools and attachments).
|
|
compare_opened = False
|
|
plus_btn = page.get_by_role(
|
|
"button", name = re.compile(r"Tools and attachments", re.I)
|
|
).first
|
|
if plus_btn.count() > 0:
|
|
plus_btn.click(force = True)
|
|
page.wait_for_timeout(400)
|
|
compare_item = page.get_by_role(
|
|
"menuitem", name = re.compile(r"Compare chat", re.I)
|
|
).first
|
|
if compare_item.count() > 0:
|
|
compare_item.click(force = True)
|
|
compare_opened = True
|
|
if not compare_opened:
|
|
soft_fail("Compare nav not found")
|
|
else:
|
|
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")
|
|
# Prefer Enter on the textarea: the shared composer's
|
|
# onKeyDown handler maps plain Enter to send(). The
|
|
# send button is rendered via TooltipIconButton +
|
|
# ComposerPrimitive.Send and its aria-label was
|
|
# added late, so older builds match nothing for
|
|
# button[aria-label="Send message"] in compare mode.
|
|
cmp_composer.press("Enter")
|
|
# Wait for at least 2 NEW assistant bubbles (one per
|
|
# pane). NOTE: the Compare view requires per-pane
|
|
# model selection to actually generate. In this CI
|
|
# flow the panes are NOT explicitly assigned -- so
|
|
# the backend rejects the request as "At least one
|
|
# non-system message is required" or similar. We
|
|
# downgrade this to runtime_warn (informational) and
|
|
# keep the structural assertions (view present,
|
|
# composer present, message text round-trips) above.
|
|
try:
|
|
page.wait_for_function(
|
|
"""(want) => {
|
|
return document.querySelectorAll(
|
|
'[data-role="assistant"]'
|
|
).length >= want;
|
|
}""",
|
|
arg = ok_count_before + 2,
|
|
timeout = 60_000,
|
|
)
|
|
info("OK Compare: 2 new assistant bubbles after first prompt")
|
|
except Exception as exc:
|
|
runtime_warn(
|
|
f"Compare: 2 bubbles didn't appear (panes likely "
|
|
f"have no model selected): {exc!r}"
|
|
)
|
|
shoot("03-compare-after-A")
|
|
|
|
# Send a second prompt -> 4 total new bubbles. Same
|
|
# caveat: this is runtime-flaky when panes have no
|
|
# explicit model selection.
|
|
cmp_composer.fill("Reply with: B")
|
|
cmp_composer.press("Enter")
|
|
try:
|
|
page.wait_for_function(
|
|
"""(want) => {
|
|
return document.querySelectorAll(
|
|
'[data-role="assistant"]'
|
|
).length >= want;
|
|
}""",
|
|
arg = ok_count_before + 4,
|
|
timeout = 60_000,
|
|
)
|
|
info(
|
|
"OK Compare: 4 total new assistant bubbles after second prompt"
|
|
)
|
|
except Exception as exc:
|
|
runtime_warn(
|
|
f"Compare: 4 bubbles didn't appear (panes likely "
|
|
f"have no model selected): {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 = 60_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")
|
|
# The Export page's HF-token field is lazy-loaded behind a
|
|
# disclosure, and on slow runners (macos-14 free) it can
|
|
# dawdle. Poll across multiple selectors for up to 8 s before
|
|
# giving up. We log this as info (not soft_fail) because it
|
|
# does not block any user-visible export workflow -- the user
|
|
# who needs to push to HF can scroll and the section will load
|
|
# within a few seconds.
|
|
hf_token = None
|
|
for _try in range(8):
|
|
page.wait_for_timeout(1000)
|
|
for cand in (
|
|
page.get_by_placeholder(re.compile(r"hf[_\\.\\-]", re.I)).first,
|
|
page.locator(
|
|
'input[placeholder*="token" i], input[placeholder*="huggingface" i]'
|
|
).first,
|
|
page.locator('input[name="hf_token"], input[id*="hf-token"]').first,
|
|
):
|
|
if cand.count() > 0:
|
|
hf_token = cand
|
|
break
|
|
if hf_token is not None:
|
|
break
|
|
if hf_token is not None:
|
|
info("OK HF token input visible")
|
|
else:
|
|
info(
|
|
"WARN HF token input not located in /export after 8s "
|
|
"(likely lazy-loaded behind a disclosure section -- "
|
|
"non-blocking for upload flow)"
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────
|
|
# 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 = 60_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")
|
|
_watchdog.cancel()
|
|
browser.close()
|