// tests/r3debug/extensions.test.js —— 用户自定义扩展分析前端单测(dev/extension-frontend 归属) // 覆盖: // 1) §4 事件帧 → 块映射(applyExtensionFrame 全事件集幂等/追加/终态纪律) // 2) sendExtension 流(用户消息落库 → 骨架 → SSE 帧分派 → 终态收口/错误标注) // 3) uploadExtensionFile / trialRunExtension / confirmExtensionRun store 动作 // 4) 卡片组件 mount 冒烟(8 个扩展卡) // 5) intent.js 创建意图词典(§5 A 层同表双跑) // // 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r3debug/extensions.test.js import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { setActivePinia, createPinia } from 'pinia'; // jsdom 无 scrollTo:polyfill(组件挂载用) if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) { window.Element.prototype.scrollTo = vi.fn(); } // ---- mock 依赖链(与 bioflowPathConfirm.test.js 同款) ---- vi.mock('../../src/api/sessions.js', () => ({ createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })), updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })), })); vi.mock('../../src/api/qa.js', () => ({ chatStream: vi.fn(async () => {}), search: vi.fn(async () => ({ answer: '' })), })); vi.mock('../../src/api/private.js', () => ({ askPrivate: vi.fn(async () => ({ hit: false })), })); vi.mock('../../src/api/docgen.js', () => ({ generate: vi.fn(), getTask: vi.fn(), getArtifactContent: vi.fn(async () => ({ markdown: '' })), getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })), saveArtifactContent: vi.fn(async () => ({})), editArtifact: vi.fn(async () => ({})), exportArtifact: vi.fn(async () => ({})), uploadLibrary: vi.fn(), listLibrary: vi.fn(async () => ({ items: [] })), removeLibrary: vi.fn(async () => ({ ok: true })), listTasks: vi.fn(async () => ({ tasks: [] })), })); // ---- 扩展 API 层 mock(观测调用 + 可控返回;vi.hoisted 供 mock factory 引用) ---- const apiMock = vi.hoisted(() => ({ createExtension: vi.fn(), buildExtension: vi.fn(), trialRunExtension: vi.fn(), repairExtension: vi.fn(), publishExtension: vi.fn(), runExtension: vi.fn(), listExtensions: vi.fn(async () => []), getExtension: vi.fn(async () => ({})), deleteExtension: vi.fn(async () => ({ ok: true })), listExtensionRuns: vi.fn(async () => []), extensionRunEvents: vi.fn(), bioflowChatStream: vi.fn(), })); vi.mock('../../src/api/bioflow.js', () => apiMock); // ---- bioflow store mock(sendExtension 需要 useBioflowStore;零调用断言) ---- vi.mock('../../src/stores/bioflow.js', () => ({ useBioflowStore: () => ({ resetSession: vi.fn(), currentJob: { jobId: null, jobStatus: null, stages: [], lastSeq: 0, autoContinue: false }, connectJobEvents: vi.fn(), applyJobEvent: vi.fn(), applyChatEvent: vi.fn(), registerReport: vi.fn(async () => {}), }), })); // ---- 被测模块 ---- import { useChatStore, applyExtensionFrame, patchExtensionUpload, patchExtensionCreated, patchExtensionBuild, patchExtensionTrial, patchExtensionRepair, patchExtensionValidate, patchExtensionPublish, patchExtensionPublished, patchExtensionCandidates, patchExtensionConfirm, patchExtensionRunEvent, ensureExtensionUploadBlock, ensureExtensionRunBlock, } from '../../src/stores/chat.js'; import { useSessionsStore } from '../../src/stores/sessions.js'; import { useExtensionsStore } from '../../src/stores/extensions.js'; import { isExtensionCreateIntent } from '../../src/utils/intent.js'; import { consumeSseStream } from '../../src/sse/parser.js'; import ExtensionUploadCard from '../../src/components/chat/ExtensionUploadCard.vue'; import ExtensionBuildCard from '../../src/components/chat/ExtensionBuildCard.vue'; import ExtensionTrialCard from '../../src/components/chat/ExtensionTrialCard.vue'; import ExtensionValidateCard from '../../src/components/chat/ExtensionValidateCard.vue'; import ExtensionPublishCard from '../../src/components/chat/ExtensionPublishCard.vue'; import ExtensionCandidatesCard from '../../src/components/chat/ExtensionCandidatesCard.vue'; import ExtensionConfirmCard from '../../src/components/chat/ExtensionConfirmCard.vue'; import ExtensionRunCard from '../../src/components/chat/ExtensionRunCard.vue'; /** 构造一个最小 SSE Response(供 bioflowChatStream mock 返回) */ function makeSseResponse(frames) { const events = frames.map((f) => { const lines = [`event: ${f.event}`]; if (f.data !== undefined) lines.push(`data: ${JSON.stringify(f.data)}`); return lines.join('\n'); }).join('\n\n'); const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(events + (events ? '\n\n' : ''))); controller.close(); }, }); return { ok: true, status: 200, body: stream }; } function freshStores() { const pinia = createPinia(); setActivePinia(pinia); const chat = useChatStore(); const sessions = useSessionsStore(); chat.sessionId = 's1'; sessions.activeId = 's1'; sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }]; return { chat, sessions }; } function plainMsg(blocks = []) { return { id: 'am', role: 'assistant', status: 'streaming', blocks: [...blocks], raw: null }; } describe('applyExtensionFrame §4 事件帧 → 块映射', () => { beforeEach(() => { vi.clearAllMocks(); }); it('extension_upload → 上传引导卡(缺则 push;重入幂等不重复建块)', () => { const msg = plainMsg(); const b1 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1' }); expect(msg.blocks.length).toBe(1); expect(msg.blocks[0].type).toBe('extension_upload'); expect(msg.blocks[0].sessionExtId).toBe('sess-ext-1'); const b2 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1', message: 'm' }); expect(msg.blocks.length).toBe(1); expect(b2 === b1).toBe(true); expect(msg.blocks[0].message).toBe('m'); }); it('extension_created → 上传卡落定 extId;迟到帧不覆盖已有 extId', () => { const msg = plainMsg(); patchExtensionCreated(msg, { ext_id: 'ext-1', display_name: '测序质量统计', status: 'draft' }); expect(msg.blocks[0].extId).toBe('ext-1'); expect(msg.blocks[0].displayName).toBe('测序质量统计'); expect(msg.blocks[0].status).toBe('created'); patchExtensionCreated(msg, { ext_id: 'ext-late', status: 'draft' }); expect(msg.blocks[0].extId).toBe('ext-1'); // 不覆盖 }); it('extension_build → 转换卡;同 ext_id 重入刷新,build_failed 只补错误不回退', () => { const msg = plainMsg(); applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', command_template: 'python {code_dir}/q.py --out {output_dir}', requirements: { pip: ['pandas'], apt: [] }, runtime_image: 'bioflow-gfpy', nl_triggers: ['质量统计'], analysis_type: 'table_filter', parameters: [{ name: 'fastq', type: 'file', required: true }], status: 'built', }); const blk = msg.blocks[0]; expect(blk.type).toBe('extension_build'); expect(blk.commandTemplate).toContain('{output_dir}'); expect(blk.requirements.pip).toEqual(['pandas']); expect(blk.runtimeImage).toBe('bioflow-gfpy'); expect(blk.nlTriggers).toEqual(['质量统计']); expect(blk.parameters.length).toBe(1); // 重入刷新(同 ext_id 不新建块) applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', nl_triggers: ['统计'] }); expect(msg.blocks.length).toBe(1); expect(blk.nlTriggers).toEqual(['统计']); // build_failed 不回退已展示内容 applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', status: 'build_failed', error: 'bad' }); expect(blk.status).toBe('build_failed'); expect(blk.error).toBe('bad'); expect(blk.commandTemplate).toContain('{output_dir}'); }); it('extension_trial → 试运行卡日志只追加;done 清错误;failed 展示错误与修复建议', () => { const msg = plainMsg(); applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line1\n' }); applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line2\n' }); const blk = msg.blocks[0]; expect(blk.logTail).toBe('line1\nline2\n'); // 追加不覆盖 expect(blk.status).toBe('running'); applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'done', exit_code: 0 }); expect(blk.status).toBe('done'); expect(blk.exitCode).toBe(0); expect(blk.error).toBeNull(); applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'failed', exit_code: 1, error: 'boom', suggestion: 'try X' }); expect(blk.status).toBe('failed'); expect(blk.error).toBe('boom'); expect(blk.suggestion).toBe('try X'); }); it('extension_repair → 试运行卡补修复状态(repair_count/suggestion)', () => { const msg = plainMsg(); patchExtensionRepair(msg, { ext_id: 'ext-1', repair_count: 2, suggestion: '加 pandas', status: 'repairing' }); expect(msg.blocks[0].type).toBe('extension_trial'); expect(msg.blocks[0].repairCount).toBe(2); expect(msg.blocks[0].suggestion).toBe('加 pandas'); }); it('extension_validate → 金标/确认卡;ready_to_publish 同源挂发布卡', () => { const msg = plainMsg(); applyExtensionFrame(msg, 'extension_validate', { ext_id: 'ext-1', golden: { ok: true }, form_defaults: [{ name: 'fastq', default: '' }], ready_to_publish: true, display_name: '测序质量统计', nl_triggers: ['质量统计'], }); const v = msg.blocks.find((b) => b.type === 'extension_validate'); expect(v.golden.ok).toBe(true); expect(v.formDefaults.length).toBe(1); const p = msg.blocks.find((b) => b.type === 'extension_publish'); expect(p).toBeTruthy(); expect(p.nlTriggers).toEqual(['质量统计']); }); it('extension_publish / extension_published → 发布卡与发布确认块(幂等去重)', () => { const msg = plainMsg(); patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X', nl_triggers: ['a'] }); patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] }); expect(msg.blocks.filter((b) => b.type === 'extension_publish').length).toBe(1); expect(msg.blocks[0].nlTriggers).toEqual(['a', 'b']); patchExtensionPublished(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] }); expect(msg.blocks.filter((b) => b.type === 'extension_published').length).toBe(1); expect(msg.blocks.find((b) => b.type === 'extension_publish').published).toBe(true); }); it('extension_candidates → 候选卡;重复帧整体替换(幂等)', () => { const msg = plainMsg(); applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }] }); applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }] }); expect(msg.blocks.length).toBe(1); expect(msg.blocks[0].candidates).toEqual([{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }]); }); it('extension_confirm → 参数确认卡;同 ext_id 重入只刷新(块幂等)', () => { const msg = plainMsg(); patchExtensionConfirm(msg, { ext_id: 'ext-1', params_schema: [{ name: 'fastq' }], extracted_params: { fastq: '/a.fq' }, files_required: ['a.fq'] }); patchExtensionConfirm(msg, { ext_id: 'ext-1', extracted_params: { fastq: '/b.fq' } }); expect(msg.blocks.length).toBe(1); expect(msg.blocks[0].extractedParams.fastq).toBe('/b.fq'); expect(msg.blocks[0].filesRequired).toEqual(['a.fq']); }); it('extension_run_* → 运行卡:run_id 换新则新块;日志追加;终态不回退', () => { const msg = plainMsg(); patchExtensionRunEvent(msg, { type: 'extension_run_started', run_id: 'r1', ext_id: 'ext-1' }); patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'a\n' }); patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'b\n' }); expect(msg.blocks.length).toBe(1); expect(msg.blocks[0].log).toBe('a\nb\n'); expect(msg.blocks[0].status).toBe('running'); patchExtensionRunEvent(msg, { type: 'extension_run_done', run_id: 'r1', outputs: ['out/x'] }); expect(msg.blocks[0].status).toBe('done'); expect(msg.blocks[0].outputs).toEqual(['out/x']); // 终态不回退:迟到的 log 帧不改变 done、不追加日志 patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'late' }); expect(msg.blocks[0].status).toBe('done'); expect(msg.blocks[0].log).toBe('a\nb\n'); // 失败帧 patchExtensionRunEvent(msg, { type: 'extension_run_failed', run_id: 'r2', ext_id: 'ext-1', exit_code: 3, error: 'empty', log_tail: 'tail' }); const f = msg.blocks.find((b) => b.runId === 'r2'); expect(f.status).toBe('failed'); expect(f.exitCode).toBe(3); expect(f.error).toBe('empty'); // 新 run 不覆盖旧块 expect(msg.blocks.length).toBe(2); }); it('未知 extension 事件忽略;无 run_id/无 ext_id 防御返回 null', () => { const msg = plainMsg(); expect(applyExtensionFrame(msg, 'extension_unknown', {})).toBeNull(); expect(msg.blocks.length).toBe(0); expect(patchExtensionRunEvent(msg, { type: 'extension_run_log', log: 'x' })).toBeNull(); expect(patchExtensionBuild(msg, { command_template: 'x' })).toBeNull(); }); it('ensure 块函数幂等(upload/run 各一次)', () => { const msg = plainMsg(); const a = ensureExtensionUploadBlock(msg); const b = ensureExtensionUploadBlock(msg); expect(a === b).toBe(true); expect(msg.blocks.length).toBe(1); const r1 = ensureExtensionRunBlock(msg); const r2 = ensureExtensionRunBlock(msg); expect(r1 === r2).toBe(true); expect(msg.blocks.length).toBe(2); }); }); describe('sendExtension 扩展引导流', () => { beforeEach(() => { vi.clearAllMocks(); }); it('发送 → 用户消息落库 + 扩展骨架(upload 块)+ SSE 帧分派 + 终态收口', async () => { const { chat } = freshStores(); apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([ { event: 'extension_upload', data: { session_ext_id: 'sess-1' } }, { event: 'extension_created', data: { ext_id: 'ext-1', display_name: 'X', status: 'draft' } }, { event: 'message_done', data: { kind: 'extension' } }, ])); await chat.sendExtension('创建一个扩展:统计 fastq 质量'); expect(chat.messages.length).toBe(2); const aMsg = chat.messages[1]; expect(aMsg.kind).toBe('bioflow'); expect(aMsg.status).toBe('done'); const up = aMsg.blocks.find((b) => b.type === 'extension_upload'); expect(up).toBeTruthy(); expect(up.extId).toBe('ext-1'); // 终态收口:ctx 已清理、busy 释放 expect(chat.busy).toBe(false); expect(chat.lastResult.ok).toBe(true); }); it('error 帧 → 消息标注可重试 + 收口', async () => { const { chat } = freshStores(); apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([ { event: 'extension_upload', data: { session_ext_id: 'sess-1' } }, { event: 'error', data: { code: 'UPSTREAM_DOWN', message: '服务不可用' } }, ])); await chat.sendExtension('创建一个扩展'); const aMsg = chat.messages[1]; expect(aMsg.status).toBe('error'); expect(aMsg.errText).toBe('服务不可用'); expect(chat.lastResult.ok).toBe(false); expect(chat.lastResult.error.retryable).toBe(true); expect(chat.busy).toBe(false); }); it('发流失败(ApiError 信封)→ 标注可重试', async () => { const { chat } = freshStores(); const err = new Error('upstream'); err.code = 'UPSTREAM_DOWN'; apiMock.bioflowChatStream.mockRejectedValue(err); await chat.sendExtension('创建一个扩展'); const aMsg = chat.messages[1]; expect(aMsg.status).toBe('error'); expect(aMsg.errText).toBe('upstream'); expect(chat.lastResult.ok).toBe(false); }); it('retry 对扩展消息走 sendExtension(meta.extension 标记)', async () => { const { chat } = freshStores(); apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([ { event: 'extension_upload', data: { session_ext_id: 'sess-1' } }, { event: 'message_done', data: {} }, ])); await chat.sendExtension('创建一个扩展'); // 手工构造失败态以触发 retry 判定 const aMsg = chat.messages[1]; aMsg.status = 'error'; aMsg.streamErr = 'disconnected'; await chat.retry(); expect(apiMock.bioflowChatStream).toHaveBeenCalledTimes(2); expect(chat.messages.length).toBe(2); // 同位置替换不新增 expect(chat.messages[1].status).toBe('done'); }); }); describe('扩展 store 动作(upload/trial/confirm)', () => { beforeEach(() => { vi.clearAllMocks(); }); it('uploadExtensionFile → FormData 上传 + 块 patch(extId/status/files 进度)', async () => { const { chat } = freshStores(); apiMock.createExtension.mockResolvedValue({ ok: true, data: { ext_id: 'ext-9', display_name: 'X' } }); const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null }; chat.messages = [aMsg]; const blk = aMsg.blocks[0]; const zip = new File(['zip'], 'x.zip', { type: 'application/zip' }); const res = await chat.uploadExtensionFile(blk, { zip, displayName: 'X', command: 'python x.py', runtimeImage: 'bioflow-gf', golden: null }); expect(res.ext_id).toBe('ext-9'); expect(blk.extId).toBe('ext-9'); expect(blk.status).toBe('created'); expect(blk.files[0].status).toBe('done'); // FormData 字段断言 const form = apiMock.createExtension.mock.calls[0][0]; expect(form instanceof FormData).toBe(true); expect(form.get('display_name')).toBe('X'); expect(form.get('command')).toBe('python x.py'); expect(form.get('runtime_image')).toBe('bioflow-gf'); }); it('uploadExtensionFile 失败 → 文件记录 error 并抛出', async () => { const { chat } = freshStores(); apiMock.createExtension.mockRejectedValue(new Error('boom')); const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null }; chat.messages = [aMsg]; const blk = aMsg.blocks[0]; await expect(chat.uploadExtensionFile(blk, { zip: new File(['z'], 'x.zip'), displayName: 'X', command: 'cmd', runtimeImage: 'bioflow-gf', golden: null, })).rejects.toThrow('boom'); expect(blk.files[0].status).toBe('error'); expect(blk.extId).toBeNull(); }); it('trialRunExtension → FormData 携带 test_inputs;返回登记结果', async () => { const { chat } = freshStores(); apiMock.trialRunExtension.mockResolvedValue({ ok: true, data: { accepted: true } }); const f = new File(['t'], 'sample.fq'); const res = await chat.trialRunExtension('ext-1', [f]); expect(res.accepted).toBe(true); const form = apiMock.trialRunExtension.mock.calls[0][1]; expect(form instanceof FormData).toBe(true); expect(form.has('test_inputs')).toBe(true); }); it('confirmExtensionRun → runExtension + 挂运行卡(run_started 形态)', async () => { const { chat } = freshStores(); apiMock.runExtension.mockResolvedValue({ ok: true, data: { run_id: 'run-7' } }); const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [ { type: 'extension_confirm', extId: 'ext-1', paramsSchema: [], extractedParams: {}, filesRequired: [] }, ], raw: null, }; chat.messages = [aMsg]; const confirmBlk = aMsg.blocks[0]; const res = await chat.confirmExtensionRun(confirmBlk, { params: { fastq: '/a.fq' }, files: [], aMsg }); expect(res.run_id).toBe('run-7'); expect(confirmBlk.submitted).toBe(true); const runBlk = aMsg.blocks.find((b) => b.type === 'extension_run'); expect(runBlk).toBeTruthy(); expect(runBlk.runId).toBe('run-7'); expect(runBlk.status).toBe('running'); // JSON body 参数透传(无文件时走 JSON 分支) const body = apiMock.runExtension.mock.calls[0][1]; expect(body.params.fastq).toBe('/a.fq'); }); }); describe('extensions store(侧栏入口数据面)', () => { beforeEach(() => { vi.clearAllMocks(); }); it('load → items 归一化;remove → 删除后出列', async () => { apiMock.listExtensions.mockResolvedValue([ { id: 'e1', display_name: 'A', status: 'published' }, { id: 'e2', display_name: 'B', status: 'draft' }, ]); const pinia = createPinia(); setActivePinia(pinia); const ext = useExtensionsStore(); await ext.load(); expect(ext.items.length).toBe(2); expect(ext.published.length).toBe(1); apiMock.deleteExtension.mockResolvedValue({ ok: true }); await ext.remove('e1'); expect(ext.items.length).toBe(1); expect(apiMock.deleteExtension).toHaveBeenCalledWith('e1'); }); it('loadRuns → runs 记录(列表/error 两态)', async () => { apiMock.listExtensionRuns.mockResolvedValue({ runs: [{ run_id: 'r1', status: 'done' }] }); const pinia = createPinia(); setActivePinia(pinia); const ext = useExtensionsStore(); const list = await ext.loadRuns('e1'); expect(list.length).toBe(1); expect(ext.runs.e1.error).toBe(false); apiMock.listExtensionRuns.mockRejectedValue(new Error('x')); await expect(ext.loadRuns('e2')).rejects.toThrow('x'); expect(ext.runs.e2.error).toBe(true); }); }); describe('intent.js 创建意图词典(§5 A 层)', () => { it('动作词 + 目标词同句命中', () => { expect(isExtensionCreateIntent('帮我创建一个扩展:统计 fastq 质量')).toBe(true); expect(isExtensionCreateIntent('做一个拓展分析 统计 fastq 质量')).toBe(true); expect(isExtensionCreateIntent('新建一个分析模板')).toBe(true); expect(isExtensionCreateIntent('上传我的分析脚本')).toBe(true); }); it('漏判安全:无动作词 / 无目标词不命中(歧义消解由服务端判定)', () => { expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false); expect(isExtensionCreateIntent('创建一个文档')).toBe(false); expect(isExtensionCreateIntent('做一下质量统计')).toBe(false); expect(isExtensionCreateIntent('扩展分析')).toBe(false); // 契约 §5:目标词命中但无动作词(「做拓展分析 xxx」)不属 A 层—— // 已发布扩展命中走运行、无命中回「是否创建?」引导卡,均由服务端 intent.py 判定 expect(isExtensionCreateIntent('做拓展分析 统计 fastq 质量')).toBe(false); }); }); describe('卡片组件 mount 冒烟', () => { beforeEach(() => { vi.clearAllMocks(); setActivePinia(createPinia()); }); afterEach(() => { setActivePinia(createPinia()); }); const mountCard = (Comp, block) => mount(Comp, { props: { block, message: { id: 'am', blocks: [block] } }, }); it('ExtensionUploadCard 渲染:镜像下拉 8 项 + 提交按钮禁用态(缺 zip)', async () => { const w = mountCard(ExtensionUploadCard, { type: 'extension_upload', status: 'idle', extId: null, files: [], runtimeImage: 'bioflow-gf' }); expect(w.text()).toContain('扩展上传引导'); const options = w.findAll('option'); expect(options.length).toBe(8); expect(options[0].text()).toContain('bioflow-gf'); const btn = w.find('button.ext-primary'); expect(btn.attributes('disabled')).toBeDefined(); // 无 zip 提交禁用 }); it('ExtensionBuildCard 渲染:模板/依赖/触发词/参数 + 提交修正', async () => { const w = mountCard(ExtensionBuildCard, { type: 'extension_build', extId: 'ext-1', status: 'built', commandTemplate: 'python {code_dir}/q.py --out {output_dir}', requirements: { pip: ['pandas'], apt: [] }, runtimeImage: 'bioflow-gfpy', nlTriggers: ['质量统计'], analysisType: 'table_filter', parameters: [{ name: 'fastq', type: 'file' }], }); expect(w.text()).toContain('命令模板'); expect(w.text()).toContain('pandas'); expect(w.text()).toContain('fastq'); const inputs = w.findAll('input.ext-input'); expect(inputs[0].element.value).toContain('{output_dir}'); // 触发词是 input 值(编辑态),不在文本节点中——断言输入值(顺序:模板/镜像/触发词/类型) expect(inputs[2].element.value).toBe('质量统计'); expect(inputs[1].element.value).toBe('bioflow-gfpy'); }); it('ExtensionTrialCard 渲染:日志尾 + 失败建议 + AI 修复按钮', async () => { const w = mountCard(ExtensionTrialCard, { type: 'extension_trial', extId: 'ext-1', status: 'failed', logTail: 'traceback...', exitCode: 1, error: 'ModuleNotFoundError', suggestion: '加 pandas', }); expect(w.text()).toContain('试运行失败'); expect(w.text()).toContain('traceback...'); expect(w.text()).toContain('加 pandas'); expect(w.text()).toContain('AI 修复'); }); it('ExtensionValidateCard 渲染:金标对比 + 参数确认', async () => { const w = mountCard(ExtensionValidateCard, { type: 'extension_validate', extId: 'ext-1', golden: { ok: true }, formDefaults: [{ name: 'fastq', default: '', description: '输入' }], }); expect(w.text()).toContain('金标'); expect(w.text()).toContain('fastq'); }); it('ExtensionPublishCard 渲染:触发词编辑 + 发布确认', async () => { const w = mountCard(ExtensionPublishCard, { type: 'extension_publish', extId: 'ext-1', displayName: 'X', nlTriggers: ['a', 'b'], published: false, }); expect(w.text()).toContain('发布扩展'); const input = w.find('input.ext-input'); expect(input.element.value).toBe('a, b'); }); it('ExtensionCandidatesCard 渲染:候选列表 + 点击选择 emit', async () => { const w = mountCard(ExtensionCandidatesCard, { type: 'extension_candidates', candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }, { ext_id: 'e2', display_name: 'B', purpose: 'p2' }], selected: null, }); expect(w.text()).toContain('匹配到多个扩展'); expect(w.text()).toContain('A'); await w.findAll('.ext-row')[1].trigger('click'); expect(w.emitted('pick')).toBeTruthy(); expect(w.emitted('pick')[0][0].ext_id).toBe('e2'); }); it('ExtensionConfirmCard 渲染:参数提取值展示 + 确认运行按钮', async () => { const w = mountCard(ExtensionConfirmCard, { type: 'extension_confirm', extId: 'ext-1', paramsSchema: [{ name: 'fastq', type: 'file', default: '' }], extractedParams: { fastq: '/a.fq' }, filesRequired: ['a.fq'], }); expect(w.text()).toContain('运行参数确认'); const input = w.find('input.ext-input'); expect(input.element.value).toBe('/a.fq'); }); it('ExtensionRunCard 渲染:badge 四态 + 日志 + 输出', async () => { const w = mountCard(ExtensionRunCard, { type: 'extension_run', runId: 'r1', extId: 'ext-1', status: 'done', log: 'a\nb\n', outputs: ['out/x'], }); expect(w.text()).toContain('已完成'); expect(w.text()).toContain('out/x'); }); });