Files
CloudSSH/tests/theme.test.ts
T
TanXinandTanXin 4eeb050576 release: 发布 v1.10.0 自定义命令片段库版本 (#93)
合并 `test` 的 v1.10.0 提交到 `main`。

**包含提交**
- `28c9433` feat: 添加自定义命令片段库
- `9aa2e80` release: 发布 v1.10.0 自定义命令片段库版本

**关联**
- Related to #90

> 本 PR 仅关联 #90,不自动关闭该 Issue。

**验证**
- `pnpm run typecheck` 0 错
- `pnpm test` 47/47 603/603 通过
- `pnpm run build:frontend` 成功

---------

Co-authored-by: TanXin <tanxin@example.com>
2026-08-20 11:42:28 +08:00

410 lines
14 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import {
applyBuiltInTheme,
applyImportedTheme,
BUILT_IN_APPEARANCE,
getActiveColorScheme,
getActiveTerminalTheme,
getActiveThemeAppearance,
isBuiltInTheme,
normalizeImportedTheme,
onColorSchemeChange,
onTerminalThemeChange,
resolveThemeAppearance,
THEME_SCHEMA_VERSION,
THEMES,
UI_STYLE_PRESETS,
UI_THEMES,
} from '../frontend/src/theme';
import { SAFE_UI_THEME_PROPERTIES, THEME_MAX_BYTES } from '../src/theme-schema';
function relativeLuminance(hex: string): number {
const channels = hex
.slice(1)
.match(/.{2}/g)!
.map((value) => parseInt(value, 16) / 255);
return channels
.map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
.reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0);
}
function contrastRatio(foreground: string, background: string): number {
const first = relativeLuminance(foreground);
const second = relativeLuminance(background);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}
describe('Standard 内置主题', () => {
afterEach(() => applyBuiltInTheme('cyberpunk'));
it('注册 Standard Dark/Light,并保持全部 UI 变量完整', () => {
expect(isBuiltInTheme('standard-dark')).toBe(true);
expect(isBuiltInTheme('standard-light')).toBe(true);
expect(Object.keys(UI_THEMES['standard-dark']).sort()).toEqual(
Object.keys(UI_THEMES.cyberpunk).sort()
);
expect(Object.keys(UI_THEMES['standard-light']).sort()).toEqual(
Object.keys(UI_THEMES.cyberpunk).sort()
);
});
it('为浅色和深色终端提供完整 ANSI 16 色', () => {
const ansiKeys = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite',
] as const;
for (const themeName of ['standard-dark', 'standard-light'] as const) {
for (const key of ansiKeys) {
expect(THEMES[themeName][key]).toMatch(/^#[0-9a-f]{6}$/i);
}
}
});
it('主要文本、次要文本和强调色达到普通文字 4.5:1 对比度', () => {
for (const themeName of ['standard-dark', 'standard-light'] as const) {
const ui = UI_THEMES[themeName];
for (const foreground of ['--text', '--text-muted', '--text-dim', '--accent', '--error']) {
expect(contrastRatio(ui[foreground], ui['--bg'])).toBeGreaterThanOrEqual(4.5);
}
}
});
it('主题变化会广播给所有订阅终端,新订阅者立即获得当前主题', () => {
const received: unknown[] = [];
const unsubscribe = onTerminalThemeChange((theme) => received.push(theme));
applyBuiltInTheme('standard-light');
expect(received).toEqual([THEMES.cyberpunk, THEMES['standard-light']]);
expect(getActiveTerminalTheme()).toBe(THEMES['standard-light']);
unsubscribe();
applyBuiltInTheme('standard-dark');
expect(received).toHaveLength(2);
});
it('主题变化会广播明暗模式,供第三方组件同步配色', () => {
const received: unknown[] = [];
const unsubscribe = onColorSchemeChange((colorScheme) => received.push(colorScheme));
applyBuiltInTheme('standard-light');
expect(received).toEqual(['dark', 'light']);
expect(getActiveColorScheme()).toBe('light');
unsubscribe();
applyBuiltInTheme('standard-dark');
expect(received).toHaveLength(2);
});
it('旧版浅色自定义主题可以推断模式并继承浅色 ANSI 配色', () => {
applyImportedTheme({
ui: {
'--bg': '#ffffff',
'--text': '#202124',
},
});
expect(getActiveTerminalTheme().background).toBe(THEMES['standard-light'].background);
expect(getActiveTerminalTheme().yellow).toBe(THEMES['standard-light'].yellow);
expect(getActiveThemeAppearance().style).toBe('standard');
});
});
describe('Theme V2 界面风格', () => {
afterEach(() => applyBuiltInTheme('cyberpunk'));
it('提供版本化外观结构,并让内置主题覆盖四种风格', () => {
expect(THEME_SCHEMA_VERSION).toBe(2);
expect(BUILT_IN_APPEARANCE).toEqual({
'standard-dark': { style: 'standard' },
'standard-light': { style: 'standard' },
cyberpunk: { style: 'cyberpunk' },
glacier: { style: 'soft' },
gruvbox: { style: 'dense' },
});
expect(Object.keys(UI_STYLE_PRESETS).sort()).toEqual([
'cyberpunk',
'dense',
'soft',
'standard',
]);
});
it('切换内置主题会同步形状、密度、字体、阴影、动效和组件风格', () => {
applyBuiltInTheme('glacier');
expect(getActiveThemeAppearance()).toEqual({
style: 'soft',
shape: 'soft',
density: 'comfortable',
font: 'system',
shadow: 'elevated',
motion: 'reduced',
components: {
button: 'soft',
input: 'boxed',
card: 'elevated',
tabs: 'segmented',
},
});
applyBuiltInTheme('gruvbox');
expect(getActiveThemeAppearance().style).toBe('dense');
expect(getActiveThemeAppearance().density).toBe('compact');
expect(getActiveThemeAppearance().components.card).toBe('flat');
});
it('自定义主题可以在预设之上安全覆盖外观枚举', () => {
applyImportedTheme({
schemaVersion: 2,
colorScheme: 'dark',
ui: { '--bg': '#101318' },
appearance: {
style: 'soft',
shape: 'square',
density: 'spacious',
font: 'mono',
shadow: 'none',
motion: 'none',
components: {
button: 'solid',
input: 'underline',
card: 'flat',
tabs: 'underline',
},
},
});
expect(getActiveThemeAppearance()).toEqual({
style: 'soft',
shape: 'square',
density: 'spacious',
font: 'mono',
shadow: 'none',
motion: 'none',
components: {
button: 'solid',
input: 'underline',
card: 'flat',
tabs: 'underline',
},
});
});
it('非法外观取值回退到所选预设,不进入页面数据属性', () => {
const resolved = resolveThemeAppearance({
style: 'soft',
shape: 'invalid' as never,
density: 'invalid' as never,
components: { button: 'invalid' as never },
});
expect(resolved.shape).toBe(UI_STYLE_PRESETS.soft.shape);
expect(resolved.density).toBe(UI_STYLE_PRESETS.soft.density);
expect(resolved.components.button).toBe(UI_STYLE_PRESETS.soft.components.button);
});
it('终端主题拒绝可触发外部资源请求的 CSS 值', () => {
applyImportedTheme({
terminal: {
background: 'url(https://example.com/tracker.png)',
foreground: '#abcdef',
},
});
expect(getActiveTerminalTheme().background).toBe(THEMES.cyberpunk.background);
expect(getActiveTerminalTheme().foreground).toBe('#abcdef');
});
it('导入时规范化 Theme V2 并保留合法的基础主题和外观配置', () => {
expect(
normalizeImportedTheme({
schemaVersion: 999,
name: ' My Theme ',
baseTheme: 'glacier',
colorScheme: 'dark',
ui: {
'--accent': '#abcdef',
'--unknown': '#ffffff',
},
appearance: {
shape: 'soft',
motion: 'invalid',
components: { button: 'solid', tabs: 'invalid' },
},
})
).toEqual({
schemaVersion: 2,
name: 'My Theme',
baseTheme: 'glacier',
colorScheme: 'dark',
ui: { '--accent': '#abcdef' },
appearance: {
shape: 'soft',
components: { button: 'solid' },
},
});
});
it('应用与服务端共享 UI 属性白名单,并拒绝 UI 中的外部资源值', () => {
expect([...SAFE_UI_THEME_PROPERTIES].sort()).toEqual(Object.keys(UI_THEMES.cyberpunk).sort());
expect(
normalizeImportedTheme({
ui: {
'--accent': '#abcdef',
'--bg': 'url(https://example.com/tracker.png)',
},
})
).toMatchObject({
ui: { '--accent': '#abcdef' },
});
});
});
describe('Standard 主题入口和编辑器', () => {
const appHtml = readFileSync(new URL('../frontend/index.html', import.meta.url), 'utf8');
const editorHtml = readFileSync(
new URL('../docs/theme-editor/index.html', import.meta.url),
'utf8'
);
const terminalSource = readFileSync(
new URL('../frontend/src/terminal.ts', import.meta.url),
'utf8'
);
const appCss = readFileSync(new URL('../frontend/src/style.css', import.meta.url), 'utf8');
const mainSource = readFileSync(new URL('../frontend/src/main.ts', import.meta.url), 'utf8');
const workerSource = readFileSync(new URL('../src/worker/index.ts', import.meta.url), 'utf8');
const userDbSource = readFileSync(new URL('../src/worker/user-db.ts', import.meta.url), 'utf8');
const presetJson = editorHtml.match(
/\/\* THEME_PRESETS_START \*\/ ([\s\S]+?) \/\* THEME_PRESETS_END \*\//
)?.[1];
const editorPresets = JSON.parse(presetJson || '{}') as Record<
string,
{
ui: Record<string, string>;
appearance: Record<string, unknown>;
}
>;
it('主项目和在线编辑器都提供两个 Standard 主题', () => {
expect(appHtml).toContain('<option value="standard-dark">Standard Dark</option>');
expect(appHtml).toContain('<option value="standard-light">Standard Light</option>');
expect(editorHtml).toContain('<select id="preset-select" class="preset-select">');
expect(editorHtml).toContain('<option value="standard-dark">Standard Dark</option>');
expect(editorHtml).toContain('<option value="standard-light">Standard Light</option>');
expect(editorHtml).toContain('colorScheme,');
});
it('用户空间和终端页都可以直接切换主题风格', () => {
expect(appHtml.match(/data-theme-selector/g)).toHaveLength(3);
expect(appHtml.match(/data-theme-import/g)).toHaveLength(3);
expect(appHtml).not.toContain('data-theme-export');
expect(appHtml).not.toContain('data-theme-delete');
expect(appHtml).toContain('Glacier · Soft');
expect(appHtml).toContain('Gruvbox · Dense');
});
it('Pages 保持独立,应用为登录用户同步单个自定义主题', () => {
expect(mainSource).toContain("localStorage.setItem('cloudssh_imported_theme'");
expect(mainSource).not.toContain('[data-theme-export]');
expect(mainSource).not.toContain('[data-theme-delete]');
expect(mainSource).toContain("fetch('/api/user/theme'");
expect(mainSource).toContain("method: 'PUT'");
expect(mainSource).toContain(
'void restoreCloudTheme(initialThemeSelection, themeSelectionRevision)'
);
expect(workerSource).toContain("url.pathname === '/api/user/theme'");
expect(userDbSource).toContain('CREATE TABLE IF NOT EXISTS user_themes');
expect(userDbSource).not.toContain('handleDeleteTheme');
expect(editorHtml).not.toContain('/api/user/theme');
});
it('样式表使用语义令牌实现外观与布局解耦', () => {
for (const token of [
'--control-radius',
'--card-radius',
'--space-scale',
'--font-ui',
'--shadow-card',
'--motion-normal',
'--terminal-frame-gap',
]) {
expect(appCss).toContain(token);
}
expect(appCss).toContain('data-component-button');
expect(appCss).toContain('data-component-input');
expect(appCss).toContain('data-component-card');
expect(appCss).toContain('data-component-tabs');
expect(appCss).not.toContain('data-server-list-layout');
expect(appCss).not.toContain('data-panel-position');
});
it('在线编辑器通过下拉框完整展示和切换全部预设', () => {
for (const themeName of [
'standard-dark',
'standard-light',
'cyberpunk',
'glacier',
'gruvbox',
]) {
expect(editorHtml).toContain(`<option value="${themeName}"`);
}
expect(editorHtml).toContain(
"document.getElementById('preset-select').addEventListener('change'"
);
expect(editorHtml).toContain("syncThemeSelectors('custom')");
expect(editorHtml).not.toContain('class="preset-chip"');
});
it('在线编辑器与主项目的 Standard UI 预设保持一致', () => {
for (const themeName of ['standard-dark', 'standard-light'] as const) {
for (const [property, value] of Object.entries(UI_THEMES[themeName])) {
expect(editorPresets[themeName].ui[property]).toBe(value);
}
}
});
it('在线编辑器覆盖 Theme V2 全部外观和组件维度,并导出版本化 JSON', () => {
for (const field of ['style', 'shape', 'density', 'font', 'shadow', 'motion']) {
expect(editorHtml).toContain(`key: '${field}'`);
}
for (const field of ['button', 'input', 'card', 'tabs']) {
expect(editorHtml).toContain(`key: '${field}'`);
}
expect(editorHtml).toContain('schemaVersion: 2');
expect(editorHtml).toContain('baseTheme: activePreset');
expect(editorHtml).toContain('sanitizeAppearance(data.appearance)');
expect(editorHtml).toContain('file.size > THEME_MAX_BYTES');
expect(editorHtml).toContain('const isValid = isSafeColor(val)');
expect(editorHtml).toContain("e.target.setAttribute('aria-invalid', String(!isValid))");
expect(editorHtml).toContain('invalidColorProperties.size > 0');
expect(editorHtml).toContain('ui: safeUiTheme');
expect(editorHtml).not.toContain('transition: all');
expect(THEME_MAX_BYTES).toBe(64 * 1024);
expect(editorPresets.glacier.appearance).toMatchObject({
style: 'soft',
shape: 'soft',
density: 'comfortable',
});
});
it('终端订阅全局主题并在销毁时解除订阅', () => {
expect(terminalSource).toContain('onTerminalThemeChange((theme)');
expect(terminalSource).toContain('this.themeCleanup()');
});
});