* Studio: drive UI font size through a typography scale, not the root font size Follow up to #7355. The preference now writes --ui-font-scale (selected / 16) and a data-ui-font-size attribute on the root instead of mutating the root font size, and the applier clears any stale inline root font-size left by older builds. Because the rem base never moves, every layout-only rem-to-px conversion from #7355 is reverted to its original form: the spacing, radius and container tokens, sidebar and thread widths, grid tracks, calc margins and hub.css dimensions match pre-#7355 main again, which also restores rem-based accessibility scaling for users with a larger browser default font size. Typography scales through tokens in index.css, all exact at 16px: - The named Tailwind sizes (--text-xs through --text-4xl) multiply their defaults by the scale, so standard utilities scale - One token per design px size (--text-ui-8 ... --text-ui-34) replaces every arbitrary text-[Npx] class; leading-ui-* mirrors the exact line heights and the numeric --leading-3..10 scale as well - CSS font-size and line-height declarations multiply by the scale - Chart labels scale through a .recharts-text rule; streamdown and react-flow px text is re-based via scaled overrides; KaTeX's 1px layout trick stays fixed by design - The logo lockups keep their half-rate behavior via the scale var - The explicit Code font size remains unmultiplied Keeps the #7355 behavior fixes: color chip min width, voice select min/max widths, and the select and dropdown menus scrolling an inner viewport so their corners stay rounded. The whitespace-password and IME rename guards that merged alongside are preserved. * Studio: contract and Playwright coverage for the UI font size scale test_ui_font_scale_contract.py pins the mechanism (scale var written, root font size never mutated, tokens scaled, code font size not multiplied, the Radix select viewport owning scroll state) and guards against new raw pixel typography, with a documented allowlist for the recharts fontSize props covered by the stylesheet override and the offscreen clipboard textarea. playwright_ui_font_scale.py drives the real appearance controls: root font size fixed at 12/16/20, text and line height scale by size/16, sidebar width invariant, explicit code font size stays fixed, an overflowing dictation select scrolls its Radix viewport by keyboard and wheel, and the default restores exactly. Wired into the UI smoke workflow against the second studio boot. The thinking-compact and descender contracts move back to the rem and token forms now that layout values no longer need px pinning.
368 lines
15 KiB
YAML
368 lines
15 KiB
YAML
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
# End-to-end Unsloth chat UI smoke via Playwright + Chromium against a
|
|
# headless Linux runner. Boots Unsloth with the smallest GGUF
|
|
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
|
|
# bundle, and asserts the full bootstrap-password / change-password /
|
|
# send-message / persist-on-reload journey works end to end.
|
|
#
|
|
# This is the only workflow that catches regressions in the wiring
|
|
# between the React frontend and the FastAPI backend, e.g. assistant-ui
|
|
# version drift, /api/auth response shape changes, runtime-provider
|
|
# regressions, or chat-history persistence breaking. Backend-only and
|
|
# frontend-only CI happily pass while the actual user-visible UI is
|
|
# broken (cf. the 2026.5.1 chat-history release).
|
|
|
|
name: Unsloth UI CI
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'studio/**'
|
|
- 'unsloth/**'
|
|
- 'unsloth_cli/**'
|
|
- 'install.sh'
|
|
- 'pyproject.toml'
|
|
# The Playwright test files themselves -- a PR that ONLY edits
|
|
# the test must still trigger UI CI.
|
|
- 'tests/studio/**'
|
|
- '.github/scripts/run-studio-permission-browser.sh'
|
|
- '.github/workflows/studio-ui-smoke.yml'
|
|
push:
|
|
branches: [main, pip]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
ui-smoke:
|
|
name: Chat UI Tests
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 25
|
|
env:
|
|
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
|
GGUF_VARIANT: UD-Q4_K_XL
|
|
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
|
STUDIO_PORT: '18892'
|
|
HF_HOME: ${{ github.workspace }}/hf-cache
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Linux deps
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends \
|
|
libcurl4-openssl-dev libssl-dev jq
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '22'
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.12'
|
|
cache: 'pip'
|
|
|
|
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
|
id: cache-hf
|
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
continue-on-error: true
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
|
|
|
- name: Prime HF_HOME with the GGUF
|
|
id: prime-hf
|
|
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
|
env:
|
|
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
|
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
|
run: |
|
|
python -m pip install --upgrade huggingface_hub
|
|
mkdir -p hf-cache
|
|
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
|
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
|
|
|
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
|
if: always() && steps.prime-hf.outcome == 'success'
|
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
with:
|
|
path: hf-cache
|
|
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
|
|
|
- name: Install Unsloth (--local, --no-torch)
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
|
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
|
run: |
|
|
mkdir -p logs
|
|
set -o pipefail
|
|
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
|
|
|
- name: Install Playwright browsers
|
|
run: |
|
|
pip install 'playwright>=1.45'
|
|
python -m playwright install --with-deps chromium firefox webkit
|
|
|
|
- name: Reset auth + boot Unsloth
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
|
> logs/studio.log 2>&1 &
|
|
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health
|
|
# 180 s -- a cold runner with venv warm-up + lazy imports has
|
|
# been seen to exceed 60 s. Failing the wait is more expensive
|
|
# than waiting an extra two minutes.
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
|
jq -e '.status == "healthy"' /tmp/health.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health.json
|
|
|
|
- name: Pass bootstrap password to the Playwright step
|
|
# The Playwright test does its OWN /change-password through the
|
|
# UI (Setup your account / Choose a new password), then loads
|
|
# the model via page.evaluate against /api/inference/load with
|
|
# the JWT it got from change-password. So the only thing we
|
|
# have to hand it is the bootstrap password (so it can verify
|
|
# post-rotation that the OLD bootstrap pw now returns 401).
|
|
#
|
|
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
|
|
# rather than hardcoded. If a workflow gets compromised, the
|
|
# attacker can't replay a known-good rotated password against
|
|
# any future / parallel Unsloth install -- the rotated value
|
|
# only ever exists for the lifetime of this single job, masked
|
|
# in the log via ::add-mask::.
|
|
run: |
|
|
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
|
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
|
echo "::add-mask::$OLD"
|
|
echo "::add-mask::$NEW"
|
|
echo "::add-mask::$NEW2"
|
|
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
|
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
|
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
|
|
|
- name: Drive the chat UI with Playwright
|
|
env:
|
|
BASE_URL: http://127.0.0.1:18892
|
|
# The test file lives in the repo so it can be run locally
|
|
# against a freshly-installed Unsloth (BASE_URL=...; STUDIO_OLD_PW=
|
|
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
|
|
PW_ART_DIR: logs/playwright
|
|
# Strict mode: in CI a missing button / nav / dialog must
|
|
# FAIL the test. Locally the test still runs against partial
|
|
# Unsloth installs without STUDIO_UI_STRICT.
|
|
STUDIO_UI_STRICT: '1'
|
|
run: |
|
|
mkdir -p logs/playwright
|
|
python tests/studio/playwright_chat_ui.py
|
|
|
|
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
|
if: always()
|
|
run: |
|
|
kill "${STUDIO_PID}" 2>/dev/null || true
|
|
sleep 2
|
|
|
|
- name: Cross-browser permission controls
|
|
run: |
|
|
bash .github/scripts/run-studio-permission-browser.sh 18893 firefox
|
|
bash .github/scripts/run-studio-permission-browser.sh 18893 webkit
|
|
bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome
|
|
|
|
# The chat UI test ends by clicking the Shutdown menuitem, which
|
|
# leaves the server dead. The extra UI test (Compare / Recipes /
|
|
# Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a
|
|
# second one on a different port. Boot is fast (~3-5s on the
|
|
# warm install we already did) so this adds little wall time.
|
|
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
|
|
> logs/studio_extra.log 2>&1 &
|
|
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health on 18894
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:18894/api/health" > /tmp/health2.json; then
|
|
jq -e '.status == "healthy"' /tmp/health2.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health2.json
|
|
|
|
- name: Pass bootstrap pw for extra UI test
|
|
run: |
|
|
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
|
echo "::add-mask::$OLD"
|
|
echo "::add-mask::$NEW"
|
|
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
|
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
|
|
|
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
|
|
env:
|
|
BASE_URL: http://127.0.0.1:18894
|
|
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
|
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
|
PW_ART_DIR: logs/playwright_extra
|
|
STUDIO_UI_STRICT: '1'
|
|
GGUF_REPO: ${{ env.GGUF_REPO }}
|
|
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
|
run: |
|
|
mkdir -p logs/playwright_extra
|
|
python tests/studio/playwright_extra_ui.py
|
|
|
|
- name: UI font size scaling regression (Playwright)
|
|
env:
|
|
BASE_URL: http://127.0.0.1:18894
|
|
STUDIO_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
|
PW_ART_DIR: logs/playwright_fontscale
|
|
run: |
|
|
mkdir -p logs/playwright_fontscale
|
|
python tests/studio/playwright_ui_font_scale.py
|
|
|
|
- name: Stop second Unsloth
|
|
if: always()
|
|
run: |
|
|
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
|
sleep 2
|
|
|
|
# Model-picker per-model-config regression (PR #7207 re-land of #6647).
|
|
# Fourth Unsloth on its own port; loads the tiny GGUF and drives the
|
|
# picker's run-settings surface: Context Length persists across a reload,
|
|
# Reset clears the stored override (never pins it), and the infra models
|
|
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
|
|
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
|
|
> logs/studio_modelcfg.log 2>&1 &
|
|
echo "STUDIO_MODELCFG_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health on 18898
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:18898/api/health" > /tmp/health4.json; then
|
|
jq -e '.status == "healthy"' /tmp/health4.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health4.json
|
|
|
|
- name: Pass bootstrap pw for model-config test
|
|
run: |
|
|
NEW="CIModelCfg-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
|
echo "::add-mask::$NEW"
|
|
echo "STUDIO_MODELCFG_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
|
|
|
- name: Drive model-picker per-model-config with Playwright
|
|
env:
|
|
BASE_URL: http://127.0.0.1:18898
|
|
STUDIO_NEW_PW: ${{ env.STUDIO_MODELCFG_NEW_PW }}
|
|
PW_ART_DIR: logs/playwright_modelcfg
|
|
STUDIO_UI_STRICT: '1'
|
|
GGUF_REPO: ${{ env.GGUF_REPO }}
|
|
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
|
STUDIO_MODEL_HINT: gemma-3-270m
|
|
run: |
|
|
mkdir -p logs/playwright_modelcfg
|
|
python tests/studio/playwright_model_config.py
|
|
|
|
- name: Stop fourth Unsloth
|
|
if: always()
|
|
run: |
|
|
kill "${STUDIO_MODELCFG_PID}" 2>/dev/null || true
|
|
sleep 2
|
|
|
|
# IME + multilingual paste regression (issue #5318 / PR #5327).
|
|
# Third Unsloth on its own port so a hang here cannot poison the
|
|
# earlier UI tests. No GGUF -- the bug surface is the composer.
|
|
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
|
|
> logs/studio_ime.log 2>&1 &
|
|
echo "STUDIO_IME_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health on 18896
|
|
run: |
|
|
for i in $(seq 1 180); do
|
|
if curl -fs "http://127.0.0.1:18896/api/health" > /tmp/health3.json; then
|
|
jq -e '.status == "healthy"' /tmp/health3.json && break
|
|
fi
|
|
sleep 1
|
|
done
|
|
jq -e '.status == "healthy"' /tmp/health3.json
|
|
|
|
- name: Pass bootstrap pw for IME / i18n test
|
|
# IME smoke does the change-password against the bootstrap that
|
|
# Unsloth's frontend injects into the page, so it only needs the
|
|
# NEW password.
|
|
run: |
|
|
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
|
echo "::add-mask::$NEW"
|
|
echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
|
|
|
- name: Drive IME + multilingual paste regression with Playwright
|
|
env:
|
|
BASE_URL: http://127.0.0.1:18896
|
|
STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }}
|
|
PW_ART_DIR: logs/playwright_ime
|
|
STUDIO_UI_STRICT: '1'
|
|
run: |
|
|
mkdir -p logs/playwright_ime
|
|
python tests/studio/playwright_chat_ime_i18n.py
|
|
|
|
- name: Stop third Unsloth
|
|
if: always()
|
|
run: |
|
|
kill "${STUDIO_IME_PID}" 2>/dev/null || true
|
|
sleep 2
|
|
# Capture backend + llama-server logs (all three Studios share this
|
|
# dir) so a stray 500 has a server-side traceback.
|
|
mkdir -p logs/server-logs
|
|
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
|
|
|
|
- name: Upload Playwright artifacts
|
|
# Always upload so a green run's screenshots stay reviewable --
|
|
# catches "passed but the UI is silently broken" regressions.
|
|
if: always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: studio-ui-smoke-artifacts
|
|
path: |
|
|
logs/studio.log
|
|
logs/studio_extra.log
|
|
logs/studio_modelcfg.log
|
|
logs/studio_ime.log
|
|
logs/install.log
|
|
logs/server-logs/
|
|
logs/playwright
|
|
logs/playwright-permissions-*
|
|
logs/playwright_extra
|
|
logs/playwright_fontscale
|
|
logs/playwright_modelcfg
|
|
logs/playwright_ime
|
|
logs/studio-permissions-*.log
|
|
retention-days: 7
|