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) })