Files
NotesAgentic/frontend/src/services/themePackageService.spec.ts
T

233 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import {
inspectThemePackage,
installTheme,
listInstalledThemes,
parseThemePackage,
uninstallTheme,
setActiveCustomTheme,
} from './themePackageService'
import type { ThemeManifest } from '@/contracts'
const manifestYaml = `theme_id: ocean-blue
name: 海洋蓝
version: 1.2.0
author: 测试作者
min_app_version: 0.1.0
css_entry: theme.css
is_dark: false`
const themeCss = `[data-theme="ocean-blue"] {
--color-accent-primary: #0077b6;
--color-background-primary: #f5fbff;
}`
/** 主题包 = YAML 清单 + 一行 `---` + CSS。 */
function pkg(manifestText = manifestYaml, css = themeCss): string {
return `${manifestText}\n---\n${css}`
}
function manifest(overrides: Partial<ThemeManifest> = {}): ThemeManifest {
return {
theme_id: 'test-theme',
name: '测试主题',
version: '1.0.0',
author: '作者',
min_app_version: '0.1.0',
is_dark: false,
css_entry: 'theme.css',
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
document.head.querySelectorAll('style[id^="theme-style-"]').forEach((el) => el.remove())
})
describe('parseThemePackage', () => {
it('用 `---` 切分清单与 CSS,两部分都完整取出', () => {
const { manifestText, css } = parseThemePackage(pkg())
expect(manifestText).toContain('theme_id: ocean-blue')
expect(manifestText).not.toContain('data-theme')
expect(css).toBe(themeCss)
})
it('CSS 里自己带 `---` 也不会被截断(只按第一行分隔符切)', () => {
const cssWithDashes = '[data-theme="ocean-blue"] { content: "---"; }\n/* --- */'
const { css } = parseThemePackage(pkg(manifestYaml, cssWithDashes))
expect(css).toBe(cssWithDashes)
})
it('兼容 CRLF 换行的文件', () => {
const { css } = parseThemePackage(pkg().replace(/\n/g, '\r\n'))
expect(css).toContain('--color-accent-primary: #0077b6;')
})
it('缺少分隔行时报 THEME_PACKAGE_INVALID', () => {
expect(() => parseThemePackage(manifestYaml)).toThrow(/THEME_PACKAGE_INVALID/)
})
it('分隔行之后没有 CSS 时报 THEME_CSS_INVALID', () => {
expect(() => parseThemePackage(`${manifestYaml}\n---\n \n`)).toThrow(/THEME_CSS_INVALID/)
})
it('识别 ZIP 二进制并明确提示不支持,而不是当文本解析出乱码', () => {
// ZIP 魔数 PK\x03\x04
expect(() => parseThemePackage('PK\x03\x04binary')).toThrow(
/THEME_PACKAGE_UNSUPPORTED_FORMAT/,
)
})
it('识别 base64 形式的 ZIPreadAsDataURL 的结果)', () => {
expect(() => parseThemePackage('data:application/zip;base64,UEsDBBQAAAA')).toThrow(
/THEME_PACKAGE_UNSUPPORTED_FORMAT/,
)
})
})
describe('inspectThemePackage', () => {
it('解析合法的 manifest 并标记为兼容', async () => {
const result = await inspectThemePackage(pkg())
expect(result.compatible).toBe(true)
expect(result.manifest.theme_id).toBe('ocean-blue')
expect(result.manifest.name).toBe('海洋蓝')
expect(result.manifest.version).toBe('1.2.0')
expect(result.error_code).toBeUndefined()
})
it('返回包内真实的 CSS,供安装时原样使用', async () => {
// 回归:之前 inspect 只解析清单不取 CSS,安装时另外生成一套假调色板,
// 用户提供的样式被整份丢掉。
const result = await inspectThemePackage(pkg())
expect(result.css).toBe(themeCss)
expect(result.css).toContain('#0077b6')
})
it('CSS 缺少 [data-theme] 选择器时给出警告', async () => {
const result = await inspectThemePackage(pkg(manifestYaml, ':root { --x: 1px; }'))
expect(result.compatible).toBe(true)
expect(result.warnings.join()).toContain('[data-theme="ocean-blue"]')
})
it('在预览阶段就拒绝不安全的 CSS,不等到用户点安装', async () => {
const result = await inspectThemePackage(pkg(manifestYaml, '@import url("https://evil.example.com/x.css");'))
expect(result.compatible).toBe(false)
expect(result.error_code).toBe('THEME_SECURITY_VIOLATION')
// 失败时不能把 CSS 透出去,避免调用方误装
expect(result.css).toBe('')
})
it('缺少必填字段时返回 THEME_MANIFEST_INVALID 而不是抛错', async () => {
const result = await inspectThemePackage(pkg('theme_id: no-name\nversion: 1.0.0'))
expect(result.compatible).toBe(false)
expect(result.error_code).toBe('THEME_MANIFEST_INVALID')
})
it('theme_id 含非法字符时判定不兼容', async () => {
const result = await inspectThemePackage(pkg(manifestYaml.replace('ocean-blue', 'Ocean Blue!')))
expect(result.compatible).toBe(false)
expect(result.error_code).toBe('THEME_MANIFEST_INVALID')
})
it('css_entry 指向远程地址时判定为安全违规', async () => {
const result = await inspectThemePackage(
pkg(manifestYaml.replace('css_entry: theme.css', 'css_entry: https://evil.example.com/theme.css')),
)
expect(result.compatible).toBe(false)
expect(result.error_code).toBe('THEME_SECURITY_VIOLATION')
})
it('ZIP 包返回不支持格式的错误码', async () => {
const result = await inspectThemePackage('PK\x03\x04binary')
expect(result.compatible).toBe(false)
expect(result.error_code).toBe('THEME_PACKAGE_UNSUPPORTED_FORMAT')
})
})
describe('installTheme 的 CSS 安全校验', () => {
it('拒绝含 @import 的 CSS', async () => {
await expect(
installTheme(manifest(), '@import url("https://evil.example.com/x.css");'),
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
})
it('拒绝含 javascript: 的 CSS', async () => {
await expect(
installTheme(manifest(), '[data-theme="test-theme"] { background: url(javascript:alert(1)); }'),
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
})
it('拒绝含 expression() 的 CSS', async () => {
await expect(
installTheme(manifest(), '[data-theme="test-theme"] { width: expression(alert(1)); }'),
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
})
it('不安全的 CSS 不会被注入页面', async () => {
await installTheme(manifest(), '@import "x.css";').catch(() => {})
expect(document.getElementById('theme-style-test-theme')).toBeNull()
})
})
describe('主题安装生命周期', () => {
const safeCss = '[data-theme="test-theme"] { --color-accent-primary: #2f6feb; }'
it('安装后可列出,且默认不启用', async () => {
const installed = await installTheme(manifest(), safeCss)
expect(installed.builtin).toBe(false)
expect(installed.enabled).toBe(false)
const themes = await listInstalledThemes()
expect(themes.map((t) => t.theme_id)).toContain('test-theme')
})
it('安装不注入 CSS,启用后才挂载独立 style 节点', async () => {
await installTheme(manifest(), safeCss)
expect(document.getElementById('theme-style-test-theme')).toBeNull()
setActiveCustomTheme('test-theme')
const styleEl = document.getElementById('theme-style-test-theme')
expect(styleEl?.textContent).toContain('--color-accent-primary')
})
it('注入的 CSS 与包内内容逐字一致', async () => {
const inspection = await inspectThemePackage(pkg())
await installTheme(inspection.manifest, inspection.css)
setActiveCustomTheme(inspection.manifest.theme_id)
expect(document.getElementById('theme-style-ocean-blue')?.textContent).toBe(themeCss)
})
it('重复安装同一 theme_id 只保留一份', async () => {
await installTheme(manifest(), safeCss)
await installTheme(manifest({ version: '2.0.0' }), safeCss)
const themes = await listInstalledThemes()
expect(themes.filter((t) => t.theme_id === 'test-theme')).toHaveLength(1)
expect(themes.find((t) => t.theme_id === 'test-theme')?.version).toBe('2.0.0')
})
it('卸载会同时移除记录与注入的样式', async () => {
await installTheme(manifest(), safeCss)
await uninstallTheme('test-theme')
const themes = await listInstalledThemes()
expect(themes.map((t) => t.theme_id)).not.toContain('test-theme')
expect(document.getElementById('theme-style-test-theme')).toBeNull()
})
})