523 lines
29 KiB
JavaScript
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)
|
|
})
|