263 lines
16 KiB
JavaScript
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
|
|
}
|
|
})
|