import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { parse } from '@vue/compiler-sfc' import { parseQuery, stringifyQuery } from 'vue-router' import ts from 'typescript' import { stripMpTicketFromUrl } from '../src/utils/mp-webview' const file = new URL('../../miniprogram/pages/common/mp-webview.vue', import.meta.url) const { descriptor } = parse(readFileSync(file, 'utf8')) const ast = ts.createSourceFile( 'mp-webview.js', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const declarations = ast.statements.filter( (node) => ts.isFunctionDeclaration(node) && ['bootstrapWebView', 'buildMpWebviewUrl', 'normalizePath', 'isAllowedPath'].includes( node.name?.text || '' ) ) // Run the page's actual URL composition with inert host/login and ticket fixtures. // This sandbox makes no browser, HTTP or database calls and reads no credentials. async function open(path: string, returnOrigin = '') { const bindings = { useUserStore: () => ({ checkLoginStatus() {}, isLoggedIn: true }), assertTicketEnvAligned() {}, fetchMpWebviewTicket: async () => 'fixture+ticket/=本次', resolveWebOrigin: () => 'https://h5.example.test/', window: returnOrigin ? { location: { origin: returnOrigin } } : undefined, errorText: { value: '' }, uni: { showToast: ({ title }: { title: string }) => assert.fail(title) } } const code = declarations.map((node) => node.getText(ast)).join('\n') const run = new Function( ...Object.keys(bindings), ` let pendingSrc = '' function tryMountWebView() {} ${code} return async (path) => { await bootstrapWebView(path) return pendingSrc } ` )(...Object.values(bindings)) const url = new URL(await run(path)) const hash = url.hash.slice(1) const question = hash.indexOf('?') return { url, path: hash.slice(0, question), query: new URLSearchParams(hash.slice(question + 1)) } } function assertBridge( result: Awaited>, expectedPath: string, returnOrigin = '' ) { assert.equal(result.url.origin, 'https://h5.example.test') assert.equal(result.url.pathname, '/') assert.equal(result.path, expectedPath) assert.equal(result.url.hash.match(/\?/g)?.length, 1) assert.equal(result.url.searchParams.get('mpTicket'), 'fixture+ticket/=本次') assert.deepEqual(result.query.getAll('mpTicket'), ['fixture+ticket/=本次']) assert.equal(result.url.searchParams.get('mpReturnOrigin'), returnOrigin || null) assert.equal(result.query.get('mpReturnOrigin'), returnOrigin || null) const cleaned = new URLSearchParams( stringifyQuery( stripMpTicketFromUrl(result.path, parseQuery(result.query.toString())) as Parameters< typeof stringifyQuery >[0] ) ) assert.equal(cleaned.has('mpTicket'), false) return cleaned } test('a route without business query keeps the root and hash ticket bootstrap contract', async () => { const result = await open('/m/crm/customer') assertBridge(result, '/m/crm/customer') }) test('copyFrom stays a business parameter and cannot absorb a second question mark and ticket', async () => { const result = await open('/m/crm/quote/form?copyFrom=quote-123') const cleaned = assertBridge(result, '/m/crm/quote/form') assert.deepEqual([...cleaned], [['copyFrom', 'quote-123']]) }) test('tabs, multiple values and platform metadata survive ticket insertion and cleanup', async () => { const result = await open( '/m/crm/customer/detail?tab=follow&id=c1&tag=a&tag=b&platform=weixin&mpPlatform=wechat' ) const cleaned = assertBridge(result, '/m/crm/customer/detail') assert.deepEqual( [...cleaned], [ ['tab', 'follow'], ['id', 'c1'], ['tag', 'a'], ['tag', 'b'], ['platform', 'weixin'], ['mpPlatform', 'wechat'] ] ) }) test('encoded business values keep their original encoding and decoded values', async () => { const query = 'copyFrom=%E5%AE%A2%E6%88%B7%3F%26%3D%2B%23%25&label=two+words&literal=%252F&flag&empty=' const result = await open(`/m/crm/quote/form?${query}`) const cleaned = assertBridge(result, '/m/crm/quote/form') assert.equal(cleaned.get('copyFrom'), '客户?&=+#%') assert.equal(cleaned.get('label'), 'two words') assert.equal(cleaned.get('literal'), '%2F') assert.equal(cleaned.get('flag'), '') assert.equal(cleaned.get('empty'), '') assert.ok(result.url.hash.includes(`?${query}&mpTicket=`)) }) test('new ticket and return origin replace all old bridge values including encoded keys', async () => { const returnOrigin = 'https://mini.example.test:8080' const result = await open( '/m/crm/quote/form?mpTicket=old©From=q1&%6DpTicket=older&mpReturnOrigin=https%3A%2F%2Fold.example.test&platform=h5', returnOrigin ) const cleaned = assertBridge(result, '/m/crm/quote/form', returnOrigin) assert.deepEqual(result.query.getAll('mpReturnOrigin'), [returnOrigin]) assert.deepEqual( [...cleaned], [ ['copyFrom', 'q1'], ['platform', 'h5'] ] ) }) test('a native launch without return origin removes stale bridge metadata and retains business data', async () => { const result = await open( '/m/crm/customer?tab=follow&mpReturnOrigin=https%3A%2F%2Fold.example.test&mpTicket=old' ) const cleaned = assertBridge(result, '/m/crm/customer') assert.deepEqual([...cleaned], [['tab', 'follow']]) }) test('an empty existing query delimiter yields exactly one hash query', async () => { const result = await open('/m/crm/customer?') assertBridge(result, '/m/crm/customer') }) test('existing bounded path decoding still preserves encoded business query data', () => { const code = declarations .filter( (node) => ts.isFunctionDeclaration(node) && ['normalizePath', 'isAllowedPath'].includes(node.name?.text || '') ) .map((node) => node.getText(ast)) .join('\n') const { normalizePath, isAllowedPath } = new Function( `${code}\nreturn { normalizePath, isAllowedPath }` )() const path = '/m/crm/quote/form?copyFrom=q%2B1&tab=follow' for (const input of [ path, encodeURIComponent(path), encodeURIComponent(encodeURIComponent(path)) ]) { assert.equal(normalizePath(input), path) assert.equal(isAllowedPath(normalizePath(input)), true) } assert.equal(isAllowedPath('/crm/quote/form'), false) })