unsloth/.github/workflows/studio-ui-smoke.yml
Daniel Han cee324725c CI: pass GH_TOKEN to install/update steps to dodge GitHub API rate limits
studio/install_llama_prebuilt.py lists releases on
ggml-org/llama.cpp via the GitHub API. Unauthenticated calls get
60/hr per source IP, which is fine for one install per workflow but
the new Studio Update CI does install + update + update back-to-back
on the same runner, blowing past the limit and falling back to a
source build (which then fails the idempotency assertion).

Surfaced on the Studio Update CI run with:
  failed to inspect published releases in ggml-org/llama.cpp:
  GitHub API returned 403 ...
  set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits.

GITHUB_TOKEN with the existing `permissions: contents: read` is more
than enough for unauthenticated read API access (1000/hr, scoped to
the repo). Wired into every install.sh and `unsloth studio update`
step across studio-update-smoke.yml, studio-inference-smoke.yml, and
studio-ui-smoke.yml so a busy runner can't trip the same fallback.
2026-05-06 13:20:45 +00:00

285 lines
12 KiB
YAML

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# End-to-end Studio chat UI smoke via Playwright + Chromium against a
# headless Linux runner. Boots Studio 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: Studio UI CI
on:
pull_request:
paths:
- 'studio/**'
- 'unsloth/**'
- 'unsloth_cli/**'
- 'install.sh'
- 'pyproject.toml'
- '.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 smoke (Playwright + Chromium)
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@v4
- 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@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@v4
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p hf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Install Playwright + Chromium
run: |
pip install 'playwright>=1.45'
# --with-deps installs the OS-level runtime libs Chromium
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
# warm runner.
python -m playwright install --with-deps chromium
- name: Reset auth + boot Studio
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
run: |
for i in $(seq 1 90); 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: Load the GGUF (so the chat send actually streams)
run: |
PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
echo "::add-mask::$PW"
# Login with the bootstrap password to get a JWT, load the
# model via the API. The Playwright run below then logs in
# again through the UI and exercises the change-password
# gate -- the model is already loaded by then so the chat
# send path can stream.
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token)
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name}'
echo "STUDIO_BOOTSTRAP_PW=$PW" >> "$GITHUB_ENV"
- name: Drive the chat UI with Playwright
env:
BASE_URL: http://127.0.0.1:18892
run: |
mkdir -p logs/playwright
python - <<'PY'
import os
import time
from pathlib import Path
from playwright.sync_api import expect, sync_playwright
BASE = os.environ["BASE_URL"]
PW = os.environ["STUDIO_BOOTSTRAP_PW"]
NEW = "CIUiSmoke12345!"
ART = Path("logs/playwright")
ART.mkdir(parents = True, exist_ok = True)
with sync_playwright() as p:
browser = p.chromium.launch(headless = True)
ctx = browser.new_context(viewport = {"width": 1280, "height": 900})
page = ctx.new_page()
page.set_default_timeout(30_000)
def shoot(name):
page.screenshot(path = str(ART / f"{name}.png"), full_page = True)
# ── 1. Bootstrap-driven change-password flow ─────────
# /api/auth/status returns requires_password_change=true
# on a fresh install, so /login auto-redirects to
# /change-password. The HTML for that route is served
# with `<script>window.__UNSLOTH_BOOTSTRAP__={...}</script>`
# injected by main._inject_bootstrap, so the form's
# current-password field is pre-seeded -- the user only
# has to fill the new password twice and submit. We jump
# straight to /change-password to avoid the /login flash
# racing with the auto-redirect.
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)
shoot("01-change-password-filled")
page.locator('button[type="submit"]').click()
# ── 3. Chat surface loads ────────────────────────────
# The Message input textarea is the chat composer. Once
# it's visible, the auth+UI bootstrap is complete.
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
shoot("02-chat-loaded")
# ── 4. Send a message and wait for a response ────────
composer.fill("Reply with the single word: hello")
# The Send button only becomes enabled once content is
# in the textarea. aria-label is set in
# components/assistant-ui/thread.tsx:680.
page.locator('button[aria-label="Send message"]').click()
# The user message bubble appears immediately; the
# assistant response streams in. Wait for an assistant
# bubble with non-empty text. assistant-ui renders user
# turns as `[data-role="user"]` and assistant turns as
# `[data-role="assistant"]`.
page.wait_for_function(
"""() => {
const els = document.querySelectorAll('[data-role="assistant"]');
for (const el of els) {
if ((el.innerText || '').trim().length > 0) return true;
}
return false;
}""",
timeout = 120_000,
)
shoot("03-assistant-replied")
# ── 5. Reload, confirm chat surface still works ──────
# We do NOT strictly require the prior conversation to
# re-appear in the active pane after reload: Studio's
# autosave + remote-thread re-hydration is async and can
# land outside a small CI timeout. The 2026.5.1 chat
# regression broke the page entirely (composer never
# mounts), which the next two checks catch:
# - the JWT survived the reload (no redirect to /login),
# - the chat composer is interactable again.
page.reload()
composer = page.locator('textarea[aria-label="Message input"]')
composer.wait_for(state = "visible", timeout = 60_000)
# If we got bounced to /login, the URL would be
# /login. Anything else means session restoration worked.
assert "/login" not in page.url, (
f"unexpected redirect to /login after reload: {page.url}"
)
shoot("04-after-reload")
# ── 6. Open the configuration / settings sheet ───────
# The "Open configuration" button is in chat-page.tsx
# line 1077. Sanity-check that toggling settings does
# not crash the app.
cfg = page.locator('button[aria-label="Open configuration"]').first
if cfg.count() > 0:
cfg.click()
shoot("05-settings-open")
# Close button is in chat-settings-sheet.tsx:857.
close = page.locator('button[aria-label="Close configuration"]').first
if close.count() > 0:
close.click()
else:
print("[ui] settings button not on this layout, skipping toggle test")
# ── 7. Old password must still be rejected on logout ─
# Hit the API directly (faster than navigating the
# account menu) and confirm the rotated password is the
# only one that works now.
import urllib.request, json as _json
def login(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 resp:
return resp.status
except urllib.error.HTTPError as exc:
return exc.code
assert login(PW) == 401, "old bootstrap password should be rejected"
assert login(NEW) == 200, "new password should now log in"
print("[ui] PASS UI flow + post-rotation auth check")
browser.close()
PY
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
- name: Upload Playwright artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: studio-ui-smoke-artifacts
path: |
logs/studio.log
logs/install.log
logs/playwright
retention-days: 7