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,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.`)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
{
"schemaVersion": 1,
"uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828",
"baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com",
"imageDirectory": "uploads/images/20260908/",
"audioDirectory": "uploads/voice/20260908/",
"firstImage": "202609081718480ee488780.jpg",
"images": [
"20260908172953806b83817.jpg",
"20260908172954a3c700294.jpg",
"202609081729547cbb37718.jpg",
"2026090817295416f2f2841.jpg",
"202609081729541a4a74577.jpg",
"20260908172954fef263003.jpg",
"20260908172954a75561985.jpg",
"20260908172954578d88427.jpg",
"2026090817295420b991364.jpg",
"20260908172954af5606377.jpg",
"2026090817303974c831720.jpg",
"20260908173040de54b5498.jpg",
"2026090817303983bc35301.jpg",
"20260908173039ad5bc2437.jpg",
"2026090817303985ee07761.jpg",
"2026090817303994b4b1401.jpg",
"20260908173039628910741.jpg",
"20260908173039668f11872.jpg",
"202609081730407a8a27957.jpg",
"202609081730409483f1003.jpg",
"2026090817310719bb62047.jpg",
"202609081731073a6bf7270.jpg",
"20260908173107b3c447822.jpg",
"202609081731074bb984948.jpg",
"202609081731073a9ad6102.jpg",
"202609081731076ef7d1167.jpg",
"202609081731075b3e30712.jpg",
"202609081731072dfe55233.jpg",
"202609081731078ac2d7004.jpg",
"20260908173107466700017.jpg",
"20260908173112ff8887242.jpg",
"20260908173112e49c90827.jpg",
"20260908173112365a59120.jpg",
"20260908173112d30437983.jpg",
"202609081731127140b3844.jpg",
"20260908173112353247130.jpg",
"20260908173112169de0256.jpg",
"20260908173112a505f4772.jpg",
"202609081731122b3399354.jpg",
"20260908173112c4da43174.jpg",
"2026090817311649e2a9550.jpg",
"20260908173116162c66104.jpg",
"20260908173116a53f24003.jpg",
"202609081731162a2021285.jpg",
"20260908173116813c87413.jpg",
"20260908173116bfec43397.jpg",
"20260908173116dbdc29125.jpg",
"20260908173116c5c618420.jpg",
"2026090817311649ffe3535.jpg",
"20260908173116ecae73791.jpg",
"202609081731352d7e82556.jpg",
"2026090817313559ab83276.jpg",
"2026090817313533e949160.jpg",
"202609081731350aa1d5788.jpg",
"202609081731357238e1677.jpg",
"20260908173135b00ff3493.jpg",
"20260908173135595af4252.jpg",
"202609081731352d9fb1212.jpg",
"2026090817313638f109229.jpg",
"2026090817313660b804375.jpg",
"20260908173138a61767853.jpg",
"20260908173138fa0762681.jpg",
"2026090817313839b279656.jpg",
"20260908173138dff5a5536.jpg",
"20260908173138191298369.jpg",
"20260908173138ec8e94150.jpg",
"20260908173138b2b761219.jpg",
"20260908173138c9d001251.jpg",
"20260908173139f46863978.jpg",
"2026090817313967a307026.jpg",
"202609081731411da1c2125.jpg",
"20260908173141cf0c01964.jpg",
"20260908173142053ed9914.jpg",
"20260908173142ef5784670.jpg",
"2026090817314274cfe2605.jpg",
"202609081731420bc315230.jpg",
"20260908173142d16d12935.jpg",
"202609081731425a0819698.jpg",
"202609081731429844c1305.jpg",
"20260908173142346106887.jpg",
"20260908173145f82762147.jpg",
"202609081731451a0a48902.jpg",
"20260908173145e43cc7774.jpg",
"20260908173145050809349.jpg",
"202609081731456267e0864.jpg",
"202609081731451e62d8082.jpg",
"2026090817314563ce19085.jpg",
"20260908173145234414957.jpg",
"2026090817314577b3d2877.jpg",
"20260908173145d06c49932.jpg",
"202609081732037cfc44506.jpg",
"202609081732034eb7f0368.jpg",
"202609081732039e6701414.jpg",
"2026090817320373f998007.jpg",
"20260908173203dace72023.jpg",
"202609081732035f2e17452.jpg",
"20260908173203e27952820.jpg",
"202609081732032db544840.jpg",
"202609081732035c9d61064.jpg",
"202609081732037c8583818.jpg",
"2026090817320602c476049.jpg",
"20260908173206dae776115.jpg",
"202609081732065676e2434.jpg",
"20260908173206a7ebe7583.jpg",
"20260908173206cb3721071.jpg",
"20260908173206b42891414.jpg",
"2026090817320615d6c3156.jpg",
"20260908173206946d11690.jpg",
"202609081732067211a5043.jpg",
"2026090817320628b5f1545.jpg",
"20260908173210f33956967.jpg",
"20260908173210dcc904301.jpg",
"202609081732104dda17591.jpg",
"202609081732103e1737129.jpg",
"20260908173210052871552.jpg",
"202609081732106acce6348.jpg",
"2026090817321063e243356.jpg",
"202609081732103c91a8938.jpg",
"20260908173210e3e400579.jpg",
"20260908173210a1bfa8588.jpg",
"2026090817321402ddc8471.jpg",
"20260908173214a4a8d4074.jpg",
"202609081732148dba50220.jpg",
"202609081732146ee292192.jpg",
"2026090817321455aa99619.jpg",
"20260908173214765e24253.jpg",
"20260908173214ac7b62068.jpg",
"202609081732143d4070553.jpg",
"2026090817321405dc63371.jpg",
"20260908173214896801232.jpg",
"20260908173218201a61275.jpg",
"20260908173218191279663.jpg",
"202609081732189a41f9598.jpg",
"202609081732189ef359918.jpg",
"20260908173218765170829.jpg"
],
"audio": [
"1788859559652-e90yvvdk.mp3", "1788859640935-23ixzy68.mp3",
"1788859681917-n7wb94nh.mp3", "1788859683117-o8y8c1ap.mp3",
"1788859684272-vx02qyha.mp3", "1788859685309-bq8hczp2.mp3",
"1788859686361-f4myg1jo.mp3", "1788859687423-at9k5kro.mp3",
"1788859688761-rv0vx59b.mp3", "1788859689813-2u5eyx28.mp3",
"1788859705645-v0njwhr6.mp3", "1788859706721-j2czunoh.mp3",
"1788859707781-5eif2sva.mp3", "1788859708857-lgkflk7r.mp3",
"1788859709963-w40dseme.mp3", "1788859710994-9ja52nj4.mp3",
"1788859712054-pqgtblz7.mp3", "1788859713116-rtg8ou4j.mp3",
"1788859714260-41ehj12t.mp3", "1788859715307-zvzm6c52.mp3",
"1788859716357-ddolkq84.mp3", "1788859717407-joxtjydj.mp3",
"1788859718453-7taqslda.mp3", "1788859719556-4h5g177p.mp3",
"1788859720601-tm2rratq.mp3", "1788859735790-h24dj5jw.mp3",
"1788859736846-tu8raupk.mp3", "1788859738725-lzs4obsj.mp3",
"1788859739954-0sdopsv6.mp3", "1788859741040-tnxabkmf.mp3",
"1788859742080-q1gq96gr.mp3", "1788859743113-kgit8i2r.mp3",
"1788859744174-pg41290p.mp3", "1788859745258-2u7m9wc0.mp3",
"1788859746348-qrqomq07.mp3", "1788859747502-csqqmkys.mp3",
"1788859748680-hx5gjcjt.mp3", "1788859749772-njzzx6f8.mp3",
"1788859750856-ubrs0bjv.mp3", "1788859751906-i84qzugn.mp3",
"1788859752961-k632st94.mp3", "1788859754110-nyj2b3le.mp3",
"1788859755676-8bbjnyhb.mp3", "1788859756823-r37bn776.mp3"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
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'
import { MEDIA_TYPES as TYPES, isMediaFile, readSourceManifest, validateSourceSnapshot, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs'
export { isMediaFile, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json')
const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js')
const SHA256 = /^[a-f0-9]{64}$/
const SERVER_DESTINATION = { bucket: 'gz-1349751149', region: 'ap-guangzhou',
baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' }
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})`)
}
}
function validUploadDate(value) {
if (!/^20\d{6}$/.test(value)) return false
const isoDate = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
const parsed = new Date(`${isoDate}T00:00:00.000Z`)
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === isoDate
}
function confirmedServerObject(entry, kind) {
if (kind === 'image' && path.extname(entry.sourcePath) === '.jpg') {
// UploadService::image and storage/engine/Server::buildSaveName.
const match = /^uploads\/images\/(20\d{6})\/(20\d{6})([0-2]\d)([0-5]\d)([0-5]\d)[a-f0-9]{5}\d{4}\.jpg$/.exec(entry.objectKey)
return Boolean(match && validUploadDate(match[1]) && match[1] === match[2] && Number(match[3]) < 24)
}
if (kind === 'audio' && path.extname(entry.sourcePath) === '.mp3') {
// Actual authenticated voice upload, using the admin direct-uploader's
// Date.now() + Math.random().toString(36).slice(2, 10) naming.
const match = /^uploads\/voice\/(20\d{6})\/([1-9]\d{12})-[a-z0-9]{8}\.mp3$/.exec(entry.objectKey)
return Boolean(match && validUploadDate(match[1]))
}
return false
}
export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH,
pruneReceipt, pruneReceiptPath } = {}) {
assert(mediaManifest && [1, 2].includes(mediaManifest.schemaVersion), 'schemaVersion must be 1 or 2')
const serverManaged = mediaManifest.schemaVersion === 2
if (serverManaged) {
assert(typeof mediaManifest.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(mediaManifest.uploadRunId), 'uploadRunId is required')
}
const source = readSourceManifest(sourceManifestPath)
assert(mediaManifest.sourceManifestSha256 === source.manifestSha256, 'source manifest SHA-256 mismatch')
const sourceManifest = source.manifest
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)
if (serverManaged) {
assert(Object.entries(SERVER_DESTINATION).every(([key, value]) => destination[key] === value),
'schema 2 destination must be the confirmed xuetang COS bucket, region and origin')
}
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}`)
assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`)
assert(entry.publicReadVerified === true, `public read verification missing: ${entry.sourcePath}`)
if (serverManaged) {
// Only these two media routes have current upload evidence. New kinds
// require their own verified route; schema 1 stays content-addressed.
assert(confirmedServerObject(entry, kind),
`object key does not match confirmed server upload contract: ${entry.sourcePath}`)
} else {
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 }))
}
const media = { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256,
manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size,
uploadRunId: mediaManifest.uploadRunId }
// Validate all surviving bytes, and permit omissions only after validating a
// complete local prune receipt bound to this exact verified upload manifest.
media.sourceSnapshot = validateSourceSnapshot({ sourceDirectory, sourceManifestPath, media, pruneReceipt, pruneReceiptPath })
return media
}
export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath,
pruneReceipt, pruneReceiptPath } = {}) {
// 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
const stat = fs.lstatSync(mediaManifestPath)
assert(stat.isFile() && !stat.isSymbolicLink(), 'media manifest must be a regular file')
mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8'))
}
return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath, pruneReceipt, pruneReceiptPath })
}
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,559 @@
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'
import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt } from './tang-detective-test-fixtures.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 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')
const source = createSyntheticSource(temporary)
write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } }))
write(outputDirectory, 'app.js', '/* retained host entry */')
const mediaManifest = remote ? fakeVerifiedManifest(source) : null
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
overlayDirectory, outputDirectory, mediaManifest }
const report = copyNativeProgram(options)
const root = path.join(outputDirectory, 'tang-detective')
const localRequire = createRequire(path.join(outputDirectory, 'test.cjs'))
return { ...source, 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(context.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', t => {
const context = fixture(t)
const media = validateCosMediaManifest(fakeVerifiedManifest(context), context.options)
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].publicReadVerified = 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(context)
mutate(manifest)
assert.throws(() => validateCosMediaManifest(manifest, context.options), /Invalid Tang Detective COS manifest/)
}
})
test('schema 2 accepts only the confirmed COS origin and exact server image / voice object formats', t => {
const context = fixture(t)
const baseUrl = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com'
const manifest = { ...context.mediaManifest, schemaVersion: 2,
destination: { bucket: 'gz-1349751149', region: 'ap-guangzhou', baseUrl },
entries: context.mediaManifest.entries.map((entry, index) => {
const objectKey = entry.kind === 'image'
? `uploads/images/20260908/20260908171848abcde${String(index).padStart(4, '0')}.jpg`
: `uploads/voice/20260908/1788859559652-${String(index).padStart(8, '0')}.mp3`
return { ...entry, objectKey, url: `${baseUrl}/${objectKey}` }
}),
}
assert.equal(validateCosMediaManifest(manifest, context.options).entries.size, 204)
const mutations = [
value => { value.uploadRunId = '' },
value => { value.destination.bucket = 'other-bucket' },
value => { value.destination.region = 'ap-shanghai' },
value => { value.destination.baseUrl = 'https://different.example.test' },
value => { value.destination.baseUrl += '/uploads' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/202609081718480ee488780.jpg?signature=x' },
value => { value.entries[0].objectKey = 'uploads/images/20260909/202609081718480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260230/202602301718480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/202609082918480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/mutable.jpg' },
value => { value.entries[0].objectKey = 'uploads/file/20260908/202609081718480ee488780.jpg' },
value => { value.entries[0].url = 'https://other.example.test/202609081718480ee488780.jpg' },
value => { value.entries[0].uploaded = false },
value => { value.entries[0].publicReadVerified = false },
value => { value.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
value => { value.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/images/20260908/1788859559652-e90yvvdk.mp3' },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-e90yvvdk.mp4' },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-unsafe_!.mp3' },
]
for (const mutate of mutations) {
const changed = structuredClone(manifest)
mutate(changed)
assert.throws(() => validateCosMediaManifest(changed, context.options), /Invalid Tang Detective COS manifest/)
}
// Confirm schema 1 is still unable to accept any server-managed object.
assert.throws(() => validateCosMediaManifest({ ...manifest, schemaVersion: 1 }, context.options), /immutable media contract/)
for (const entry of context.sourceMedia) fs.unlinkSync(path.join(context.sourceDirectory, entry.path))
const options = { ...context.options, mediaManifest: manifest, pruneReceipt: fakePruneReceipt(manifest) }
assert.equal(copyNativeProgram(options).source.omittedMediaFiles, 204)
assert.deepEqual(validateNativeOutput(context.outputDirectory, options).errors, [])
})
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, publicReadVerified: 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(context)
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 context.sourceManifest.files) {
assert.equal(sha256(fs.readFileSync(path.join(context.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(context)
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(context) }), /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))), context.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 originalRecord = sourceManifest.files.find(file => file.path === sourcePage.illustrationAsset.replace(/^\//, ''))
assert.ok(originalRecord, `Original selected source record missing: ${sourcePage.illustrationAsset}`)
const expected = context.hashReplacements.get(originalRecord.sha256)
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, context.options)
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, context.options)
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(context) : 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))), context.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(context) }), /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, context.options)
assert.deepEqual(local.errors, [])
assert.equal(local.passed, true)
assert.equal(local.mediaMode, 'local')
assert.equal(local.packagedMediaFiles, 204)
assert.ok(local.relativeDependencies > 300)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,392 @@
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'
import { DEFAULT_PRUNE_RECEIPT_PATH, validateSourceSnapshot } from './tang-detective-source-validation.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, sourceManifestPath, pruneReceipt, pruneReceiptPath }) {
assertNamespace(namespace)
const outputGuard = createNativeOutputGuard(outputDirectory)
outputDirectory = outputGuard.root
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath, sourceManifestPath, pruneReceipt, pruneReceiptPath })
// This preflight must finish before collecting output or touching existing
// import ownership state, including when local mode was explicitly selected.
const sourceSnapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory, sourceManifestPath })
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),
source: { manifestSha256: sourceSnapshot.manifestSha256, originalFiles: sourceSnapshot.files.size,
presentFiles: sourceSnapshot.actual.size, omittedMediaFiles: sourceSnapshot.missingMedia.length,
...(sourceSnapshot.prune ? { prune: sourceSnapshot.prune } : {}) },
...(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() {
const sourceDirectory = path.resolve(root, sourceRelative)
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest: options.mediaManifest,
mediaManifestPath: options.mediaManifestPath, sourceManifestPath: options.sourceManifestPath,
pruneReceiptPath: options.pruneReceiptPath })
if (!media) validateSourceSnapshot({ sourceDirectory, sourceManifestPath: options.sourceManifestPath })
this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH)
this.addWatchFile(options.sourceManifestPath || SOURCE_MANIFEST_PATH)
this.addWatchFile(options.pruneReceiptPath || DEFAULT_PRUNE_RECEIPT_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,
sourceManifestPath: options.sourceManifestPath,
pruneReceiptPath: options.pruneReceiptPath,
})
this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`)
},
},
}
}
@@ -0,0 +1,265 @@
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'
import { createSyntheticSource } from './tang-detective-test-fixtures.mjs'
import { loadCosMediaManifest } from './tang-detective-cos-media.mjs'
import { validateSourceSnapshot } from './tang-detective-source-validation.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 source = createSyntheticSource(temporary)
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: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
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('original snapshot keeps 473 recorded paths and requires valid remote pruning evidence for any omitted media', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
assert.equal(manifest.files.length, 473)
assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204)
const media = loadCosMediaManifest({ sourceDirectory })
const snapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory })
assert.equal(snapshot.actual.size + snapshot.missingMedia.length, 473)
assert.equal([...snapshot.actual.keys()].filter(file => !mediaPattern.test(file)).length, 269)
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(context.sourceDirectory).filter(file => mediaPattern.test(file))) {
assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(context.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
}
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json')
export const DEFAULT_PRUNE_RECEIPT_PATH = path.join(buildDirectory, 'tang-detective-media-prune-receipt.json')
export const ORIGINAL_SOURCE_MANIFEST_SHA256 = '3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20'
export const MEDIA_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(MEDIA_TYPES[path.extname(value).toLowerCase()])
export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
export const canonicalJsonSha256 = value => sha256(`${JSON.stringify(value, null, 2)}\n`)
const SHA256 = /^[a-f0-9]{64}$/
function assert(condition, message) {
if (!condition) throw new Error(`Invalid Tang Detective source snapshot: ${message}`)
}
export function safeSourcePath(value) {
return typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)
&& !/[\\?#\u0000-\u0020]/.test(value)
&& value.split('/').every(part => part && part !== '.' && part !== '..')
}
function lstatOrNull(filename) {
try { return fs.lstatSync(filename) } catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
export function readSourceManifest(sourceManifestPath = SOURCE_MANIFEST_PATH) {
const stat = lstatOrNull(sourceManifestPath)
assert(stat?.isFile() && !stat.isSymbolicLink(), 'source manifest must be a regular file')
const bytes = fs.readFileSync(sourceManifestPath)
const manifestSha256 = sha256(bytes)
if (path.resolve(sourceManifestPath) === SOURCE_MANIFEST_PATH) {
assert(manifestSha256 === ORIGINAL_SOURCE_MANIFEST_SHA256, 'original source manifest changed')
}
const manifest = JSON.parse(bytes)
assert(Array.isArray(manifest.files) && manifest.files.length > 0, 'source manifest files are required')
const files = new Map()
for (const record of manifest.files) {
assert(record && safeSourcePath(record.path) && !files.has(record.path), 'unsafe or duplicate source manifest path')
assert(Number.isSafeInteger(record.bytes) && record.bytes >= 0 && SHA256.test(record.sha256), `invalid source metadata: ${record.path}`)
files.set(record.path, record)
}
return { manifest, manifestSha256, files }
}
/** Inspect the entire tree, including dangling links and unexpected file types.
* No media omission may hide an extra file, source edit or symlink. */
export function inspectSourceFiles(sourceDirectory, source = readSourceManifest()) {
const root = path.resolve(sourceDirectory)
const rootStat = lstatOrNull(root)
assert(rootStat?.isDirectory() && !rootStat.isSymbolicLink(), 'source root must be a real directory, not a symbolic link')
const actual = new Map()
const expectedDirectories = new Set([...source.files.keys()].flatMap(relative => {
const parts = relative.split('/')
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/'))
}))
function visit(directory, prefix = '') {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
const filename = path.join(directory, entry.name)
assert(!entry.isSymbolicLink(), `source symbolic links are forbidden: ${relative}`)
if (entry.isDirectory()) {
assert(expectedDirectories.has(relative), `unexpected source directory: ${relative}`)
visit(filename, relative)
continue
}
assert(entry.isFile(), `unexpected source file type: ${relative}`)
assert(source.files.has(relative), `unexpected source file: ${relative}`)
const record = source.files.get(relative)
const bytes = fs.readFileSync(filename)
assert(bytes.length === record.bytes && sha256(bytes) === record.sha256, `source bytes changed: ${relative}`)
actual.set(relative, record)
}
}
visit(root)
const missingMedia = []
for (const record of source.files.values()) {
if (actual.has(record.path)) continue
assert(isMediaFile(record.path), `missing non-media source: ${record.path}`)
missingMedia.push(record.path)
}
return { actual, missingMedia }
}
export function validatePruneReceipt(receipt, { source, media }) {
assert(receipt?.schemaVersion === 1 && receipt.status === 'completed', 'completed project-local media prune receipt is required')
assert(receipt.sourceManifestSha256 === source.manifestSha256, 'prune receipt source manifest hash mismatch')
assert(receipt.mediaManifestSha256 === media.manifestSha256, 'prune receipt media manifest hash mismatch')
assert(typeof media.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(media.uploadRunId)
&& receipt.uploadRunId === media.uploadRunId, 'prune receipt upload run mismatch')
assert(receipt.backup?.format === 'tar.gz' && SHA256.test(receipt.backup.sha256)
&& Number.isSafeInteger(receipt.backup.bytes) && receipt.backup.bytes > 0, 'prune receipt verified backup hash and size are required')
const expectedMedia = [...source.files.values()].filter(record => isMediaFile(record.path))
assert(media.entries instanceof Map && media.entries.size === expectedMedia.length
&& expectedMedia.every(record => {
const entry = media.entries.get(record.path)
return entry?.sourcePath === record.path && entry.sha256 === record.sha256 && entry.bytes === record.bytes
&& entry.uploaded === true && entry.publicReadVerified === true && entry.remoteVerifiedSha256 === record.sha256
&& (MEDIA_TYPES[path.extname(record.path)][0] === 'image' || entry.rangeVerified === true)
}), 'prune receipt requires complete verified source media coverage')
assert(Array.isArray(receipt.entries) && receipt.entries.length === expectedMedia.length, 'prune receipt media coverage is incomplete')
const seen = new Set()
for (const entry of receipt.entries) {
assert(entry && safeSourcePath(entry.sourcePath) && !seen.has(entry.sourcePath), 'unsafe or duplicate prune receipt entry')
const expected = media.entries.get(entry.sourcePath)
assert(expected && entry.sha256 === expected.sha256 && entry.bytes === expected.bytes
&& entry.url === expected.url && entry.objectKey === expected.objectKey
&& entry.backupSha256 === receipt.backup.sha256, `prune receipt entry mismatch: ${entry.sourcePath}`)
seen.add(entry.sourcePath)
}
return { receiptSha256: canonicalJsonSha256(receipt), uploadRunId: receipt.uploadRunId, backupSha256: receipt.backup.sha256 }
}
export function validateSourceSnapshot({ sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH,
media = null, pruneReceipt, pruneReceiptPath = DEFAULT_PRUNE_RECEIPT_PATH } = {}) {
const source = readSourceManifest(sourceManifestPath)
const inspection = inspectSourceFiles(sourceDirectory, source)
let prune = null
if (inspection.missingMedia.length) {
assert(media, `local mode requires all media; ${inspection.missingMedia.length} source media files are missing`)
assert(media.sourceManifestSha256 === source.manifestSha256, 'remote source manifest binding mismatch')
if (pruneReceipt === undefined) {
const stat = lstatOrNull(pruneReceiptPath)
assert(stat?.isFile() && !stat.isSymbolicLink(), 'completed project-local media prune receipt is required')
pruneReceipt = JSON.parse(fs.readFileSync(pruneReceiptPath, 'utf8'))
}
prune = validatePruneReceipt(pruneReceipt, { source, media })
}
return { ...source, ...inspection, prune }
}
@@ -0,0 +1,184 @@
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 { fileURLToPath } from 'node:url'
import nativePlugin, { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { validateCosMediaManifest } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt, writeFixture } from './tang-detective-test-fixtures.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
function fixture(t, { prune = false } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-source-test-')))
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const source = createSyntheticSource(temporary)
const mediaManifest = fakeVerifiedManifest(source)
const pruneReceipt = fakePruneReceipt(mediaManifest)
const pruneReceiptPath = path.join(temporary, 'test-only-prune-receipt.json')
writeFixture(temporary, 'test-only-prune-receipt.json', JSON.stringify(pruneReceipt))
if (prune) for (const file of source.sourceMedia) fs.unlinkSync(path.join(source.sourceDirectory, file.path))
const outputDirectory = path.join(temporary, 'output')
writeFixture(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'] }))
writeFixture(outputDirectory, 'app.js', '/* host unchanged */')
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
overlayDirectory: path.join(projectRoot, 'native-adapter/tang-detective'), outputDirectory,
mediaManifest, pruneReceiptPath }
return { temporary, ...source, options, mediaManifest, pruneReceipt, pruneReceiptPath, outputDirectory }
}
const snapshot = directory => listFiles(directory).map(file => [file, sha256(fs.readFileSync(path.join(directory, file)))])
test('the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files', () => {
const source = readSourceManifest()
assert.equal(source.files.size, 473)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.jpg')).length, 160)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.mp3')).length, 44)
})
test('a fully pruned clone builds and validates from 269 intact files with no external backup access', t => {
const context = fixture(t, { prune: true })
// A portable receipt records the archive hash. It never requires opening a
// prior machine's absolute path, and no archive exists in this offline test.
context.pruneReceipt.backup.originalPath = '/unavailable-on-another-machine/test-only.tar.gz'
writeFixture(context.temporary, 'test-only-prune-receipt.json', JSON.stringify(context.pruneReceipt))
const media = validateCosMediaManifest(context.mediaManifest, context.options)
assert.equal(media.sourceSnapshot.actual.size, 269)
assert.equal(media.sourceSnapshot.missingMedia.length, 204)
assert.equal(media.sourceSnapshot.prune.backupSha256, context.pruneReceipt.backup.sha256)
const report = copyNativeProgram(context.options)
assert.equal(report.source.omittedMediaFiles, 204)
assert.equal(report.source.presentFiles, 269)
assert.equal(report.sizes.mediaFileCount, 0)
const validation = validateNativeOutput(context.outputDirectory, context.options)
assert.deepEqual(validation.errors, [])
assert.equal(validation.passed, true)
const before = snapshot(context.outputDirectory)
copyNativeProgram(context.options)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('missing local media and missing remote prune receipt fail before any output changes', t => {
const context = fixture(t)
copyNativeProgram({ ...context.options, mediaManifest: null })
const before = snapshot(context.outputDirectory)
fs.unlinkSync(path.join(context.sourceDirectory, context.sourceMedia[0].path))
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: null }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
fs.unlinkSync(context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
assert.deepEqual(snapshot(context.outputDirectory), before)
const report = validateNativeOutput(context.outputDirectory, { ...context.options, mediaManifest: null })
assert.equal(report.passed, false)
assert.ok(report.errors.some(error => error.includes('local mode requires all media')))
})
test('the Vite buildStart preflight rejects missing local media before bundle output can begin', t => {
const context = fixture(t, { prune: true })
const plugin = nativePlugin({ ...context.options, mediaManifest: null })
plugin.configResolved({ root: context.temporary, build: { outDir: 'output' } })
const before = snapshot(context.outputDirectory)
assert.throws(() => plugin.buildStart.call({ addWatchFile() {} }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash', t => {
const context = fixture(t, { prune: true })
const mutations = [
receipt => { receipt.schemaVersion = 2 },
receipt => { receipt.status = 'planned' },
receipt => { receipt.sourceManifestSha256 = '0'.repeat(64) },
receipt => { receipt.mediaManifestSha256 = '0'.repeat(64) },
receipt => { receipt.uploadRunId = 'different-run' },
receipt => { receipt.backup.sha256 = 'not-a-hash' },
receipt => { receipt.backup.bytes = 0 },
receipt => { receipt.backup.format = 'zip' },
receipt => { receipt.entries.pop() },
receipt => { receipt.entries[1] = { ...receipt.entries[0] } },
receipt => { receipt.entries[0].sourcePath = '../escape.jpg' },
receipt => { receipt.entries[0].sourcePath = 'app.json' },
receipt => { receipt.entries[0].sha256 = '0'.repeat(64) },
receipt => { receipt.entries[0].bytes++ },
receipt => { receipt.entries[0].url += '?tampered' },
receipt => { receipt.entries[0].objectKey = 'wrong.jpg' },
receipt => { receipt.entries[0].backupSha256 = '0'.repeat(64) },
]
const before = snapshot(context.outputDirectory)
for (const mutate of mutations) {
const receipt = structuredClone(context.pruneReceipt)
mutate(receipt)
assert.throws(() => copyNativeProgram({ ...context.options, pruneReceipt: receipt }), /Invalid Tang Detective source snapshot/)
assert.deepEqual(snapshot(context.outputDirectory), before)
}
// Even a single omitted file needs evidence for the entire upload inventory.
const partial = fixture(t)
fs.unlinkSync(path.join(partial.sourceDirectory, partial.sourceMedia[0].path))
assert.equal(validateCosMediaManifest(partial.mediaManifest, partial.options).sourceSnapshot.missingMedia.length, 1)
const invalidManifest = { ...context.mediaManifest, uploadRunId: undefined }
assert.throws(() => validateCosMediaManifest(invalidManifest, {
...context.options, pruneReceipt: fakePruneReceipt(invalidManifest),
}), /upload run mismatch/)
})
test('remote pruning never allows missing nonmedia, changed surviving bytes or extra source files', t => {
const context = fixture(t, { prune: true })
const nonmedia = 'utils/storage.js'
const original = fs.readFileSync(path.join(context.sourceDirectory, nonmedia))
fs.unlinkSync(path.join(context.sourceDirectory, nonmedia))
assert.throws(() => copyNativeProgram(context.options), /missing non-media source: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, 'changed code')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, original)
for (const relative of ['assets/unexpected.jpg', 'package-game/data/unexpected.js']) {
writeFixture(context.sourceDirectory, relative, 'unexpected')
assert.throws(() => copyNativeProgram(context.options), /unexpected source file/)
fs.unlinkSync(path.join(context.sourceDirectory, relative))
}
fs.mkdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
assert.throws(() => copyNativeProgram(context.options), /unexpected source directory/)
fs.rmdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
const sourceMedia = context.sourceMedia[0]
writeFixture(context.sourceDirectory, sourceMedia.path, 'changed surviving media')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed/)
assert.equal(listFiles(context.outputDirectory).length, 2)
})
test('source and evidence symlinks, including dangling media links, cannot authorize or hide omission', t => {
const context = fixture(t, { prune: true })
const target = path.join(context.sourceDirectory, context.sourceMedia[0].path)
fs.symlinkSync(path.join(context.temporary, 'missing-media.jpg'), target)
assert.throws(() => copyNativeProgram(context.options), /source symbolic links are forbidden/)
fs.unlinkSync(target)
const outside = path.join(context.temporary, 'original-source-directory')
fs.renameSync(context.sourceDirectory, outside)
fs.symlinkSync(outside, context.sourceDirectory, 'dir')
try { assert.throws(() => copyNativeProgram(context.options), /source root must be a real directory/) }
finally { fs.unlinkSync(context.sourceDirectory); fs.renameSync(outside, context.sourceDirectory) }
const savedReceipt = path.join(context.temporary, 'original-prune-receipt.json')
fs.renameSync(context.pruneReceiptPath, savedReceipt)
fs.symlinkSync(savedReceipt, context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
fs.unlinkSync(context.pruneReceiptPath)
fs.renameSync(savedReceipt, context.pruneReceiptPath)
const savedSource = path.join(context.temporary, 'original-source-manifest.json')
fs.renameSync(context.sourceManifestPath, savedSource)
fs.symlinkSync(savedSource, context.sourceManifestPath)
assert.throws(() => validateSourceSnapshot(context.options), /source manifest must be a regular file/)
})
test('remote output validation rejects later receipt drift or removal', t => {
const context = fixture(t, { prune: true })
copyNativeProgram(context.options)
const invalid = { ...context.pruneReceipt, uploadRunId: 'changed-run' }
const failed = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: invalid })
assert.equal(failed.passed, false)
assert.ok(failed.errors.some(error => error.includes('upload run mismatch')))
const rebound = structuredClone(context.pruneReceipt)
rebound.backup.sha256 = '1'.repeat(64)
for (const entry of rebound.entries) entry.backupSha256 = rebound.backup.sha256
const changedReceipt = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: rebound })
assert.ok(changedReceipt.errors.includes('Media prune receipt changed after import'))
fs.unlinkSync(context.pruneReceiptPath)
assert.equal(validateNativeOutput(context.outputDirectory, context.options).passed, false)
})
@@ -0,0 +1,63 @@
// Test-only synthetic transport bytes. Never use this module for a real
// activation manifest, upload evidence, prune receipt or source backup.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { isMediaFile, readSourceManifest, sha256 } from './tang-detective-source-validation.mjs'
const originalSourceDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../native/tang-detective')
export function writeFixture(directory, relative, bytes) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, bytes)
}
export function createSyntheticSource(temporary) {
const sourceDirectory = path.join(temporary, 'source')
const original = readSourceManifest()
const replacements = new Map([...original.files.values()].filter(file => isMediaFile(file.path))
.map(file => [file.sha256, Buffer.from(`Test-only media fixture for ${file.sha256}\n`)]))
const hashReplacements = new Map([...replacements].map(([originalHash, bytes]) => [originalHash, sha256(bytes)]))
const files = []
for (const file of original.files.values()) {
let bytes
if (isMediaFile(file.path)) bytes = replacements.get(file.sha256)
else {
const source = fs.readFileSync(path.join(originalSourceDirectory, file.path))
if (source.length !== file.bytes || sha256(source) !== file.sha256) throw new Error(`Original non-media source drift: ${file.path}`)
bytes = Buffer.from(source.toString('utf8').replace(/[a-f0-9]{64}/g, value => hashReplacements.get(value) || value))
}
writeFixture(sourceDirectory, file.path, bytes)
files.push({ path: file.path, bytes: bytes.length, sha256: sha256(bytes) })
}
const sourceManifest = { version: 1, files }
const sourceBytes = Buffer.from(`${JSON.stringify(sourceManifest, null, 2)}\n`)
const sourceManifestPath = path.join(temporary, 'synthetic-source-manifest.json')
fs.writeFileSync(sourceManifestPath, sourceBytes)
return { sourceDirectory, sourceManifest, sourceManifestPath, sourceBytes,
sourceMedia: files.filter(file => isMediaFile(file.path)), hashReplacements }
}
export function fakeVerifiedManifest({ sourceBytes, sourceMedia }) {
const baseUrl = 'https://tang-assets.example.test'
return {
schemaVersion: 1, uploadRunId: 'test-only-offline-run', 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, publicReadVerified: true, remoteVerifiedSha256: file.sha256,
...(kind === 'audio' ? { rangeVerified: true } : {}) }
}),
}
}
export function fakePruneReceipt(mediaManifest) {
const backup = { sha256: sha256('Test-only archive receipt; no real archive or upload'), bytes: 123, format: 'tar.gz' }
return { schemaVersion: 1, status: 'completed', sourceManifestSha256: mediaManifest.sourceManifestSha256,
mediaManifestSha256: sha256(`${JSON.stringify(mediaManifest, null, 2)}\n`), uploadRunId: mediaManifest.uploadRunId, backup,
entries: mediaManifest.entries.map(({ sourcePath, sha256: digest, bytes, url, objectKey }) => ({
sourcePath, sha256: digest, bytes, url, objectKey, backupSha256: backup.sha256,
})) }
}
@@ -0,0 +1,118 @@
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'
import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath,
sourceDirectory = path.join(projectRoot, 'native/tang-detective'), sourceManifestPath, pruneReceipt, pruneReceiptPath } = {}) {
const outputGuard = createNativeOutputGuard(outputDirectory)
const output = outputGuard.root
const nativeRoot = outputGuard.path('tang-detective', 'directory')
const sourceRoot = sourceDirectory
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 = readSourceManifest(sourceManifestPath).manifest
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,
sourceManifestPath, pruneReceipt, pruneReceiptPath })
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')
if (imported.source?.prune) {
check(imported.source.prune.receiptSha256 === media.sourceSnapshot.prune?.receiptSha256, 'Media prune receipt changed after import')
}
}
} catch (error) { errors.push(error.message) }
} else {
try { validateSourceSnapshot({ sourceDirectory: sourceRoot, sourceManifestPath }) }
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) {
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
}
@@ -0,0 +1,37 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const output = path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin')
const config = JSON.parse(fs.readFileSync(path.join(output, 'app.json'), 'utf8'))
const sourcePages = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pages.json'), 'utf8'))
const allPages = new Set([...config.pages, ...(config.subPackages || config.subpackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))])
assert.equal(config.pages[0], 'tongji/pages/weekly', 'The standalone Tongji home must remain the startup page')
for (const page of sourcePages.pages) assert(allPages.has(page.path), `Missing existing host route: ${page.path}`)
for (const pkg of sourcePages.subPackages || []) {
for (const page of pkg.pages) assert(allPages.has(`${pkg.root}/${page.path}`), `Missing host subpackage route: ${pkg.root}/${page.path}`)
}
assert(allPages.has('tongji/tang-detective/index'))
assert(allPages.has('tang-detective/pages/home/home'))
assert(allPages.has('tongji/endless-game/index'))
const apiText = fs.readFileSync(path.join(projectRoot, 'config/api.js'), 'utf8')
const sourceApi = apiText.match(/export\s+const\s+API_BASE_URL\s*=\s*['"]([^'"]+)['"]/)
assert(sourceApi, 'Public API config must export API_BASE_URL')
const platform = { module: { exports: {} } }
vm.runInNewContext(fs.readFileSync(path.join(output, 'tang-detective/utils/platformConfig.js'), 'utf8'), platform, { timeout: 1000 })
assert.deepEqual(Object.keys(platform.module.exports), ['apiBaseUrl'], 'Only the public API base belongs in native platformConfig')
assert.equal(platform.module.exports.apiBaseUrl, sourceApi[1], 'Native progress calls must use this host API, not the previous project API')
const weekly = fs.readFileSync(path.join(output, 'tongji/pages/weekly.wxml'), 'utf8')
assert(weekly.includes('唐侦探'), 'The standalone weekly page must contain the game entry')
assert(weekly.includes('识糖小课堂'), 'Existing match-three entry must remain visible')
assert(!fs.existsSync(path.join(output, 'TUICallKit')), 'Do not import the unrelated call SDK into TongjiUniApp')
const home = fs.readFileSync(path.join(output, 'tang-detective/pages/home/home.js'), 'utf8')
assert(home.includes("wx.reLaunch({ url: '/tongji/pages/weekly' })"), 'Return must target the standalone host home')
console.log(JSON.stringify({ passed: true, project: 'TongjiUniApp', startupPage: config.pages[0],
sourceHostRoutes: sourcePages.pages.length + (sourcePages.subPackages || []).reduce((sum, pkg) => sum + pkg.pages.length, 0),
nativePages: [...allPages].filter(page => page.startsWith('tang-detective/')).length,
apiBaseUrl: sourceApi[1], unrelatedCallSdkImported: false,
boundary: 'Built output routing/configuration checks only; not a live backend or device test.' }, null, 2))