Files
shanxia2.0/web-admin/scripts/crm-existing-issues.test.ts
T
2026-10-19 18:28:02 +08:00

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(/&lt;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
}
})