返回 oh-my-ppt
style-package.test.ts
根目录 / tests / unit / styles / style-package.test.ts
1 import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'
2 import os from 'node:os'
3 import path from 'node:path'
4 import { describe, expect, it, vi } from 'vitest'
5 import { unzipSync, zipSync } from 'fflate'
6
7 vi.mock('electron', () => ({
8 app: {
9 getPath: vi.fn(() => path.join(os.tmpdir(), 'ohmyppt-test-user-data'))
10 }
11 }))
12
13 vi.mock('@electron-toolkit/utils', () => ({
14 is: { dev: true }
15 }))
16
17 vi.mock('../../../src/main/io/assets-handlers', () => ({
18 allowLocalAssetRoot: vi.fn()
19 }))
20 import {
21 compareStyleVersion,
22 listStylePackageDirectories,
23 normalizeStyleVersion,
24 readStylePackage,
25 writeStylePackage
26 } from '../../../src/main/styles/style-package'
27 import { initializeStyles } from '../../../src/main/styles/style-initializer'
28 import { setStylesRuntime } from '../../../src/main/styles/style-runtime'
29 import {
30 backfillUserStylePackagesFromDatabase,
31 createStyleSkill,
32 deleteStyleSkill,
33 exportStylePackageZip,
34 importStylePackageDirectory,
35 importStylePackageZip,
36 saveGeneratedStylePreview,
37 setStyleDb,
38 updateStyleSkill
39 } from '../../../src/main/styles/catalog'
40
41 async function makeStyle(
42 root: string,
43 style: string,
44 version: string,
45 skillMarkdown = '# Style Skill\n'
46 ): Promise<void> {
47 const styleDir = path.join(root, style)
48 await mkdir(styleDir, { recursive: true })
49 await writeFile(
50 path.join(styleDir, 'style.json'),
51 JSON.stringify(
52 {
53 style,
54 name: { zh: '极简白', en: 'Minimal White' },
55 description: 'Test style',
56 category: '测试',
57 aliases: ['minimal'],
58 styleCase: 'Unit test',
59 version,
60 source: 'builtin'
61 },
62 null,
63 2
64 ) + '\n',
65 'utf8'
66 )
67 await writeFile(path.join(styleDir, 'SKILL.md'), skillMarkdown, 'utf8')
68 await writeFile(path.join(styleDir, 'preview.html'), '<!doctype html><html><body></body></html>', 'utf8')
69 }
70
71 function makeStyleZip(style = 'imported-style', includePreview = true): Uint8Array {
72 const files: Record<string, Uint8Array> = {
73 [style + '/style.json']: Buffer.from(
74 JSON.stringify(
75 {
76 style,
77 name: { zh: '导入风格', en: 'Imported Style' },
78 description: 'Imported package',
79 category: '测试',
80 aliases: ['imported'],
81 styleCase: 'Zip import',
82 version: '1.2.3',
83 source: 'custom'
84 },
85 null,
86 2
87 ) + '\n'
88 ),
89 [style + '/SKILL.md']: Buffer.from('imported skill\n')
90 }
91 if (includePreview) {
92 files[style + '/preview.html'] = Buffer.from('<!doctype html><html><body>preview</body></html>')
93 }
94 return zipSync(files)
95 }
96
97 function makeStyleDb() {
98 const rows: Array<Record<string, unknown>> = []
99 return {
100 rows,
101 db: {
102 listStyleRowsSync: vi.fn(() => rows),
103 getStyleRowByStyleSync: vi.fn((style: string) => rows.find((row) => row.style === style)),
104 getStyleRowSync: vi.fn((id: string) => rows.find((row) => row.id === id)),
105 createStyleRow: vi.fn(async (row: Record<string, unknown>) => {
106 rows.push({
107 ...row,
108 aliases: JSON.stringify(row.aliases || []),
109 active: true,
110 createdAt: 1,
111 updatedAt: 1
112 })
113 return row.id
114 }),
115 updateStyleRow: vi.fn(async (id: string, patch: Record<string, unknown>) => {
116 const row = rows.find((item) => item.id === id)
117 if (row) Object.assign(row, patch)
118 })
119 }
120 }
121 }
122
123 describe('style packages', () => {
124 it('uses semver strings for style package versions', () => {
125 expect(normalizeStyleVersion(1)).toBe('1.0.0')
126 expect(normalizeStyleVersion('v2.3')).toBe('2.3.0')
127 expect(compareStyleVersion('1.10.0', '1.2.0')).toBeGreaterThan(0)
128 })
129
130 it('reads and writes style.json + SKILL.md + preview.html packages', async () => {
131 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-package-'))
132 const dir = path.join(tmp, 'minimal-white')
133 await writeStylePackage({
134 dir,
135 json: {
136 style: 'minimal-white',
137 name: { zh: '极简白', en: 'Minimal White' },
138 description: 'Test style',
139 category: '测试',
140 aliases: ['minimal'],
141 styleCase: 'Unit test',
142 version: '1.0.0',
143 source: 'builtin'
144 },
145 skillMarkdown: '# Minimal White\n'
146 })
147
148 const pkg = await readStylePackage(dir)
149 expect(pkg.json).toMatchObject({
150 style: 'minimal-white',
151 name: { zh: '极简白', en: 'Minimal White' },
152 version: '1.0.0'
153 })
154 expect(pkg.skillMarkdown).toContain('Minimal White')
155 const rawJson = JSON.parse(await readFile(path.join(dir, 'style.json'), 'utf8'))
156 expect(rawJson.schemaVersion).toBeUndefined()
157 expect(rawJson.styleSkill).toBeUndefined()
158 })
159
160 it('allows standard inline SVG namespace declarations in preview.html', async () => {
161 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-svg-preview-'))
162 const dir = path.join(tmp, 'svg-preview')
163
164 await writeStylePackage({
165 dir,
166 json: {
167 style: 'svg-preview',
168 name: { zh: 'SVG 预览', en: 'SVG Preview' },
169 description: 'Inline SVG preview',
170 category: '测试',
171 aliases: [],
172 styleCase: 'Unit test',
173 version: '1.0.0',
174 source: 'custom'
175 },
176 skillMarkdown: '# SVG Preview\n',
177 previewHtml:
178 '<!doctype html><html><body><svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="gradient"></linearGradient></defs><rect fill="url(#gradient)" /></svg></body></html>'
179 })
180
181 await expect(readStylePackage(dir)).resolves.toMatchObject({ previewPath: path.join(dir, 'preview.html') })
182 })
183
184 it.each([
185 '<!doctype html><html><body><img src="https://example.com/image.png"></body></html>',
186 '<!doctype html><html><body><img src="data:image/png;base64,AAAA"></body></html>',
187 '<!doctype html><html><body><a href="javascript:alert(1)">open</a></body></html>',
188 '<!doctype html><html><body onload="alert(1)"></body></html>',
189 '<!doctype html><html><body><iframe srcdoc="&lt;img src=https://example.com/a.png&gt;"></iframe></body></html>',
190 '<!doctype html><html><style>body{background:url(http://example.com/bg.png)}</style></html>',
191 '<!doctype html><html><style>body{background:url(\\68 ttp://example.com/bg.png)}</style></html>',
192 '<!doctype html><html><style>@import "https://example.com/style.css";</style></html>',
193 '<!doctype html><html><meta http-equiv="refresh" content="0; url=https://example.com"></html>'
194 ])('rejects unsafe references or executable markup in preview.html', async (previewHtml) => {
195 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-remote-preview-'))
196
197 await expect(
198 writeStylePackage({
199 dir: path.join(tmp, 'remote-preview'),
200 json: {
201 style: 'remote-preview',
202 name: { zh: '远程预览', en: 'Remote Preview' },
203 description: 'Remote preview',
204 category: '测试',
205 aliases: [],
206 styleCase: 'Unit test',
207 version: '1.0.0',
208 source: 'custom'
209 },
210 skillMarkdown: '# Remote Preview\n',
211 previewHtml
212 })
213 ).rejects.toThrow(/forbidden|Forbidden/)
214 })
215
216 it('rejects oversized preview.html files before persisting them', async () => {
217 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-large-preview-'))
218
219 await expect(
220 writeStylePackage({
221 dir: path.join(tmp, 'large-preview'),
222 json: {
223 style: 'large-preview',
224 name: { zh: '大预览', en: 'Large Preview' },
225 description: 'Large preview',
226 category: '测试',
227 aliases: [],
228 styleCase: 'Unit test',
229 version: '1.0.0',
230 source: 'custom'
231 },
232 skillMarkdown: '# Large Preview\n',
233 previewHtml: '<!doctype html><html><body>' + 'x'.repeat(1024 * 1024) + '</body></html>'
234 })
235 ).rejects.toThrow('preview.html must not exceed 1MB')
236 })
237
238 it('rewrites old installed system packages from bundled styles', async () => {
239 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-init-'))
240 const bundled = path.join(tmp, 'bundled')
241 const installed = path.join(tmp, 'installed')
242 await makeStyle(bundled, 'minimal-white', '1.0.0', 'new skill\n')
243
244 const oldInstalledDir = path.join(installed, 'system', 'minimal-white')
245 await mkdir(oldInstalledDir, { recursive: true })
246 await writeFile(
247 path.join(oldInstalledDir, 'style.json'),
248 JSON.stringify(
249 {
250 style: 'minimal-white',
251 styleName: '旧极简白',
252 description: 'Old style',
253 category: '旧',
254 aliases: [],
255 styleCase: '',
256 version: '1.0.0',
257 source: 'builtin',
258 styleSkill: 'old inline skill'
259 },
260 null,
261 2
262 ) + '\n',
263 'utf8'
264 )
265 await writeFile(path.join(oldInstalledDir, 'preview.html'), '<!doctype html><html></html>', 'utf8')
266
267 const result = await initializeStyles({
268 bundledSourcePath: bundled,
269 installedRootPath: installed
270 })
271
272 expect(result.copiedCount).toBe(1)
273 expect(result.skippedCount).toBe(0)
274 const pkg = await readStylePackage(oldInstalledDir)
275 expect(pkg.skillMarkdown).toBe('new skill\n')
276 expect(pkg.json.name).toEqual({ zh: '极简白', en: 'Minimal White' })
277 })
278
279 it('skips system style sync when release manifest version is unchanged', async () => {
280 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-system-manifest-'))
281 const bundled = path.join(tmp, 'bundled')
282 const installed = path.join(tmp, 'installed')
283 await makeStyle(bundled, 'minimal-white', '1.0.0', 'new skill\n')
284 await writeFile(
285 path.join(bundled, 'manifest.json'),
286 JSON.stringify({ version: '1.0.0', time: '2026-06-13', author: 'arcsin1' }, null, 2) + '\n',
287 'utf8'
288 )
289
290 const first = await initializeStyles({
291 bundledSourcePath: bundled,
292 installedRootPath: installed
293 })
294 expect(first.copiedCount).toBe(1)
295 expect(first.skippedCount).toBe(0)
296 await writeFile(path.join(installed, 'system', 'minimal-white', 'SKILL.md'), 'local unchanged\n', 'utf8')
297
298 const second = await initializeStyles({
299 bundledSourcePath: bundled,
300 installedRootPath: installed
301 })
302 expect(second).toMatchObject({
303 bundledCount: 0,
304 copiedCount: 0,
305 skippedCount: 1,
306 failedCount: 0
307 })
308 const pkg = await readStylePackage(path.join(installed, 'system', 'minimal-white'))
309 expect(pkg.skillMarkdown).toBe('local unchanged\n')
310 })
311
312 it('lists style package directories with valid names and style.json files', async () => {
313 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-package-list-'))
314 await makeStyle(tmp, 'minimal-white', '1.0.0')
315 await makeStyle(tmp, 'tokyo-night', '1.0.0')
316 await mkdir(path.join(tmp, 'missing-json'), { recursive: true })
317 await writeFile(path.join(tmp, 'not-a-dir'), 'ignored', 'utf8')
318 await mkdir(path.join(tmp, '.minimal-white-tmp'), { recursive: true })
319
320 await expect(listStylePackageDirectories(tmp)).resolves.toEqual(['minimal-white', 'tokyo-night'])
321 })
322
323 it('imports and exports strict style package zips', async () => {
324 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-zip-'))
325 const installed = path.join(tmp, 'installed')
326 const sourceZip = path.join(tmp, 'imported-style.zip')
327 await writeFile(sourceZip, Buffer.from(makeStyleZip()))
328 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
329 const fake = makeStyleDb()
330 setStyleDb(fake.db as never)
331
332 const imported = await importStylePackageZip(sourceZip)
333 expect(imported).toEqual({ id: 'imported-style', source: 'custom' })
334 const installedPackage = await readStylePackage(path.join(installed, 'user', 'imported-style'))
335 expect(installedPackage.json.version).toBe('1.2.3')
336 expect(installedPackage.skillMarkdown).toBe('imported skill\n')
337 expect(await readFile(installedPackage.previewPath || '', 'utf8')).toContain('preview')
338
339 const outputZip = path.join(tmp, 'exported.zip')
340 await exportStylePackageZip('imported-style', outputZip)
341 const exported = unzipSync(new Uint8Array(await readFile(outputZip)))
342 expect(Object.keys(exported).sort()).toEqual([
343 'imported-style/SKILL.md',
344 'imported-style/preview.html',
345 'imported-style/style.json'
346 ])
347 })
348
349 it('imports a style package directory and ignores unrelated files', async () => {
350 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-directory-'))
351 const installed = path.join(tmp, 'installed')
352 await makeStyle(tmp, 'hand-drawn-autumn', '1.0.0', '# Hand-drawn Autumn\n')
353 const sourceDir = path.join(tmp, 'hand-drawn-autumn')
354 await mkdir(path.join(sourceDir, '.claude'), { recursive: true })
355 await writeFile(path.join(sourceDir, '.claude', 'settings.local.json'), '{}', 'utf8')
356 await mkdir(path.join(sourceDir, 'assets', 'nested'), { recursive: true })
357 await writeFile(path.join(sourceDir, 'assets', 'nested', 'texture.png'), 'ignored', 'utf8')
358 await writeFile(path.join(sourceDir, 'README.md'), 'ignored', 'utf8')
359 await writeFile(path.join(sourceDir, 'notes.txt'), 'ignored', 'utf8')
360 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
361 setStyleDb(makeStyleDb().db as never)
362
363 await expect(importStylePackageDirectory(sourceDir)).resolves.toEqual({
364 id: 'hand-drawn-autumn',
365 source: 'custom'
366 })
367 const installedDir = path.join(installed, 'user', 'hand-drawn-autumn')
368 const installedPackage = await readStylePackage(installedDir)
369 expect(installedPackage.skillMarkdown).toBe('# Hand-drawn Autumn\n')
370 expect((await readdir(installedDir)).sort()).toEqual(['SKILL.md', 'preview.html', 'style.json'])
371 })
372
373 it('backfills legacy user styles into user packages without preview.html', async () => {
374 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-legacy-backfill-'))
375 const installed = path.join(tmp, 'installed')
376 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
377 const fake = makeStyleDb()
378 fake.rows.push({
379 id: 'style-bgnyzgo0pd66',
380 style: 'style-bgnyzgo0pd66',
381 styleName: '旧解析风格',
382 styleNameZh: '旧解析风格',
383 styleNameEn: '',
384 description: 'legacy parsed style',
385 category: '自定义',
386 aliases: '[]',
387 source: 'custom',
388 styleSkill: 'legacy parsed skill\n',
389 version: '1.0.0',
390 styleCase: '',
391 packageDir: '',
392 active: true,
393 createdAt: 1,
394 updatedAt: 1
395 })
396 setStyleDb(fake.db as never)
397
398 const result = await backfillUserStylePackagesFromDatabase(installed)
399 expect(result).toEqual({ scanned: 1, created: 1, skipped: 0, failed: 0 })
400 const packageDir = path.join(installed, 'user', 'style-bgnyzgo0pd66')
401 const stylePackage = await readStylePackage(packageDir)
402 expect(stylePackage.json.name.zh).toBe('旧解析风格')
403 expect(stylePackage.skillMarkdown).toBe('legacy parsed skill\n')
404 expect(stylePackage.previewPath).toBeUndefined()
405 expect(fake.rows[0].packageDir).toBe('user/style-bgnyzgo0pd66')
406
407 const outputZip = path.join(tmp, 'legacy-exported.zip')
408 await exportStylePackageZip('style-bgnyzgo0pd66', outputZip)
409 const exported = unzipSync(new Uint8Array(await readFile(outputZip)))
410 expect(Object.keys(exported).sort()).toEqual([
411 'style-bgnyzgo0pd66/SKILL.md',
412 'style-bgnyzgo0pd66/style.json'
413 ])
414 })
415
416 it('creates styles without a preview and preserves an existing preview on update', async () => {
417 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-create-no-preview-'))
418 const installed = path.join(tmp, 'installed')
419 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
420 const fake = makeStyleDb()
421 setStyleDb(fake.db as never)
422
423 await createStyleSkill({
424 id: 'parsed-style',
425 label: '解析风格',
426 description: 'Parsed style',
427 category: '自定义',
428 aliases: [],
429 prompt: '# Parsed Style\n',
430 styleCase: '产品介绍'
431 })
432
433 const packageDir = path.join(installed, 'user', 'parsed-style')
434 const createdPackage = await readStylePackage(packageDir)
435 expect(createdPackage.previewPath).toBeUndefined()
436
437 const previewPath = path.join(packageDir, 'preview.html')
438 await writeFile(
439 previewPath,
440 '<!doctype html><html><body>keep preview</body></html>',
441 'utf8'
442 )
443 await updateStyleSkill({
444 id: 'parsed-style',
445 label: '更新后的解析风格',
446 description: 'Updated style',
447 category: '自定义',
448 aliases: [],
449 prompt: '# Updated Parsed Style\n',
450 styleCase: '产品介绍'
451 })
452
453 expect(await readFile(previewPath, 'utf8')).toContain('keep preview')
454 })
455
456 it('soft deletes builtin and custom styles from the active catalog', async () => {
457 const fake = makeStyleDb()
458 fake.rows.push(
459 { id: 'builtin-style', style: 'builtin-style', source: 'builtin', active: true },
460 { id: 'custom-style', style: 'custom-style', source: 'custom', active: true }
461 )
462 setStyleDb(fake.db as never)
463
464 await expect(deleteStyleSkill('builtin-style')).resolves.toEqual({ deleted: true })
465 await expect(deleteStyleSkill('custom-style')).resolves.toEqual({ deleted: true })
466 expect(fake.rows).toEqual([
467 expect.objectContaining({ id: 'builtin-style', active: false }),
468 expect.objectContaining({ id: 'custom-style', active: false })
469 ])
470 })
471
472 it('persists a generated builtin preview as a user override package', async () => {
473 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-preview-override-'))
474 const installed = path.join(tmp, 'installed')
475 const systemDir = path.join(installed, 'system', 'minimal-white')
476 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
477 await writeStylePackage({
478 dir: systemDir,
479 json: {
480 style: 'minimal-white',
481 name: { zh: '极简白', en: 'Minimal White' },
482 description: 'Test style',
483 category: '测试',
484 aliases: [],
485 styleCase: 'Unit test',
486 version: '1.0.0',
487 source: 'builtin'
488 },
489 skillMarkdown: '# Minimal White\n'
490 })
491 const fake = makeStyleDb()
492 fake.rows.push({
493 id: 'minimal-white',
494 style: 'minimal-white',
495 styleName: '极简白',
496 styleNameZh: '极简白',
497 styleNameEn: 'Minimal White',
498 description: 'Test style',
499 category: '测试',
500 aliases: '[]',
501 source: 'builtin',
502 styleSkill: '# Minimal White\n',
503 version: '1.0.0',
504 styleCase: 'Unit test',
505 packageDir: 'system/minimal-white',
506 active: true,
507 createdAt: 1,
508 updatedAt: 1
509 })
510 setStyleDb(fake.db as never)
511
512 const result = await saveGeneratedStylePreview(
513 'minimal-white',
514 '<!doctype html><html><body>generated preview</body></html>'
515 )
516
517 expect(result.previewPath).toBe(
518 path.join(installed, 'user', 'minimal-white', 'preview.html')
519 )
520 const overridePackage = await readStylePackage(path.join(installed, 'user', 'minimal-white'))
521 expect(overridePackage.json.source).toBe('override')
522 expect(await readFile(overridePackage.previewPath || '', 'utf8')).toContain('generated preview')
523 expect(fake.rows[0]).toMatchObject({
524 source: 'override',
525 packageDir: 'user/minimal-white'
526 })
527 const systemPackage = await readStylePackage(systemDir)
528 expect(systemPackage.previewPath).toBeUndefined()
529 })
530
531 it('does not backfill user packages with empty style skills', async () => {
532 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-empty-skill-'))
533 const installed = path.join(tmp, 'installed')
534 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
535 const fake = makeStyleDb()
536 fake.rows.push({
537 id: 'style-empty-skill',
538 style: 'style-empty-skill',
539 styleName: '空技能风格',
540 styleNameZh: '空技能风格',
541 styleNameEn: '',
542 description: '',
543 category: '自定义',
544 aliases: '[]',
545 source: 'custom',
546 styleSkill: ' ',
547 version: '1.0.0',
548 styleCase: '',
549 packageDir: '',
550 active: true,
551 createdAt: 1,
552 updatedAt: 1
553 })
554 setStyleDb(fake.db as never)
555 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
556
557 const result = await backfillUserStylePackagesFromDatabase(installed)
558 expect(result).toEqual({ scanned: 1, created: 0, skipped: 0, failed: 1 })
559 expect(warn).toHaveBeenCalledWith(
560 '[styles] failed to backfill user style package',
561 expect.objectContaining({
562 styleId: 'style-empty-skill',
563 message: expect.stringContaining(
564 '跳过用户风格包回填:style-empty-skill。原因:style_skill 为空,无法生成 SKILL.md'
565 )
566 })
567 )
568 warn.mockRestore()
569 await expect(readStylePackage(path.join(installed, 'user', 'style-empty-skill'))).rejects.toThrow()
570 })
571
572 it('does not backfill user packages with invalid style json metadata', async () => {
573 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-invalid-json-'))
574 const installed = path.join(tmp, 'installed')
575 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
576 const fake = makeStyleDb()
577 fake.rows.push({
578 id: 'style-invalid-json',
579 style: 'Invalid Style Key',
580 styleName: '非法风格',
581 styleNameZh: '非法风格',
582 styleNameEn: '',
583 description: '',
584 category: '自定义',
585 aliases: '[]',
586 source: 'custom',
587 styleSkill: 'valid skill\n',
588 version: '1.0.0',
589 styleCase: '',
590 packageDir: '',
591 active: true,
592 createdAt: 1,
593 updatedAt: 1
594 })
595 setStyleDb(fake.db as never)
596 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
597
598 const result = await backfillUserStylePackagesFromDatabase(installed)
599 expect(result).toEqual({ scanned: 1, created: 0, skipped: 0, failed: 1 })
600 expect(warn).toHaveBeenCalledWith(
601 '[styles] failed to backfill user style package',
602 expect.objectContaining({
603 styleId: 'style-invalid-json',
604 message: expect.stringContaining(
605 '跳过用户风格包回填:style-invalid-json。原因:style.json 无效'
606 )
607 })
608 )
609 warn.mockRestore()
610 await expect(readStylePackage(path.join(installed, 'user', 'style-invalid-json'))).rejects.toThrow()
611 })
612
613 it('imports and exports package zips without preview.html', async () => {
614 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-zip-no-preview-'))
615 const installed = path.join(tmp, 'installed')
616 const sourceZip = path.join(tmp, 'imported-style.zip')
617 await writeFile(sourceZip, Buffer.from(makeStyleZip('imported-style', false)))
618 setStylesRuntime({ installedStylesPath: installed, ready: Promise.resolve() })
619 setStyleDb(makeStyleDb().db as never)
620
621 await importStylePackageZip(sourceZip)
622 const installedPackage = await readStylePackage(path.join(installed, 'user', 'imported-style'))
623 expect(installedPackage.previewPath).toBeUndefined()
624
625 const outputZip = path.join(tmp, 'exported.zip')
626 await exportStylePackageZip('imported-style', outputZip)
627 const exported = unzipSync(new Uint8Array(await readFile(outputZip)))
628 expect(Object.keys(exported).sort()).toEqual([
629 'imported-style/SKILL.md',
630 'imported-style/style.json'
631 ])
632 })
633
634 it('rejects zip packages with files outside the style root', async () => {
635 const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-zip-invalid-'))
636 const zipPath = path.join(tmp, 'bad.zip')
637 await writeFile(
638 zipPath,
639 Buffer.from(
640 zipSync({
641 'style-a/style.json': Buffer.from('{}'),
642 'style-a/SKILL.md': Buffer.from('skill'),
643 'style-a/preview.html': Buffer.from('<!doctype html><html></html>'),
644 'style-a/extra.txt': Buffer.from('extra')
645 })
646 )
647 )
648 setStylesRuntime({ installedStylesPath: path.join(tmp, 'installed'), ready: Promise.resolve() })
649 setStyleDb(makeStyleDb().db as never)
650
651 await expect(importStylePackageZip(zipPath)).rejects.toThrow(/必须只包含/)
652 })
653 })
654
654 lines TYPESCRIPT