import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue' import * as Vue from 'vue' import { compileTemplate, parse } from '@vue/compiler-sfc' import ts from 'typescript' import { buildAttachmentUploadFormData } from '../src/utils/crm-upload' const file = new URL('../src/views/m/crm/customer/CustomerAttachments.vue', import.meta.url) const { descriptor } = parse(readFileSync(file, 'utf8')) const ast = ts.createSourceFile( 'CustomerAttachments.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) // Run every setup statement, replacing only external imports and SFC macros. const source = ast.statements .filter((node) => !ts.isImportDeclaration(node)) .map((node) => node.getText(ast)) .join('\n') function harness(initialProps: Record = {}) { let resolve!: (value: unknown) => void let reject!: (error: Error) => void const response = new Promise((done, fail) => { resolve = done reject = fail }) const requests: FormData[] = [] const events: unknown[][] = [] const notices: string[] = [] const previews: unknown[] = [] const downloads: unknown[] = [] let exposed: { hasUploading: () => boolean } const scope = effectScope() const bindings = { computed, nextTick, reactive, ref, watch, defineProps: () => initialProps, withDefaults: (props: object, defaults: object) => reactive({ ...defaults, ...props }), defineEmits: () => (...args: unknown[]) => events.push(args), defineExpose: (value: typeof exposed) => { exposed = value }, showToast: (message: string) => notices.push(message), showFailToast: (message: string) => notices.push(message), buildAttachmentUploadFormData, fetchUploadAttachment: (data: FormData) => { requests.push(data) return response }, // Only external URL resolution and navigation are stubbed; Vue state and FormData stay real. parseCrmAttachmentField: (value: string) => value.split(',').filter(Boolean), resolveCrmAttachmentFiles: (urls: string[]) => urls.map((url) => ({ url, name: url.split('/').pop() })), resolveCrmAttachmentUrl: (url: string) => `https://files.example/${url}`, isCrmImageFile: ({ name }: { name: string }) => name.endsWith('.png'), openCrmAttachmentPreviewInNewTab: (item: unknown) => previews.push(item), downloadCrmAttachment: (item: unknown) => downloads.push(item) } const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext }) const instance = scope.run(() => new Function( ...Object.keys(bindings), `${code}\nreturn { props, onPick, gridItems, fileList, uploading, onPreview, onDownload, openPicker, onRemove, uploadOne, fileInputRef }` )(...Object.values(bindings)) ) return { ...instance, resolve, reject, requests, events, notices, previews, downloads, hasUploading: () => exposed.hasUploading(), stop: () => scope.stop() } } async function startUpload(h: ReturnType) { const raw = new File(['fixture'], '客户附件.png', { type: 'image/png' }) const input = { files: [raw], value: 'selected' } const pending = h.onPick({ target: input }) await nextTick() // Prime the computed cache while transport is pending, just as the template does. assert.equal(h.gridItems.value[0].uploading, true) assert.equal(h.gridItems.value[0].url, '') assert.equal(h.uploading.value, 1) assert.equal(h.hasUploading(), true) assert.equal(input.value, '') assert.equal(h.requests.length, 1) assert.equal(h.requests[0].get('bizType'), 'crm_customer_certificate') assert.equal(h.requests[0].get('folder'), 'crm/customer') assert.equal((h.requests[0].get('file') as File).name, raw.name) return { pending } } test('pending customer upload refreshes the cached grid and enables preview on success', async () => { const h = harness() try { const { pending } = await startUpload(h) h.resolve({ data: { fileUrl: 'customer/saved.png' } }) await pending await nextTick() assert.equal(h.gridItems.value[0].uploading, false) assert.equal(h.gridItems.value[0].url, 'https://files.example/customer/saved.png') assert.equal(h.fileList.value[0].status, 'done') assert.equal(h.uploading.value, 0) assert.equal(h.hasUploading(), false) assert.equal(h.requests.length, 1) assert.deepEqual(h.events, [['update:modelValue', 'customer/saved.png']]) h.onPreview(h.gridItems.value[0]) assert.deepEqual(h.previews, [{ ossId: 'customer/saved.png', name: '客户附件.png' }]) assert.deepEqual(h.notices, []) } finally { h.stop() } }) test('rejected customer upload clears the cached grid uploading state without emitting a URL', async () => { const h = harness() try { const { pending } = await startUpload(h) h.reject(new Error('fixture upload failure')) await pending await nextTick() assert.equal(h.gridItems.value[0].uploading, false) assert.equal(h.gridItems.value[0].url, '') assert.equal(h.fileList.value[0].status, 'failed') assert.equal(h.uploading.value, 0) assert.equal(h.hasUploading(), false) assert.equal(h.requests.length, 1) assert.deepEqual(h.events, []) assert.deepEqual(h.notices, ['fixture upload failure']) h.onPreview(h.gridItems.value[0]) assert.deepEqual(h.previews, []) } finally { h.stop() } }) function renderNodes(h: ReturnType) { const template = compileTemplate({ source: descriptor.template!.content, filename: 'CustomerAttachments.vue', id: 'attachments' }) assert.deepEqual(template.errors, []) const exports: any = {} new Function( 'require', 'exports', ts.transpile(template.code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext }) )(() => ({ ...Vue, resolveComponent: (name: string) => name }), exports) const nodes: any[] = [] function visit(node: any) { if (!node || typeof node !== 'object') return if (Array.isArray(node)) return node.forEach(visit) nodes.push(node) if (Array.isArray(node.children)) visit(node.children) } visit(exports.render(Vue.proxyRefs({ ...h.props, ...h }), [])) return nodes } test('readonly attachments render no native file input while edit mode keeps the picker', () => { const h = harness({ readonly: true, modelValue: 'customer/existing.png' }) try { assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 0) h.props.readonly = false assert.equal(renderNodes(h).filter((node) => node.type === 'input').length, 1) } finally { h.stop() } }) test('readonly attachment picker and removal events cannot mutate the field', () => { const h = harness({ readonly: true, modelValue: 'customer/existing.png' }) let clicks = 0 h.fileInputRef.value = { click: () => { clicks++ } } try { h.openPicker() assert.equal(clicks, 0) const existing = h.gridItems.value[0] h.onRemove(existing) assert.equal(h.fileList.value.length, 1) assert.deepEqual(h.events, []) h.onPreview(existing) h.onDownload(existing) assert.equal(h.previews.length, 1) assert.equal(h.downloads.length, 1) } finally { h.stop() } }) test('readonly change events and direct upload calls never send a file', async () => { const h = harness({ readonly: true, modelValue: 'customer/existing.png' }) h.resolve({ data: { fileUrl: 'customer/unwanted.png' } }) const raw = new File(['fixture'], 'unwanted.png', { type: 'image/png' }) const input = { files: [raw], value: 'selected' } try { await h.onPick({ target: input }) await h.uploadOne(raw) assert.equal(h.requests.length, 0) assert.equal(input.value, '') assert.equal(h.fileList.value.length, 1) assert.deepEqual(h.events, []) } finally { h.stop() } }) test('switching to readonly after opening the native picker rejects its late change event', async () => { const h = harness({ readonly: false }) let clicks = 0 h.fileInputRef.value = { click: () => { clicks++ } } h.resolve({ data: { fileUrl: 'customer/unwanted.png' } }) try { h.openPicker() assert.equal(clicks, 1) h.props.readonly = true await h.onPick({ target: { files: [new File(['x'], 'late.png')], value: 'selected' } }) assert.equal(h.requests.length, 0) assert.deepEqual(h.events, []) } finally { h.stop() } })