import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { runInNewContext } from 'node:vm' import ts from 'typescript' import { parse } from '@vue/compiler-sfc' import { createSSRApp, h } from 'vue' import { renderToString } from 'vue/server-renderer' import { parseTime } from '../src/utils/format' // 执行组件中的真实处理函数,只替换网络与弹窗边界,避免写入业务库。 function source(path: string) { const file = readFileSync(new URL('../src/views/crm/' + path, import.meta.url), 'utf8') return ts.createSourceFile( path, parse(file).descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) } function evaluate(code: string, context: Record) { return runInNewContext( ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText, context ) } function handler(path: string, name: string, context: Record) { const file = source(path) const statement = file.statements.find( (s) => (ts.isFunctionDeclaration(s) && s.name?.text === name) || (ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => d.name.getText(file) === name)) ) assert.ok(statement, `组件应保留 ${name} 处理入口`) return evaluate(statement.getText(file) + '\n' + name, context) } for (const [path, name, api] of [ ['customer/FollowFormDialog.vue', 'loadContactList', 'fetchContactByCustomerId'], ['customer/components/ContactList.vue', 'getList', 'fetchContactsByCustomerId'] ]) { test(`${path}:解析实际已解包联系人数组并兼容旧包装`, async () => { const rows = [{ id: '90071992547409931', contactName: '联系人甲' }] for (const response of [rows, { data: rows }, [], null]) { const contactList = { value: [{ id: 'old' }] } const load = handler(path, name, { [api]: async () => response, contactList, props: { customerId: 'customer-1' }, loading: { value: false }, console }) await load('customer-1') assert.equal( JSON.stringify(contactList.value), JSON.stringify( response === rows || (!Array.isArray(response) && response?.data) ? rows : [] ) ) } }) test(`${path}:请求失败清空旧联系人`, async () => { const contactList = { value: [{ id: 'old' }] } const loading = { value: false } const load = handler(path, name, { [api]: async () => { throw new Error('offline') }, contactList, props: { customerId: 'customer-1' }, loading, console: { error() {} } }) await load('customer-1') assert.equal(contactList.value.length, 0) assert.equal(loading.value, false) }) } test('操作日志各字段只能作为文本渲染,不能注入标签或事件', async () => { const payload = '&"\n第二行' let message: any let options: any const show = handler('customer/CustomerDetailDialog.vue', 'handleViewLogDetail', { h, ElMessageBox: { alert(content: unknown, _title: string, config: unknown) { message = content options = config } } }) show( Object.fromEntries( ['title', 'operName', 'operTime', 'operUrl', 'operParam'].map((k) => [k, payload]) ) ) const html = typeof message === 'string' && options.dangerouslyUseHTMLString ? message : await renderToString(createSSRApp({ render: () => message })) assert.doesNotMatch(html, / { const previousTZ = process.env.TZ process.env.TZ = 'Asia/Shanghai' try { const file = source('opportunity/components/FollowDialog.vue') const watcher = file.statements.find( (s) => ts.isExpressionStatement(s) && ts.isCallExpression(s.expression) && s.expression.expression.getText(file) === 'watch' )! for (const instant of ['2026-10-17T20:01:01+08:00', '2026-10-17T00:01:01+08:00']) { let callback: (open: boolean) => Promise const form = { value: {} as Record } const props: any = { opportunityId: '90071992547409931' } evaluate(watcher.getText(file), { watch: (_source: unknown, fn: typeof callback) => { callback = fn }, props, form, visible: { value: false }, attachmentUrls: { value: '' }, resetForm: () => { form.value = {} }, Date: class extends Date { constructor() { super(instant) } }, parseTime, getOpportunityFollow: async () => ({ data: { id: 'follow-1', followTime: '2026-10-16 09:30:12', nextTime: '2026-10-17 20:01:01' } }) }) await callback!(true) assert.equal(form.value.followTime, instant.slice(0, 19).replace('T', ' ')) props.followData = { id: 'follow-1' } await callback!(true) assert.equal(form.value.followTime, '2026-10-16 09:30:12') assert.equal(form.value.nextTime, '2026-10-17 20:01:01') } } finally { if (previousTZ == null) delete process.env.TZ else process.env.TZ = previousTZ } })