This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,28 @@
# 唐侦探原生页面接入
`native/tang-detective/` 是原生微信页面、数据和素材的原字节快照。原 `app.js``sitemap.json` 不导入;`app.json` 仅用作构建元数据,`app.wxss` 输出为游戏页面局部导入的 `shared.wxss`。不要直接修改快照。源文件哈希登记在 `tang-detective-source-manifest.json`
`tang-detective-native-plugin.mjs` 仅在微信构建启用。它以 `writeBundle: { order: 'post', sequential: true }` 在 uni 产物生成后完成以下工作,监听重建时同样执行:
1. 将原页面和分包输出到 `tang-detective/` 命名空间,修改本地绝对路径、动态分包根及路径白名单。
2. 给 24 个页面补齐横屏、自定义导航配置和局部共享样式。
3. 逐字节覆盖 `native-adapter/tang-detective/` 的适配文件;适配文件必须写最终输出路径,不进行第二次路径替换。
4. 将全部页面的唯一顶层 `Page({` 注册转换为相对引入根 `utils/tangPage.js` 的调用;由适配层负责账号存档初始化。适配页面保留原 `Page({` 写法。
5. 根据 `vite.config.ts` 传入的 `API_BASE_URL` 生成 CommonJS `utils/platformConfig.js`。不读取密钥和原应用入口。
6. 增量合并宿主 `app.json` 的页面、分包和预加载;不覆盖宿主 `app.js`、全局样式、工程配置和其他页面。
适配层不能替换媒体、应用入口或导入状态。`.native-import-state.json` 记录输出文件归属和哈希;只删除清单内且未经外部修改的过期文件,避免误删用户文件。它不会清理其他命名空间。
运行检查:
```sh
node --test build/tang-detective-native-plugin.test.mjs
npm run build:mp-weixin
node build/validate-tang-detective-output.mjs
```
静态测试覆盖 473 份源文件哈希、204 份媒体、24 个原生页面、120 张章节画页的实际图片选择、第一章两个播放器的包内资源、全部相对模块依赖、重复构建、宿主文件保护与适配文件保护。构建后校验脚本进一步检查当前真实产物并输出各包原始文件字节数。
静态导入不代表微信原生 WXML 编译、模拟器/真机播放、包体积验收或上传通过。原生全量内容约 28.6 MiB,接入会增加主包内容,必须保留实际体积报告。H5 构建不会包含原生页面;完整 H5 体验需要独立页面适配,不能据此声称跨端可玩。
如需刷新快照,先核对来源变化,再运行 `node build/import-tang-detective-native.mjs <源 miniprogram 目录>`。脚本遇到已存在但哈希不同的文件会停止,避免无提示覆盖已有快照;差异更新需要单独审查。
@@ -0,0 +1,30 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { listFiles, sha256 } from './tang-detective-native-plugin.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = process.argv[2]
if (!sourceDirectory) throw new Error('Usage: node build/import-tang-detective-native.mjs <source-miniprogram-directory>')
const targetDirectory = path.join(projectRoot, 'native/tang-detective')
const allowedRootFiles = new Set(['app.json', 'app.wxss'])
const allowedDirectories = /^(?:assets|data|utils|pages|package-[a-z0-9-]+)\//
const selectedFiles = listFiles(sourceDirectory).filter(relative => allowedRootFiles.has(relative) || allowedDirectories.test(relative))
if (!selectedFiles.includes('app.json') || !selectedFiles.includes('pages/home/home.js')) throw new Error('Source is not the expected native Tang Detective program')
const files = selectedFiles.map(relative => {
const bytes = fs.readFileSync(path.join(sourceDirectory, relative))
const destination = path.join(targetDirectory, relative)
const hash = sha256(bytes)
if (fs.existsSync(destination) && sha256(fs.readFileSync(destination)) !== hash) {
throw new Error(`Existing snapshot differs; refusing to overwrite: ${relative}`)
}
return { path: relative, bytes: bytes.length, sha256: hash }
})
for (const file of files) {
const destination = path.join(targetDirectory, file.path)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(sourceDirectory, file.path), destination)
}
const manifest = { version: 1, excludedRootFiles: ['app.js', 'sitemap.json'], files }
fs.writeFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`Imported ${files.length} byte-preserved native files; no source App or project configuration was imported.`)
@@ -0,0 +1,212 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json')
export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json')
const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js')
const SHA256 = /^[a-f0-9]{64}$/
const TYPES = {
'.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
'.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'],
'.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'],
'.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'],
'.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'],
'.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'],
}
export const isMediaFile = value => Boolean(TYPES[path.extname(value).toLowerCase()])
const hash = value => crypto.createHash('sha256').update(value).digest('hex')
const json = value => `${JSON.stringify(value, null, 2)}\n`
function assert(condition, message) {
if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`)
}
function safeRelative(value) {
return typeof value === 'string' && value.length > 0 && !value.startsWith('/')
&& !/[\\?#\u0000-\u0020]/.test(value)
&& value.split('/').every(part => part && part !== '.' && part !== '..')
}
function httpsUrl(value) {
try {
assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters')
const parsed = new URL(value)
assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password
&& !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS')
return parsed
} catch (error) {
throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`)
}
}
export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH } = {}) {
assert(mediaManifest && mediaManifest.schemaVersion === 1, 'schemaVersion must be 1')
const sourceBytes = fs.readFileSync(sourceManifestPath)
assert(mediaManifest.sourceManifestSha256 === hash(sourceBytes), 'source manifest SHA-256 mismatch')
const sourceManifest = JSON.parse(sourceBytes)
const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file]))
const destination = mediaManifest.destination
assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim()
&& typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required')
const base = httpsUrl(destination.baseUrl)
assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete')
const entries = new Map()
const objects = new Map()
for (const entry of mediaManifest.entries) {
assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath')
assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`)
const recorded = expected.get(entry.sourcePath)
const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()]
assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`)
assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256
&& Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`)
const actual = fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))
assert(actual.length === entry.bytes && hash(actual) === entry.sha256, `source bytes changed: ${entry.sourcePath}`)
assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`)
const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}`
assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`)
if (kind === 'audio' || kind === 'video') {
assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`)
}
const url = httpsUrl(entry.url)
const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}`
assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`)
const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}`
assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`)
objects.set(entry.url, identity)
entries.set(entry.sourcePath, Object.freeze({ ...entry }))
}
return { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256,
manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size }
}
export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath } = {}) {
// Explicit null is useful for a local/offline comparison without touching a
// verified manifest owned by another task. Undefined uses automatic discovery.
if (mediaManifest === null) return null
if (mediaManifest === undefined) {
if (!fs.existsSync(mediaManifestPath)) return null
mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8'))
}
return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath })
}
function relativeHelper(relative, helper = 'utils/cosMedia.js') {
const result = path.posix.relative(path.posix.dirname(relative), helper)
return result.startsWith('.') ? result : `./${result}`
}
function replaceOnce(source, before, after, filename) {
assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`)
return source.replace(before, after)
}
function evaluateData(source, filename) {
const context = { module: { exports: {} } }
vm.runInNewContext(source, context, { filename, timeout: 1000 })
return context.module.exports
}
export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) {
if (!media) return
const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry]))
const lookup = value => {
if (byUrl.has(value)) return byUrl.get(value)
let relative = value.replace(/^\//, '')
if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1)
return media.entries.get(relative)
}
const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi
for (const [relative, bytes] of files) {
if (isMediaFile(relative)) { files.delete(relative); continue }
if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue
let source = bytes.toString('utf8')
const helper = `require(${JSON.stringify(relativeHelper(relative))})`
if (relative.endsWith('/data/releaseAssetManifest.js')) {
const exported = evaluateData(source, relative)
for (const asset of Object.values(exported.releaseAssets)) {
if (!asset.localSeed) continue
const entry = lookup(asset.localSeed)
assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`)
asset.localSeed = ''
asset.remoteUrl = entry.url
}
const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg')
assert(share, 'share preview entry is missing')
exported.releaseAssets['image.tang.share-preview'] = {
kind: 'image', localSeed: '', remoteUrl: share.url,
remotePath: share.objectKey, sha256: share.sha256,
}
files.set(relative, Buffer.from(`module.exports = ${json(exported)}`))
continue
}
source = source.replace(staticPath, (match, quote, value) => {
const entry = lookup(value)
assert(entry, `unregistered static media: ${relative}: ${value}`)
return `${quote}${entry.url}${quote}`
})
if (relative.endsWith('/pages/chapter/chapterPages.js')) {
source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)',
` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative)
}
if (relative.endsWith('/data/playableVisualPolicy.js')) {
const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),'
source = replaceOnce(source, previous,
` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative)
}
if (relative.endsWith('/utils/comicPageModel.js')) {
source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,',
` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative)
source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative)
// Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved
// remote full-page audio uses the existing asynchronous verified player.
}
if (relative.endsWith('/utils/assetManager.js')) {
source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {',
` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative)
source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))',
'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative)
}
if (relative.endsWith('/pages/chapter/chapter.js')) {
const start = source.indexOf(' prepareSharePreview() {')
const end = source.indexOf(' loadChapter(', start)
assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`)
source = source.slice(0, start)
+ ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n`
+ source.slice(end)
source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()",
" const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative)
source = replaceOnce(source, ' if (!pageData.currentPage) return',
` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative)
source = replaceOnce(source, ' onComicImageLoad() {',
` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative)
const errorStart = source.indexOf(' onComicImageError() {')
const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart)
assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`)
let handler = source.slice(errorStart, errorEnd)
handler = replaceOnce(handler, ' onComicImageError() {',
` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative)
handler = replaceOnce(handler, ' actorFallback\n',
` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative)
handler = replaceOnce(handler, ' fallback\n',
` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative)
handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`)
source = source.slice(0, errorStart) + handler + source.slice(errorEnd)
}
if (relative.endsWith('/pages/chapter/chapter.wxml')) {
assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`)
source = source.replaceAll('binderror="onComicImageError"',
'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"')
}
files.set(relative, Buffer.from(source))
}
files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH))
files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({
namespace,
entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })),
})}`))
}
@@ -0,0 +1,522 @@
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 { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective')
const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH)
const sourceManifest = JSON.parse(sourceBytes)
const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs'))
const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path))
function write(directory, relative, value) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, value)
}
function fakeVerifiedManifest() {
// Deliberately test-only, reserved .test domain. This is never written to the
// real manifest location and is not evidence of any external upload.
const baseUrl = 'https://tang-assets.example.test'
return {
schemaVersion: 1, sourceManifestSha256: sha256(sourceBytes),
destination: { bucket: 'test-bucket', region: 'test-region', baseUrl },
entries: sourceMedia.map(file => {
const kind = file.path.endsWith('.jpg') ? 'image' : 'audio'
const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}`
return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg',
bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: file.sha256, ...(kind === 'audio' ? { rangeVerified: true } : {}) }
}),
}
}
function fixture(t, { remote = true } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-')))
// Every path removed below was created exclusively by this test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } }))
write(outputDirectory, 'app.js', '/* retained host entry */')
const mediaManifest = remote ? fakeVerifiedManifest() : null
const options = { sourceDirectory, overlayDirectory, outputDirectory, mediaManifest }
const report = copyNativeProgram(options)
const root = path.join(outputDirectory, 'tang-detective')
const localRequire = createRequire(path.join(outputDirectory, 'test.cjs'))
return { temporary, root, options, report, outputDirectory, mediaManifest,
require: relative => localRequire(path.join(root, relative)) }
}
function mockPlatform(responses) {
const files = new Map()
const downloads = []
let sequence = 0
let failDownloads = false
const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value)
const fsApi = {
mkdir: options => options.success({}),
access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')),
readFile(options) {
if (!files.has(options.filePath)) { options.fail(new Error('missing')); return }
const data = files.get(options.filePath)
options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) })
},
writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) },
saveFile(options) {
if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return }
files.set(options.filePath, files.get(options.tempFilePath))
files.delete(options.tempFilePath)
options.success({ savedFilePath: options.filePath })
},
stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) },
unlink(options) { files.delete(options.filePath); options.success({}) },
}
const platform = {
env: { USER_DATA_PATH: '/test-owned-user-data' },
getFileSystemManager: () => fsApi,
downloadFile(options) {
downloads.push(options.url)
if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return }
const body = responses.get(options.url)
const tempFilePath = `/test-owned-temp/${++sequence}`
files.set(tempFilePath, Buffer.from(body))
options.success({ statusCode: 200, tempFilePath, fileSize: body.length })
},
}
return { platform, files, downloads, failDownloads: value => { failDownloads = value } }
}
function managerFixture(context) {
const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js')
const managerApi = context.require('package-game/utils/assetManager.js')
const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))]))
const mock = mockPlatform(responses)
const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' })
return { ...mock, manager, managerApi, releaseAssets, responses }
}
test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', () => {
const media = validateCosMediaManifest(fakeVerifiedManifest(), { sourceDirectory })
assert.equal(media.entries.size, 204)
assert.equal(media.objectCount, 180)
const mutations = [
manifest => { manifest.entries.pop() },
manifest => { manifest.entries[1] = { ...manifest.entries[0] } },
manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].sha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].bytes += 1 },
manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].uploaded = false },
manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') },
manifest => { manifest.entries[0].url += '?signature=not-allowed' },
manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' },
manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" },
manifest => { manifest.entries[0].sourcePath = '../outside.jpg' },
manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' },
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified },
manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
manifest => { manifest.entries[0].contentType = 'text/html' },
]
for (const mutate of mutations) {
const manifest = fakeVerifiedManifest()
mutate(manifest)
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory }), /Invalid Tang Detective COS manifest/)
}
})
test('video manifest also requires a true range receipt and its exact immutable object key', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'video-source')
const sourcePath = 'assets/video/test.mp4'
const bytes = Buffer.from('offline transport contract fixture')
write(source, sourcePath, bytes)
const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) }
const smallSource = JSON.stringify({ files: [record] })
write(context.temporary, 'video-source-manifest.json', smallSource)
const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json')
const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4`
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{
sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256,
objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: record.sha256, rangeVerified: true,
}] }
assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1)
for (const value of [undefined, false, 'true']) {
manifest.entries[0].rangeVerified = value
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/)
}
})
test('source byte drift fails even when a manifest still claims the expected hash', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'changed-source')
const original = context.mediaManifest.entries[0]
write(source, original.sourcePath, 'changed source bytes')
const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] })
write(context.temporary, 'small-source-manifest.json', smallSource)
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] }
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source,
sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/)
})
test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => {
const context = fixture(t, { remote: false })
const missing = path.join(context.temporary, 'not-uploaded.json')
assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null)
assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204)
assert.equal(context.report.sizes.mediaFileCount, 204)
assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false)
const manifest = fakeVerifiedManifest()
write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest))
const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') }
const report = copyNativeProgram(options)
assert.equal(report.sizes.mediaFileCount, 0)
assert.equal(report.media.sourceMediaFiles, 204)
assert.equal(listFiles(context.root).filter(isMediaFile).length, 0)
const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8')
copyNativeProgram(options)
assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state)
assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */')
for (const file of sourceManifest.files) {
assert.equal(sha256(fs.readFileSync(path.join(sourceDirectory, file.path))), file.sha256, file.path)
}
for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) {
assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file)
}
})
test('invalid manifest and externally edited old media are rejected before output mutation', t => {
const context = fixture(t, { remote: false })
const before = fs.readFileSync(path.join(context.root, '.native-import-state.json'))
const invalid = fakeVerifiedManifest()
invalid.entries.pop()
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/)
assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before)
const edited = sourceMedia.at(-1).path
write(context.root, edited, 'external user edit')
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Stale native output has external edits/)
assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit')
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
})
test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => {
const context = fixture(t)
const season = context.require('data/season.js')
const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js'))
const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js'))
const media = context.require('utils/cosMedia.js')
const counts = { pages: 0, formal: 0, provisional: 0 }
for (let number = 1; number <= 15; number++) {
const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}`
const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`)
const comic = context.require(`${packageRoot}/utils/comicPageModel.js`)
const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`)
const chapter = season.chapters[number - 1]
const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number)
const model = buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const [index, page] of model.pageSequence.entries()) {
const sourcePage = original.pageSequence[index]
const expected = sha256(fs.readFileSync(path.join(sourceDirectory, sourcePage.illustrationAsset)))
const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}`
const asset = releaseAssets[assetId]
const image = comic.buildComicImageState(page, asset, {})
assert.equal(media.entryFor(image.src).sha256, expected, page.pageId)
assert.equal(image.src, page.illustrationAsset, page.pageId)
assert.equal(asset.remoteUrl, image.src)
assert.equal(asset.localSeed, '')
assert.match(image.src, /^https:\/\//)
assert.equal(image.source, 'remote-url')
assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible)
assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus)
assert.equal(media.entryFor(image.fallback).kind, 'image')
assert.equal(comic.isPackagedPath(image.src), false)
counts.pages++
if (page.playableVisual.formalReleaseEligible) counts.formal++
if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++
}
}
assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 })
assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '')
assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '')
})
test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => {
const context = fixture(t)
const media = context.require('utils/cosMedia.js')
const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg')
assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`))
for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image')
let count = 0
for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) {
const pages = context.require(`${packageRoot}/data/audioPages.js`)
let player
vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), {
require(specifier) {
if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition }
if (specifier === '../../data/audioPages') return pages
throw new Error(`Unexpected player dependency: ${specifier}`)
},
})
assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending')
for (const page of Object.values(pages)) {
assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending')
assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256)
assert.equal(media.entryFor(page.imageSrc).kind, 'image')
let prepared = ''
player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } })
assert.equal(prepared, page.audioSrc)
count++
}
}
assert.equal(count, 8)
const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8')
assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg')))
})
test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => {
const context = fixture(t)
const { manager, downloads, releaseAssets } = managerFixture(context)
const pages = context.require('package-game/data/remotePageAudioManifest.js')
const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js')
const comic = context.require('package-game/utils/comicPageModel.js')
assert.deepEqual(pages.remotePageAudioPages, {})
assert.deepEqual(playerPages.remotePageAudioPages, {})
for (let chapter = 2; chapter <= 15; chapter++) {
for (let page = 1; page <= 8; page++) {
const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}`
assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null)
}
}
for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) {
assert.equal((await manager.resolve(id)).reason, 'audio-unapproved')
}
const approvedId = 'audio.s01.c01.s01-c01-ms007'
assert.equal(releaseAssets[approvedId].reviewStatus, 'approved')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '')
assert.equal(downloads.length, 0)
})
test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => {
const context = fixture(t)
const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context)
const id = 'comic.s01.c01.p01'
const first = await manager.resolve(id)
assert.equal(first.available, true)
assert.equal(first.persistent, true)
assert.equal(downloads[0], releaseAssets[id].remoteUrl)
assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256)
assert.equal((await manager.resolve(id)).source, 'cache')
assert.equal(downloads.length, 1)
files.set(first.uri, Buffer.from('tampered cache'))
assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed')
assert.equal(files.has(first.uri), false)
const second = await manager.resolve(id)
assert.equal(second.available, true)
assert.equal(downloads.length, 2)
const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' }
const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform })
assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed')
assert.equal(downloads.length, 2)
const badId = 'comic.s01.c01.p03'
responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download'))
assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed')
assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false)
})
test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => {
const context = fixture(t)
const { manager, files, downloads, failDownloads } = managerFixture(context)
const media = context.require('utils/cosMedia.js')
const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true,
setData(update) { Object.assign(this.data, update) } }
const first = await media.prepareSharePreview(page, manager)
assert.match(first, /^\/test-owned-user-data\//)
assert.equal(page.data.sharePreviewLocalPath, first)
const cached = await media.prepareSharePreview(page, manager)
assert.equal(cached, first)
assert.equal(downloads.length, 1)
files.set(first, Buffer.from('corrupted thumbnail'))
assert.equal(await media.prepareSharePreview(page, manager), '')
assert.equal(page.data.sharePreviewLocalPath, '')
failDownloads(true)
assert.equal(await media.prepareSharePreview(page, manager), '')
failDownloads(false)
assert.ok(await media.prepareSharePreview(page, manager))
let finish
const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) }
const pending = media.prepareSharePreview(page, delayed)
await Promise.resolve()
page.__tangDead = true
const prior = page.data.sharePreviewLocalPath
finish({ available: true, uri: '/should-not-be-shared' })
assert.equal(await pending, '')
assert.equal(page.data.sharePreviewLocalPath, prior)
const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8')
assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())'))
assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated"))
assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE'))
})
test('real-output static validator supports COS mode and rejects extra packaged media', t => {
const context = fixture(t)
const report = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.deepEqual(report.errors, [])
assert.equal(report.passed, true)
assert.equal(report.mediaFiles, 204)
assert.equal(report.packagedMediaFiles, 0)
assert.equal(report.mediaMode, 'cos')
write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video')
const invalid = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.equal(invalid.passed, false)
assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode'))
})
test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => {
const context = fixture(t)
let definition
const modules = new Map()
function load(filename) {
if (!path.extname(filename)) filename += '.js'
if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options }
if (filename.endsWith('/utils/storage.js')) return {}
if (modules.has(filename)) return modules.get(filename).exports
const module = { exports: {} }
modules.set(filename, module)
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module,
require: specifier => load(path.resolve(path.dirname(filename), specifier)),
}, { filename })
return module.exports
}
load(path.join(context.root, 'package-game/pages/chapter/chapter.js'))
const chapter = context.require('data/season.js').chapters[0]
const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1)
let updates = 0
const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model,
setData(patch) { updates++; Object.assign(this.data, patch) } }
const reader = index => ({ valid: true, currentPageIndex: index,
currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false })
const event = () => ({ currentTarget: { dataset: {
cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc,
cosImageGeneration: page.data.comicImageGeneration,
} } })
page.applyComicReaderState(reader(2), false)
assert.ok(page.data.comicImageActorFallback)
assert.ok(page.data.comicImageFallback)
const originalImage = page.data.comicImageSrc
const originalPage = JSON.stringify(page.data.currentPage)
const interactionEnabled = page.data.currentInteractionEnabled
const firstEvent = event()
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSource, 'actor-fallback')
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const actorUpdates = updates
page.onComicImageError(firstEvent)
assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback')
const actorEvent = event()
page.onComicImageError(actorEvent)
assert.equal(page.data.comicImageSrc, page.data.comicImageFallback)
const sceneEvent = event()
page.onComicImageError(sceneEvent)
assert.equal(page.data.comicImageSrc, '')
assert.equal(page.data.comicImageSource, 'text-fallback')
assert.ok(page.data.comicImageError)
assert.equal(JSON.stringify(page.data.currentPage), originalPage)
assert.equal(page.data.currentInteractionEnabled, interactionEnabled)
const terminalUpdates = updates
for (let index = 0; index < 10; index++) page.onComicImageError(event())
page.onComicImageLoad(sceneEvent)
assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL')
page.applyComicReaderState(reader(3), false)
const nextImage = page.data.comicImageSrc
assert.ok(nextImage)
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt')
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored')
page.onComicImageError(event())
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8')
assert.equal((template.match(/data-cos-image-generation=/g) || []).length,
(template.match(/binderror="onComicImageError"/g) || []).length)
})
test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => {
const context = fixture(t, { remote: false })
const snapshots = target => {
if (fs.lstatSync(target).isDirectory()) {
return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))])
}
return sha256(fs.readFileSync(target))
}
const scenarios = [
{ target: context.outputDirectory },
{ target: context.root },
{ target: path.join(context.outputDirectory, 'app.json') },
{ target: path.join(context.root, '.native-import-state.json') },
{ target: path.join(context.root, 'utils/storage.js') },
{ target: path.join(context.root, 'utils') },
{ target: path.join(context.root, 'assets/scenes'), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true },
]
for (const [index, scenario] of scenarios.entries()) {
const external = path.join(context.temporary, `external-preserved-${index}`)
fs.renameSync(scenario.target, external)
const before = snapshots(external)
fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target)
try {
assert.throws(() => copyNativeProgram({ ...context.options,
mediaManifest: scenario.remote ? fakeVerifiedManifest() : null }), /Native output forbids symbolic links/)
assert.deepEqual(snapshots(external), before, scenario.target)
} finally {
fs.unlinkSync(scenario.target)
fs.renameSync(external, scenario.target)
}
// Even a cleanup conflict near the end must leave earlier media untouched.
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
}
const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js')
const newHelper = path.join(context.root, 'utils/cosMedia.js')
fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile)
fs.symlinkSync(externalNewFile, newHelper)
const newHash = sha256(fs.readFileSync(externalNewFile))
try {
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Native output forbids symbolic links/)
assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash)
} finally { fs.unlinkSync(newHelper) }
const alias = path.join(context.temporary, 'ancestor-alias')
fs.symlinkSync(context.temporary, alias, 'dir')
assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204)
const local = validateNativeOutput(context.outputDirectory, { mediaManifest: null })
assert.deepEqual(local.errors, [])
assert.equal(local.passed, true)
assert.equal(local.mediaMode, 'local')
assert.equal(local.packagedMediaFiles, 204)
assert.ok(local.relativeDependencies > 300)
})
@@ -0,0 +1,376 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
export const NAMESPACE = 'tang-detective'
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs'])
const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle']
const STATE_FILE = '.native-import-state.json'
const BOOT_MASK_WXML = '<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'
const BOOT_MASK_WXSS = `
.tang-boot-mask {
position: fixed;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 24px;
background: #201711;
color: #f3e5bd;
font-size: 18px;
text-align: center;
}
`
export function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
export function listFiles(directory) {
if (!fs.existsSync(directory)) return []
const result = []
function visit(current, prefix = '') {
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`)
if (entry.isDirectory()) visit(path.join(current, entry.name), relative)
else if (entry.isFile()) result.push(relative)
}
}
visit(directory)
return result
}
function assertNamespace(namespace) {
if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`)
}
function outputPath(directory, relative) {
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)
|| relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) {
throw new Error(`Unsafe native output path: ${relative}`)
}
return path.join(directory, relative)
}
function lstatOrNull(filename) {
try { return fs.lstatSync(filename) } catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
/** Ancestors such as macOS /tmp may be aliases, but the supplied output root
* itself and every component below it must be actual directories/files. */
export function createNativeOutputGuard(directory) {
const suppliedRoot = path.resolve(directory)
const suppliedStat = lstatOrNull(suppliedRoot)
if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`)
if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`)
const root = fs.realpathSync(suppliedRoot)
function check(relative, expectedType = 'file') {
const rootStat = lstatOrNull(root)
if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`)
if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`)
const destination = outputPath(root, relative)
const parts = relative.split('/')
let current = root
for (const [index, part] of parts.entries()) {
current = path.join(current, part)
const stat = lstatOrNull(current)
if (!stat) break
if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`)
const real = fs.realpathSync(current)
const inside = path.relative(root, real)
if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) {
throw new Error(`Native output escaped its real directory: ${current}`)
}
const needsDirectory = index < parts.length - 1 || expectedType === 'directory'
if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) {
throw new Error(`Unexpected native output path type: ${current}`)
}
}
return destination
}
function write(relative, data) {
const destination = check(relative)
fs.mkdirSync(path.dirname(destination), { recursive: true })
check(relative)
// Re-check parent components after mkdir and prevent following a replaced
// final-file link between lstat and opening the destination.
const descriptor = fs.openSync(destination,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW)
try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) }
}
return { root, path: check, write }
}
function jsonBytes(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */
export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source
return source
.replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`)
.replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`)
.replaceAll(
String.raw`/^\/package-[a-z0-9-]+\//i`,
String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`,
)
}
export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || []
const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`]))
const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`)
const subPackages = sourcePackages.map(item => ({
...item,
root: `${namespace}/${item.root}`,
name: packageNames.get(item.name || item.root),
pages: [...item.pages],
}))
const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [
`${namespace}/${page}`,
{ ...rule, packages: rule.packages.map(name => {
const mapped = packageNames.get(name)
|| subPackages.find(item => item.root === `${namespace}/${name}`)?.name
if (!mapped) throw new Error(`Unregistered native preload package: ${name}`)
return mapped
}) },
]))
const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route')
return { namespace, pages, subPackages, preloadRule, allPages }
}
/** Append native routes without replacing the host's app settings or route ordering. */
export function mergeNativeAppManifest(hostApp, nativeManifest) {
const result = structuredClone(hostApp)
const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages')
? 'subpackages' : 'subPackages'
const hostPages = result.pages || []
const hostPackages = result[packageKey] || []
const nativePages = new Set(nativeManifest.allPages)
for (const item of hostPackages) {
const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root)
if (proposed) {
if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`)
continue
}
if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) {
throw new Error(`Native page conflicts with host subpackage: ${item.root}`)
}
if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) {
throw new Error(`Native subpackage name conflicts with host: ${item.name}`)
}
}
for (const page of hostPages) {
if (nativePages.has(page) && !nativeManifest.pages.includes(page)) {
throw new Error(`Native subpackage page is already a host main page: ${page}`)
}
}
result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))]
result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))]
const preloadRule = { ...(result.preloadRule || {}) }
for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) {
if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) {
throw new Error(`Native preload conflicts with host: ${page}`)
}
preloadRule[page] = rule
}
result.preloadRule = preloadRule
return result
}
export function wrapNativePage(source, relativePath) {
const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js')
const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)]
if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`)
return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`)
}
function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) {
const files = new Map()
const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1)))
const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS
.filter(key => sourceApp.window?.[key] !== undefined)
.map(key => [key, sourceApp.window[key]]))
for (const relative of listFiles(sourceDirectory)) {
if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue
const original = fs.readFileSync(outputPath(sourceDirectory, relative))
if (relative === 'app.wxss') {
files.set('shared.wxss', original)
continue
}
const extension = path.extname(relative)
if (!TEXT_EXTENSIONS.has(extension)) {
files.set(relative, original)
continue
}
let content = transformNativeText(original.toString('utf8'), relative, nativeManifest)
const isPage = sourcePages.has(relative.slice(0, -extension.length))
if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) })
if (isPage && extension === '.wxss') {
content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}`
}
files.set(relative, Buffer.from(content))
}
// Adapters contain final output paths. Do not transform them a second time.
for (const relative of listFiles(overlayDirectory)) {
// Remote helpers are activated together with a verified manifest only.
if (relative === 'utils/cosMedia.js') continue
if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative)
|| /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) {
throw new Error(`Adapter cannot replace the host app or import state: ${relative}`)
}
if (isMediaFile(relative)) {
throw new Error(`Adapter cannot replace source media: ${relative}`)
}
files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative)))
}
files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`))
// Media conversion also covers the adapter's final paths, without repeating
// namespace conversion or changing the host/account lifecycle adapters.
applyCosMediaOutput(files, media, nativeManifest)
if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js')
files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`))
for (const page of sourcePages) {
for (const extension of ['.js', '.json', '.wxml', '.wxss']) {
if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`)
}
const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8'))
if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') {
throw new Error(`Adapter lost native page configuration: ${page}`)
}
files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`)))
files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`))
}
return files
}
export function summarizePackageSizes(files, nativeManifest) {
const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 }
for (const [relative, value] of files) {
const fullPath = `${nativeManifest.namespace}/${relative}`
const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`))
result.sourceFileBytes += value.length
if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length
else result.mainPackageBytes += value.length
if (isMediaFile(relative)) {
result.mediaFileCount += 1
result.mediaBytes += value.length
}
}
return result
}
/** Reusable by the Vite hook and by static tests; no build or server is started here. */
export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '', mediaManifest, mediaManifestPath }) {
assertNamespace(namespace)
const outputGuard = createNativeOutputGuard(outputDirectory)
outputDirectory = outputGuard.root
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath })
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const hostAppPath = outputGuard.path('app.json')
const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8'))
const nativeManifest = createNativeManifest(sourceApp, { namespace })
const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest)
const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media)
const nativeOutputDirectory = outputGuard.path(namespace, 'directory')
const nativeTarget = relative => {
// Validate the untrusted old-state relative path before adding namespace.
const target = outputPath(nativeOutputDirectory, relative)
return outputGuard.path(path.relative(outputDirectory, target))
}
const statePath = nativeTarget(STATE_FILE)
const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null
if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch')
const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256]))
// Refuse to overwrite files whose ownership or later edits cannot be established.
for (const [relative, value] of files) {
const destination = nativeTarget(relative)
if (!fs.existsSync(destination)) continue
const existingHash = sha256(fs.readFileSync(destination))
if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) {
throw new Error(`Refusing to overwrite unowned native output: ${relative}`)
}
}
const staleFiles = []
for (const [relative, expectedHash] of previousFiles) {
if (files.has(relative)) continue
const stalePath = nativeTarget(relative)
if (fs.existsSync(stalePath)) {
if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`)
staleFiles.push(relative)
}
}
// Check every stale file before removing any: a conflict late in the media
// list must not leave a previously usable local build partially stripped.
for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative))
for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value)
const state = {
version: 1,
namespace,
pages: nativeManifest.allPages,
files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })),
sizes: summarizePackageSizes(files, nativeManifest),
...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256,
manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}),
validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.',
}
outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state))
outputGuard.write('app.json', jsonBytes(mergedApp))
return { ...state, nativeManifest }
}
export default function tangDetectiveNativePlugin(options = {}) {
let root
let buildOutput
const sourceRelative = options.sourceDirectory || 'native/tang-detective'
const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective'
return {
name: 'tang-detective-native-pages',
enforce: 'post',
apply: () => process.env.UNI_PLATFORM === 'mp-weixin',
configResolved(config) {
root = config.root
buildOutput = path.resolve(root, config.build.outDir)
},
buildStart() {
this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH)
this.addWatchFile(SOURCE_MANIFEST_PATH)
for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) {
this.addWatchFile(directory)
for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative))
}
},
// Sequential post-order runs after normal write hooks and is repeated for watch rebuilds.
writeBundle: {
order: 'post',
sequential: true,
handler(outputOptions) {
const report = copyNativeProgram({
sourceDirectory: path.resolve(root, sourceRelative),
overlayDirectory: path.resolve(root, overlayRelative),
outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput,
namespace: options.namespace || NAMESPACE,
apiBaseUrl: options.apiBaseUrl || '',
mediaManifest: options.mediaManifest,
mediaManifestPath: options.mediaManifestPath,
})
this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`)
},
},
}
}
@@ -0,0 +1,262 @@
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
}
})
@@ -0,0 +1,146 @@
{
"passed": true,
"nativePages": 24,
"mediaFiles": 204,
"relativeDependencies": 328,
"nativeSizes": {
"sourceFileBytes": 30098730,
"mainPackageBytes": 1903370,
"subPackages": {
"tang-detective/package-audio-c01-a": 1733274,
"tang-detective/package-audio-c01-b": 1231110,
"tang-detective/package-audio-player": 107278,
"tang-detective/package-chapter-02": 1571087,
"tang-detective/package-chapter-03": 1995560,
"tang-detective/package-chapter-04": 1180764,
"tang-detective/package-chapter-05": 1605611,
"tang-detective/package-chapter-06": 1707290,
"tang-detective/package-chapter-07": 1663468,
"tang-detective/package-chapter-08": 1747028,
"tang-detective/package-chapter-09": 1754206,
"tang-detective/package-chapter-10": 1783701,
"tang-detective/package-chapter-11": 1547432,
"tang-detective/package-chapter-12": 1709680,
"tang-detective/package-chapter-13": 1960330,
"tang-detective/package-chapter-14": 1877271,
"tang-detective/package-chapter-15": 1757833,
"tang-detective/package-game": 1262437
},
"mediaFileCount": 204,
"mediaBytes": 24052082
},
"outputSizes": {
"totalFileBytes": 32595927,
"mainPackageFileBytes": 3157378,
"subPackages": {
"doctor": 25092,
"tang-detective/package-audio-c01-a": 1733274,
"tang-detective/package-audio-c01-b": 1231110,
"tang-detective/package-audio-player": 107278,
"tang-detective/package-chapter-02": 1571087,
"tang-detective/package-chapter-03": 1995560,
"tang-detective/package-chapter-04": 1180764,
"tang-detective/package-chapter-05": 1605611,
"tang-detective/package-chapter-06": 1707290,
"tang-detective/package-chapter-07": 1663468,
"tang-detective/package-chapter-08": 1747028,
"tang-detective/package-chapter-09": 1754206,
"tang-detective/package-chapter-10": 1783701,
"tang-detective/package-chapter-11": 1547432,
"tang-detective/package-chapter-12": 1709680,
"tang-detective/package-chapter-13": 1960330,
"tang-detective/package-chapter-14": 1877271,
"tang-detective/package-chapter-15": 1757833,
"tang-detective/package-game": 1262437,
"tongji": 574912,
"training": 173057,
"TUICallKit/src/Components": 172402,
"TUIKit": 297726
}
},
"errors": [],
"boundary": "Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.",
"verificationDate": "2026-09-08",
"builds": {
"weixinExitCode": 0,
"h5ExitCode": 0
},
"tests": {
"nativeImport": 7,
"platform": 27,
"realPageLifecycle": 10,
"total": 44,
"failed": 0
},
"nativeCompiler": {
"nativePages": 24,
"results": [
{
"tool": "wcc",
"exitCode": 0,
"passed": true,
"generatedOutputBytes": 1183577,
"diagnostics": "",
"error": null
},
{
"tool": "wcsc",
"exitCode": 0,
"passed": true,
"generatedOutputBytes": 1346916,
"diagnostics": "",
"error": null
}
],
"boundary": "Installed compiler syntax check only, not simulator/device, networking or upload acceptance."
},
"provenance": {
"sourceFiles": 473,
"sourceDrift": [],
"sha256": {
"main.js": "ef7412ffcb94520615c4289c45ca63929de44f2258d73aa7134cb3dacebd7b11",
"package.json": "63ffaf1f3224ab685e6ba9c1895030d66400d8371b37786cb73e2e41b868b16c",
"pages.json": "3987ba37f2175ed100058861d5053726413232d6599fff4da807654d2661a17a",
"tongji/pages/weekly.vue": "c8033a6237c46f5e1d170628ba82a8638a4275388b4be5f6c9336d0552981cb7",
"config/api.js": "c0efc1db4daa21e91a404784170c12efc127a6fd042f476ad131aa4a2642ff74",
"vite.config.ts": "28efc83adbd45629731b83c7702e184ba775d32091150ede54874d0975bd1352",
"build/tang-detective-native-plugin.mjs": "7b64e465a246b7696019d17d7777a58a7ffa3a4ad4bfe7187ac09f5495954a30",
"dist/build/mp-weixin/app.json": "bd6aa0028069fd2b0801681e2e62ab8faf3ddc9544ea5375e8f7db5c33e6c92e",
"dist/build/mp-weixin/tang-detective/.native-import-state.json": "8518b430dbe156a19a6dd317034e8935090e7be805337ab35c29c944f5721b32",
"native-adapter/tang-detective/pages/catalog/catalog.js": "0f98ca3ec3c7f4d296afea27d6e2ec86f9a1456805c3cf12fafe622e2c597b90",
"native-adapter/tang-detective/pages/home/home.js": "880b7561c6a33ac0d45986ea492cdac56ad9fa837198c93f546291db7b2b1f35",
"native-adapter/tang-detective/pages/home/home.wxml": "d2ad74344fef441594817d9ecdb7273a743536a013b50e3bdaacd065882fe10d",
"native-adapter/tang-detective/pages/home/home.wxss": "c00be0cf7c8bbf4ed50f9ad0eeb80922424b6b7e0c58f89edc873c2625774c2c",
"native-adapter/tang-detective/utils/identityHash.js": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a",
"native-adapter/tang-detective/utils/platformBridge.js": "d0fac62908d4caab65864d0c401f621561525aa32be12d536ecb9070988e9aaa",
"native-adapter/tang-detective/utils/platformCore.js": "398f822d4dd492f962957d18e997138eda3dc10e42e6b0b0e8c453f40f324927",
"native-adapter/tang-detective/utils/progressContract.js": "fb3e1107298ca11983f1136dc13105588c815c3240e289baab2bcaab88cf71f0",
"native-adapter/tang-detective/utils/storage.js": "f6028151609f3a02efd633ca7d696300ee7e6e5eeb06ee0edcee01f1e08f9835",
"native-adapter/tang-detective/utils/tangPage.js": "9aeafc0b944b4adc87179144d52f351a60ce20b0c6b9e466c32418367db47e35"
}
},
"independentReview": {
"originalFindings": 5,
"closedByOfflineRetest": 5,
"productionApproval": false
},
"notRun": [
"PHP contract scripts: PHP runtime unavailable",
"MySQL and SQL migration",
"live HTTP API/authentication integration",
"simulator interaction and real-device testing",
"audio listening and media rights approval",
"upload and production deployment"
],
"resources": {
"taskProcesses": "exited",
"taskServersStarted": 0,
"browserOrPlayerOpened": false,
"preserved": [
"source snapshots",
"dependencies and SDK",
"build outputs",
"user-existing applications and services"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath } = {}) {
const outputGuard = createNativeOutputGuard(outputDirectory)
const output = outputGuard.root
const nativeRoot = outputGuard.path('tang-detective', 'directory')
const sourceRoot = path.join(projectRoot, 'native/tang-detective')
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8'))
const manifest = createNativeManifest(sourceApp)
const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8'))
const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8'))
const sourceManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
const localRequire = createRequire(path.join(output, 'native-validation.cjs'))
const errors = []
const check = (condition, message) => { if (!condition) errors.push(message) }
const remoteMode = imported.media?.mode === 'cos'
let media = null
if (remoteMode) {
try {
media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath })
check(Boolean(media), 'COS output requires its verified media manifest')
if (media) {
check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import')
check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import')
}
} catch (error) { errors.push(error.message) }
}
const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
let mediaFiles = 0
for (const file of sourceManifest.files) {
check(sha256(fs.readFileSync(path.join(sourceRoot, file.path))) === file.sha256, `Source snapshot drift: ${file.path}`)
if (!isMediaFile(file.path)) continue
mediaFiles += 1
outputGuard.path(`tang-detective/${file.path}`)
if (remoteMode) {
check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`)
check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`)
} else {
check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`)
}
}
for (const file of imported.files) {
check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`)
}
const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length
if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode')
for (const page of manifest.allPages) {
check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`)
for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`)
const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8'))
check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`)
const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8')
check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`)
const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8')
check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`)
}
let dependencies = 0
for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeRoot, relative)
const script = fs.readFileSync(filename, 'utf8')
try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) }
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
dependencies += 1
if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue }
try {
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const dependency = path.relative(output, resolved).split(path.sep).join('/')
check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`)
check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`)
} catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) }
}
}
const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} }
const outputPackages = hostApp.subPackages || hostApp.subpackages || []
for (const relative of listFiles(output)) {
const bytes = fs.statSync(path.join(output, relative)).size
outputSizes.totalFileBytes += bytes
const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`))
if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes
else outputSizes.mainPackageFileBytes += bytes
}
return {
passed: errors.length === 0,
nativePages: manifest.allPages.length,
mediaFiles,
packagedMediaFiles,
mediaMode: remoteMode ? 'cos' : 'local',
relativeDependencies: dependencies,
nativeSizes: imported.sizes,
outputSizes,
errors,
boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.',
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin'))
if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
if (!report.passed) process.exitCode = 1
}