| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- 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<ReturnType<typeof open>>,
- 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)
- })
|