返回 oh-my-ppt
edit-deck-batch-flow.test.ts
根目录 / tests / unit / generation / edit-deck-batch-flow.test.ts
1 import fs from 'fs'
2 import os from 'os'
3 import path from 'path'
4 import { afterEach, describe, expect, it, vi } from 'vitest'
5
6 const logMocks = vi.hoisted(() => ({
7 info: vi.fn(),
8 warn: vi.fn(),
9 error: vi.fn()
10 }))
11
12 vi.mock('electron-log/main.js', () => ({ default: logMocks }))
13
14 import {
15 BATCH_EDIT_CHUNK_SIZE,
16 DeckEditIndexMutationError,
17 buildDeckEditPageUserMessage,
18 executeDeckEditBatchFlow,
19 type DeckEditBatchPageRef
20 } from '../../../src/main/generation/edit-deck-batch-flow'
21
22 const tempDirs: string[] = []
23
24 const validPageHtml = (pageId: string, text: string): string => `<!doctype html>
25 <html>
26 <body>
27 <div class="ppt-page-root" data-ppt-guard-root="1" data-page-id="${pageId}">
28 <div class="ppt-page-content">${text}</div>
29 </div>
30 </body>
31 </html>`
32
33 const createFixture = async (
34 pageCount: number
35 ): Promise<{
36 projectDir: string
37 indexPath: string
38 pageRefs: DeckEditBatchPageRef[]
39 }> => {
40 const projectDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'deck-edit-batch-'))
41 tempDirs.push(projectDir)
42 const indexPath = path.join(projectDir, 'index.html')
43 await fs.promises.writeFile(indexPath, '<html><body>index</body></html>', 'utf-8')
44 const pageRefs: DeckEditBatchPageRef[] = []
45 for (let index = 0; index < pageCount; index += 1) {
46 const pageNumber = index + 1
47 const pageId = `page-${pageNumber}`
48 const htmlPath = path.join(projectDir, `${pageId}.html`)
49 await fs.promises.writeFile(htmlPath, validPageHtml(pageId, `before-${pageId}`), 'utf-8')
50 pageRefs.push({
51 id: `id-${pageNumber}`,
52 pageNumber,
53 title: `Page ${pageNumber}`,
54 pageId,
55 htmlPath
56 })
57 }
58 return { projectDir, indexPath, pageRefs }
59 }
60
61 afterEach(async () => {
62 vi.clearAllMocks()
63 await Promise.all(
64 tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true }))
65 )
66 })
67
68 describe('deck edit batch flow', () => {
69 it('limits active page workers with p-limit and continues through the queue', async () => {
70 const fixture = await createFixture(6)
71 let activeWorkers = 0
72 let maxActiveWorkers = 0
73 const startedPages: string[] = []
74 const releaseWorkers: Array<() => void> = []
75
76 const waitForWorkerRelease = (): Promise<void> =>
77 new Promise((resolve) => {
78 releaseWorkers.push(resolve)
79 })
80
81 const flowPromise = executeDeckEditBatchFlow({
82 pageRefs: fixture.pageRefs,
83 indexPath: fixture.indexPath,
84 originalUserMessage: '批量修改',
85 runId: 'run-p-limit',
86 appLocale: 'zh',
87 launchStaggerMs: 0,
88 emit: () => undefined,
89 validateChangedPages: () => [],
90 buildRetryMessage: () => null,
91 runPageAttempt: async ({ pageId }) => {
92 startedPages.push(pageId)
93 activeWorkers += 1
94 maxActiveWorkers = Math.max(maxActiveWorkers, activeWorkers)
95 await waitForWorkerRelease()
96 activeWorkers -= 1
97 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
98 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
99 return 'done'
100 }
101 })
102
103 await vi.waitFor(() => {
104 expect(startedPages).toHaveLength(BATCH_EDIT_CHUNK_SIZE)
105 })
106 expect(startedPages).toEqual(['page-1', 'page-2'])
107 expect(maxActiveWorkers).toBe(BATCH_EDIT_CHUNK_SIZE)
108
109 releaseWorkers.splice(0, BATCH_EDIT_CHUNK_SIZE).forEach((release) => release())
110 await vi.waitFor(() => {
111 expect(startedPages).toHaveLength(4)
112 })
113 expect(startedPages).toEqual(['page-1', 'page-2', 'page-3', 'page-4'])
114 releaseWorkers.splice(0, BATCH_EDIT_CHUNK_SIZE).forEach((release) => release())
115 await vi.waitFor(() => {
116 expect(startedPages).toHaveLength(6)
117 })
118 expect(startedPages).toEqual(['page-1', 'page-2', 'page-3', 'page-4', 'page-5', 'page-6'])
119 releaseWorkers.splice(0).forEach((release) => release())
120
121 const results = await flowPromise
122 expect(results.every((result) => result.status === 'completed')).toBe(true)
123 expect(maxActiveWorkers).toBe(BATCH_EDIT_CHUNK_SIZE)
124 })
125
126 it('builds a per-page edit message with the target page id', () => {
127 const message = buildDeckEditPageUserMessage({
128 originalUserMessage: '统一标题颜色',
129 pageId: 'page-4'
130 })
131 expect(message).toContain('统一标题颜色')
132 expect(message).toContain('Edit ONLY this page: page-4')
133 expect(message).toContain('must not write them')
134 })
135
136 it('uses only one retry for a no-change page', async () => {
137 const fixture = await createFixture(1)
138 let attempts = 0
139 const results = await executeDeckEditBatchFlow({
140 pageRefs: fixture.pageRefs,
141 indexPath: fixture.indexPath,
142 originalUserMessage: '修改第一页',
143 runId: 'run-1',
144 appLocale: 'zh',
145 launchStaggerMs: 0,
146 emit: () => undefined,
147 validateChangedPages: () => [],
148 buildRetryMessage: ({ baseMessage, kind }) =>
149 kind === 'no_change' ? `${baseMessage}\nretry` : null,
150 runPageAttempt: async ({ pageId, isRetry }) => {
151 attempts += 1
152 if (isRetry) {
153 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
154 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
155 }
156 return 'done'
157 }
158 })
159
160 expect(attempts).toBe(2)
161 expect(results).toHaveLength(1)
162 expect(results[0].status).toBe('completed')
163 })
164
165 it('reports the page failure reason before retrying and advances progress after recovery', async () => {
166 const fixture = await createFixture(1)
167 const emitted: Array<{ label?: string; detail?: string; progress?: number }> = []
168 let attempts = 0
169 const results = await executeDeckEditBatchFlow({
170 pageRefs: fixture.pageRefs,
171 indexPath: fixture.indexPath,
172 originalUserMessage: '修改第一页',
173 runId: 'run-retry-reason',
174 appLocale: 'zh',
175 launchStaggerMs: 0,
176 emit: (chunk) => {
177 if (chunk.type !== 'llm_status') return
178 emitted.push(chunk.payload)
179 },
180 validateChangedPages: () => [],
181 buildRetryMessage: ({ baseMessage, kind }) =>
182 kind === 'agent' ? `${baseMessage}\nretry` : null,
183 runPageAttempt: async ({ pageId, isRetry }) => {
184 attempts += 1
185 if (!isRetry) throw new Error('provider timed out')
186 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
187 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
188 return 'done'
189 }
190 })
191
192 expect(attempts).toBe(2)
193 expect(results[0]).toMatchObject({ status: 'completed', retryCount: 1 })
194 expect(emitted).toContainEqual(
195 expect.objectContaining({
196 label: 'P1 首次处理失败,准备重试',
197 detail: 'provider timed out'
198 })
199 )
200 expect(emitted).toContainEqual(
201 expect.objectContaining({ label: 'P1 重试成功', progress: 90 })
202 )
203 })
204
205 it('describes a page-level completion as validation instead of whole-run completion', async () => {
206 const fixture = await createFixture(1)
207 const emittedLabels: string[] = []
208 await executeDeckEditBatchFlow({
209 pageRefs: fixture.pageRefs,
210 indexPath: fixture.indexPath,
211 originalUserMessage: '修改第一页',
212 runId: 'run-page-completed-label',
213 appLocale: 'zh',
214 launchStaggerMs: 0,
215 emit: (chunk) => {
216 if ('label' in chunk.payload) emittedLabels.push(chunk.payload.label)
217 },
218 validateChangedPages: () => [],
219 buildRetryMessage: () => null,
220 runPageAttempt: async ({ pageId, emit }) => {
221 emit({
222 type: 'llm_status',
223 payload: {
224 runId: 'run-page-completed-label',
225 stage: 'editing',
226 label: '已完成',
227 progress: 100
228 }
229 })
230 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
231 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
232 return 'done'
233 }
234 })
235
236 expect(emittedLabels).toContain('P1 当前步骤完成,正在校验页面')
237 expect(emittedLabels).toContain('P1 编辑完成')
238 expect(emittedLabels).not.toContain('已完成')
239 })
240
241 it('logs a heartbeat without emitting user-visible progress while a model request is silent', async () => {
242 const fixture = await createFixture(1)
243 const emitted: Array<{ label?: string; detail?: string }> = []
244 await executeDeckEditBatchFlow({
245 pageRefs: fixture.pageRefs,
246 indexPath: fixture.indexPath,
247 originalUserMessage: '修改第一页',
248 runId: 'run-silent-model',
249 appLocale: 'zh',
250 launchStaggerMs: 0,
251 heartbeatIntervalMs: 10,
252 emit: (chunk) => {
253 if (chunk.type === 'llm_status') emitted.push(chunk.payload)
254 },
255 validateChangedPages: () => [],
256 buildRetryMessage: () => null,
257 runPageAttempt: async ({ pageId }) => {
258 await new Promise((resolve) => setTimeout(resolve, 35))
259 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
260 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
261 return 'done'
262 }
263 })
264
265 expect(emitted).not.toContainEqual(
266 expect.objectContaining({ label: 'P1 正在等待模型响应' })
267 )
268 expect(logMocks.warn).toHaveBeenCalledWith(
269 '[deck-edit:page] model response silent',
270 expect.objectContaining({
271 runId: 'run-silent-model',
272 pageId: 'page-1',
273 pageNumber: 1,
274 attempt: 1,
275 silentForMs: expect.any(Number)
276 })
277 )
278 })
279
280 it('does not start a third attempt when the retry also makes no changes', async () => {
281 const fixture = await createFixture(1)
282 let attempts = 0
283 const results = await executeDeckEditBatchFlow({
284 pageRefs: fixture.pageRefs,
285 indexPath: fixture.indexPath,
286 originalUserMessage: '修改第一页',
287 runId: 'run-no-third-attempt',
288 appLocale: 'zh',
289 launchStaggerMs: 0,
290 emit: () => undefined,
291 validateChangedPages: () => [],
292 buildRetryMessage: ({ baseMessage, kind }) =>
293 kind === 'no_change' ? `${baseMessage}\nretry` : null,
294 runPageAttempt: async () => {
295 attempts += 1
296 return 'done'
297 }
298 })
299
300 expect(attempts).toBe(2)
301 expect(results[0]).toMatchObject({ status: 'failed', retryCount: 1 })
302 })
303
304 it('restores a failed page and continues with later pages', async () => {
305 const fixture = await createFixture(7)
306 const startedPages: string[] = []
307 const emittedLabels: string[] = []
308 const results = await executeDeckEditBatchFlow({
309 pageRefs: fixture.pageRefs,
310 indexPath: fixture.indexPath,
311 originalUserMessage: '批量修改',
312 runId: 'run-2',
313 appLocale: 'zh',
314 launchStaggerMs: 0,
315 emit: (chunk) => {
316 if ('label' in chunk.payload && typeof chunk.payload.label === 'string') {
317 emittedLabels.push(chunk.payload.label)
318 }
319 },
320 validateChangedPages: () => [],
321 buildRetryMessage: () => null,
322 runPageAttempt: async ({ pageId, emit }) => {
323 startedPages.push(pageId)
324 emit({
325 type: 'llm_status',
326 payload: {
327 runId: 'run-2',
328 stage: 'editing',
329 label: `编辑 ${pageId}`,
330 progress: 50,
331 currentPage: 1,
332 totalPages: 1
333 }
334 })
335 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
336 const pageIndex = fixture.pageRefs.findIndex((ref) => ref.pageId === pageId)
337 await fs.promises.writeFile(
338 page.htmlPath,
339 validPageHtml(page.pageId, `after-page-${pageIndex}`),
340 'utf-8'
341 )
342 if (pageIndex >= 3 && pageIndex <= 5) throw new Error('provider unavailable')
343 return `page-${pageIndex}`
344 }
345 })
346
347 expect(startedPages).toEqual([
348 'page-1',
349 'page-2',
350 'page-3',
351 'page-4',
352 'page-5',
353 'page-6',
354 'page-7'
355 ])
356 expect(results.map((result) => result.status)).toEqual([
357 'completed',
358 'completed',
359 'completed',
360 'failed',
361 'failed',
362 'failed',
363 'completed'
364 ])
365 expect(emittedLabels).toContain('正在编辑 P4')
366 expect(emittedLabels).toContain('正在编辑 P7')
367 expect(await fs.promises.readFile(fixture.pageRefs[3].htmlPath, 'utf-8')).toContain(
368 'before-page-4'
369 )
370 expect(await fs.promises.readFile(fixture.pageRefs[6].htmlPath, 'utf-8')).toContain(
371 'after-page-6'
372 )
373 })
374
375 it('restores the whole operation when index.html is modified', async () => {
376 const fixture = await createFixture(4)
377 await expect(
378 executeDeckEditBatchFlow({
379 pageRefs: fixture.pageRefs,
380 indexPath: fixture.indexPath,
381 originalUserMessage: '批量修改',
382 runId: 'run-3',
383 appLocale: 'zh',
384 launchStaggerMs: 0,
385 emit: () => undefined,
386 validateChangedPages: () => [],
387 buildRetryMessage: () => null,
388 runPageAttempt: async ({ pageId }) => {
389 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
390 const pageIndex = fixture.pageRefs.findIndex((ref) => ref.pageId === pageId)
391 await fs.promises.writeFile(
392 page.htmlPath,
393 validPageHtml(page.pageId, `after-page-${pageIndex}`),
394 'utf-8'
395 )
396 if (pageIndex === 3) {
397 await fs.promises.writeFile(fixture.indexPath, '<html>changed index</html>', 'utf-8')
398 }
399 return 'done'
400 }
401 })
402 ).rejects.toBeInstanceOf(DeckEditIndexMutationError)
403
404 expect(await fs.promises.readFile(fixture.indexPath, 'utf-8')).toBe(
405 '<html><body>index</body></html>'
406 )
407 for (const page of fixture.pageRefs) {
408 expect(await fs.promises.readFile(page.htmlPath, 'utf-8')).toContain(`before-${page.pageId}`)
409 }
410 })
411
412 it('restores the whole operation when cancellation arrives after an agent attempt', async () => {
413 const fixture = await createFixture(2)
414 const controller = new AbortController()
415 const publishedPages: string[] = []
416 await expect(
417 executeDeckEditBatchFlow({
418 pageRefs: fixture.pageRefs,
419 indexPath: fixture.indexPath,
420 originalUserMessage: '批量修改',
421 runId: 'run-cancelled',
422 appLocale: 'zh',
423 launchStaggerMs: 0,
424 signal: controller.signal,
425 emit: () => undefined,
426 validateChangedPages: () => [],
427 buildRetryMessage: () => null,
428 runPageAttempt: async ({ pageId }) => {
429 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
430 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
431 controller.abort()
432 return 'done'
433 },
434 onPageCompleted: async (result) => {
435 publishedPages.push(result.pageId)
436 },
437 onPageFailed: async (result) => {
438 publishedPages.push(result.pageId)
439 }
440 })
441 ).rejects.toThrow('生成已取消')
442
443 expect(publishedPages).toEqual([])
444 for (const page of fixture.pageRefs) {
445 expect(await fs.promises.readFile(page.htmlPath, 'utf-8')).toContain(`before-${page.pageId}`)
446 }
447 })
448
449 it('stagger-launches concurrent page requests to reduce provider 429 bursts', async () => {
450 const fixture = await createFixture(2)
451 const launchTimes: number[] = []
452 const startedAt = Date.now()
453 const results = await executeDeckEditBatchFlow({
454 pageRefs: fixture.pageRefs,
455 indexPath: fixture.indexPath,
456 originalUserMessage: '批量修改',
457 runId: 'run-staggered',
458 appLocale: 'zh',
459 emit: () => undefined,
460 validateChangedPages: () => [],
461 buildRetryMessage: () => null,
462 runPageAttempt: async ({ pageId }) => {
463 launchTimes.push(Date.now() - startedAt)
464 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
465 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
466 return 'done'
467 }
468 })
469
470 expect(results.every((result) => result.status === 'completed')).toBe(true)
471 expect(launchTimes).toHaveLength(2)
472 expect(launchTimes[1] - launchTimes[0]).toBeGreaterThanOrEqual(70)
473 })
474
475 it('removes abort listeners after stagger sleeps resolve', async () => {
476 const fixture = await createFixture(3)
477 const controller = new AbortController()
478 const originalAddEventListener = controller.signal.addEventListener.bind(controller.signal)
479 const originalRemoveEventListener = controller.signal.removeEventListener.bind(
480 controller.signal
481 )
482 let abortListenerCount = 0
483
484 controller.signal.addEventListener = ((type, listener, options) => {
485 if (type === 'abort') abortListenerCount += 1
486 return originalAddEventListener(type, listener, options)
487 }) as AbortSignal['addEventListener']
488 controller.signal.removeEventListener = ((type, listener, options) => {
489 if (type === 'abort') abortListenerCount -= 1
490 return originalRemoveEventListener(type, listener, options)
491 }) as AbortSignal['removeEventListener']
492
493 const results = await executeDeckEditBatchFlow({
494 pageRefs: fixture.pageRefs,
495 indexPath: fixture.indexPath,
496 originalUserMessage: '批量修改',
497 runId: 'run-sleep-cleanup',
498 appLocale: 'zh',
499 signal: controller.signal,
500 launchStaggerMs: 1,
501 emit: () => undefined,
502 validateChangedPages: () => [],
503 buildRetryMessage: () => null,
504 runPageAttempt: async ({ pageId }) => {
505 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
506 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
507 return 'done'
508 }
509 })
510
511 expect(results.every((result) => result.status === 'completed')).toBe(true)
512 expect(abortListenerCount).toBe(0)
513 })
514
515 it('calls onPageCompleted for each completed page', async () => {
516 const fixture = await createFixture(2)
517 const completedPages: string[] = []
518 const failedPages: string[] = []
519
520 const results = await executeDeckEditBatchFlow({
521 pageRefs: fixture.pageRefs,
522 indexPath: fixture.indexPath,
523 originalUserMessage: '批量修改',
524 runId: 'run-callbacks',
525 appLocale: 'zh',
526 launchStaggerMs: 0,
527 emit: () => undefined,
528 validateChangedPages: () => [],
529 buildRetryMessage: () => null,
530 runPageAttempt: async ({ pageId }) => {
531 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
532 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
533 return 'done'
534 },
535 onPageCompleted: async (result) => {
536 completedPages.push(result.pageId)
537 },
538 onPageFailed: async (result) => {
539 failedPages.push(result.pageId)
540 }
541 })
542
543 expect(results.every((r) => r.status === 'completed')).toBe(true)
544 expect(completedPages.sort()).toEqual(['page-1', 'page-2'])
545 expect(failedPages).toEqual([])
546 })
547
548 it('calls onPageFailed for each failed page', async () => {
549 const fixture = await createFixture(2)
550 const completedPages: string[] = []
551 const failedPages: string[] = []
552
553 const results = await executeDeckEditBatchFlow({
554 pageRefs: fixture.pageRefs,
555 indexPath: fixture.indexPath,
556 originalUserMessage: '批量修改',
557 runId: 'run-callbacks-fail',
558 appLocale: 'zh',
559 launchStaggerMs: 0,
560 emit: () => undefined,
561 validateChangedPages: () => [],
562 buildRetryMessage: () => null,
563 runPageAttempt: async () => {
564 throw new Error('always fails')
565 },
566 onPageCompleted: async (result) => {
567 completedPages.push(result.pageId)
568 },
569 onPageFailed: async (result) => {
570 failedPages.push(result.pageId)
571 }
572 })
573
574 expect(results.every((r) => r.status === 'failed')).toBe(true)
575 expect(completedPages).toEqual([])
576 expect(failedPages.sort()).toEqual(['page-1', 'page-2'])
577 })
578
579 it('calls both callbacks in mixed success/failure scenarios', async () => {
580 const fixture = await createFixture(3)
581 const completedPages: string[] = []
582 const failedPages: string[] = []
583
584 const results = await executeDeckEditBatchFlow({
585 pageRefs: fixture.pageRefs,
586 indexPath: fixture.indexPath,
587 originalUserMessage: '批量修改',
588 runId: 'run-callbacks-mixed',
589 appLocale: 'zh',
590 launchStaggerMs: 0,
591 emit: () => undefined,
592 validateChangedPages: () => [],
593 buildRetryMessage: () => null,
594 runPageAttempt: async ({ pageId }) => {
595 const pageIndex = fixture.pageRefs.findIndex((item) => item.pageId === pageId)
596 if (pageIndex === 1) throw new Error('page 2 fails')
597 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
598 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
599 return 'done'
600 },
601 onPageCompleted: async (result) => {
602 completedPages.push(result.pageId)
603 },
604 onPageFailed: async (result) => {
605 failedPages.push(result.pageId)
606 }
607 })
608
609 expect(results.filter((r) => r.status === 'completed')).toHaveLength(2)
610 expect(results.filter((r) => r.status === 'failed')).toHaveLength(1)
611 expect(completedPages.sort()).toEqual(['page-1', 'page-3'])
612 expect(failedPages).toEqual(['page-2'])
613 })
614
615 it('does not retry the model request when onPageCompleted persistence fails', async () => {
616 const fixture = await createFixture(1)
617 let attemptCount = 0
618 let completedCallbackCount = 0
619
620 await expect(
621 executeDeckEditBatchFlow({
622 pageRefs: fixture.pageRefs,
623 indexPath: fixture.indexPath,
624 originalUserMessage: '批量修改',
625 runId: 'run-completed-callback-fails',
626 appLocale: 'zh',
627 launchStaggerMs: 0,
628 emit: () => undefined,
629 validateChangedPages: () => [],
630 buildRetryMessage: () => 'retry model request',
631 runPageAttempt: async ({ pageId }) => {
632 attemptCount += 1
633 const page = fixture.pageRefs.find((item) => item.pageId === pageId)!
634 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
635 return 'done'
636 },
637 onPageCompleted: async () => {
638 completedCallbackCount += 1
639 throw new Error('persist failed')
640 }
641 })
642 ).rejects.toThrow('persist failed')
643
644 expect(attemptCount).toBe(1)
645 expect(completedCallbackCount).toBe(1)
646 expect(await fs.promises.readFile(fixture.pageRefs[0].htmlPath, 'utf-8')).toContain('after')
647 })
648
649 it('does not publish page callbacks when a later worker triggers global rollback', async () => {
650 const fixture = await createFixture(4)
651 const completedPages: string[] = []
652 const failedPages: string[] = []
653
654 await expect(
655 executeDeckEditBatchFlow({
656 pageRefs: fixture.pageRefs,
657 indexPath: fixture.indexPath,
658 originalUserMessage: '批量修改',
659 runId: 'run-two-phase-rollback',
660 appLocale: 'zh',
661 launchStaggerMs: 0,
662 emit: () => undefined,
663 validateChangedPages: () => [],
664 buildRetryMessage: () => null,
665 runPageAttempt: async ({ pageId }) => {
666 const pageIndex = fixture.pageRefs.findIndex((item) => item.pageId === pageId)
667 const page = fixture.pageRefs[pageIndex]
668 await fs.promises.writeFile(page.htmlPath, validPageHtml(page.pageId, 'after'), 'utf-8')
669 if (pageIndex === 3) {
670 await fs.promises.writeFile(fixture.indexPath, '<html>changed index</html>', 'utf-8')
671 }
672 return 'done'
673 },
674 onPageCompleted: async (result) => {
675 completedPages.push(result.pageId)
676 },
677 onPageFailed: async (result) => {
678 failedPages.push(result.pageId)
679 }
680 })
681 ).rejects.toBeInstanceOf(DeckEditIndexMutationError)
682
683 expect(completedPages).toEqual([])
684 expect(failedPages).toEqual([])
685 for (const page of fixture.pageRefs) {
686 expect(await fs.promises.readFile(page.htmlPath, 'utf-8')).toContain(`before-${page.pageId}`)
687 }
688 })
689 })
690
690 lines TYPESCRIPT