返回 oh-my-ppt
page-beautify-job-guard.test.ts
根目录 / tests / unit / edit-jobs / page-beautify-job-guard.test.ts
1 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2 import os from 'node:os'
3 import path from 'node:path'
4 import { afterEach, describe, expect, it, vi } from 'vitest'
5
6 const {
7 ensureHistoryBaselineSafeMock,
8 recordHistoryOperationStrictMock,
9 resolveGlobalModelTimeoutsMock,
10 resolveModelConfigForTaskMock,
11 resolvePageHtmlPathMock,
12 runPageBeautifyAgentMock,
13 replacePageContentFragmentMock
14 } = vi.hoisted(() => ({
15 ensureHistoryBaselineSafeMock: vi.fn(),
16 recordHistoryOperationStrictMock: vi.fn(),
17 resolveGlobalModelTimeoutsMock: vi.fn(),
18 resolveModelConfigForTaskMock: vi.fn(),
19 resolvePageHtmlPathMock: vi.fn(),
20 runPageBeautifyAgentMock: vi.fn(),
21 replacePageContentFragmentMock: vi.fn()
22 }))
23
24 vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }))
25 vi.mock('electron-log/main.js', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } }))
26 vi.mock('../../../src/main/edit-jobs/page-beautify-agent', () => ({
27 runPageBeautifyAgent: runPageBeautifyAgentMock
28 }))
29 vi.mock('../../../src/main/config/model-config-utils', () => ({
30 resolveGlobalModelTimeouts: resolveGlobalModelTimeoutsMock,
31 resolveModelConfigForTask: resolveModelConfigForTaskMock
32 }))
33 vi.mock('../../../src/main/generation/generation-utils', () => ({
34 resolvePageHtmlPath: resolvePageHtmlPathMock
35 }))
36 vi.mock('../../../src/main/history/git-history-service', () => ({
37 ensureHistoryBaselineSafe: ensureHistoryBaselineSafeMock,
38 recordHistoryOperationStrict: recordHistoryOperationStrictMock
39 }))
40 vi.mock('../../../src/main/presentation/html/page-writer-core', () => ({
41 replacePageContentFragment: replacePageContentFragmentMock
42 }))
43
44 import {
45 extractPageBeautifyContent,
46 hasMeaningfulPageBeautifyChange,
47 PageBeautifyJobService
48 } from '../../../src/main/edit-jobs/page-beautify-job-service'
49 import { JobCoordinator, sessionLockKey } from '../../../src/main/agent-runtime'
50
51 describe('page beautify layout review', () => {
52 it('requires a re-layout instead of accepting text, animation, and data-attribute churn', () => {
53 const original = `
54 <section data-page-scaffold="1"><main data-role="content">
55 <!-- original note --><div class="grid grid-cols-3 gap-4" data-block-id="content">
56 <p data-block-id="summary">完整说明文字</p>
57 </div>
58 </main></section>
59 `
60 const superficial = `
61 <section data-page-scaffold="1"><main data-role="content">
62 <!-- rewritten note --><div class="grid grid-cols-3 gap-4" data-anim="fade-up">
63 <p>修正后的说明文字</p>
64 </div>
65 </main></section>
66 `
67 const reflowed = `
68 <section data-page-scaffold="1"><main data-role="content">
69 <div class="grid grid-cols-2 gap-6"><p>摘要说明</p></div>
70 </main></section>
71 `
72
73 expect(hasMeaningfulPageBeautifyChange(original, superficial)).toBe(false)
74 expect(hasMeaningfulPageBeautifyChange(original, reflowed)).toBe(true)
75 })
76 })
77
78 describe('PageBeautifyJobService guards', () => {
79 const roots: string[] = []
80
81 afterEach(async () => {
82 ensureHistoryBaselineSafeMock.mockReset()
83 recordHistoryOperationStrictMock.mockReset()
84 resolveGlobalModelTimeoutsMock.mockReset()
85 resolveModelConfigForTaskMock.mockReset()
86 resolvePageHtmlPathMock.mockReset()
87 runPageBeautifyAgentMock.mockReset()
88 replacePageContentFragmentMock.mockReset()
89 for (const root of roots.splice(0)) {
90 await rm(root, { recursive: true, force: true })
91 }
92 })
93
94 it('does not start when another session write Job holds the lease', async () => {
95 const sessionId = 'session-lease'
96 const ctx = { sessionRunStates: new Map() }
97 const coordinator = new JobCoordinator()
98 await coordinator.reserve({
99 jobId: 'deck-edit-run',
100 domain: 'edit',
101 owner: { kind: 'session', id: sessionId },
102 claims: { write: [sessionLockKey(sessionId)] },
103 wait: 'fail'
104 })
105 const service = new PageBeautifyJobService(ctx as never, coordinator)
106
107 await expect(
108 service.start({} as Electron.IpcMainInvokeEvent, {
109 sessionId,
110 selectedPageId: 'page-1'
111 })
112 ).resolves.toMatchObject({ success: true, alreadyRunning: true })
113 expect(resolveModelConfigForTaskMock).not.toHaveBeenCalled()
114 })
115
116 it('does not create a Job when the target page disappears', async () => {
117 const sessionId = 'session-missing-page'
118 const updateSessionStatus = vi.fn(async () => undefined)
119 resolveModelConfigForTaskMock.mockResolvedValueOnce({
120 id: 'model-1',
121 name: 'Model',
122 provider: 'provider',
123 model: 'model',
124 apiKey: 'key',
125 baseUrl: 'https://example.com',
126 maxTokens: 1000
127 })
128 resolveGlobalModelTimeoutsMock.mockResolvedValueOnce({ agent: 1000 })
129 const ctx = {
130 sessionRunStates: new Map(),
131 db: {
132 getSession: vi.fn(async () => ({ status: 'completed' })),
133 getProject: vi.fn(async () => ({ id: 'project-1' })),
134 listSessionPages: vi.fn(async () => []),
135 getOrCreateSessionStyleSnapshot: vi.fn(async () => ({
136 styleId: 'style-1',
137 styleKey: 'style',
138 styleName: 'Style',
139 styleSkill: 'style prompt',
140 version: '1'
141 })),
142 getAllSettings: vi.fn(async () => ({ locale: 'zh' })),
143 updateSessionStatus
144 }
145 }
146 const service = new PageBeautifyJobService(
147 ctx as never,
148 new JobCoordinator()
149 )
150
151 await expect(
152 service.start({} as Electron.IpcMainInvokeEvent, {
153 sessionId,
154 selectedPageId: 'page-1'
155 })
156 ).rejects.toThrow('一键美化的目标页面不存在')
157
158 expect(updateSessionStatus).not.toHaveBeenCalled()
159 expect(runPageBeautifyAgentMock).not.toHaveBeenCalled()
160 })
161
162 it('exposes only the persisted page content to the beautify Agent', () => {
163 const html = `<!doctype html><html><head><title>Injected shell</title></head><body>
164 <main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope">
165 <div class="ppt-page-content"><section data-page-scaffold="1"><main data-role="content"><h1>Current content</h1></main></section></div>
166 </div></main><script id="ppt-page-fit">runtime</script></body></html>`
167
168 const fragment = extractPageBeautifyContent(html)
169
170 expect(fragment).toContain('Current content')
171 expect(fragment).toContain('data-page-scaffold')
172 expect(fragment).not.toContain('ppt-page-root')
173 expect(fragment).not.toContain('ppt-page-fit-scope')
174 expect(fragment).not.toContain('ppt-page-fit')
175 })
176
177 it('limits the history commit to the current page file', async () => {
178 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-page-beautify-history-'))
179 roots.push(root)
180 const targetPagePath = path.join(root, 'page-1.html')
181 const originalHtml = `<!doctype html><html><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"><div class="ppt-page-content"><h1>Target</h1></div></div></main></body></html>`
182 const updatedHtml = `<!doctype html><html><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"><div class="ppt-page-content"><h1>Target</h1><div class="grid"></div></div></div></main></body></html>`
183 await writeFile(targetPagePath, originalHtml, 'utf-8')
184 ensureHistoryBaselineSafeMock.mockResolvedValueOnce(undefined)
185 runPageBeautifyAgentMock.mockResolvedValueOnce('<h1>Target</h1><div class="grid"></div>')
186 replacePageContentFragmentMock.mockReturnValueOnce({
187 html: updatedHtml,
188 content: '<h1>Target</h1><div class="grid"></div>',
189 repaired: false
190 })
191 recordHistoryOperationStrictMock.mockResolvedValueOnce(undefined)
192 const sessionId = 'session-history-scope'
193 const ctx = {
194 sessionRunStates: new Map(),
195 db: {
196 upsertGenerationPage: vi.fn(async () => undefined),
197 upsertSessionPage: vi.fn(async () => undefined),
198 updateGenerationRunStatus: vi.fn(async () => undefined),
199 updateProjectStatus: vi.fn(async () => undefined),
200 updateSessionJobStatus: vi.fn(async () => undefined),
201 updateSessionMetadata: vi.fn(async () => undefined),
202 updateSessionStatus: vi.fn(async () => undefined)
203 },
204 createDeckProgressEmitter: vi.fn(() => vi.fn()),
205 emitGenerateChunk: vi.fn(),
206 emitRuntimeJobTerminal: vi.fn(),
207 getPageSourceUrl: vi.fn(() => 'file://page-1.html')
208 }
209 const coordinator = new JobCoordinator()
210 const reservation = await coordinator.reserve({
211 jobId: 'history-scope-run',
212 domain: 'edit',
213 owner: { kind: 'session', id: sessionId },
214 claims: { write: [sessionLockKey(sessionId)] },
215 wait: 'fail'
216 })
217 if (reservation.status !== 'acquired') throw new Error('Expected a new page-beautify lease')
218 const service = new PageBeautifyJobService(ctx as never, coordinator)
219 const context = {
220 sessionId,
221 runId: 'history-scope-run',
222 previousSessionStatus: 'active',
223 appLocale: 'zh',
224 apiKey: 'key',
225 model: 'model',
226 modelTimeouts: { agent: 1000 },
227 provider: 'provider',
228 providerBaseUrl: 'https://example.com',
229 maxTokens: 1000,
230 styleId: 'style-1',
231 styleKey: 'style',
232 styleName: 'Style',
233 styleVersion: '1',
234 styleSkillPrompt: 'style prompt',
235 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
236 projectDir: root,
237 projectId: 'project-1',
238 userMessage: '一键美化第 1 页',
239 target: {
240 id: 'page-record-1',
241 legacyPageId: 'page-1',
242 pageId: 'page-1',
243 pageNumber: 1,
244 title: 'Target',
245 htmlPath: targetPagePath
246 }
247 }
248
249 await (service as any).run({
250 sessionId,
251 runId: 'history-scope-run',
252 lease: reservation.lease,
253 context,
254 targetPageId: 'page-1',
255 targetPageNumber: 1,
256 targetPagePath
257 })
258
259 expect(recordHistoryOperationStrictMock).toHaveBeenCalledWith(
260 expect.anything(),
261 expect.objectContaining({ allowedPaths: ['page-1.html'] })
262 )
263 })
264
265 it('treats an unchanged agent fragment as completed without writing history or touching disk', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-page-beautify-unchanged-'))
266 roots.push(root)
267 const targetPagePath = path.join(root, 'page-1.html')
268 const originalHtml = `<!doctype html><html><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"><div class="ppt-page-content"><h1>Target</h1></div></div></main></body></html>`
269 await writeFile(targetPagePath, originalHtml, 'utf-8')
270 const originalFragment = extractPageBeautifyContent(originalHtml)
271 ensureHistoryBaselineSafeMock.mockResolvedValueOnce(undefined)
272 // Agent returns the same fragment it was given.
273 runPageBeautifyAgentMock.mockResolvedValueOnce(originalFragment)
274 replacePageContentFragmentMock.mockReturnValueOnce({
275 html: originalHtml,
276 content: originalFragment,
277 repaired: false
278 })
279 const updateGenerationRunStatus = vi.fn(async () => undefined)
280 const updateGenerationRunMetadata = vi.fn(async () => undefined)
281 const updateSessionStatus = vi.fn(async () => undefined)
282 const updateSessionJobStatus = vi.fn(async () => undefined)
283 const emitRuntimeJobTerminal = vi.fn()
284 const emit = vi.fn()
285 const sessionId = 'session-unchanged'
286 const ctx = {
287 sessionRunStates: new Map(),
288 db: {
289 updateGenerationRunStatus,
290 updateGenerationRunMetadata,
291 updateSessionStatus,
292 updateSessionJobStatus
293 },
294 createDeckProgressEmitter: vi.fn(() => emit),
295 emitGenerateChunk: vi.fn(),
296 emitRuntimeJobTerminal
297 }
298 const coordinator = new JobCoordinator()
299 const reservation = await coordinator.reserve({
300 jobId: 'unchanged-run',
301 domain: 'edit',
302 owner: { kind: 'session', id: sessionId },
303 claims: { write: [sessionLockKey(sessionId)] },
304 wait: 'fail'
305 })
306 if (reservation.status !== 'acquired') throw new Error('Expected a new page-beautify lease')
307 const service = new PageBeautifyJobService(ctx as never, coordinator)
308 const context = {
309 sessionId,
310 runId: 'unchanged-run',
311 previousSessionStatus: 'completed',
312 appLocale: 'zh',
313 apiKey: 'key',
314 model: 'model',
315 modelTimeouts: { agent: 1000 },
316 provider: 'provider',
317 providerBaseUrl: 'https://example.com',
318 maxTokens: 1000,
319 styleId: 'style-1',
320 styleKey: 'style',
321 styleName: 'Style',
322 styleVersion: '1',
323 styleSkillPrompt: 'style prompt',
324 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
325 projectDir: root,
326 projectId: 'project-1',
327 userMessage: '一键美化第 1 页',
328 target: {
329 id: 'page-record-1',
330 legacyPageId: 'page-1',
331 pageId: 'page-1',
332 pageNumber: 1,
333 title: 'Target',
334 htmlPath: targetPagePath
335 }
336 }
337
338 await (service as any).run({
339 sessionId,
340 runId: 'unchanged-run',
341 lease: reservation.lease,
342 context,
343 targetPageId: 'page-1',
344 targetPageNumber: 1,
345 targetPagePath
346 })
347
348 // Run is completed with outcome='unchanged' metadata.
349 expect(updateGenerationRunStatus).toHaveBeenCalledWith('unchanged-run', 'completed', null)
350 expect(updateGenerationRunMetadata).toHaveBeenCalledWith('unchanged-run', {
351 outcome: 'unchanged'
352 })
353 expect(updateSessionJobStatus).toHaveBeenCalledWith('unchanged-run', 'finished')
354 expect(emitRuntimeJobTerminal).toHaveBeenCalledWith({
355 sessionId,
356 jobId: 'unchanged-run',
357 domain: 'edit',
358 status: 'completed'
359 })
360 expect(updateSessionJobStatus.mock.invocationCallOrder[0]).toBeLessThan(
361 emitRuntimeJobTerminal.mock.invocationCallOrder[0]
362 )
363 // No history commit, no rollback, no fake page_updated.
364 expect(recordHistoryOperationStrictMock).not.toHaveBeenCalled()
365 const pageUpdatedCall = emit.mock.calls.find((call) => call[0]?.type === 'page_updated')
366 expect(pageUpdatedCall).toBeUndefined()
367 const runCompletedCall = emit.mock.calls.find((call) => call[0]?.type === 'run_completed')
368 expect(runCompletedCall?.[0]?.payload).toMatchObject({ outcome: 'unchanged' })
369 // File on disk is unchanged.
370 await expect(readFile(targetPagePath, 'utf-8')).resolves.toBe(originalHtml)
371 // No .beautify-tmp leftover.
372 await expect(
373 readFile(`${targetPagePath}.beautify-tmp`, 'utf-8').catch(() => 'no-tmp')
374 ).resolves.toBe('no-tmp')
375 })
376
377 it('emits a granular, monotonic progress sequence across every post-agent milestone', async () => {
378 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-page-beautify-progress-'))
379 roots.push(root)
380 const targetPagePath = path.join(root, 'page-1.html')
381 const originalHtml = `<!doctype html><html><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"><div class="ppt-page-content"><h1>Target</h1></div></div></main></body></html>`
382 const updatedHtml = `<!doctype html><html><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"><div class="ppt-page-content"><h1>Target</h1><div class="grid"></div></div></div></main></body></html>`
383 await writeFile(targetPagePath, originalHtml, 'utf-8')
384 ensureHistoryBaselineSafeMock.mockResolvedValueOnce(undefined)
385 runPageBeautifyAgentMock.mockImplementationOnce(async ({ onProgress }) => {
386 onProgress?.(0.3)
387 onProgress?.(0.6)
388 onProgress?.(0.82)
389 return '<h1>Target</h1><div class="grid"></div>'
390 })
391 replacePageContentFragmentMock.mockReturnValueOnce({
392 html: updatedHtml,
393 content: '<h1>Target</h1><div class="grid"></div>',
394 repaired: false
395 })
396 recordHistoryOperationStrictMock.mockResolvedValueOnce(undefined)
397 const emit = vi.fn()
398 const sessionId = 'session-progress'
399 const ctx = {
400 sessionRunStates: new Map(),
401 db: {
402 upsertGenerationPage: vi.fn(async () => undefined),
403 upsertSessionPage: vi.fn(async () => undefined),
404 updateGenerationRunStatus: vi.fn(async () => undefined),
405 updateProjectStatus: vi.fn(async () => undefined),
406 updateSessionJobStatus: vi.fn(async () => undefined),
407 updateSessionMetadata: vi.fn(async () => undefined),
408 updateSessionStatus: vi.fn(async () => undefined)
409 },
410 createDeckProgressEmitter: vi.fn(() => emit),
411 emitGenerateChunk: vi.fn(),
412 emitRuntimeJobTerminal: vi.fn(),
413 getPageSourceUrl: vi.fn(() => 'file://page-1.html')
414 }
415 const coordinator = new JobCoordinator()
416 const reservation = await coordinator.reserve({
417 jobId: 'progress-run',
418 domain: 'edit',
419 owner: { kind: 'session', id: sessionId },
420 claims: { write: [sessionLockKey(sessionId)] },
421 wait: 'fail'
422 })
423 if (reservation.status !== 'acquired') throw new Error('Expected a new page-beautify lease')
424 const service = new PageBeautifyJobService(ctx as never, coordinator)
425 const context = {
426 sessionId,
427 runId: 'progress-run',
428 previousSessionStatus: 'active',
429 appLocale: 'zh',
430 apiKey: 'key',
431 model: 'model',
432 modelTimeouts: { agent: 1000 },
433 provider: 'provider',
434 providerBaseUrl: 'https://example.com',
435 maxTokens: 1000,
436 styleId: 'style-1',
437 styleKey: 'style',
438 styleName: 'Style',
439 styleVersion: '1',
440 styleSkillPrompt: 'style prompt',
441 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
442 projectDir: root,
443 projectId: 'project-1',
444 userMessage: '一键美化第 1 页',
445 target: {
446 id: 'page-record-1',
447 legacyPageId: 'page-1',
448 pageId: 'page-1',
449 pageNumber: 1,
450 title: 'Target',
451 htmlPath: targetPagePath
452 }
453 }
454
455 await (service as any).run({
456 sessionId,
457 runId: 'progress-run',
458 lease: reservation.lease,
459 context,
460 targetPageId: 'page-1',
461 targetPageNumber: 1,
462 targetPagePath
463 })
464
465 const progresses = emit.mock.calls
466 .map((call) => call[0]?.payload?.progress)
467 .filter((value): value is number => typeof value === 'number')
468 // The post-agent milestones must each be announced — not just 10 → 100.
469 for (const expected of [5, 12, 20, 83, 87, 91, 95, 100]) {
470 expect(progresses).toContain(expected)
471 }
472 // Strictly monotonic: the bar never moves backwards.
473 for (let i = 1; i < progresses.length; i += 1) {
474 expect(progresses[i]).toBeGreaterThan(progresses[i - 1])
475 }
476 })
477
478 it('hydrates the unchanged outcome from generation_runs metadata after restart', async () => {
479 const sessionId = 'session-hydrate'
480 const ctx = {
481 sessionRunStates: new Map(),
482 db: {
483 getLatestSessionJob: vi.fn(async () => ({
484 id: 'hydrate-run',
485 session_id: sessionId,
486 status: 'finished',
487 target_page_id: 'page-1',
488 target_page_number: 1,
489 previous_session_status: 'completed',
490 abort_reason: null,
491 activated_at: 1000,
492 created_at: 1000,
493 updated_at: 2000
494 })),
495 getGenerationRun: vi.fn(async () => ({
496 id: 'hydrate-run',
497 status: 'completed',
498 error: null,
499 metadata: JSON.stringify({ outcome: 'unchanged' })
500 }))
501 }
502 }
503 const service = new PageBeautifyJobService(ctx as never, new JobCoordinator())
504
505 const state = await service.getState(sessionId)
506
507 expect(state).toMatchObject({
508 status: 'completed',
509 outcome: 'unchanged',
510 targetPageId: 'page-1'
511 })
512 })
513
514 it('defaults to outcome="changed" when a completed run has no outcome metadata', async () => {
515 const sessionId = 'session-hydrate-changed'
516 const ctx = {
517 sessionRunStates: new Map(),
518 db: {
519 getLatestSessionJob: vi.fn(async () => ({
520 id: 'changed-run',
521 session_id: sessionId,
522 status: 'finished',
523 target_page_id: 'page-2',
524 target_page_number: 2,
525 previous_session_status: 'completed',
526 abort_reason: null,
527 activated_at: 1000,
528 created_at: 1000,
529 updated_at: 2000
530 })),
531 getGenerationRun: vi.fn(async () => ({
532 id: 'changed-run',
533 status: 'completed',
534 error: null,
535 metadata: null
536 }))
537 }
538 }
539 const service = new PageBeautifyJobService(ctx as never, new JobCoordinator())
540
541 const state = await service.getState(sessionId)
542
543 expect(state.outcome).toBe('changed')
544 })
545 })
546
546 lines TYPESCRIPT