fix: address phase two review and theme benchmark page

This commit is contained in:
2026-09-07 13:26:14 +08:00
parent 0e3f2a7325
commit 47c53b6f38
12 changed files with 451 additions and 35 deletions
+47 -1
View File
@@ -1,5 +1,9 @@
import {describe,it,expect,vi} from 'vitest'
// @vitest-environment jsdom
import {webcrypto} from 'node:crypto'
import {describe,it,expect,vi,afterEach} from 'vitest'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
import {renderMermaid} from './mermaidService'
import {apiClient} from './apiClient'
import {exportService} from './exportService'
describe('export snapshot contract',()=>{
@@ -16,3 +20,45 @@ describe('export snapshot contract',()=>{
expect(apiClient.post).not.toHaveBeenCalled()
})
})
const reviewOptions={theme_id:'light',include_title:true,page_size:'A4'}
const queued={job_id:'review-job',status:'queued',warnings:[],file:null,error:null}
afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();vi.clearAllMocks()})
it('cancels a created server job after an in-flight submission is aborted',async()=>{
let finish!:(value:unknown)=>void
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'cancelled'})
const controller=new AbortController()
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
controller.abort();finish(queued)
await expect(pending).rejects.toMatchObject({name:'AbortError'})
expect(apiClient.post).toHaveBeenLastCalledWith('/api/exports/review-job/cancel')
})
it('does not report cancellation when the server job already completed',async()=>{
let finish!:(value:unknown)=>void
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'completed'})
const controller=new AbortController()
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
controller.abort();finish(queued)
await expect(pending).rejects.toThrow('导出已完成,无法取消')
})
it('surfaces a server cancellation failure instead of claiming it was cancelled',async()=>{
let finish!:(value:unknown)=>void
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockRejectedValue(new Error('network failure'))
const controller=new AbortController()
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
controller.abort();finish(queued)
await expect(pending).rejects.toThrow('network failure')
})
it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset for %s',async language=>{
vi.stubGlobal('crypto',webcrypto)
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
vi.mocked(apiClient.post).mockResolvedValue(queued)
await exportService.create('```'+language+'\nflowchart LR\n A-->B\n```','review','html',reviewOptions)
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
})
+12 -2
View File
@@ -44,7 +44,7 @@ export const exportService = {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string }, signal?: AbortSignal, filePath?: string) {
const blocks: string[] = []
const parser = new Marked()
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang === 'mermaid') blocks.push(token.text) })
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
const assets = []
for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted()
@@ -54,7 +54,17 @@ export const exportService = {
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal) })
}
signal?.throwIfAborted()
return mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets }))
// Keep the response handle when cancellation arrives during submission:
// aborting HTTP alone could leave an undiscoverable running server job.
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets }))
if (signal?.aborted) {
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`)
if (current.status === 'completed') throw new Error('导出已完成,无法取消;请在任务列表中下载。')
if (current.status === 'failed') throw new Error(current.error || '导出任务已失败,请查看任务列表。')
signal.throwIfAborted()
}
return job
},
async get(id: string) { return mapJob(await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(id)}`)) },
async list() { const response = await apiClient.get<{ items: JobWire[] }>('/api/exports'); return response.items.map(mapJob) },