Files
xuetang/TUICallKit-Vue3/build/tang-detective-native-plugin.test.mjs
2026-09-09 14:47:29 +08:00

263 lines
16 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import nativePlugin, {
copyNativeProgram,
createNativeManifest,
listFiles,
mergeNativeAppManifest,
sha256,
transformNativeText,
wrapNativePage,
} from './tang-detective-native-plugin.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const mediaPattern = /\.(?:jpg|jpeg|png|webp|mp3|wav|aac|m4a|ogg)$/i
function write(directory, relative, content) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, content)
}
function fixture(t) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-native-test-')))
// This directory is created and exclusively owned by this individual test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
const overlayDirectory = path.join(temporary, 'overlay')
const hostApp = {
pages: ['pages/index/index'],
subPackages: [{ root: 'tongji', pages: ['endless-game/index'] }],
window: { navigationStyle: 'default', pageOrientation: 'portrait', backgroundColor: '#ffffff' },
tabBar: { list: [{ pagePath: 'pages/index/index', text: '首页' }] },
permission: { 'scope.record': { desc: '通话' } },
preloadRule: { 'pages/index/index': { network: 'wifi', packages: ['tongji'] } },
}
write(outputDirectory, 'app.json', JSON.stringify(hostApp))
write(outputDirectory, 'app.js', '/* host App entry must stay unchanged */')
write(outputDirectory, 'app.wxss', '/* host global styles must stay unchanged */')
write(outputDirectory, 'project.config.json', '{"description":"host project"}')
write(outputDirectory, 'pages/index/index.js', '/* host home page */')
write(outputDirectory, 'tongji/endless-game/index.js', '/* existing game */')
write(overlayDirectory, 'utils/tangPage.js', 'module.exports = function (definition) { return Page(definition) }\n')
const options = { sourceDirectory, outputDirectory, overlayDirectory, apiBaseUrl: 'https://api.example.test', mediaManifest: null }
return { ...options, options, hostApp }
}
function packageOwner(relative, manifest) {
return manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
}
test('imported snapshot matches every recorded source hash and preserves all 204 media files', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
assert.equal(manifest.files.length, 473)
assert.equal(listFiles(sourceDirectory).length, manifest.files.length)
assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204)
for (const file of manifest.files) {
const bytes = fs.readFileSync(path.join(sourceDirectory, file.path))
assert.equal(bytes.length, file.bytes, file.path)
assert.equal(sha256(bytes), file.sha256, file.path)
}
assert.equal(fs.existsSync(path.join(sourceDirectory, 'app.js')), false)
})
test('path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires', () => {
const source = [
"const a = '/pages/share/share?x=1'",
'const b = "/assets/share/card.jpg"',
"const c = 'package-game'",
'const d = `package-chapter-${number}`',
'const e = `/${packageRoot}/assets/comic/page.jpg`',
"const f = require('../../../utils/storage')",
"const remote = 'https://cdn.example.test/assets/share/card.jpg'",
String.raw`const valid = /^\/package-[a-z0-9-]+\//i`,
].join('\n')
const result = transformNativeText(source, 'example.js')
assert.match(result, /'\/tang-detective\/pages\/share\/share\?x=1'/)
assert.match(result, /"\/tang-detective\/assets\/share\/card.jpg"/)
assert.match(result, /'tang-detective\/package-game'/)
assert.ok(result.includes('`tang-detective/package-chapter-${number}`'))
assert.ok(result.includes('`/${packageRoot}/assets/comic/page.jpg`'))
assert.ok(result.includes("require('../../../utils/storage')"))
assert.ok(result.includes("'https://cdn.example.test/assets/share/card.jpg'"))
assert.ok(result.includes(String.raw`/^\/tang-detective\/package-[a-z0-9-]+\//i`))
assert.equal(transformNativeText(result, 'example.js'), result)
new vm.Script(result)
})
test('manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes', () => {
const manifest = createNativeManifest(sourceApp)
assert.equal(manifest.pages.length, 6)
assert.equal(manifest.subPackages.length, 18)
assert.equal(manifest.allPages.length, 24)
const host = { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } }
const merged = mergeNativeAppManifest(host, manifest)
assert.deepEqual(host, { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } })
assert.deepEqual(merged.window, host.window)
assert.equal(merged.subPackages, undefined)
assert.deepEqual(mergeNativeAppManifest(merged, manifest), merged)
assert.deepEqual(merged.preloadRule['tang-detective/package-game/pages/chapter/chapter'].packages, ['tang-detective-audio-c01-a'])
assert.throws(() => mergeNativeAppManifest({ pages: [manifest.subPackages[0].root + '/pages/chapter/chapter'] }, manifest), /already a host main page/)
assert.throws(() => mergeNativeAppManifest({ subPackages: [{ root: manifest.subPackages[0].root, pages: ['wrong'] }] }, manifest), /conflicts with host/)
})
test('all native pages copy into owned output with legal relative requires and unchanged host/media bytes', t => {
const context = fixture(t)
const guardedFiles = ['app.js', 'app.wxss', 'project.config.json', 'pages/index/index.js', 'tongji/endless-game/index.js']
const before = new Map(guardedFiles.map(file => [file, sha256(fs.readFileSync(path.join(context.outputDirectory, file)))]))
// The final adapter path must be copied literally, without a second prefix pass.
const overlay = "module.exports = { home: '/tang-detective/pages/home/home', host: '/pages/index/index' }\n"
write(context.overlayDirectory, 'utils/overlayProbe.js', overlay)
// Final safety UI is added after adapters, including a fully replaced home template.
const homeTemplate = '<view>adapted home</view>'
write(context.overlayDirectory, 'pages/home/home.wxml', homeTemplate)
const report = copyNativeProgram(context.options)
assert.equal(report.pages.length, 24)
assert.equal(report.sizes.mediaFileCount, 204)
const nativeDirectory = path.join(context.outputDirectory, 'tang-detective')
assert.equal(fs.readFileSync(path.join(nativeDirectory, 'utils/overlayProbe.js'), 'utf8'), overlay)
assert.ok(fs.readFileSync(path.join(nativeDirectory, 'pages/home/home.wxml'), 'utf8').startsWith(homeTemplate))
const sharedStyle = fs.readFileSync(path.join(nativeDirectory, 'shared.wxss'), 'utf8')
assert.equal((sharedStyle.match(/\.tang-boot-mask\s*\{/g) || []).length, 1)
const maskStyle = sharedStyle.slice(sharedStyle.indexOf('.tang-boot-mask'))
for (const declaration of ['position: fixed', 'inset: 0', 'z-index: 2147483647', 'background: #201711', 'color: #f3e5bd', 'font-size: 18px', 'align-items: center', 'justify-content: center']) {
assert.ok(maskStyle.includes(declaration), declaration)
}
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
assert.deepEqual(localRequire(path.join(nativeDirectory, 'utils/platformConfig.js')), { apiBaseUrl: context.apiBaseUrl })
for (const file of guardedFiles) assert.equal(sha256(fs.readFileSync(path.join(context.outputDirectory, file))), before.get(file), file)
const merged = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8'))
for (const key of ['window', 'tabBar', 'permission']) assert.deepEqual(merged[key], context.hostApp[key])
assert.deepEqual(merged.preloadRule['pages/index/index'], context.hostApp.preloadRule['pages/index/index'])
for (const relative of listFiles(sourceDirectory).filter(file => mediaPattern.test(file))) {
assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(sourceDirectory, relative))), relative)
}
for (const page of report.pages) {
const config = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, `${page}.json`), 'utf8'))
assert.equal(config.navigationStyle, 'custom', page)
assert.equal(config.pageOrientation, 'landscape', page)
const style = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxss`), 'utf8')
assert.ok(style.startsWith('@import "/tang-detective/shared.wxss";'), page)
const script = fs.readFileSync(path.join(context.outputDirectory, `${page}.js`), 'utf8')
assert.doesNotMatch(script, /^Page\(\{/m, page)
assert.match(script, /require\("\.\.\/(?:\.\.\/)*utils\/tangPage\.js"\)\(\{/, page)
const template = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxml`), 'utf8')
assert.equal((template.match(/class="tang-boot-mask"/g) || []).length, 1, page)
assert.ok(template.trimEnd().endsWith('<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'), page)
}
let dependencyCount = 0
for (const relative of listFiles(nativeDirectory).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeDirectory, relative)
const script = fs.readFileSync(filename, 'utf8')
new vm.Script(script, { filename: relative })
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
assert.ok(specifier.startsWith('.'), `${relative}: native dependencies must be relative (${specifier})`)
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const resolvedRelative = path.relative(context.outputDirectory, resolved).split(path.sep).join('/')
assert.ok(resolvedRelative.startsWith('tang-detective/'), `${relative}: dependency escaped namespace`)
const caller = packageOwner(`tang-detective/${relative}`, report.nativeManifest)
const dependency = packageOwner(resolvedRelative, report.nativeManifest)
assert.ok(dependency === 'main' || caller === dependency, `${relative} imports a sibling subpackage: ${specifier}`)
dependencyCount += 1
}
}
assert.ok(dependencyCount > 300)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.js')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.json')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'sitemap.json')), false)
const firstState = fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8')
copyNativeProgram(context.options)
assert.equal(fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8'), firstState)
assert.equal(JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')).pages.length, 7)
})
test('120 chapter page image selections and C01 full tracks remain in main or their own subpackage', t => {
const context = fixture(t)
const report = copyNativeProgram(context.options)
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
const root = path.join(context.outputDirectory, 'tang-detective')
const season = localRequire(path.join(root, 'data/season.js'))
const routing = localRequire(path.join(root, 'utils/chapterRoute.js'))
function assertLocalAsset(asset, owner) {
if (!asset) return
assert.ok(asset.startsWith('/tang-detective/'), asset)
const relative = asset.slice(1)
const targetOwner = packageOwner(relative, report.nativeManifest)
assert.ok(targetOwner === 'main' || targetOwner === owner, `Illegal sibling asset read: ${owner} -> ${asset}`)
assert.ok(fs.existsSync(path.join(context.outputDirectory, relative)), `Missing selected asset: ${asset}`)
}
let pages = 0
for (let number = 1; number <= 15; number += 1) {
const packageRoot = routing.chapterPackageRoot(number)
assert.ok(report.pages.includes(routing.chapterRoute(number).split('?')[0].slice(1)))
const chapter = season.chapters.find(item => item.chapterNumber === number)
const chapterPages = localRequire(path.join(context.outputDirectory, packageRoot, 'pages/chapter/chapterPages.js'))
const modelUtils = localRequire(path.join(context.outputDirectory, packageRoot, 'utils/comicPageModel.js'))
const { releaseAssets } = localRequire(path.join(context.outputDirectory, packageRoot, 'data/releaseAssetManifest.js'))
assert.equal(modelUtils.isPackagedPath(`/${packageRoot}/assets/example.jpg`), true)
assert.equal(modelUtils.isPackagedPath('/package-game/assets/example.jpg'), false)
const model = chapterPages.buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const page of model.pageSequence) {
const [, seasonNumber, chapterNumber, pageNumber] = page.pageId.match(/^S(\d+)-C(\d+)-P(\d+)$/)
const asset = releaseAssets[`comic.s${seasonNumber}.c${chapterNumber}.p${pageNumber}`]
const image = modelUtils.buildComicImageState(page, asset, {})
assertLocalAsset(image.src, packageRoot)
assertLocalAsset(image.fallback, packageRoot)
pages += 1
}
}
assert.equal(pages, 120)
for (const audioRoot of ['tang-detective/package-audio-c01-a', 'tang-detective/package-audio-c01-b']) {
const tracks = localRequire(path.join(context.outputDirectory, audioRoot, 'data/audioPages.js'))
for (const track of Object.values(tracks)) {
assertLocalAsset(track.audioSrc, audioRoot)
assertLocalAsset(track.imageSrc, audioRoot)
assert.equal(track.reviewStatus, 'technical-qa-pass-human-listening-pending')
}
}
})
test('overlay protection and owned cleanup preserve media, host state, and external edits', t => {
const context = fixture(t)
write(context.overlayDirectory, 'utils/obsolete.js', 'module.exports = 1')
copyNativeProgram(context.options)
fs.unlinkSync(path.join(context.overlayDirectory, 'utils/obsolete.js'))
copyNativeProgram(context.options)
assert.equal(fs.existsSync(path.join(context.outputDirectory, 'tang-detective/utils/obsolete.js')), false)
write(context.outputDirectory, 'tang-detective/utils/storage.js', '/* external user edit */')
assert.throws(() => copyNativeProgram(context.options), /unowned native output/)
write(context.overlayDirectory, 'app.js', 'App({})')
assert.throws(() => copyNativeProgram(context.options), /cannot replace the host app/)
fs.unlinkSync(path.join(context.overlayDirectory, 'app.js'))
write(context.overlayDirectory, 'assets/replacement.jpg', 'not an allowed media replacement')
assert.throws(() => copyNativeProgram(context.options), /cannot replace source media/)
})
test('page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only', () => {
assert.equal(wrapNativePage('Page({\n})', 'pages/home/home.js'), 'require("../../utils/tangPage.js")({\n})')
assert.throws(() => wrapNativePage('Page({})\nPage({})', 'pages/home/home.js'), /one top-level/)
const plugin = nativePlugin()
assert.equal(plugin.enforce, 'post')
assert.equal(plugin.writeBundle.order, 'post')
assert.equal(plugin.writeBundle.sequential, true)
const previous = process.env.UNI_PLATFORM
try {
process.env.UNI_PLATFORM = 'h5'
assert.equal(plugin.apply(), false)
process.env.UNI_PLATFORM = 'mp-weixin'
assert.equal(plugin.apply(), true)
} finally {
if (previous === undefined) delete process.env.UNI_PLATFORM
else process.env.UNI_PLATFORM = previous
}
})