import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync, existsSync } from 'node:fs' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' function deferred() { let resolve!: (value?: any) => void let reject!: (error: any) => void const promise = new Promise((a, b) => { resolve = a reject = b }) return { promise, resolve, reject } } const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() } // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url) function evaluate(page: string, props: any, overrides: Record, events: any[]) { const source = parse( readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true) const vueEntries = Object.entries(vue).filter( ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default' ) const bindings: Record = Object.fromEntries(vueEntries) delete bindings.default for (const statement of ast.statements) { if (!ts.isImportDeclaration(statement)) continue const clause = statement.importClause if (clause?.name) bindings[clause.name.text] = () => undefined if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined } if (existsSync(helperPath)) { const helper = ts.transpile( readFileSync(helperPath, 'utf8').replace('export function', 'function'), { target: ts.ScriptTarget.ESNext } ) bindings.useCustomerPanelIdentity = new Function( ...vueEntries.map(([k]) => k), `${helper};return useCustomerPanelIdentity` )(...vueEntries.map(([, v]) => v)) } Object.assign(bindings, { defineProps: () => props, defineEmits: () => (...args: any[]) => events.push(args), defineExpose() {}, useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }), unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []), unwrapData: (v: any) => v?.data || v, canDeleteByCreator: () => true, contactFields: [{ key: 'contactName', required: true }], invoiceFields: [{ key: 'companyName' }], requiredFieldsError: () => '', contactDetailsError: () => '', percentageError: () => '', apiTime: (v: any) => v, opportunityProgress: () => ({}), showCrmConfirmDialog: async () => {}, fetchContactByCustomerId: async () => [], fetchFollowByCustomerId: async () => [], getInvoiceInfoByCustomerId: async () => [], fetchContactDetail: async (v: any) => v, fetchUserOptions: async () => [], fetchCustomerOptions: async () => [], listOpportunity: async () => [], listContract: async () => [], listOperLog: async () => [], getOpportunity: async (id: string) => ({ id }), listFollowByOpportunity: async () => [], getStageLogList: async () => [], ...overrides }) const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s)) const names = statements.flatMap((s) => ts.isFunctionDeclaration(s) ? [s.name!.text] : ts.isVariableStatement(s) ? s.declarationList.declarations.map((d) => d.name.getText(ast)) : [] ) const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)( ...Object.values(bindings) ) } function mount(page: string, overrides: Record = {}) { const routeState = vue.reactive({ params: { id: 'A' }, query: { tab: 'basic' }, path: '/A' }) const route = { get id() { return routeState.params.id }, set id(id: string) { routeState.params.id = id }, get tab() { return routeState.query.tab }, set tab(tab: string) { routeState.query.tab = tab } } const events: any[] = [] let api: any let actions: any const renderer = vue.createRenderer({ patchProp() {}, insert() {}, remove() {}, createElement: () => ({}), createText: () => ({}), createComment: () => ({}), setText() {}, setElementText() {}, parentNode: () => null, nextSibling: () => null }) const NestedActions = vue.defineComponent({ props: ['opportunity', 'getIdentity'], setup(props) { actions = evaluate('OpportunityActions', props, overrides, events) return () => vue.h('div') } }) const Child = vue.defineComponent({ props: ['customerId', 'opportunity', 'getIdentity', 'kind'], setup(props) { api = evaluate( page === 'RelatedPanelActions' ? 'RelatedPanel' : page, props, overrides, events ) return () => page === 'RelatedPanelActions' && api.detail.value && api.detailOpen.value ? vue.h(NestedActions, { opportunity: api.detail.value, getIdentity: api.identity.key }) : vue.h('div') } }) const Parent = vue.defineComponent({ setup() { const parent = evaluate( 'detail', {}, { useRoute: () => routeState, useRouter: () => ({ replace() {}, push() {} }) }, [] ) const getIdentity = parent.getPanelIdentity || (() => route.id) return () => vue.h(Child, { customerId: route.id, opportunity: { id: route.id, status: 'following' }, kind: 'opportunity', getIdentity }) } }) const app = renderer.createApp(Parent) app.mount({}) return { get api() { return api }, get actions() { return actions }, route, events, unmount: () => app.unmount() } } const listApis: Record = { ContactPanel: 'fetchContactByCustomerId', FollowPanel: 'fetchFollowByCustomerId', InvoicePanel: 'getInvoiceInfoByCustomerId', RelatedPanel: 'listOpportunity' } for (const [page, read] of Object.entries(listApis)) { test(`${page}: late initial load cannot overwrite B or ABA`, async () => { const old = deferred() let calls = 0 const h = mount(page, { [read]: () => (++calls === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }) h.route.id = 'B' await flush() h.route.id = 'A' await flush() old.resolve([{ id: 'old' }]) await flush() assert.deepEqual(h.api.rows.value, [{ id: 'fresh' }]) h.unmount() }) } const deletes: Record = { ContactPanel: 'fetchDeleteContact', FollowPanel: 'fetchDeleteCustomerFollow', InvoicePanel: 'delInvoiceInfo', OpportunityActions: 'delOpportunity' } for (const [page, write] of Object.entries(deletes)) { for (const outcome of ['switch', 'ABA', 'unmount']) test(`${page}: confirmation resolves before same-tick ${outcome}`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount(page, { showCrmConfirmDialog: () => confirm.promise, [write]: async (v: any) => writes.push(v) }) await flush() const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' }) confirm.resolve() if (outcome === 'unmount') h.unmount() else { h.route.id = 'B' if (outcome === 'ABA') h.route.id = 'A' } await pending assert.deepEqual(writes, []) assert.deepEqual(h.events, []) if (outcome !== 'unmount') h.unmount() }) test(`${page}: same-ID tab query retains legitimate confirmation`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount(page, { showCrmConfirmDialog: () => confirm.promise, [write]: async (v: any) => writes.push(v) }) await flush() const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' }) h.route.tab = 'contact' confirm.resolve() await pending assert.equal(writes.length, 1) h.unmount() }) } test('ContactPanel: reversed detail opens retain latest row', async () => { const first = deferred(), second = deferred() const h = mount('ContactPanel', { fetchContactDetail: ({ id }: any) => (id === 'one' ? first.promise : second.promise) }) const a = h.api.open({ id: 'one' }), b = h.api.open({ id: 'two' }) second.resolve({ id: 'two' }) await b first.resolve({ id: 'one' }) await a assert.equal(h.api.form.value.id, 'two') h.unmount() }) test('OpportunityActions: reversed customer searches retain latest query', async () => { const first = deferred(), second = deferred() const h = mount('OpportunityActions', { fetchCustomerOptions: ({ keyword }: any) => (keyword === 'one' ? first.promise : second.promise) }) await h.api.open('edit') h.api.customerKeyword.value = 'one' const a = h.api.searchCustomers() h.api.customerKeyword.value = 'two' const b = h.api.searchCustomers() second.resolve([{ id: 'two', customerName: 'two' }]) await b first.resolve([{ id: 'one', customerName: 'one' }]) await a assert.equal(h.api.customers.value[0].value, 'two') h.unmount() }) const saves: Record = { ContactPanel: 'fetchCreateContact', FollowPanel: 'fetchCreateCustomerFollow', InvoicePanel: 'addInvoiceInfo', OpportunityActions: 'addOpportunityFollow' } for (const [page, write] of Object.entries(saves)) { test(`${page}: old save completion does not close, emit or clear new busy`, async () => { const old = deferred(), fresh = deferred() let count = 0 const h = mount(page, { [write]: () => (++count === 1 ? old.promise : fresh.promise) }) await flush() await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open()) Object.assign(h.api.form.value, { contactName: 'A', companyName: 'A', followContent: 'A', followType: 'phone' }) const a = h.api.save() h.route.id = 'B' await flush() await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open()) Object.assign(h.api.form.value, { contactName: 'B', companyName: 'B', followContent: 'B', followType: 'phone' }) const b = h.api.save() old.resolve() await a assert.equal((h.api.acting || h.api.busy).value, true) assert.equal( (h.api.editing || h.api.mode).value, page === 'OpportunityActions' ? 'follow' : true ) assert.deepEqual(h.events, []) fresh.resolve() await b assert.equal((h.api.acting || h.api.busy).value, false) h.unmount() }) } test('FollowPanel: close/reopen ignores old contacts error', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('FollowPanel', { fetchContactByCustomerId: () => (++calls === 1 ? first.promise : second.promise) }) const a = h.api.open() h.api.closeEditing() const b = h.api.open() second.resolve([{ id: 'new' }]) await b first.reject(new Error('old error')) await a assert.equal(h.api.saveError.value, '') assert.deepEqual(h.api.contacts.value, [{ id: 'new' }]) h.unmount() }) test('RelatedPanel: reversed opportunity opens keep fresh detail and actions', async () => { const first = deferred(), second = deferred() const h = mount('RelatedPanel', { getOpportunity: (id: string) => (id === 'one' ? first.promise : second.promise) }) const a = h.api.open({ id: 'one' }), b = h.api.open({ id: 'two' }) second.resolve({ id: 'two' }) await b first.resolve({ id: 'one' }) await a assert.equal(h.api.detail.value.id, 'two') h.unmount() }) for (const transition of ['openB', 'close', 'closeReopenA', 'routeABA']) { test(`nested RelatedPanel/OpportunityActions: confirmation cannot survive ${transition}`, async () => { const confirm = deferred(), writes: any[] = [] const h = mount('RelatedPanelActions', { showCrmConfirmDialog: () => confirm.promise, delOpportunity: async (id: string) => writes.push(id) }) await flush() await h.api.open({ id: 'opportunity-A' }) await flush() const pending = h.actions.remove() confirm.resolve() if (transition === 'openB') void h.api.open({ id: 'opportunity-B' }) if (transition === 'close') h.api.closeDetail() if (transition === 'closeReopenA') { h.api.closeDetail() void h.api.open({ id: 'opportunity-A' }) } if (transition === 'routeABA') { h.route.id = 'B' h.route.id = 'A' } await pending assert.deepEqual(writes, []) assert.deepEqual(h.events, []) h.unmount() }) } for (const [page, read] of Object.entries(listApis)) { test(`${page}: old list error and finally cannot overwrite B loading`, async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount(page, { [read]: () => (++calls === 1 ? first.promise : second.promise) }) h.route.id = 'B' await flush() first.reject(new Error('old error')) await flush() assert.equal(h.api.error.value, '') assert.equal(h.api.loading.value, true) second.resolve([{ id: 'B' }]) await flush() assert.deepEqual(h.api.rows.value, [{ id: 'B' }]) h.unmount() }) } test('ContactPanel: stale primary confirmation cannot set a row on the next customer', async () => { const confirm = deferred(), writes: any[] = [] const h = mount('ContactPanel', { showCrmConfirmDialog: () => confirm.promise, fetchSetPrimaryContact: async (v: any) => writes.push(v) }) await flush() const pending = h.api.primary({ id: 'contact-A' }) confirm.resolve() h.route.id = 'B' await pending assert.deepEqual(writes, []) h.unmount() }) test('InvoicePanel: default write failure cannot replace the next customer error or busy', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('InvoicePanel', { request: { post: () => (++calls === 1 ? first.promise : second.promise) } }) await flush() const a = h.api.setDefault({ id: 'invoice-A' }) h.route.id = 'B' await flush() const b = h.api.setDefault({ id: 'invoice-B' }) first.reject(new Error('old error')) await a assert.equal(h.api.error.value, '') assert.equal(h.api.acting.value, true) second.resolve() await b assert.equal(h.api.acting.value, false) h.unmount() }) test('OpportunityActions: old owner options cannot replace a reopened edit session', async () => { const first = deferred(), second = deferred() let calls = 0 const h = mount('OpportunityActions', { fetchUserOptions: () => (++calls === 1 ? first.promise : second.promise) }) const a = h.api.open('edit') const b = h.api.open('edit') second.resolve([{ value: 'B', label: 'B' }]) await b first.resolve([{ value: 'A', label: 'A' }]) await a assert.deepEqual(h.api.users.value, [{ value: 'B', label: 'B' }]) h.unmount() }) test('OpportunityActions: delete uses the captured follow ID', async () => { const confirm = deferred(), writes: any[] = [] const h = mount('OpportunityActions', { showCrmConfirmDialog: () => confirm.promise, delOpportunityFollow: async (v: any) => writes.push(v) }) const row = { id: 'follow-A' } const pending = h.api.remove(row) row.id = 'follow-B' confirm.resolve() await pending assert.deepEqual(writes, ['follow-A']) assert.deepEqual(h.events, [['changed']]) h.unmount() })