Files
shanxia2.0/web-admin/scripts/mobile-mp-webview-url.test.ts
2026-10-22 16:24:46 +08:00

179 lines
6.3 KiB
TypeScript

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&copyFrom=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)
})