| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- 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
- }
- })
|