163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
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<string, unknown>) {
|
|
return runInNewContext(
|
|
ts.transpileModule(code, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }
|
|
}).outputText,
|
|
context
|
|
)
|
|
}
|
|
function handler(path: string, name: string, context: Record<string, unknown>) {
|
|
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 = '<img src=x onerror="alert(1)"></pre><script>alert(2)</script>&"\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, /<img|<script/i)
|
|
assert.equal((html.match(/<img/g) || []).length, 5)
|
|
assert.ok(html.includes('第二行'))
|
|
assert.notEqual(options.dangerouslyUseHTMLString, true)
|
|
})
|
|
|
|
test('新增商机跟进默认时间使用本地时间,跨日不偏移;编辑不覆盖已有值', async () => {
|
|
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<void>
|
|
const form = { value: {} as Record<string, unknown> }
|
|
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
|
|
}
|
|
})
|