415 lines
33 KiB
JavaScript
415 lines
33 KiB
JavaScript
const test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs');
|
|
const path = require('node:path'), vm = require('node:vm'), crypto = require('node:crypto'), zlib = require('node:zlib');
|
|
const { pack } = require('../packages/activity-bundles/pack'), sha256 = require('../packages/activity-bundles/sha256');
|
|
const runtime = fs.readFileSync('packages/activity-bundles/runtime.js', 'utf8');
|
|
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
function fixture(t, names = ['pyramid', 'other'], md5Cache = true) {
|
|
fs.mkdirSync('temp', { recursive: true });
|
|
const parent = fs.mkdtempSync(path.resolve('temp/activity-bundles-test-')), root = path.join(parent, 'game');
|
|
t.after(() => { assert(parent.startsWith(path.resolve('temp') + path.sep)); fs.rmSync(parent, { recursive: true, force: true }); });
|
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(root, 'src/settings.js'), 'window._CCSettings=' + JSON.stringify({ server: 'https://cdn.example/game/', remoteBundles: names, bundleVers: md5Cache ? Object.fromEntries(names.map(n => [n, 'abc'])) : undefined }));
|
|
fs.writeFileSync(path.join(root, 'game.js'), "require('./cloud-rise-package');\nwindow.boot();");
|
|
fs.writeFileSync(path.join(root, 'project.config.json'), JSON.stringify({ packOptions: { ignore: [{ type: 'file', value: 'keep' }] } }));
|
|
for (const name of names) {
|
|
const folder = path.join(root, 'remote', name);
|
|
fs.mkdirSync(path.join(folder, 'native'), { recursive: true }); fs.mkdirSync(path.join(folder, 'import'), { recursive: true });
|
|
fs.writeFileSync(path.join(folder, md5Cache ? 'config.abc.json' : 'config.json'), JSON.stringify({ name, isZip: false, importBase: 'import', nativeBase: 'native', deps: ['main'] }));
|
|
fs.writeFileSync(path.join(folder, 'native/icon.png'), name + ' art'); fs.writeFileSync(path.join(folder, 'import/pack.json'), '{}');
|
|
fs.mkdirSync(path.join(root, 'src/scripts', name), { recursive: true }); fs.writeFileSync(path.join(root, 'src/scripts', name, 'index.js'), '// local code');
|
|
}
|
|
let publication = pack(root, names);
|
|
return { root, parent, get publication() { return publication; },
|
|
change(name, text) { fs.writeFileSync(path.join(root, 'remote', name, 'native/icon.png'), text); publication = pack(root, names); },
|
|
publish() { publication = pack(root, names); return publication; } };
|
|
}
|
|
// Read ZIP central directory independently, then inflate the referenced local payloads.
|
|
function unzip(bytes, target, createParents = true) {
|
|
let pos = bytes.readUInt32LE(bytes.length - 6), count = bytes.readUInt16LE(bytes.length - 12);
|
|
for (let i = 0; i < count; i++) {
|
|
const len = bytes.readUInt16LE(pos + 28), name = bytes.toString('utf8', pos + 46, pos + 46 + len);
|
|
const size = bytes.readUInt32LE(pos + 20), local = bytes.readUInt32LE(pos + 42);
|
|
const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
|
|
const data = zlib.inflateRawSync(bytes.subarray(start, start + size));
|
|
assert.equal(data.length, bytes.readUInt32LE(pos + 24));
|
|
const out = path.resolve(target, name); assert(out.startsWith(path.resolve(target) + path.sep));
|
|
if (createParents) fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
fs.writeFileSync(out, data);
|
|
pos += 46 + len + bytes.readUInt16LE(pos + 30) + bytes.readUInt16LE(pos + 32);
|
|
}
|
|
}
|
|
function loader(f, options = {}) {
|
|
const storage = options.storage || new Map(), requests = [], downloads = [], activations = [], releases = [], removed = [];
|
|
const userRoot = path.join(f.parent, 'user').replace(/\\/g, '/'), bundles = new Map(), state = {};
|
|
const complete = (o, fn) => queueMicrotask(() => { try { o.success(fn()); } catch (error) { o.fail(error); } });
|
|
const wxfs = {
|
|
mkdir: o => complete(o, () => { if (state.mkdirFail && o.dirPath.endsWith('/pyramid/native')) throw Error('mkdir fail'); fs.mkdirSync(o.dirPath, { recursive: true }); }),
|
|
readFile: o => complete(o, () => { const bytes = fs.readFileSync(o.filePath); return { data: o.encoding ? bytes.toString(o.encoding) : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }; }),
|
|
unzip: o => complete(o, () => { if (state.unzipFail) throw Error('unzip fail'); unzip(fs.readFileSync(o.zipFilePath), o.targetPath, false); if (state.missingFile) fs.unlinkSync(o.targetPath + '/native/icon.png'); }),
|
|
unlink: o => complete(o, () => fs.unlinkSync(o.filePath)),
|
|
rmdir: o => complete(o, () => { assert(o.dirPath.startsWith(userRoot + '/activity-bundles/')); removed.push(o.dirPath); fs.rmSync(o.dirPath, { recursive: true, force: true }); })
|
|
};
|
|
const wx = {
|
|
env: { USER_DATA_PATH: userRoot }, getFileSystemManager: () => wxfs,
|
|
getStorageSync: key => structuredClone(storage.get(key)), setStorageSync: (key, value) => { if (state.storageFail) throw Error('storage full'); storage.set(key, structuredClone(value)); },
|
|
request(o) { requests.push(o.url); const data = structuredClone(state.manifest || f.publication.manifest);
|
|
const reply = () => state.manifestFail ? o.fail(Error('offline')) : o.success({ statusCode: state.manifestStatus || 200, data });
|
|
if (state.holdManifest) state.reply = reply; else queueMicrotask(reply); },
|
|
downloadFile(o) { downloads.push(o.url); complete(o, () => {
|
|
if (state.downloadFail) throw Error('offline');
|
|
const relative = new URL(o.url).pathname.replace(/^\/game\/remote\//, '');
|
|
const bytes = fs.readFileSync(path.join(f.publication.publishDir, relative));
|
|
if (state.corruptZip) bytes[bytes.length - 1] ^= 1;
|
|
fs.writeFileSync(o.filePath, bytes); return { statusCode: 200, filePath: o.filePath };
|
|
}); }
|
|
};
|
|
const manager = { getBundle: name => bundles.get(name), bundles: { remove: n => bundles.delete(n), add: (n, b) => bundles.set(n, b) },
|
|
loadBundle(dir, opts, done) { if (typeof opts === 'function') { done = opts; opts = {}; }
|
|
if (!dir.startsWith(userRoot)) { done(null, { name: dir }); return; }
|
|
activations.push(dir); queueMicrotask(() => {
|
|
if (state.activationFail) return done(Error('engine fail'));
|
|
const data = JSON.parse(fs.readFileSync(dir + '/config.' + (opts.version ? opts.version + '.' : '') + 'json'));
|
|
const bundle = { name: data.name, dir, releaseAll: () => releases.push(dir) }; bundles.set(bundle.name, bundle); done(null, bundle);
|
|
}); }
|
|
};
|
|
const window = {}, config = { baseUrl: options.baseUrl || 'https://cdn.example/game/remote/', bundles: Object.keys(f.publication.manifest.bundles), groups: options.groups || {} };
|
|
vm.runInNewContext(runtime, { wx, cc: { assetManager: manager }, window, console, Uint8Array, DataView, Date, Promise,
|
|
require: name => name.endsWith('-config') ? config : sha256 });
|
|
return { api: window.__activityBundles, manager, state, storage, requests, downloads, activations, releases, removed,
|
|
record: name => storage.get('activity-bundle:v1:' + config.baseUrl + ':' + name) };
|
|
}
|
|
test('SHA-256 matches Node across padding boundaries and large binary inputs', () => {
|
|
for (const length of [0, 1, 3, 55, 56, 63, 64, 65, 127, 128, 1024 * 1024]) {
|
|
const bytes = crypto.randomBytes(length); assert.equal(sha256(bytes), hash(bytes));
|
|
}
|
|
assert.equal(sha256(Buffer.from('abc')), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
|
});
|
|
test('build emits deterministic ZIPs and one common manifest, retaining local scripts and boot hooks', t => {
|
|
const f = fixture(t), first = f.publication; assert.deepEqual(f.publish().manifest, first.manifest);
|
|
for (const name of ['pyramid', 'other']) {
|
|
const entry = first.manifest.bundles[name], archive = fs.readFileSync(path.join(first.publishDir, entry.file));
|
|
assert.deepEqual(Object.keys(entry).sort(), ['file', 'hash', 'size']);
|
|
assert.equal(entry.file, name + '/' + name + '.' + entry.hash + '.zip');
|
|
assert.equal(hash(archive), entry.hash); assert.equal(archive.length, entry.size);
|
|
const dir = path.join(f.parent, 'unpacked', name); unzip(archive, dir);
|
|
const metadata = JSON.parse(fs.readFileSync(path.join(dir, 'bundle-manifest.json')));
|
|
assert.equal(metadata.name, name); assert.equal(metadata.config, 'config.abc.json');
|
|
for (const file of metadata.files) assert.equal(hash(fs.readFileSync(path.join(dir, file.path))), file.sha256);
|
|
assert(fs.existsSync(path.join(f.root, 'src/scripts', name, 'index.js')));
|
|
}
|
|
const boot = fs.readFileSync(path.join(f.root, 'game.js'), 'utf8');
|
|
assert.equal(boot.split("require('./activity-bundles');").length - 1, 1); assert(!boot.includes("require('./cloud-rise-package');"));
|
|
const ignored = JSON.parse(fs.readFileSync(path.join(f.root, 'project.config.json'))).packOptions.ignore;
|
|
assert(ignored.some(e => e.value === 'remote')); assert(ignored.some(e => e.value === 'keep'));
|
|
f.change('pyramid', 'updated'); assert.notEqual(f.publication.manifest.bundles.pyramid.hash, first.manifest.bundles.pyramid.hash);
|
|
assert.equal(f.publication.manifest.bundles.other.hash, first.manifest.bundles.other.hash);
|
|
});
|
|
test('MD5-disabled builds load config.json, reuse cached ZIPs and refresh changed resources', async t => {
|
|
const f = fixture(t, ['pyramid'], false), first = f.publication.manifest.bundles.pyramid;
|
|
fs.writeFileSync(path.join(f.root, 'remote/pyramid/config.old.json'), '{}');
|
|
assert.deepEqual(f.publish().manifest.bundles.pyramid, first, 'stale hashed configs stay outside the ZIP');
|
|
const l = loader(f), original = await l.api.ensureBundle('pyramid');
|
|
const metadata = JSON.parse(fs.readFileSync(original.dir + '/bundle-manifest.json'));
|
|
assert.equal(metadata.version, ''); assert.equal(metadata.config, 'config.json');
|
|
assert(fs.existsSync(original.dir + '/config.json'));
|
|
assert(!fs.existsSync(original.dir + '/config.old.json'));
|
|
const cached = loader(f, { storage: l.storage });
|
|
await cached.api.ensureBundle('pyramid'); assert.equal(cached.downloads.length, 0);
|
|
f.change('pyramid', 'updated without MD5'); l.api.beginCycle();
|
|
const updated = await l.api.ensureBundle('pyramid');
|
|
assert.notEqual(updated, original); assert.equal(l.downloads.length, 2);
|
|
assert.notEqual(f.publication.manifest.bundles.pyramid.hash, first.hash);
|
|
assert.equal(fs.readFileSync(updated.dir + '/native/icon.png', 'utf8'), 'updated without MD5');
|
|
});
|
|
|
|
test('MD5-disabled builds reject a missing config.json instead of publishing a stale hashed config', t => {
|
|
const f = fixture(t, ['pyramid'], false), manifest = fs.readFileSync(path.join(f.publication.publishDir, 'manifest.json'));
|
|
fs.renameSync(path.join(f.root, 'remote/pyramid/config.json'), path.join(f.root, 'remote/pyramid/config.old.json'));
|
|
assert.throws(() => f.publish(), /ENOENT.*config\.json/);
|
|
assert.deepEqual(fs.readFileSync(path.join(f.publication.publishDir, 'manifest.json')), manifest);
|
|
});
|
|
|
|
test('only requested bundles download, parallel calls share tasks and activities share one manifest per cycle', async t => {
|
|
const f = fixture(t), l = loader(f); const [a,b] = await Promise.all([l.api.ensureBundle('pyramid'), l.api.ensureBundle('pyramid')]);
|
|
assert.equal(a,b); assert.equal(l.requests.length,1); assert.equal(l.downloads.length,1);
|
|
await l.api.ensureBundle('other'); assert.equal(l.requests.length,1); assert.equal(l.downloads.length,2);
|
|
l.api.beginCycle(); await Promise.all([l.api.ensureBundle('pyramid'),l.api.ensureBundle('other')]);
|
|
assert.equal(l.requests.length,2); assert.equal(l.downloads.length,2); assert.equal(l.activations.length,2);
|
|
});
|
|
test('unzip without automatic directory creation loads nested siblings and root files', async t => {
|
|
const f = fixture(t, ['pyramid']), folder = path.join(f.root, 'remote/pyramid/native/icons/deep');
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
for (const name of ['first.png', 'second.png']) fs.writeFileSync(path.join(folder, name), name);
|
|
f.publish();
|
|
const l = loader(f), bundle = await l.api.ensureBundle('pyramid');
|
|
for (const name of ['first.png', 'second.png'])
|
|
assert.equal(fs.readFileSync(path.join(bundle.dir, 'native/icons/deep', name), 'utf8'), name);
|
|
assert.equal(JSON.parse(fs.readFileSync(bundle.dir + '/config.abc.json')).name, 'pyramid');
|
|
assert.equal(JSON.parse(fs.readFileSync(bundle.dir + '/bundle-manifest.json')).name, 'pyramid');
|
|
});
|
|
test('cold restart reuses complete disk cache without ZIP downloads and missing/corrupt files repair only that bundle', async t => {
|
|
const f=fixture(t), l=loader(f); await l.api.ensureBundle('pyramid'); await l.api.ensureBundle('other');
|
|
const next=loader(f,{storage:l.storage});await next.api.ensureBundle('pyramid');assert.equal(next.downloads.length,0);
|
|
fs.unlinkSync(next.record('pyramid').dir+'/native/icon.png');next.api.beginCycle();await next.api.ensureBundle('pyramid');assert.equal(next.downloads.length,1);
|
|
const icon=next.record('pyramid').dir+'/native/icon.png',bytes=fs.readFileSync(icon);bytes[0]^=1;fs.writeFileSync(icon,bytes);
|
|
next.api.beginCycle();await next.api.ensureBundle('pyramid');assert.equal(next.downloads.length,2);
|
|
assert(fs.existsSync(l.record('other').dir+'/native/icon.png'));
|
|
});
|
|
test('new hashes download and activate without clearing another activity', async t => {
|
|
const f=fixture(t),l=loader(f),old=await l.api.ensureBundle('pyramid');await l.api.ensureBundle('other');const other=l.record('other');
|
|
f.change('pyramid','new art');l.api.beginCycle();const next=await l.api.ensureBundle('pyramid');
|
|
assert.notEqual(next,old);assert.equal(l.downloads.length,3);assert.equal(l.releases.length,1);assert.deepEqual(l.record('other'),other);
|
|
assert(fs.existsSync(other.dir+'/native/icon.png'));assert.equal(l.record('pyramid').hash,f.publication.manifest.bundles.pyramid.hash);
|
|
});
|
|
test('download, digest, mkdir, unzip, incomplete extraction and storage failure preserve the old cache pointer', async t => {
|
|
for(const failure of ['downloadFail','corruptZip','mkdirFail','unzipFail','missingFile','storageFail']) {
|
|
const f=fixture(t),l=loader(f);await l.api.ensureBundle('pyramid');const old=l.record('pyramid');
|
|
f.change('pyramid','new art');l.state[failure]=true;l.api.beginCycle();await assert.rejects(l.api.ensureBundle('pyramid'), failure === 'mkdirFail' ? /mkdir fail/ : undefined);
|
|
assert.deepEqual(l.record('pyramid'),old);assert(fs.existsSync(old.dir+'/native/icon.png'));assert.equal(l.releases.length,0);
|
|
if (failure === 'mkdirFail') { assert.equal(l.removed.length, 1); assert(!fs.existsSync(l.removed[0])); }
|
|
await assert.rejects(l.api.ensureBundle('pyramid'));assert.equal(l.downloads.length,2,'no render retry storm');
|
|
l.state[failure]=false;l.api.beginCycle();await l.api.ensureBundle('pyramid');assert.notEqual(l.record('pyramid').dir,old.dir);
|
|
}
|
|
});
|
|
test('open views retain their bundle while the new hash downloads; closing permits activation', async t => {
|
|
const f=fixture(t),l=loader(f),lease=await l.api.acquireBundle('pyramid');
|
|
f.change('pyramid','new art');l.api.beginCycle();assert.equal(await l.api.ensureBundle('pyramid'),lease.bundle);
|
|
assert.equal(l.downloads.length,2);assert.equal(l.releases.length,0);assert(fs.existsSync(lease.bundle.dir+'/native/icon.png'));
|
|
lease.release();lease.release();const next=await l.api.ensureBundle('pyramid');assert.notEqual(next,lease.bundle);assert.equal(l.downloads.length,2);assert.equal(l.requests.length,2);
|
|
});
|
|
test('resource server addresses isolate cache even for identical hashes', async t => {
|
|
const f=fixture(t),l=loader(f);await l.api.ensureBundle('pyramid');const other=loader(f,{storage:l.storage,baseUrl:'https://other.example/game/remote/'});
|
|
await other.api.ensureBundle('pyramid');assert.equal(other.downloads.length,1);assert.notEqual(other.record('pyramid').dir,l.record('pyramid').dir);
|
|
});
|
|
test('manifest failure is shared until the next lifecycle; loading resources never loads all activities', async t => {
|
|
const f=fixture(t),l=loader(f);l.state.manifestFail=true;await assert.rejects(l.api.ensureBundle('pyramid'));await assert.rejects(l.api.ensureBundle('other'));
|
|
assert.equal(l.requests.length,1);assert.equal(l.downloads.length,0);l.state.manifestFail=false;l.api.beginCycle();await l.api.ensureBundle('pyramid');assert.equal(l.downloads.length,1);
|
|
});
|
|
|
|
test('unavailable manifest reuses verified MD5-disabled disk caches without downloads or pointer writes, then updates next cycle', async t => {
|
|
const f=fixture(t,['pyramid','other'],false),seed=loader(f);
|
|
await Promise.all([seed.api.ensureBundle('pyramid'),seed.api.ensureBundle('other')]);
|
|
const before=structuredClone([...seed.storage]),l=loader(f,{storage:seed.storage});
|
|
l.state.manifestFail=true;l.state.storageFail=true;
|
|
const [first,duplicate,other]=await Promise.all([l.api.ensureBundle('pyramid'),l.api.ensureBundle('pyramid'),l.api.ensureBundle('other')]);
|
|
assert.equal(first,duplicate);assert.equal(first.dir,l.record('pyramid').dir);assert.equal(other.dir,l.record('other').dir);
|
|
assert.equal(JSON.parse(fs.readFileSync(first.dir+'/bundle-manifest.json')).version,'');
|
|
assert.equal(l.requests.length,1);assert.equal(l.downloads.length,0);assert.equal(l.activations.length,2);
|
|
assert.deepEqual([...l.storage],before);assert.equal(l.removed.length,0);
|
|
f.change('pyramid','updated after offline cache');l.state.manifestFail=false;l.state.storageFail=false;
|
|
assert.equal(await l.api.ensureBundle('pyramid'),first);assert.equal(l.requests.length,1);
|
|
l.api.beginCycle();const next=await l.api.ensureBundle('pyramid');
|
|
assert.notEqual(next,first);assert.equal(l.requests.length,2);assert.equal(l.downloads.length,1);
|
|
assert.equal(l.record('pyramid').hash,f.publication.manifest.bundles.pyramid.hash);
|
|
assert.equal(fs.readFileSync(next.dir+'/native/icon.png','utf8'),'updated after offline cache');
|
|
assert.deepEqual(l.record('other'),before.find(([key])=>key.endsWith(':other'))[1]);
|
|
});
|
|
|
|
test('HTTP, JSON and invalid manifest failures can all use a verified local cache', async t => {
|
|
const f=fixture(t,['pyramid']),seed=loader(f);await seed.api.ensureBundle('pyramid');
|
|
const before=structuredClone([...seed.storage]);
|
|
for(const failure of [{manifestStatus:503},{manifest:'{broken'},{manifest:{schemaVersion:2,bundles:{}}}]){
|
|
const l=loader(f,{storage:seed.storage});Object.assign(l.state,failure);
|
|
const bundle=await l.api.ensureBundle('pyramid');assert.equal(bundle.dir,l.record('pyramid').dir);
|
|
assert.equal(l.requests.length,1);assert.equal(l.downloads.length,0);assert.deepEqual([...l.storage],before);
|
|
}
|
|
});
|
|
|
|
test('unavailable manifest cannot use absent, corrupt, unverified or foreign disk caches', async t => {
|
|
for(const failure of ['absent','missing-file','corrupt-file','invalid-hash','missing-metadata-hash','metadata-hash','foreign-path']){
|
|
const f=fixture(t,['pyramid']),seed=loader(f);await seed.api.ensureBundle('pyramid');
|
|
const record=seed.record('pyramid'),icon=record.dir+'/native/icon.png';
|
|
if(failure==='absent')seed.storage.clear();
|
|
else if(failure==='missing-file')fs.unlinkSync(icon);
|
|
else if(failure==='corrupt-file'){const bytes=fs.readFileSync(icon);bytes[0]^=1;fs.writeFileSync(icon,bytes);}
|
|
else if(failure==='invalid-hash'){
|
|
const dir=record.dir.replace('/'+record.hash+'/', '/../escape/');
|
|
fs.cpSync(record.dir,dir,{recursive:true});record.hash='../escape';record.dir=dir;
|
|
}
|
|
else if(failure==='missing-metadata-hash')delete record.metadataHash;
|
|
else if(failure==='metadata-hash')record.metadataHash='0'.repeat(64);
|
|
else {const dir=path.join(f.parent,'foreign','pyramid').replace(/\\/g,'/');fs.cpSync(record.dir,dir,{recursive:true});record.dir=dir;}
|
|
const before=structuredClone([...seed.storage]),l=loader(f,{storage:seed.storage});l.state.manifestFail=true;
|
|
await assert.rejects(l.api.ensureBundle('pyramid'));
|
|
assert.equal(l.requests.length,1);assert.equal(l.downloads.length,0);assert.equal(l.activations.length,0);
|
|
assert.deepEqual([...l.storage],before);assert.equal(l.removed.length,0);
|
|
}
|
|
});
|
|
|
|
test('a valid manifest missing the bundle or a failed new ZIP cannot fall back to old cache', async t => {
|
|
for(const failure of ['missing-bundle','download']){
|
|
const f=fixture(t,['pyramid']),seed=loader(f);await seed.api.ensureBundle('pyramid');
|
|
const before=structuredClone([...seed.storage]),l=loader(f,{storage:seed.storage});
|
|
if(failure==='missing-bundle')l.state.manifest={schemaVersion:1,bundles:{}};
|
|
else {f.change('pyramid','new version');l.state.downloadFail=true;}
|
|
await assert.rejects(l.api.ensureBundle('pyramid'));
|
|
assert.equal(l.requests.length,1);assert.equal(l.downloads.length,failure==='download'?1:0);assert.equal(l.activations.length,0);
|
|
assert.deepEqual([...l.storage],before);assert(fs.existsSync(seed.record('pyramid').dir+'/native/icon.png'));
|
|
}
|
|
});
|
|
|
|
test('a new lifecycle supersedes a late failed manifest instead of activating its old cache', async t => {
|
|
const f=fixture(t,['pyramid']),seed=loader(f);await seed.api.ensureBundle('pyramid');
|
|
const l=loader(f,{storage:seed.storage});l.state.holdManifest=true;
|
|
const old=l.api.ensureBundle('pyramid'),oldReply=l.state.reply;old.catch(()=>{});
|
|
f.change('pyramid','latest lifecycle');l.api.beginCycle();
|
|
const next=l.api.ensureBundle('pyramid'),newReply=l.state.reply;
|
|
newReply();l.state.manifestFail=true;oldReply();l.state.manifestFail=false;
|
|
await assert.rejects(old);const bundle=await next;
|
|
assert.equal(l.requests.length,2);assert.equal(l.downloads.length,1);assert.equal(l.activations.length,1);
|
|
assert.equal(l.record('pyramid').hash,f.publication.manifest.bundles.pyramid.hash);
|
|
assert.equal(fs.readFileSync(bundle.dir+'/native/icon.png','utf8'),'latest lifecycle');
|
|
});
|
|
test('legacy loadBundle signature redirects only integrated names and engine failures restore the loaded bundle', async t => {
|
|
const f=fixture(t),l=loader(f),load=name=>new Promise((resolve,reject)=>l.manager.loadBundle(name,(e,b)=>e?reject(e):resolve(b)));
|
|
assert.equal((await load('shop')).name,'shop');assert.equal(l.requests.length,0);
|
|
const old=await load('pyramid');f.change('pyramid','new');l.state.activationFail=true;l.api.beginCycle();await assert.rejects(load('pyramid'));
|
|
assert.equal(l.manager.getBundle('pyramid'),old);assert.equal(l.releases.length,0);
|
|
});
|
|
test('malformed manifest and unlisted bundles fail without downloads', async t => {
|
|
const f=fixture(t),l=loader(f);await assert.rejects(l.api.ensureBundle('missing'));assert.equal(l.requests.length,0);
|
|
l.state.manifest=structuredClone(f.publication.manifest);l.state.manifest.bundles.pyramid.file='../escape.zip';
|
|
await assert.rejects(l.api.ensureBundle('pyramid'));assert.equal(l.downloads.length,0);
|
|
});
|
|
test('a new lifecycle ignores a late old manifest without overwriting the latest cache', async t => {
|
|
const f=fixture(t),l=loader(f);l.state.holdManifest=true;
|
|
const old=l.api.ensureBundle('pyramid'), oldReply=l.state.reply;old.catch(()=>{});
|
|
f.change('pyramid','new lifecycle');l.api.beginCycle();const next=l.api.ensureBundle('pyramid'),newReply=l.state.reply;
|
|
newReply();oldReply();await assert.rejects(old);await next;
|
|
assert.equal(l.requests.length,2);assert.equal(l.downloads.length,1);assert.equal(l.record('pyramid').hash,f.publication.manifest.bundles.pyramid.hash);
|
|
});
|
|
|
|
test('camelCase bundle names use the agreed public format and reuse their cache', async t => {
|
|
const f = fixture(t, ['pyramid', 'goldMiner']), l = loader(f);
|
|
await l.api.ensureBundle('pyramid'); assert.equal(l.downloads.length, 1);
|
|
const bundle = await l.api.ensureBundle('goldMiner'); assert.equal(bundle.name, 'goldMiner');
|
|
assert.equal(l.requests.length, 1); assert.equal(l.downloads.length, 2);
|
|
const next = loader(f, { storage: l.storage }); await next.api.ensureBundle('goldMiner');
|
|
assert.equal(next.downloads.length, 0);
|
|
});
|
|
|
|
test('missing or modified internal metadata triggers cache repair', async t => {
|
|
const f = fixture(t), l = loader(f); await l.api.ensureBundle('pyramid');
|
|
fs.unlinkSync(l.record('pyramid').dir + '/bundle-manifest.json');
|
|
l.api.beginCycle(); await l.api.ensureBundle('pyramid'); assert.equal(l.downloads.length, 2);
|
|
const file = l.record('pyramid').dir + '/bundle-manifest.json';
|
|
const metadata = JSON.parse(fs.readFileSync(file));
|
|
metadata.files = metadata.files.filter(entry => entry.path !== 'native/icon.png');
|
|
fs.writeFileSync(file, JSON.stringify(metadata));
|
|
fs.unlinkSync(l.record('pyramid').dir + '/native/icon.png');
|
|
l.api.beginCycle(); await l.api.ensureBundle('pyramid'); assert.equal(l.downloads.length, 3);
|
|
assert(fs.existsSync(l.record('pyramid').dir + '/native/icon.png'));
|
|
});
|
|
|
|
test('a valid ZIP hash does not bypass internal metadata and archive consistency checks', async t => {
|
|
for (const invalid of ['wrong-name', 'unsafe-path', 'undeclared-file', 'missing-metadata']) {
|
|
const f = fixture(t), l = loader(f); await l.api.ensureBundle('pyramid');
|
|
const old = l.record('pyramid');
|
|
const metadata = JSON.parse(fs.readFileSync(old.dir + '/bundle-manifest.json'));
|
|
const entries = metadata.files.map(file => ({ name: file.path, data: fs.readFileSync(old.dir + '/' + file.path) }));
|
|
if (invalid === 'wrong-name') metadata.name = 'other';
|
|
if (invalid === 'unsafe-path') metadata.files[0].path = '../escape';
|
|
if (invalid === 'undeclared-file') entries.push({ name: 'undeclared.txt', data: Buffer.from('extra') });
|
|
if (invalid !== 'missing-metadata') entries.push({ name: 'bundle-manifest.json', data: Buffer.from(JSON.stringify(metadata)) });
|
|
const bytes = require('../packages/activity-bundles/zip')(entries), digest = hash(bytes);
|
|
const file = 'pyramid/pyramid.' + digest + '.zip';
|
|
fs.writeFileSync(path.join(f.publication.publishDir, file), bytes);
|
|
l.state.manifest = structuredClone(f.publication.manifest);
|
|
l.state.manifest.bundles.pyramid = { file, hash: digest, size: bytes.length };
|
|
l.api.beginCycle(); await assert.rejects(l.api.ensureBundle('pyramid'), /活动/);
|
|
assert.deepEqual(l.record('pyramid'), old); assert.equal(l.releases.length, 0);
|
|
}
|
|
});
|
|
test('Creator GUI and CLI hooks generate packages only for WeChat', t => {
|
|
let hook;const exported={exports:{}}, logs=[];
|
|
vm.runInNewContext(fs.readFileSync('packages/activity-bundles/main.js','utf8'),{
|
|
module:exported, require:()=>({pack}), Editor:{log:m=>logs.push(m),error:e=>{throw e;},Builder:{on:(_name,fn)=>hook=fn,removeListener:()=>hook=null}}
|
|
});
|
|
exported.exports.load();
|
|
for(const platform of [{platform:'mini-game',actualPlatform:'wechatgame'},{platform:'wechatgame'}]){
|
|
const f=fixture(t,require('../packages/activity-bundles/bundles.json'));let calls=0;hook({...platform,dest:f.root},error=>{assert.ifError(error);calls++;});assert.equal(calls,1);
|
|
}
|
|
let skipped=0;hook({platform:'web-mobile'},()=>skipped++);assert.equal(skipped,1);assert.equal(logs.length,2);
|
|
exported.exports.unload();assert.equal(hook,null);
|
|
});
|
|
|
|
const cloudNames = ['matching', 'stage1', 'stage2', 'stage3', 'art'];
|
|
const cloudGroups = { cloudRise: cloudNames };
|
|
test('Cloud Rise reserves a release without downloading and retains unseen stages across foreground checks', async t => {
|
|
const f = fixture(t, [...cloudNames, 'pyramid', 'coin_madness']), l = loader(f, { groups: cloudGroups });
|
|
const release = l.api.retainGroup('cloudRise');
|
|
assert.equal(l.requests.length, 0, 'reserving the overview does not start remote loading');
|
|
await l.api.prepareGroup('cloudRise'); assert.equal(l.requests.length, 1); assert.equal(l.downloads.length, 0);
|
|
const oldArt = await l.api.ensureBundle('art'), oldStage = f.publication.manifest.bundles.stage2;
|
|
await l.api.ensureBundle('matching');
|
|
assert.equal(l.downloads.length, 2);
|
|
f.change('art', 'new shared art'); f.change('stage2', 'new stage'); l.api.beginCycle();
|
|
await l.api.ensureBundle('pyramid');
|
|
assert.equal(await l.api.ensureBundle('art'), oldArt);
|
|
await l.api.ensureBundle('stage2');
|
|
assert.equal(l.record('stage2').hash, oldStage.hash, 'an unseen stage uses the retained snapshot');
|
|
assert.equal(l.releases.length, 0);
|
|
release(); release();
|
|
const newArt = await l.api.ensureBundle('art');
|
|
assert.notEqual(newArt, oldArt);
|
|
assert.equal(l.record('art').hash, f.publication.manifest.bundles.art.hash);
|
|
assert.equal(l.releases.at(-1), oldArt.dir, 'dependent pages release before shared art');
|
|
assert(!l.downloads.some(url => url.includes('/stage1/') || url.includes('/stage3/')));
|
|
assert.equal(l.requests.length, 2, 'other activities and the newly released group share this cycle');
|
|
await l.api.ensureBundle('stage2');
|
|
assert.equal(l.record('stage2').hash, f.publication.manifest.bundles.stage2.hash);
|
|
});
|
|
|
|
test('a page-only update also retires the shared art and cached dependent pages as one set', async t => {
|
|
const f = fixture(t, cloudNames), l = loader(f, { groups: cloudGroups });
|
|
const art = await l.api.ensureBundle('art'), page = await l.api.ensureBundle('matching');
|
|
f.change('stage3', 'new last stage'); l.api.beginCycle();
|
|
const updatedArt = await l.api.ensureBundle('art');
|
|
assert.notEqual(updatedArt, art); assert.equal(l.downloads.length, 2, 'unchanged ZIPs stay cached');
|
|
assert.deepEqual(l.releases, [page.dir, art.dir]);
|
|
assert.notEqual(await l.api.ensureBundle('matching'), page);
|
|
});
|
|
|
|
test('group acquisitions retain dependencies; failed downloads leave the active group intact', async t => {
|
|
const f = fixture(t, cloudNames), l = loader(f, { groups: cloudGroups });
|
|
await l.api.ensureBundle('art');
|
|
const lease = await l.api.acquireBundle('matching');
|
|
f.change('art', 'changed'); l.api.beginCycle();
|
|
await l.api.ensureBundle('art'); assert.equal(l.releases.length, 0);
|
|
lease.release(); l.state.downloadFail = true;
|
|
await assert.rejects(l.api.ensureBundle('art')); assert.equal(l.releases.length, 0);
|
|
l.state.downloadFail = false; l.api.beginCycle(); await l.api.ensureBundle('art');
|
|
assert.equal(l.releases.length, 2);
|
|
});
|
|
|
|
test('offline groups accept only complete caches matching the saved common snapshot', async t => {
|
|
const f = fixture(t, cloudNames), seed = loader(f, { groups: cloudGroups });
|
|
await seed.api.ensureBundle('art'); await seed.api.ensureBundle('matching');
|
|
const cached = loader(f, { groups: cloudGroups, storage: seed.storage }); cached.state.manifestFail = true;
|
|
await cached.api.ensureBundle('art'); await cached.api.ensureBundle('matching');
|
|
await assert.rejects(cached.api.ensureBundle('stage1'));
|
|
assert.equal(cached.downloads.length, 0);
|
|
f.change('matching', 'updated page'); seed.api.beginCycle(); await seed.api.ensureBundle('art');
|
|
const mixed = loader(f, { groups: cloudGroups, storage: seed.storage }); mixed.state.manifestFail = true;
|
|
await mixed.api.ensureBundle('art'); await assert.rejects(mixed.api.ensureBundle('matching'));
|
|
assert.equal(mixed.downloads.length, 0, 'never guess URLs or mix an old page with a new snapshot');
|
|
});
|
|
|
|
test('new build removes obsolete subpackage declarations and disables the old build hook', t => {
|
|
const names = require('../packages/activity-bundles/bundles.json'), f = fixture(t, names);
|
|
const context = { window: {} }; vm.runInNewContext(fs.readFileSync(f.root + '/src/settings.js', 'utf8'), context);
|
|
context.window._CCSettings.subpackages = ['cloud_rise_home', 'shop', 'coin_madness', 'stage1', 'cloud_rise'];
|
|
fs.writeFileSync(f.root + '/src/settings.js', 'window._CCSettings=' + JSON.stringify(context.window._CCSettings));
|
|
fs.writeFileSync(f.root + '/game.json', JSON.stringify({ subpackages: context.window._CCSettings.subpackages.map(name => ({ name })) }));
|
|
f.publish();
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(f.root + '/game.json')).subpackages.map(x => x.name), ['cloud_rise_home', 'shop']);
|
|
const built = { module: { exports: {} } };
|
|
vm.runInNewContext(fs.readFileSync(f.root + '/activity-bundles-config.js', 'utf8'), built);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(built.module.exports.groups)), cloudGroups);
|
|
const legacy = { module: { exports: {} } }; vm.runInNewContext(fs.readFileSync('packages/cloud-rise-package/main.js', 'utf8'), legacy);
|
|
legacy.module.exports.load(); legacy.module.exports.unload();
|
|
const meta = JSON.parse(fs.readFileSync('assets/coin_madness.meta'));
|
|
assert.equal(meta.isRemoteBundle.wechatgame, true); assert.equal(meta.compressionType.wechatgame, 'merge_all_json');
|
|
assert.equal(meta.compressionType.bytedance, 'subpackage');
|
|
});
|