返回 oh-my-ppt
page-beautify-agent.test.ts
根目录 / tests / unit / edit-jobs / page-beautify-agent.test.ts
1 import { mkdtemp, 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 { createDeepAgentMock, resolveModelMock, attachProductSkillsBackendMock } = vi.hoisted(() => ({
7 createDeepAgentMock: vi.fn(),
8 resolveModelMock: vi.fn(() => ({ provider: 'test' })),
9 attachProductSkillsBackendMock: vi.fn(() => ({
10 backend: { __isMockBackend: true },
11 middleware: [],
12 skillSource: '/.ohmyppt-skills/page-beautify/',
13 enabled: true
14 }))
15 }))
16
17 vi.mock('deepagents', () => ({
18 createDeepAgent: createDeepAgentMock,
19 FilesystemBackend: class {
20 constructor(public options: unknown) {}
21 }
22 }))
23 vi.mock('../../../src/main/agent-runtime/model', () => ({ resolveModel: resolveModelMock }))
24 vi.mock('../../../src/main/agent-runtime/skills', () => ({
25 attachProductSkillsBackend: attachProductSkillsBackendMock
26 }))
27
28 import { runPageBeautifyAgent } from '../../../src/main/edit-jobs/page-beautify-agent'
29
30 describe('page beautify Agent', () => {
31 const tmpRoots: string[] = []
32
33 afterEach(async () => {
34 createDeepAgentMock.mockReset()
35 resolveModelMock.mockReset()
36 attachProductSkillsBackendMock.mockReset()
37 attachProductSkillsBackendMock.mockReturnValue({
38 backend: { __isMockBackend: true },
39 middleware: [],
40 skillSource: '/.ohmyppt-skills/page-beautify/',
41 enabled: true
42 })
43 for (const root of tmpRoots.splice(0)) {
44 await rm(root, { recursive: true, force: true })
45 }
46 })
47
48 it('reads the full page HTML from disk, exposes only read_page_html + save_current_page_content, and returns the submitted fragment', async () => {
49 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-beautify-agent-'))
50 tmpRoots.push(root)
51 const targetHtmlPath = path.join(root, 'page-1.html')
52 const fullPageHtml =
53 '<!doctype html><html><head><link rel="stylesheet" href="./assets/inter.css"></head><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-content"><h1>Current</h1></div></main></body></html>'
54 await writeFile(targetHtmlPath, fullPageHtml, 'utf-8')
55
56 let agentOptions: { tools: Array<{ name: string; invoke: (input: unknown) => Promise<unknown> }> } | null =
57 null
58 let readResult: unknown = undefined
59 let taskMessage = ''
60 createDeepAgentMock.mockImplementation((options) => {
61 agentOptions = options
62 return {
63 stream: async (input: { messages?: Array<{ content?: unknown }> }) => {
64 taskMessage = String(input?.messages?.[0]?.content || '')
65 const readTool = options.tools.find((candidate: { name: string }) => candidate.name === 'read_page_html')
66 const saveTool = options.tools.find((candidate: { name: string }) => candidate.name === 'save_current_page_content')
67 readResult = await readTool.invoke({})
68 // Second read must be a no-op hint, not the full HTML again.
69 const secondRead = await readTool.invoke({})
70 expect(typeof secondRead).toBe('string')
71 expect(secondRead as string).toMatch(/already read the full page HTML/)
72
73 return (async function* () {
74 yield ['page-beautify', 'updates', { model: {} }]
75 yield ['page-beautify', 'updates', { model: {} }]
76 await saveTool.invoke({ content: '<section class="grid"><h1>Current</h1></section>' })
77 yield ['page-beautify', 'updates', { model: {} }]
78 })()
79 }
80 }
81 })
82
83 const onProgress = vi.fn()
84 const content = await runPageBeautifyAgent({
85 provider: 'provider',
86 apiKey: 'key',
87 model: 'model',
88 baseUrl: 'https://example.com',
89 maxTokens: 1000,
90 modelTimeoutMs: { agent: 1000 },
91 signal: new AbortController().signal,
92 styleKey: 'editorial',
93 styleName: 'Editorial',
94 styleSkillPrompt: 'Use an editorial hierarchy.',
95 styleCase: '',
96 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
97 layoutSkillName: 'oh-my-ppt-layout',
98 layoutAudit: 'Canvas: 1600px x 900px.\nMeasured defects:\n- [text-overflow] <p>: text needs 86px more width',
99 targetPageId: 'page-1',
100 targetPageNumber: 1,
101 targetHtmlPath,
102 onProgress
103 })
104
105 expect(content).toBe('<section class="grid"><h1>Current</h1></section>')
106 // The agent gets the exact fixed render bounds before the COMPLETE persisted
107 // HTML (head + fonts + body + scripts), so a long document cannot bury the
108 // fact that overflow is clipped by the host canvas.
109 expect(readResult).toContain('fixed 16:9 canvas: 1600px wide x 900px high')
110 expect(readResult).toContain('x=0..1599, y=0..899')
111 expect(readResult).toContain(fullPageHtml)
112 expect(agentOptions?.tools.map((tool) => tool.name)).toEqual([
113 'read_page_html',
114 'save_current_page_content'
115 ])
116 // The layout skill for this slide size is attached read-only via the same
117 // product-skills backend used by the deck/edit pipelines, so the model can
118 // read_file SKILL.md and references on demand instead of being handed a
119 // pre-stuffed prompt.
120 expect(attachProductSkillsBackendMock).toHaveBeenCalledTimes(1)
121 expect(resolveModelMock).toHaveBeenCalledWith(
122 'provider',
123 'key',
124 'model',
125 'https://example.com',
126 0.5,
127 1000,
128 undefined
129 )
130 const skillCall = attachProductSkillsBackendMock.mock.calls[0]
131 expect(skillCall[1]).toBe('page-beautify')
132 expect(skillCall[2]).toEqual(['oh-my-ppt-layout'])
133 // The prompt tells the model to read the layout skill before re-layouting.
134 expect(agentOptions?.systemPrompt).toMatch(/oh-my-ppt-layout/)
135 expect(agentOptions?.systemPrompt).toMatch(/read_file/)
136 expect(agentOptions?.systemPrompt).toContain('creative version upgrade within the selected style')
137 expect(agentOptions?.systemPrompt).toContain('text needs 86px more width')
138 expect(agentOptions?.systemPrompt).toContain('fixed 16:9 canvas: 1600px wide x 900px high')
139 expect(agentOptions?.systemPrompt).toContain('overflow:hidden')
140 expect(taskMessage).toContain('Produce a visibly new creative version')
141 expect(taskMessage).toContain('within its established style')
142 expect(taskMessage).toContain('audit the finished composition')
143 expect(taskMessage).toContain('browser-measured layout audit')
144 expect(taskMessage).toContain('This is not proofreading')
145 const ratios = onProgress.mock.calls.map((call) => call[0])
146 expect(ratios.length).toBeGreaterThanOrEqual(2)
147 for (let i = 1; i < ratios.length; i += 1) {
148 expect(ratios[i]).toBeGreaterThanOrEqual(ratios[i - 1])
149 }
150 expect(ratios[0]).toBeGreaterThanOrEqual(0.25)
151 expect(Math.max(...ratios)).toBeCloseTo(0.82, 2)
152 })
153
154 it('classifies a stream timeout as a retryable timeout error, not a generic failure', async () => {
155 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-beautify-agent-timeout-'))
156 tmpRoots.push(root)
157 const targetHtmlPath = path.join(root, 'page-1.html')
158 await writeFile(targetHtmlPath, '<!doctype html><html></html>', 'utf-8')
159
160 createDeepAgentMock.mockReturnValue({
161 stream: async () => {
162 return (async function* () {
163 yield ['page-beautify', 'updates', { model: {} }]
164 const timeoutError = new Error('The operation was aborted due to timeout')
165 timeoutError.name = 'TimeoutError'
166 throw timeoutError
167 })()
168 }
169 })
170
171 await expect(
172 runPageBeautifyAgent({
173 provider: 'provider',
174 apiKey: 'key',
175 model: 'model',
176 baseUrl: 'https://example.com',
177 maxTokens: 1000,
178 modelTimeoutMs: { agent: 5000 },
179 signal: new AbortController().signal,
180 styleKey: 'editorial',
181 styleName: 'Editorial',
182 styleSkillPrompt: 'Use an editorial hierarchy.',
183 styleCase: '',
184 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
185 layoutSkillName: 'oh-my-ppt-layout',
186 targetPageId: 'page-1',
187 targetPageNumber: 1,
188 targetHtmlPath
189 })
190 ).rejects.toThrow(/模型响应超时/)
191 })
192
193 it('classifies an aborted user signal as a cancellation, not a timeout', async () => {
194 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-beautify-agent-cancel-'))
195 tmpRoots.push(root)
196 const targetHtmlPath = path.join(root, 'page-1.html')
197 await writeFile(targetHtmlPath, '<!doctype html><html></html>', 'utf-8')
198
199 createDeepAgentMock.mockReturnValue({
200 stream: async () => {
201 return (async function* () {
202 const abortError = new Error('The operation was aborted')
203 abortError.name = 'AbortError'
204 throw abortError
205 })()
206 }
207 })
208 const userController = new AbortController()
209 userController.abort()
210
211 await expect(
212 runPageBeautifyAgent({
213 provider: 'provider',
214 apiKey: 'key',
215 model: 'model',
216 baseUrl: 'https://example.com',
217 maxTokens: 1000,
218 modelTimeoutMs: { agent: 5000 },
219 signal: userController.signal,
220 styleKey: 'editorial',
221 styleName: 'Editorial',
222 styleSkillPrompt: 'Use an editorial hierarchy.',
223 styleCase: '',
224 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
225 layoutSkillName: 'oh-my-ppt-layout',
226 targetPageId: 'page-1',
227 targetPageNumber: 1,
228 targetHtmlPath
229 })
230 ).rejects.toThrow('生成已取消')
231 })
232
233 it('emits heartbeat progress during the silent first-token wait so the bar does not stall at 20%', async () => {
234 vi.useFakeTimers({ shouldAdvanceTime: false })
235 const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-beautify-agent-heartbeat-'))
236 tmpRoots.push(root)
237 const targetHtmlPath = path.join(root, 'page-1.html')
238 await writeFile(targetHtmlPath, '<!doctype html><html></html>', 'utf-8')
239
240 let resolveFirstChunk!: () => void
241 const firstChunkGate = new Promise<void>((resolve) => {
242 resolveFirstChunk = resolve
243 })
244
245 createDeepAgentMock.mockImplementation((options) => {
246 return {
247 stream: async () => {
248 const readTool = options.tools.find(
249 (candidate: { name: string }) => candidate.name === 'read_page_html'
250 )
251 const saveTool = options.tools.find(
252 (candidate: { name: string }) => candidate.name === 'save_current_page_content'
253 )
254 return (async function* () {
255 // Mark the page as read so save_current_page_content is accepted.
256 await readTool.invoke({})
257 // Simulate the silent window: model is reading the page HTML and waiting for
258 // first token. No `updates` chunks arrive until the gate resolves.
259 await firstChunkGate
260 yield ['page-beautify', 'updates', { model: {} }]
261 await saveTool.invoke({ content: '<section class="grid"><h1>Current</h1></section>' })
262 })()
263 }
264 }
265 })
266
267 const onProgress = vi.fn()
268 const runPromise = runPageBeautifyAgent({
269 provider: 'provider',
270 apiKey: 'key',
271 model: 'model',
272 baseUrl: 'https://example.com',
273 maxTokens: 1000,
274 modelTimeoutMs: { agent: 60000 },
275 signal: new AbortController().signal,
276 styleKey: 'editorial',
277 styleName: 'Editorial',
278 styleSkillPrompt: 'Use an editorial hierarchy.',
279 styleCase: '',
280 slideSize: { id: 'wide-16-9', label: '16:9', width: 1600, height: 900 },
281 layoutSkillName: 'oh-my-ppt-layout',
282 targetPageId: 'page-1',
283 targetPageNumber: 1,
284 targetHtmlPath,
285 onProgress
286 })
287
288 // Advance wall-clock past several heartbeat ticks while the stream is silent.
289 // Without the heartbeat, onProgress would never fire during this window and the
290 // service-side bar would freeze at 20% for the entire first-token latency.
291 await vi.advanceTimersByTimeAsync(5000)
292
293 const heartbeatRatios = onProgress.mock.calls.map((call) => call[0])
294 expect(heartbeatRatios.length).toBeGreaterThanOrEqual(3)
295 for (let i = 1; i < heartbeatRatios.length; i += 1) {
296 expect(heartbeatRatios[i]).toBeGreaterThanOrEqual(heartbeatRatios[i - 1])
297 }
298 // Heartbeat is capped under 0.4 so the first real model update (floor 0.25, then growing)
299 // always overtakes it cleanly.
300 expect(Math.max(...heartbeatRatios)).toBeLessThan(0.4)
301 expect(Math.max(...heartbeatRatios)).toBeGreaterThan(0.1)
302
303 // Release the stream and let it finish; model-update progress must then take over.
304 const ratiosBeforeResume = heartbeatRatios.length
305 resolveFirstChunk()
306 await runPromise
307 const ratiosAfter = onProgress.mock.calls.map((call) => call[0])
308 expect(ratiosAfter.length).toBeGreaterThan(ratiosBeforeResume)
309 vi.useRealTimers()
310 })
311 })
312
312 lines TYPESCRIPT