179 lines
6.3 KiB
TypeScript
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©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)
|
|
})
|