Files
2026-07-13 13:12:33 +08:00

288 lines
13 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { relativeLuminance } from './utils'
// End-to-end coverage for the pluggable theme engine (P0): the value-theme axis
// (light / dark / system) driven through the real appearance menu, persistence,
// registry-driven validation of the persisted choice, and the layered token
// contract (L1 per-theme values, the new ink/fill pairs, and L0 foundation
// tokens that stay invariant across themes). Runs against whatever build serves
// the SPA at the base URL — the gateway in CI, or a static server locally.
const CONTROL = '/control/'
const THEME_KEY = 'opensquilla-theme'
// Topbar menu labels as rendered by themePickerOptions({ scope: 'basic' }): the
// built-in modes use the translated chrome.themeMode.* keys (English in CI). The
// topbar now lists only these three plus a "More themes…" action; custom themes
// (shown by their manifest name) live in Settings → Appearance. Selecting by
// label stays correct as the registry grows.
const MENU_LABEL = { light: 'Light', dark: 'Dark', system: 'System' } as const
async function bootShell(page: Page) {
// The topbar (and its appearance control) render for every route and do not
// depend on the RPC backend, so the shell is reachable without live data.
await page.goto(CONTROL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
// initTheme() applies data-theme on mount; wait until it has.
await expect
.poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme')))
.toMatch(/^(light|dark)$/)
}
function dataTheme(page: Page) {
return page.evaluate(() => document.documentElement.getAttribute('data-theme'))
}
function bodyBackground(page: Page) {
return page.evaluate(() => getComputedStyle(document.body).backgroundColor)
}
/** Resolve a custom property through a real element so var() chains substitute. */
function resolveColor(page: Page, expr: string) {
return page.evaluate((value) => {
const el = document.createElement('span')
el.style.color = value
document.body.appendChild(el)
const c = getComputedStyle(el).color
el.remove()
return c
}, expr)
}
function rootToken(page: Page, name: string) {
return page.evaluate(
(n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(),
name,
)
}
async function pickTheme(page: Page, mode: keyof typeof MENU_LABEL) {
// Target the appearance button by its accessible name — `.theme-menu-wrap` is
// not unique (the language switcher reuses the wrapper class).
await page.getByRole('button', { name: 'Theme' }).click()
await page.waitForSelector('.theme-menu', { timeout: 5000 })
await page
.locator('.theme-menu .theme-menu__item')
.filter({ hasText: MENU_LABEL[mode] })
.first()
.click()
// menu closes after a pick
await expect(page.locator('.theme-menu')).toHaveCount(0)
}
// The topbar menu now lists only the basic modes + a "More themes…" action; the
// full custom-theme list lives in Settings → Appearance. A custom theme is
// therefore selected in Settings and persisted like any other choice — so, as
// with the P2 skin coverage below, custom/world themes are exercised here by
// seeding the persisted id and booting, which drives the very same apply +
// lazy-world path the Settings radio does, without depending on the settings
// dialog rendering in the backend-free shell.
async function bootWithTheme(page: Page, themeId: string) {
await page.addInitScript(
(v) => localStorage.setItem('opensquilla-theme', v),
themeId,
)
await page.goto(CONTROL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
await expect.poll(() => dataTheme(page)).toBe(themeId)
}
test.describe('Theme engine (P0)', () => {
test('appearance menu applies each value theme and persists across reload', async ({ page }) => {
await bootShell(page)
await pickTheme(page, 'light')
expect(await dataTheme(page)).toBe('light')
// body background-color transitions (~200ms), so poll until it settles.
await expect.poll(async () => relativeLuminance(await bodyBackground(page))).toBeGreaterThan(0.7)
const bgLight = await rootToken(page, '--bg')
await pickTheme(page, 'dark')
expect(await dataTheme(page)).toBe('dark')
await expect.poll(async () => relativeLuminance(await bodyBackground(page))).toBeLessThan(0.3)
const bgDark = await rootToken(page, '--bg')
expect(bgLight).not.toBe(bgDark) // the L1 layer really re-skinned
// persistence: the picked theme survives a fresh load (localStorage-backed)
await bootShell(page)
expect(await dataTheme(page)).toBe('dark')
expect(await page.evaluate((k) => localStorage.getItem(k), THEME_KEY)).toBe('dark')
})
test('system mode follows the OS colour-scheme, live', async ({ page }) => {
await page.addInitScript((k) => localStorage.removeItem(k), THEME_KEY)
await page.emulateMedia({ colorScheme: 'dark' })
await bootShell(page)
expect(await dataTheme(page)).toBe('dark') // no saved choice → system → OS dark
// flipping the OS preference updates the ground live (matchMedia listener)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => dataTheme(page)).toBe('light')
})
test('a persisted value-theme id is honoured over the OS preference', async ({ page }) => {
await page.addInitScript((k) => localStorage.setItem(k, 'light'), THEME_KEY)
await page.emulateMedia({ colorScheme: 'dark' })
await bootShell(page)
// registry recognises 'light' as a value theme → it wins over OS dark
expect(await dataTheme(page)).toBe('light')
})
test('an unknown persisted theme falls back to system (registry validation)', async ({ page }) => {
await page.addInitScript((k) => localStorage.setItem(k, 'ferrari-red'), THEME_KEY)
await page.emulateMedia({ colorScheme: 'dark' })
await bootShell(page)
// isValueThemeId('ferrari-red') === false → store stays 'system' → OS dark
expect(await dataTheme(page)).toBe('dark')
})
test('the ink/fill token pairs resolve and alias their ink (P0 contract)', async ({ page }) => {
await bootShell(page)
await pickTheme(page, 'dark')
for (const ch of ['ok', 'warn', 'danger', 'info', 'queued']) {
const fill = await resolveColor(page, `var(--${ch}-fill)`)
const ink = await resolveColor(page, `var(--${ch})`)
expect(fill, `--${ch}-fill must resolve to a real colour`).toMatch(/^rgb/)
// dark aliases ink as fill; warn-fill is hand-tuned but still a real colour.
if (ch !== 'warn') {
expect(fill, `--${ch}-fill should alias --${ch}`).toBe(ink)
}
}
})
test('L0 foundation tokens stay invariant across themes', async ({ page }) => {
await bootShell(page)
await pickTheme(page, 'dark')
const darkRadius = await rootToken(page, '--radius-md')
const darkFs = await rootToken(page, '--fs-md')
await pickTheme(page, 'light')
const lightRadius = await rootToken(page, '--radius-md')
const lightFs = await rootToken(page, '--fs-md')
expect(darkRadius).toBe(lightRadius)
expect(darkFs).toBe(lightFs)
expect(darkRadius).toBe('8px') // the ladder's control tier, unchanged by P0
})
test('the topbar menu lists only the basic modes plus "More themes…"', async ({ page }) => {
await bootShell(page)
await page.getByRole('button', { name: 'Theme' }).click()
await page.waitForSelector('.theme-menu', { timeout: 5000 })
const items = page.locator('.theme-menu .theme-menu__item')
// Light / Dark / System + the "More themes…" action = 4 rows, no custom themes.
await expect(items).toHaveCount(4)
await expect(items.filter({ hasText: 'Arctic' })).toHaveCount(0)
await expect(items.filter({ hasText: 'Synthwave' })).toHaveCount(0)
await expect(items.filter({ hasText: 'More themes' })).toHaveCount(1)
// "More themes…" deep-links to Settings → Appearance (the full theme list).
await items.filter({ hasText: 'More themes' }).click()
await expect(page).toHaveURL(/\/settings\/appearance$/)
})
test('a custom value theme (Arctic) applies when selected and is visually distinct', async ({ page }) => {
// A custom theme is chosen in Settings → Appearance and persisted; persisting
// the id + reloading drives the same apply path. Arctic is a real re-skin, not
// dark relabelled (regression guard for the "custom tokens never bundled" bug).
await bootShell(page)
await page.evaluate((k) => localStorage.setItem(k, 'dark'), THEME_KEY)
await page.reload()
// data-theme is stamped by the pre-paint head script, so poll the TOKEN,
// not just the attribute — the stylesheet may still be streaming and
// getComputedStyle would read custom properties as "" in that window.
await expect.poll(() => dataTheme(page)).toBe('dark')
await expect.poll(() => rootToken(page, '--bg')).not.toBe('')
const darkBg = await rootToken(page, '--bg')
await page.evaluate((k) => localStorage.setItem(k, 'arctic'), THEME_KEY)
await page.reload()
await expect.poll(() => dataTheme(page)).toBe('arctic')
await expect.poll(() => rootToken(page, '--bg')).not.toBe('')
const arcticBg = await rootToken(page, '--bg')
expect(arcticBg).not.toBe(darkBg)
})
test('a legacy persisted theme id ("nord") migrates to its renamed theme ("arctic")', async ({ page }) => {
// Old clients persisted 'nord'; after the rename it must resolve to 'arctic'
// and NOT fall back to system/default. (Can't use bootShell here — it asserts
// a light/dark ground, whereas this loads straight into the renamed theme.)
await page.addInitScript((k) => localStorage.setItem(k, 'nord'), THEME_KEY)
await page.goto(CONTROL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
await expect.poll(() => dataTheme(page)).toBe('arctic')
// the canonical id is written back on first load
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), THEME_KEY))
.toBe('arctic')
})
})
function contentBackground(page: Page) {
return page.evaluate(() => getComputedStyle(document.getElementById('content')!).backgroundColor)
}
test.describe('Global world theme (Terminal)', () => {
test('selecting Terminal applies its palette globally and lazy-loads the world layer', async ({ page }) => {
await bootWithTheme(page, 'terminal')
// The mono type remap is now EAGER (moved to tokens.css so it applies before
// first paint — no sans→mono reflow on cold load), so it proves the palette,
// not the world.
await expect.poll(() => rootToken(page, '--font-sans')).toMatch(/mono/i)
// The lazy WORLD layer (world.css) paints the global CRT scanline overlay on
// body::after — a signal ONLY world.css provides — proving it loaded and
// applied to the whole console.
await expect
.poll(() => page.evaluate(() => getComputedStyle(document.body, '::after').backgroundImage))
.toMatch(/gradient/i)
})
test('a new world theme (Vapor) applies and lazy-loads its world', async ({ page }) => {
await bootWithTheme(page, 'vapor')
// the display remap is eager (applies with the palette, before first paint)…
await expect.poll(() => rootToken(page, '--font-display')).toMatch(/mono/i)
// …while the lazy WORLD layer paints the neon perspective grid on the theme
// host's ::before — a world-only signal proving world.css loaded + applied.
await expect
.poll(() =>
page.evaluate(() => getComputedStyle(document.documentElement, '::before').backgroundImage),
)
.toMatch(/gradient/i)
})
})
test.describe('Expressive skin (P2 — Out of Register)', () => {
test('applies to a skinned route, scoped to the content area only', async ({ page }) => {
await page.addInitScript((k) => localStorage.setItem(k, 'dark'), THEME_KEY) // force a dark shell
await page.goto(CONTROL + 'changelog')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
// the content area opts into the skin via meta.skin
await expect(page.locator('#content')).toHaveAttribute('data-skin', 'out-of-register')
// the lazy skin CSS turns the content newsprint (light)…
await expect.poll(async () => relativeLuminance(await contentBackground(page))).toBeGreaterThan(0.6)
// …while the shell stays on the dark ground — proof the skin is scoped, not global
expect(relativeLuminance(await bodyBackground(page))).toBeLessThan(0.3)
})
test('does NOT apply to operational routes', async ({ page }) => {
await page.goto(CONTROL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
await expect(page.locator('#content')).not.toHaveAttribute('data-skin')
})
test('lazily loads its serif face on the skinned route', async ({ page }) => {
await page.goto(CONTROL + 'changelog')
await page.waitForSelector('.conn-pill', { timeout: 20000 })
await expect
.poll(() => page.evaluate(() => document.fonts.check('900 40px Fraunces')))
.toBe(true)
})
})