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 = 'adapted home' 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('正在读取阅读存档…'), 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 } })