fix(client): apply theme before plugin loading
This commit is contained in:
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Host index injection and the resulting pre-plugin browser theme. */
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { injectBootTheme } from '../src/boot-theme.ts'
|
||||
import type { ThemePreference } from '../src/theme-settings.ts'
|
||||
|
||||
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
function mockSystemDark(matches: boolean): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
}
|
||||
|
||||
function executeBootstrap(
|
||||
preference?: ThemePreference,
|
||||
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
|
||||
): string {
|
||||
const injected = injectBootTheme(html, preference)
|
||||
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
|
||||
if (source === undefined) throw new Error('theme bootstrap script missing')
|
||||
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
|
||||
return injected
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
})
|
||||
|
||||
describe('theme boot index transform', () => {
|
||||
it('runs immediately inside the body before the shell mount', () => {
|
||||
mockSystemDark(false)
|
||||
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
|
||||
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
|
||||
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets durable light override a dark OS and clears stale dark state', () => {
|
||||
document.body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
mockSystemDark(true)
|
||||
executeBootstrap('light')
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'dark', true],
|
||||
[false, 'light', false],
|
||||
] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
|
||||
mockSystemDark(matches)
|
||||
executeBootstrap('system')
|
||||
expect(document.documentElement.style.colorScheme).toBe(colorScheme)
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
|
||||
})
|
||||
|
||||
it('defaults to system and falls back to light when matchMedia is unavailable', () => {
|
||||
vi.stubGlobal('matchMedia', undefined)
|
||||
executeBootstrap()
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('appends the script to a body-less fragment', () => {
|
||||
const html = injectBootTheme('<main>loading</main>', 'dark')
|
||||
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
@@ -27,4 +28,38 @@ describe('ui-theme host', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
it('renders the current durable preference and disposes the index transform', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as HttpServerService)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
|
||||
await fiber.dispose()
|
||||
expect(disposed).toBe(true)
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as HttpServerService)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half waits for an optional settings provider', () => {
|
||||
it('node-half waits for optional Host services', () => {
|
||||
nodeApply(new Context())
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user