Files
xuetang/TUICallKit-Vue3/build/tang-detective-cos-media.test.mjs
2026-09-09 14:47:29 +08:00

523 lines
29 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective')
const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH)
const sourceManifest = JSON.parse(sourceBytes)
const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs'))
const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path))
function write(directory, relative, value) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, value)
}
function fakeVerifiedManifest() {
// Deliberately test-only, reserved .test domain. This is never written to the
// real manifest location and is not evidence of any external upload.
const baseUrl = 'https://tang-assets.example.test'
return {
schemaVersion: 1, sourceManifestSha256: sha256(sourceBytes),
destination: { bucket: 'test-bucket', region: 'test-region', baseUrl },
entries: sourceMedia.map(file => {
const kind = file.path.endsWith('.jpg') ? 'image' : 'audio'
const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}`
return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg',
bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: file.sha256, ...(kind === 'audio' ? { rangeVerified: true } : {}) }
}),
}
}
function fixture(t, { remote = true } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-')))
// Every path removed below was created exclusively by this test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } }))
write(outputDirectory, 'app.js', '/* retained host entry */')
const mediaManifest = remote ? fakeVerifiedManifest() : null
const options = { sourceDirectory, overlayDirectory, outputDirectory, mediaManifest }
const report = copyNativeProgram(options)
const root = path.join(outputDirectory, 'tang-detective')
const localRequire = createRequire(path.join(outputDirectory, 'test.cjs'))
return { temporary, root, options, report, outputDirectory, mediaManifest,
require: relative => localRequire(path.join(root, relative)) }
}
function mockPlatform(responses) {
const files = new Map()
const downloads = []
let sequence = 0
let failDownloads = false
const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value)
const fsApi = {
mkdir: options => options.success({}),
access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')),
readFile(options) {
if (!files.has(options.filePath)) { options.fail(new Error('missing')); return }
const data = files.get(options.filePath)
options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) })
},
writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) },
saveFile(options) {
if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return }
files.set(options.filePath, files.get(options.tempFilePath))
files.delete(options.tempFilePath)
options.success({ savedFilePath: options.filePath })
},
stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) },
unlink(options) { files.delete(options.filePath); options.success({}) },
}
const platform = {
env: { USER_DATA_PATH: '/test-owned-user-data' },
getFileSystemManager: () => fsApi,
downloadFile(options) {
downloads.push(options.url)
if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return }
const body = responses.get(options.url)
const tempFilePath = `/test-owned-temp/${++sequence}`
files.set(tempFilePath, Buffer.from(body))
options.success({ statusCode: 200, tempFilePath, fileSize: body.length })
},
}
return { platform, files, downloads, failDownloads: value => { failDownloads = value } }
}
function managerFixture(context) {
const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js')
const managerApi = context.require('package-game/utils/assetManager.js')
const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))]))
const mock = mockPlatform(responses)
const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' })
return { ...mock, manager, managerApi, releaseAssets, responses }
}
test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', () => {
const media = validateCosMediaManifest(fakeVerifiedManifest(), { sourceDirectory })
assert.equal(media.entries.size, 204)
assert.equal(media.objectCount, 180)
const mutations = [
manifest => { manifest.entries.pop() },
manifest => { manifest.entries[1] = { ...manifest.entries[0] } },
manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].sha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].bytes += 1 },
manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].uploaded = false },
manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') },
manifest => { manifest.entries[0].url += '?signature=not-allowed' },
manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' },
manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" },
manifest => { manifest.entries[0].sourcePath = '../outside.jpg' },
manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' },
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified },
manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
manifest => { manifest.entries[0].contentType = 'text/html' },
]
for (const mutate of mutations) {
const manifest = fakeVerifiedManifest()
mutate(manifest)
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory }), /Invalid Tang Detective COS manifest/)
}
})
test('video manifest also requires a true range receipt and its exact immutable object key', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'video-source')
const sourcePath = 'assets/video/test.mp4'
const bytes = Buffer.from('offline transport contract fixture')
write(source, sourcePath, bytes)
const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) }
const smallSource = JSON.stringify({ files: [record] })
write(context.temporary, 'video-source-manifest.json', smallSource)
const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json')
const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4`
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{
sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256,
objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`,
uploaded: true, remoteVerifiedSha256: record.sha256, rangeVerified: true,
}] }
assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1)
for (const value of [undefined, false, 'true']) {
manifest.entries[0].rangeVerified = value
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/)
}
})
test('source byte drift fails even when a manifest still claims the expected hash', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'changed-source')
const original = context.mediaManifest.entries[0]
write(source, original.sourcePath, 'changed source bytes')
const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] })
write(context.temporary, 'small-source-manifest.json', smallSource)
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] }
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source,
sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/)
})
test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => {
const context = fixture(t, { remote: false })
const missing = path.join(context.temporary, 'not-uploaded.json')
assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null)
assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204)
assert.equal(context.report.sizes.mediaFileCount, 204)
assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false)
const manifest = fakeVerifiedManifest()
write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest))
const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') }
const report = copyNativeProgram(options)
assert.equal(report.sizes.mediaFileCount, 0)
assert.equal(report.media.sourceMediaFiles, 204)
assert.equal(listFiles(context.root).filter(isMediaFile).length, 0)
const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8')
copyNativeProgram(options)
assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state)
assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */')
for (const file of sourceManifest.files) {
assert.equal(sha256(fs.readFileSync(path.join(sourceDirectory, file.path))), file.sha256, file.path)
}
for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) {
assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file)
}
})
test('invalid manifest and externally edited old media are rejected before output mutation', t => {
const context = fixture(t, { remote: false })
const before = fs.readFileSync(path.join(context.root, '.native-import-state.json'))
const invalid = fakeVerifiedManifest()
invalid.entries.pop()
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/)
assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before)
const edited = sourceMedia.at(-1).path
write(context.root, edited, 'external user edit')
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Stale native output has external edits/)
assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit')
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
})
test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => {
const context = fixture(t)
const season = context.require('data/season.js')
const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js'))
const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js'))
const media = context.require('utils/cosMedia.js')
const counts = { pages: 0, formal: 0, provisional: 0 }
for (let number = 1; number <= 15; number++) {
const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}`
const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`)
const comic = context.require(`${packageRoot}/utils/comicPageModel.js`)
const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`)
const chapter = season.chapters[number - 1]
const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number)
const model = buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const [index, page] of model.pageSequence.entries()) {
const sourcePage = original.pageSequence[index]
const expected = sha256(fs.readFileSync(path.join(sourceDirectory, sourcePage.illustrationAsset)))
const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}`
const asset = releaseAssets[assetId]
const image = comic.buildComicImageState(page, asset, {})
assert.equal(media.entryFor(image.src).sha256, expected, page.pageId)
assert.equal(image.src, page.illustrationAsset, page.pageId)
assert.equal(asset.remoteUrl, image.src)
assert.equal(asset.localSeed, '')
assert.match(image.src, /^https:\/\//)
assert.equal(image.source, 'remote-url')
assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible)
assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus)
assert.equal(media.entryFor(image.fallback).kind, 'image')
assert.equal(comic.isPackagedPath(image.src), false)
counts.pages++
if (page.playableVisual.formalReleaseEligible) counts.formal++
if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++
}
}
assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 })
assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '')
assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '')
})
test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => {
const context = fixture(t)
const media = context.require('utils/cosMedia.js')
const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg')
assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`))
for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image')
let count = 0
for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) {
const pages = context.require(`${packageRoot}/data/audioPages.js`)
let player
vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), {
require(specifier) {
if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition }
if (specifier === '../../data/audioPages') return pages
throw new Error(`Unexpected player dependency: ${specifier}`)
},
})
assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending')
for (const page of Object.values(pages)) {
assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending')
assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256)
assert.equal(media.entryFor(page.imageSrc).kind, 'image')
let prepared = ''
player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } })
assert.equal(prepared, page.audioSrc)
count++
}
}
assert.equal(count, 8)
const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8')
assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg')))
})
test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => {
const context = fixture(t)
const { manager, downloads, releaseAssets } = managerFixture(context)
const pages = context.require('package-game/data/remotePageAudioManifest.js')
const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js')
const comic = context.require('package-game/utils/comicPageModel.js')
assert.deepEqual(pages.remotePageAudioPages, {})
assert.deepEqual(playerPages.remotePageAudioPages, {})
for (let chapter = 2; chapter <= 15; chapter++) {
for (let page = 1; page <= 8; page++) {
const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}`
assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null)
}
}
for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) {
assert.equal((await manager.resolve(id)).reason, 'audio-unapproved')
}
const approvedId = 'audio.s01.c01.s01-c01-ms007'
assert.equal(releaseAssets[approvedId].reviewStatus, 'approved')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '')
assert.equal(downloads.length, 0)
})
test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => {
const context = fixture(t)
const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context)
const id = 'comic.s01.c01.p01'
const first = await manager.resolve(id)
assert.equal(first.available, true)
assert.equal(first.persistent, true)
assert.equal(downloads[0], releaseAssets[id].remoteUrl)
assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256)
assert.equal((await manager.resolve(id)).source, 'cache')
assert.equal(downloads.length, 1)
files.set(first.uri, Buffer.from('tampered cache'))
assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed')
assert.equal(files.has(first.uri), false)
const second = await manager.resolve(id)
assert.equal(second.available, true)
assert.equal(downloads.length, 2)
const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' }
const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform })
assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed')
assert.equal(downloads.length, 2)
const badId = 'comic.s01.c01.p03'
responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download'))
assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed')
assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false)
})
test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => {
const context = fixture(t)
const { manager, files, downloads, failDownloads } = managerFixture(context)
const media = context.require('utils/cosMedia.js')
const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true,
setData(update) { Object.assign(this.data, update) } }
const first = await media.prepareSharePreview(page, manager)
assert.match(first, /^\/test-owned-user-data\//)
assert.equal(page.data.sharePreviewLocalPath, first)
const cached = await media.prepareSharePreview(page, manager)
assert.equal(cached, first)
assert.equal(downloads.length, 1)
files.set(first, Buffer.from('corrupted thumbnail'))
assert.equal(await media.prepareSharePreview(page, manager), '')
assert.equal(page.data.sharePreviewLocalPath, '')
failDownloads(true)
assert.equal(await media.prepareSharePreview(page, manager), '')
failDownloads(false)
assert.ok(await media.prepareSharePreview(page, manager))
let finish
const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) }
const pending = media.prepareSharePreview(page, delayed)
await Promise.resolve()
page.__tangDead = true
const prior = page.data.sharePreviewLocalPath
finish({ available: true, uri: '/should-not-be-shared' })
assert.equal(await pending, '')
assert.equal(page.data.sharePreviewLocalPath, prior)
const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8')
assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())'))
assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated"))
assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE'))
})
test('real-output static validator supports COS mode and rejects extra packaged media', t => {
const context = fixture(t)
const report = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.deepEqual(report.errors, [])
assert.equal(report.passed, true)
assert.equal(report.mediaFiles, 204)
assert.equal(report.packagedMediaFiles, 0)
assert.equal(report.mediaMode, 'cos')
write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video')
const invalid = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest })
assert.equal(invalid.passed, false)
assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode'))
})
test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => {
const context = fixture(t)
let definition
const modules = new Map()
function load(filename) {
if (!path.extname(filename)) filename += '.js'
if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options }
if (filename.endsWith('/utils/storage.js')) return {}
if (modules.has(filename)) return modules.get(filename).exports
const module = { exports: {} }
modules.set(filename, module)
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module,
require: specifier => load(path.resolve(path.dirname(filename), specifier)),
}, { filename })
return module.exports
}
load(path.join(context.root, 'package-game/pages/chapter/chapter.js'))
const chapter = context.require('data/season.js').chapters[0]
const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1)
let updates = 0
const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model,
setData(patch) { updates++; Object.assign(this.data, patch) } }
const reader = index => ({ valid: true, currentPageIndex: index,
currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false })
const event = () => ({ currentTarget: { dataset: {
cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc,
cosImageGeneration: page.data.comicImageGeneration,
} } })
page.applyComicReaderState(reader(2), false)
assert.ok(page.data.comicImageActorFallback)
assert.ok(page.data.comicImageFallback)
const originalImage = page.data.comicImageSrc
const originalPage = JSON.stringify(page.data.currentPage)
const interactionEnabled = page.data.currentInteractionEnabled
const firstEvent = event()
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSource, 'actor-fallback')
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const actorUpdates = updates
page.onComicImageError(firstEvent)
assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback')
const actorEvent = event()
page.onComicImageError(actorEvent)
assert.equal(page.data.comicImageSrc, page.data.comicImageFallback)
const sceneEvent = event()
page.onComicImageError(sceneEvent)
assert.equal(page.data.comicImageSrc, '')
assert.equal(page.data.comicImageSource, 'text-fallback')
assert.ok(page.data.comicImageError)
assert.equal(JSON.stringify(page.data.currentPage), originalPage)
assert.equal(page.data.currentInteractionEnabled, interactionEnabled)
const terminalUpdates = updates
for (let index = 0; index < 10; index++) page.onComicImageError(event())
page.onComicImageLoad(sceneEvent)
assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL')
page.applyComicReaderState(reader(3), false)
const nextImage = page.data.comicImageSrc
assert.ok(nextImage)
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt')
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored')
page.onComicImageError(event())
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8')
assert.equal((template.match(/data-cos-image-generation=/g) || []).length,
(template.match(/binderror="onComicImageError"/g) || []).length)
})
test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => {
const context = fixture(t, { remote: false })
const snapshots = target => {
if (fs.lstatSync(target).isDirectory()) {
return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))])
}
return sha256(fs.readFileSync(target))
}
const scenarios = [
{ target: context.outputDirectory },
{ target: context.root },
{ target: path.join(context.outputDirectory, 'app.json') },
{ target: path.join(context.root, '.native-import-state.json') },
{ target: path.join(context.root, 'utils/storage.js') },
{ target: path.join(context.root, 'utils') },
{ target: path.join(context.root, 'assets/scenes'), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true },
]
for (const [index, scenario] of scenarios.entries()) {
const external = path.join(context.temporary, `external-preserved-${index}`)
fs.renameSync(scenario.target, external)
const before = snapshots(external)
fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target)
try {
assert.throws(() => copyNativeProgram({ ...context.options,
mediaManifest: scenario.remote ? fakeVerifiedManifest() : null }), /Native output forbids symbolic links/)
assert.deepEqual(snapshots(external), before, scenario.target)
} finally {
fs.unlinkSync(scenario.target)
fs.renameSync(external, scenario.target)
}
// Even a cleanup conflict near the end must leave earlier media untouched.
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256)
}
const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js')
const newHelper = path.join(context.root, 'utils/cosMedia.js')
fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile)
fs.symlinkSync(externalNewFile, newHelper)
const newHash = sha256(fs.readFileSync(externalNewFile))
try {
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Native output forbids symbolic links/)
assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash)
} finally { fs.unlinkSync(newHelper) }
const alias = path.join(context.temporary, 'ancestor-alias')
fs.symlinkSync(context.temporary, alias, 'dir')
assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204)
const local = validateNativeOutput(context.outputDirectory, { mediaManifest: null })
assert.deepEqual(local.errors, [])
assert.equal(local.passed, true)
assert.equal(local.mediaMode, 'local')
assert.equal(local.packagedMediaFiles, 204)
assert.ok(local.relativeDependencies > 300)
})