// @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 { 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 形式的 ZIP(readAsDataURL 的结果)', () => { 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() }) })