- 会话/扩展/知识图谱/设置 4 按钮自侧栏迁移至 TopBar 右侧(与主题按钮并列) - 按需求收敛为扩展/设置两按钮,与主题按钮右上角同行水平对齐 (.theme-toggle-anim 改 inline-flex + svg 脱离基线,消除图标下沉) - SessionRail 全档位删除(桌面底部工具栏/折叠态图标轨/窄屏抽屉) - 清理死接线:openKg 函数与 toggle-side/goto-kg 事件 - 同步删除 r3debug 的 SessionRail 测试用例 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
634 lines
27 KiB
JavaScript
634 lines
27 KiB
JavaScript
// 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');
|
||
});
|
||
|
||
});
|