返回 oh-my-ppt
html-thumbnail-service.test.ts
根目录 / tests / unit / thumbnail / html-thumbnail-service.test.ts
1 import fs from 'node:fs'
2 import os from 'node:os'
3 import path from 'node:path'
4 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
6 const state = vi.hoisted(() => ({
7 userDataPath: `/tmp/ohmyppt-thumbnail-test-${Date.now()}`,
8 capturePage: vi.fn(async () => ({
9 resize: vi.fn(() => ({ toPNG: vi.fn(() => Buffer.from('png')) }))
10 })),
11 destroy: vi.fn(),
12 loadFile: vi.fn(async () => undefined),
13 executeJavaScript: vi.fn(async () => undefined),
14 setContentSize: vi.fn(),
15 setZoomFactor: vi.fn()
16 }))
17
18 vi.mock('electron', () => ({
19 app: { getPath: vi.fn(() => state.userDataPath) },
20 BrowserWindow: vi.fn(function () {
21 return {
22 loadFile: state.loadFile,
23 setContentSize: state.setContentSize,
24 isDestroyed: vi.fn(() => false),
25 destroy: state.destroy,
26 webContents: {
27 capturePage: state.capturePage,
28 executeJavaScript: state.executeJavaScript,
29 setZoomFactor: state.setZoomFactor
30 }
31 }
32 })
33 }))
34 vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }))
35 vi.mock('../../../src/main/io/assets-handlers', () => ({ allowLocalAssetRoot: vi.fn() }))
36
37 describe('html thumbnail background service', () => {
38 beforeEach(() => {
39 vi.clearAllMocks()
40 })
41
42 afterEach(() => {
43 fs.rmSync(state.userDataPath, { recursive: true, force: true })
44 vi.restoreAllMocks()
45 })
46
47 it('removes stale screenshot temp files during startup configuration', async () => {
48 const cacheRoot = path.join(state.userDataPath, 'html-thumbnails-dev')
49 fs.mkdirSync(cacheRoot, { recursive: true })
50 const staleTempPath = path.join(cacheRoot, 'stale.png.tmp')
51 const completedPath = path.join(cacheRoot, 'completed.png')
52 fs.writeFileSync(staleTempPath, 'partial')
53 fs.writeFileSync(completedPath, 'png')
54
55 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
56 service.configureHtmlThumbnailService({} as never)
57
58 expect(fs.existsSync(staleTempPath)).toBe(false)
59 expect(fs.existsSync(completedPath)).toBe(true)
60 })
61
62 it('returns queued immediately and persists completion by real resource ID', async () => {
63 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-source-'))
64 const sourcePath = path.join(sourceRoot, 'index.html')
65 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
66 const records = new Map<string, Record<string, unknown>>()
67 const db = {
68 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
69 records.get(`${resourceType}:${resourceId}:${variant}`)
70 ),
71 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
72 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
73 })
74 }
75
76 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
77 service.configureHtmlThumbnailService(db as never)
78 const queued = await service.enqueueHtmlThumbnail({
79 resourceType: 'session',
80 resourceId: 'session-real-id',
81 variant: 'cover',
82 sourcePath
83 })
84 expect(queued).toMatchObject({ status: 'queued', resourceId: 'session-real-id' })
85
86 await vi.waitFor(async () => {
87 const task = await service.getHtmlThumbnailTask('session', 'session-real-id', 'cover')
88 expect(task?.status).toBe('completed')
89 expect(task?.thumbnailPath).toMatch(/\.png$/)
90 })
91 expect(db.upsertThumbnailRecord).toHaveBeenCalledWith(
92 expect.objectContaining({
93 resourceType: 'session',
94 resourceId: 'session-real-id',
95 variant: 'cover',
96 status: 'completed'
97 })
98 )
99 fs.rmSync(sourceRoot, { recursive: true, force: true })
100 })
101
102 it('persists template cover completion with the template resource type', async () => {
103 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-template-thumbnail-source-'))
104 const sourcePath = path.join(sourceRoot, 'page-1.html')
105 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
106 const records = new Map<string, Record<string, unknown>>()
107 const db = {
108 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
109 records.get(`${resourceType}:${resourceId}:${variant}`)
110 ),
111 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
112 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
113 })
114 }
115
116 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
117 service.configureHtmlThumbnailService(db as never)
118 await service.enqueueHtmlThumbnail({
119 resourceType: 'template',
120 resourceId: 'template-real-id',
121 variant: 'cover',
122 sourcePath
123 })
124
125 await vi.waitFor(() => {
126 expect(db.upsertThumbnailRecord).toHaveBeenCalledWith(
127 expect.objectContaining({
128 resourceType: 'template',
129 resourceId: 'template-real-id',
130 variant: 'cover',
131 status: 'completed',
132 thumbnailPath: expect.stringMatching(/\.png$/)
133 })
134 )
135 })
136 fs.rmSync(sourceRoot, { recursive: true, force: true })
137 })
138
139 it('invalidates cached thumbnails when source mtime or capture signature changes', async () => {
140 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-freshness-'))
141 const sourcePath = path.join(sourceRoot, 'index.html')
142 const thumbnailPath = path.join(sourceRoot, 'thumbnail.png')
143 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
144 fs.writeFileSync(thumbnailPath, 'png')
145 const sourceMtimeMs = Math.floor(fs.statSync(sourcePath).mtimeMs)
146 const request = {
147 resourceType: 'style',
148 resourceId: 'style-cache',
149 sourcePath
150 }
151 const normalizedSignature = JSON.stringify({
152 resourceType: request.resourceType,
153 resourceId: request.resourceId,
154 variant: 'default',
155 sourcePath: request.sourcePath,
156 pageId: '',
157 query: {},
158 captureWidth: 1600,
159 captureHeight: 900,
160 thumbnailWidth: 640,
161 thumbnailHeight: 360
162 })
163 const record = {
164 resourceType: 'style',
165 resourceId: 'style-cache',
166 variant: 'default',
167 sourcePath,
168 sourceMtimeMs,
169 signature: normalizedSignature,
170 thumbnailPath,
171 status: 'completed',
172 error: null
173 }
174 const db = { getThumbnailRecord: vi.fn(async () => record) }
175 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
176 service.configureHtmlThumbnailService(db as never)
177
178 await expect(service.getFreshHtmlThumbnailPath(request)).resolves.toBe(thumbnailPath)
179 await expect(
180 service.getFreshHtmlThumbnailPath({ ...request, thumbnailWidth: 320 })
181 ).resolves.toBeNull()
182
183 const newerTime = new Date(sourceMtimeMs + 2_000)
184 fs.utimesSync(sourcePath, newerTime, newerTime)
185 await expect(service.getFreshHtmlThumbnailPath(request)).resolves.toBeNull()
186 fs.rmSync(sourceRoot, { recursive: true, force: true })
187 })
188
189 it('returns an existing fresh thumbnail without opening a capture window', async () => {
190 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-existing-'))
191 const sourcePath = path.join(sourceRoot, 'page-1.html')
192 const thumbnailPath = path.join(sourceRoot, 'thumbnail.png')
193 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
194 fs.writeFileSync(thumbnailPath, 'png')
195 const request = {
196 resourceType: 'session',
197 resourceId: 'session-existing',
198 variant: 'first-page',
199 sourcePath
200 }
201 const db = {
202 getThumbnailRecord: vi.fn(async () => ({
203 resourceType: request.resourceType,
204 resourceId: request.resourceId,
205 variant: request.variant,
206 sourcePath,
207 sourceMtimeMs: Math.floor(fs.statSync(sourcePath).mtimeMs),
208 signature: JSON.stringify({
209 ...request,
210 pageId: '',
211 query: {},
212 captureWidth: 1600,
213 captureHeight: 900,
214 thumbnailWidth: 640,
215 thumbnailHeight: 360
216 }),
217 thumbnailPath,
218 status: 'completed',
219 error: null
220 })),
221 upsertThumbnailRecord: vi.fn()
222 }
223 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
224 service.configureHtmlThumbnailService(db as never)
225
226 await expect(service.enqueueHtmlThumbnail(request)).resolves.toMatchObject({
227 status: 'completed',
228 thumbnailPath
229 })
230 expect(state.capturePage).not.toHaveBeenCalled()
231 expect(db.upsertThumbnailRecord).not.toHaveBeenCalled()
232 fs.rmSync(sourceRoot, { recursive: true, force: true })
233 })
234
235 it('waits until an enqueued thumbnail task completes', async () => {
236 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-wait-'))
237 const sourcePath = path.join(sourceRoot, 'preview.html')
238 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
239 const records = new Map<string, Record<string, unknown>>()
240 const db = {
241 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
242 records.get(`${resourceType}:${resourceId}:${variant}`)
243 ),
244 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
245 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
246 })
247 }
248 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
249 service.configureHtmlThumbnailService(db as never)
250
251 const completed = service.waitForHtmlThumbnailTask('style', 'style-wait', 'default', 2_000)
252 await service.enqueueHtmlThumbnail({
253 resourceType: 'style',
254 resourceId: 'style-wait',
255 sourcePath
256 })
257
258 await expect(completed).resolves.toMatchObject({
259 resourceType: 'style',
260 resourceId: 'style-wait',
261 status: 'completed',
262 thumbnailPath: expect.stringMatching(/\.png$/)
263 })
264 fs.rmSync(sourceRoot, { recursive: true, force: true })
265 })
266
267 it('retries capture when the source changes and records the stable source mtime', async () => {
268 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-changing-'))
269 const sourcePath = path.join(sourceRoot, 'page-1.html')
270 fs.writeFileSync(sourcePath, '<!doctype html><html><body>first</body></html>')
271 const records = new Map<string, Record<string, unknown>>()
272 const db = {
273 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
274 records.get(`${resourceType}:${resourceId}:${variant}`)
275 ),
276 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
277 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
278 })
279 }
280 state.capturePage.mockImplementationOnce(async () => {
281 fs.writeFileSync(sourcePath, '<!doctype html><html><body>second</body></html>')
282 const changedTime = new Date(Date.now() + 2_000)
283 fs.utimesSync(sourcePath, changedTime, changedTime)
284 return {
285 resize: vi.fn(() => ({ toPNG: vi.fn(() => Buffer.from('stale')) }))
286 }
287 })
288
289 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
290 service.configureHtmlThumbnailService(db as never)
291 await service.enqueueHtmlThumbnail({
292 resourceType: 'session',
293 resourceId: 'session-changing',
294 variant: 'first-page',
295 sourcePath
296 })
297
298 await vi.waitFor(() => {
299 const completed = db.upsertThumbnailRecord.mock.calls.find(
300 ([record]) => record.status === 'completed'
301 )?.[0]
302 expect(completed).toBeTruthy()
303 expect(completed?.sourceMtimeMs).toBe(Math.floor(fs.statSync(sourcePath).mtimeMs))
304 })
305 expect(state.capturePage).toHaveBeenCalledTimes(2)
306 fs.rmSync(sourceRoot, { recursive: true, force: true })
307 })
308
309 it('runs at most two screenshot tasks concurrently', async () => {
310 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-concurrency-'))
311 const sourcePaths = Array.from({ length: 3 }, (_, index) => {
312 const sourcePath = path.join(sourceRoot, `${index + 1}.html`)
313 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
314 return sourcePath
315 })
316 const records = new Map<string, Record<string, unknown>>()
317 const db = {
318 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
319 records.get(`${resourceType}:${resourceId}:${variant}`)
320 ),
321 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
322 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
323 })
324 }
325 let activeCaptures = 0
326 let maxActiveCaptures = 0
327 const releaseCaptures: Array<() => void> = []
328 state.capturePage.mockImplementation(async () => {
329 activeCaptures += 1
330 maxActiveCaptures = Math.max(maxActiveCaptures, activeCaptures)
331 await new Promise<void>((resolve) => releaseCaptures.push(resolve))
332 activeCaptures -= 1
333 return {
334 resize: vi.fn(() => ({ toPNG: vi.fn(() => Buffer.from('png')) }))
335 }
336 })
337
338 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
339 service.configureHtmlThumbnailService(db as never)
340 await service.enqueueHtmlThumbnails(
341 sourcePaths.map((sourcePath, index) => ({
342 resourceType: 'style',
343 resourceId: `style-${index + 1}`,
344 sourcePath
345 }))
346 )
347
348 await vi.waitFor(() => expect(state.capturePage).toHaveBeenCalledTimes(2))
349 expect(maxActiveCaptures).toBe(2)
350 releaseCaptures.shift()?.()
351 await vi.waitFor(() => expect(state.capturePage).toHaveBeenCalledTimes(3))
352 expect(maxActiveCaptures).toBe(2)
353 while (releaseCaptures.length > 0) releaseCaptures.shift()?.()
354 await vi.waitFor(async () => {
355 const task = await service.getHtmlThumbnailTask('style', 'style-3')
356 expect(task?.status).toBe('completed')
357 })
358
359 fs.rmSync(sourceRoot, { recursive: true, force: true })
360 })
361
362 it('resolves multiple fresh thumbnails with a single batched DB query', async () => {
363 const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-thumbnail-batch-'))
364 const records = new Map<string, Record<string, unknown>>()
365 const db = {
366 getThumbnailRecord: vi.fn(async (resourceType: string, resourceId: string, variant: string) =>
367 records.get(`${resourceType}:${resourceId}:${variant}`)
368 ),
369 getThumbnailRecords: vi.fn(
370 async (resourceType: string, resourceIds: string[], variant: string) =>
371 resourceIds
372 .map((id) => records.get(`${resourceType}:${id}:${variant}`))
373 .filter((record): record is Record<string, unknown> => Boolean(record))
374 ),
375 upsertThumbnailRecord: vi.fn(async (record: Record<string, unknown>) => {
376 records.set(`${record.resourceType}:${record.resourceId}:${record.variant}`, record)
377 })
378 }
379 const service = await import('../../../src/main/io/thumbnails/html-thumbnail-service')
380 service.configureHtmlThumbnailService(db as never)
381
382 const writeSource = (name: string): string => {
383 const sourcePath = path.join(sourceRoot, name)
384 fs.writeFileSync(sourcePath, '<!doctype html><html></html>')
385 return sourcePath
386 }
387 const makeSignature = (resourceId: string, sourcePath: string, thumbnailWidth = 640) =>
388 JSON.stringify({
389 resourceType: 'session',
390 resourceId,
391 variant: 'first-page',
392 sourcePath,
393 pageId: '',
394 query: {},
395 captureWidth: 1600,
396 captureHeight: 900,
397 thumbnailWidth,
398 thumbnailHeight: 360
399 })
400 const writeThumbnail = (name: string): string => {
401 const thumbnailPath = path.join(sourceRoot, name)
402 fs.writeFileSync(thumbnailPath, 'png')
403 return thumbnailPath
404 }
405
406 const freshSource = writeSource('fresh.html')
407 const freshThumbnail = writeThumbnail('fresh.png')
408 const staleSource = writeSource('stale.html')
409 const staleThumbnail = writeThumbnail('stale.png')
410 const resizedSource = writeSource('resized.html')
411 const resizedThumbnail = writeThumbnail('resized.png')
412
413 records.set('session:fresh:first-page', {
414 resourceType: 'session',
415 resourceId: 'fresh',
416 variant: 'first-page',
417 sourcePath: freshSource,
418 sourceMtimeMs: Math.floor(fs.statSync(freshSource).mtimeMs),
419 signature: makeSignature('fresh', freshSource),
420 thumbnailPath: freshThumbnail,
421 status: 'completed',
422 error: null
423 })
424 records.set('session:stale:first-page', {
425 resourceType: 'session',
426 resourceId: 'stale',
427 variant: 'first-page',
428 sourcePath: staleSource,
429 sourceMtimeMs: Math.floor(fs.statSync(staleSource).mtimeMs) - 5_000,
430 signature: makeSignature('stale', staleSource),
431 thumbnailPath: staleThumbnail,
432 status: 'completed',
433 error: null
434 })
435 records.set('session:resized:first-page', {
436 resourceType: 'session',
437 resourceId: 'resized',
438 variant: 'first-page',
439 sourcePath: resizedSource,
440 sourceMtimeMs: Math.floor(fs.statSync(resizedSource).mtimeMs),
441 signature: makeSignature('resized', resizedSource, 320),
442 thumbnailPath: resizedThumbnail,
443 status: 'completed',
444 error: null
445 })
446
447 const newerTime = new Date(Date.now() + 2_000)
448 fs.utimesSync(staleSource, newerTime, newerTime)
449
450 const result = await service.getFreshHtmlThumbnailPaths([
451 {
452 resourceType: 'session',
453 resourceId: 'fresh',
454 variant: 'first-page',
455 sourcePath: freshSource
456 },
457 {
458 resourceType: 'session',
459 resourceId: 'stale',
460 variant: 'first-page',
461 sourcePath: staleSource
462 },
463 {
464 resourceType: 'session',
465 resourceId: 'resized',
466 variant: 'first-page',
467 sourcePath: resizedSource
468 },
469 {
470 resourceType: 'session',
471 resourceId: 'missing-source',
472 variant: 'first-page',
473 sourcePath: path.join(sourceRoot, 'does-not-exist.html')
474 },
475 {
476 resourceType: 'session',
477 resourceId: 'no-record',
478 variant: 'first-page',
479 sourcePath: writeSource('no-record.html')
480 }
481 ])
482
483 expect(result.get('fresh')).toBe(freshThumbnail)
484 expect(result.has('stale')).toBe(false)
485 expect(result.has('resized')).toBe(false)
486 expect(result.has('missing-source')).toBe(false)
487 expect(result.has('no-record')).toBe(false)
488 expect(db.getThumbnailRecords).toHaveBeenCalledTimes(1)
489 expect(db.getThumbnailRecord).not.toHaveBeenCalled()
490
491 fs.rmSync(sourceRoot, { recursive: true, force: true })
492 })
493 })
494
494 lines TYPESCRIPT