import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import { ref, reactive, computed, nextTick, watch, effectScope } from 'vue' function harness(file: string) { const base = `/m/pl/${file}` let path = base let leave: any let unmount: any const hooks = new Set() const scrolls: number[] = [] const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a' } }) const bindings = { ref, nextTick, watch, useRoute: () => ({ path }), useRouter: () => ({ afterEach: (fn: any) => { hooks.add(fn) return () => hooks.delete(fn) } }), useUserStore: () => user, onBeforeRouteLeave: (fn: any) => { leave = fn }, onBeforeUnmount: (fn: any) => { unmount = fn }, window: { scrollY: 720, scrollTo: ({ top }: any) => scrolls.push(top) } } const helper = readFileSync( new URL('../src/views/m/crm/useListReturnState.ts', import.meta.url), 'utf8' ) .replace(/^import .*$/gm, '') .replace('export function', 'function') const useState = new Function( ...Object.keys(bindings), ts.transpile(helper, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState' )(...Object.values(bindings)) const descriptor = parse( readFileSync(new URL(`../src/views/m/pl/${file}/index.vue`, import.meta.url), 'utf8') ).descriptor const source = ts.createSourceFile( 'page.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const scopes: ReturnType[] = [] function create(enableRetry = true) { const requests: any[] = [] const state: any = { userStore: user, page: ref(1), plans: ref([]), total: ref(60), loading: ref(false), error: ref(''), forbidden: ref(false), failedMore: ref(false), selected: ref(new Set()), expandedIds: ref(new Set()), pageSize: 20, plPlanListTabToFilters: () => ({}), HttpError: Error, ApiStatus: { forbidden: 403 }, planApi: { list: (params: any) => new Promise((resolve, reject) => requests.push({ params, resolve, reject })) } } for (const key of [ 'keyword', 'appliedKeyword', 'activeTab', 'dateShortcut', 'customFrom', 'customTo', 'customDateLabel', 'tab', 'dateKind', 'readyKind' ]) state[key] = ref('') for (const key of ['dateRange', 'dates']) state[key] = ref([]) state.filterState = ref({}) state.sortState = ref({}) state.hasMore = computed(() => state.plans.value.length < state.total.value) state.query = () => ({ q: state.appliedKeyword.value, ...state.filterState.value }) const names = ['requestKey', 'buildParams', 'fetchPage', 'load', 'retryLoad'] const code = source.statements .filter( (n) => (ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')) || (ts.isVariableStatement(n) && n.declarationList.declarations.some((d) => ['sessionScope', 'listReturnState'].includes(d.name.getText(source)) )) ) .map((n) => n.getText(source)) .join('\n') const scope = effectScope() scopes.push(scope) const api = scope.run(() => new Function( ...Object.keys(state), 'useListReturnState', `let disposed=false;let requestVersion=0;${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {state:listReturnState,retry:typeof retryLoad==='function'?retryLoad:undefined,run:${file === 'plan' ? 'fetchPage' : '(reset)=>load(!reset)'}}` )(...Object.values(state), (options: any) => { if (!enableRetry) delete options.retryFailedRestore return useState(options) }) ) return { ...state, ...api, requests } } function navigate(to: string) { const from = path path = to for (const fn of hooks) fn({ path: to }, { path: from }) } function returned(enableRetry = true) { const first = create() first.keyword.value = first.appliedKeyword.value = 'saved' first.page.value = 3 first.expandedIds.value = new Set(['row-2']) leave({ path: `${base}/row-2` }) navigate(`${base}/row-2`) unmount() navigate(base) return create(enableRetry) } return { create, returned, user, scrolls, leave: () => leave({ path: '/workbench' }), unmount: () => unmount(), close: () => scopes.forEach((s) => s.stop()), template: descriptor.template!.content } } async function flush() { for (let i = 0; i < 8; i++) await nextTick() } async function success(h: any, index: number) { assert.ok(h.requests[index], `request ${index} exists`) const page = h.requests[index].params.page h.requests[index].resolve({ list: [{ id: `row-${page}` }], total: 60 }) await flush() } for (const file of ['plan', 'plan-cert']) { for (const failedPage of [1, 2]) test(`${file}: page ${failedPage} restoration failure retries via real page handler then consumes snapshot`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() if (failedPage === 2) await success(h, 0) h.requests[failedPage - 1].reject(new Error('offline')) await first assert.equal(h.keyword.value, 'saved') assert.equal(h.state.restoring.value, false) assert.equal(typeof h.retry, 'function', 'page must route retry through restoration') assert.match(env.template, /@click="retryLoad"/) const start = h.requests.length const retry = h.retry() await flush() for (let i = 0; i < 3; i++) await success(h, start + i) await retry assert.equal(h.page.value, 3) assert.deepEqual([...h.expandedIds.value], ['row-2']) assert.deepEqual(env.scrolls, [720]) const ordinary = h.retry() await flush() await success(h, h.requests.length - 1) await ordinary assert.equal(h.page.value, 4) assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) test(`${file}: duplicate retry does not create requests or release restoring`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() await h.retry() assert.equal(h.requests.length, 2) assert.equal(h.state.restoring.value, true) for (let i = 1; i <= 3; i++) await success(h, i) await retry assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) for (const change of ['query', 'account', 'tenant', 'unmount', 'leave']) test(`${file}: ${change} invalidates failed snapshot permanently`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first if (change === 'query') { h.keyword.value = 'new' h.keyword.value = 'saved' } if (change === 'account') { env.user.accessToken = 'b' env.user.accessToken = 'a' } if (change === 'tenant') { env.user.getUserInfo.tenantNo = 'b' env.user.getUserInfo.tenantNo = 'a' } if (change === 'unmount') env.unmount() if (change === 'leave') env.leave() assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() if (h.requests.length > 1) await success(h, 1) await retry assert.ok(h.requests.length <= 2) assert.deepEqual(env.scrolls, []) assert.deepEqual([...h.expandedIds.value], []) } finally { env.close() } }) test(`${file}: obsolete finally cannot release a newer restore`, async () => { const env = harness(file) try { const h = env.returned() const old = h.state.restore() h.keyword.value = 'new' const fresh = h.state.restore() h.requests[0].reject(new Error('old')) await old assert.equal(h.state.restoring.value, true) await success(h, 1) await fresh assert.equal(h.state.restoring.value, false) assert.deepEqual(env.scrolls, []) } finally { env.close() } }) for (const more of [false, true]) test(`${file}: ordinary ${more ? 'pagination' : 'initial'} retry keeps existing semantics`, async () => { const env = harness(file) try { const h = env.create() const failed = h.run(!more) h.requests[0].reject(new Error('offline')) await failed assert.equal(typeof h.retry, 'function') const retry = h.retry() await flush() assert.equal(h.requests[1].params.page, more ? 2 : 1) await success(h, 1) await retry assert.deepEqual(env.scrolls, []) } finally { env.close() } }) } for (const file of ['plan', 'plan-cert']) { test(`${file}: absent opt-in retains one-shot CRM default restoration`, async () => { const env = harness(file) try { const h = env.returned(false) const first = h.state.restore() await success(h, 0) h.requests[1].reject(new Error('offline')) await first const retry = h.retry() await success(h, 2) await retry assert.equal(h.page.value, 2) assert.equal(h.requests.length, 3) assert.deepEqual([...h.expandedIds.value], []) assert.deepEqual(env.scrolls, []) } finally { env.close() } }) for (const invalidation of ['query', 'unmount', 'leave']) { test(`${file}: pending retry ${invalidation} cannot restore expansion or scroll`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first const retry = h.retry() if (invalidation === 'query') { h.keyword.value = 'new' h.keyword.value = 'saved' } if (invalidation === 'unmount') env.unmount() if (invalidation === 'leave') env.leave() await success(h, 1) await retry assert.equal(h.requests.length, 2) assert.deepEqual([...h.expandedIds.value], []) assert.deepEqual(env.scrolls, []) assert.equal(h.state.restoring.value, false) } finally { env.close() } }) } } for (const file of ['plan', 'plan-cert']) { test(`${file}: equivalent user info replacement preserves failed return snapshot`, async () => { const env = harness(file) try { const h = env.returned() const first = h.state.restore() h.requests[0].reject(new Error('offline')) await first env.user.getUserInfo = { tenantNo: 'a' } const retry = h.retry() for (let i = 1; i <= 3; i++) await success(h, i) await retry assert.equal(h.page.value, 3) assert.deepEqual([...h.expandedIds.value], ['row-2']) assert.deepEqual(env.scrolls, [720]) } finally { env.close() } }) }