MatchMaster/tools/test-cloud-rise-package.cjs
2026-09-24 16:50:43 +08:00

235 lines
16 KiB
JavaScript

const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const { pack, names } = require('../packages/cloud-rise-package/pack');
const runtime = fs.readFileSync('packages/cloud-rise-package/runtime.js', 'utf8');
const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
function buildFixture(t) {
fs.mkdirSync('temp', {recursive:true});
const parent = fs.mkdtempSync(path.resolve('temp/cloud-rise-remote-test-')), root = path.join(parent,'game');
t.after(()=> {
assert(parent.startsWith(path.resolve('temp')+path.sep) && path.basename(parent).startsWith('cloud-rise-remote-test-'));
fs.rmSync(parent,{recursive:true,force:true});
});
fs.mkdirSync(path.join(root,'src'),{recursive:true});
const settings={server:'https://cdn.example.com/game',remoteBundles:names,subpackages:['shop'],bundleVers:{...Object.fromEntries(names.map(n=>[n,'abc'])),'cloud_rise_home':'home1'}};
fs.writeFileSync(path.join(root,'src/settings.js'),'window._CCSettings='+JSON.stringify(settings)+';');
fs.writeFileSync(path.join(root,'game.json'),JSON.stringify({subpackages:[{name:'shop',root:'subpackages/shop'}]}));
fs.writeFileSync(path.join(root,'project.config.json'),JSON.stringify({packOptions:{ignore:[{type:'file',value:'unrelated.txt'}]}}));
fs.writeFileSync(path.join(root,'game.js'),"require('./src/settings');\nwindow.boot();");
for(const name of names){
const folder=path.join(root,'remote',name);fs.mkdirSync(path.join(folder,'native'),{recursive:true});
fs.writeFileSync(path.join(folder,'config.abc.json'),JSON.stringify({name,deps:['cloud_rise_home'],uuids:['unchanged']}));
fs.writeFileSync(path.join(folder,'native/image.png'),'image-'+name);
fs.mkdirSync(path.join(root,'src/scripts',name),{recursive:true});
fs.writeFileSync(path.join(root,'src/scripts',name,'index.js'),'/* '+name+' local code */');
}
const versionFile=path.join(parent,'version.json');
const release=version=>fs.writeFileSync(versionFile,JSON.stringify({version}));
release('1.0.0');
return {root,parent,settings,release,versionFile,pack:()=>pack(root,versionFile)};
}
function configFor(f) {
const context={module:{exports:{}}};vm.runInNewContext(fs.readFileSync(path.join(f.root,'cloud-rise-package-config.js'),'utf8'),context);
return JSON.parse(JSON.stringify(context.module.exports));
}
test('publication puts fixed bundle folders beside version.json, preserving assets and local scripts',t=>{
const f=buildFixture(t),first=f.pack();
assert.equal(first.version,'1.0.0');assert(!first.publishDir.startsWith(f.root+path.sep));
assert.deepEqual(fs.readdirSync(first.publishDir).sort(),[...names,'version.json'].sort());
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(first.publishDir,'version.json'))),{version:'1.0.0'});
assert.deepEqual(configFor(f),{baseUrl:'https://cdn.example.com/game/remote/cloud_rise/'});
let size=0;
for(const name of names){
const folder=path.join(first.publishDir,name),data=fs.readFileSync(path.join(folder,'config.json'));
assert.deepEqual(JSON.parse(data),{name,deps:['cloud_rise_home'],uuids:['unchanged'],cloudRiseVersion:'1.0.0'});
assert.equal(fs.readFileSync(path.join(folder,'native/image.png'),'utf8'),'image-'+name);
assert(fs.existsSync(path.join(f.root,'src/scripts',name,'index.js')));
size+=data.length+Buffer.byteLength('image-'+name);
}
assert.equal(first.size,size);
fs.writeFileSync(path.join(first.publishDir,'cloud_rise.zip'),'obsolete');
f.pack();assert(!fs.existsSync(path.join(first.publishDir,'cloud_rise.zip')));
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(f.root,'game.json'))).subpackages,[{name:'shop',root:'subpackages/shop'}]);
const project=JSON.parse(fs.readFileSync(path.join(f.root,'project.config.json')));
assert(project.packOptions.ignore.some(p=>p.value==='remote'));assert(project.packOptions.ignore.some(p=>p.value==='unrelated.txt'));
const boot=fs.readFileSync(path.join(f.root,'game.js'),'utf8');
assert.equal(boot.split("require('./cloud-rise-package');").length,2);
assert(boot.indexOf("require('./cloud-rise-package');")<boot.indexOf('window.boot();'));
f.release('1.0.1');f.pack();
assert.deepEqual(fs.readdirSync(first.publishDir).sort(),[...names,'version.json'].sort());
assert.equal(JSON.parse(fs.readFileSync(first.publishDir+'/art/config.json')).cloudRiseVersion,'1.0.1');
});
test('invalid versions and incomplete output fail before changing client boot configuration',t=>{
const f=buildFixture(t);f.pack();
const before=fs.readFileSync(path.join(f.root,'cloud-rise-package-config.js'),'utf8');
f.release('../bad');assert.throws(()=>f.pack(),/three numeric parts/);
f.release('1.0.2');fs.unlinkSync(path.join(f.root,'remote/stage3/config.abc.json'));
assert.throws(()=>f.pack(),/Missing remote Cloud Rise bundle/);
assert.equal(fs.readFileSync(path.join(f.root,'cloud-rise-package-config.js'),'utf8'),before);
});
test('only WeChat GUI and CLI builds publish the independent remote bundles',t=>{
const hooks={},logs=[],module={exports:{}};
vm.runInNewContext(fs.readFileSync('packages/cloud-rise-package/main.js','utf8'),{
module,require:()=>({pack}),Editor:{log:s=>logs.push(s),error:e=>{throw e;},Builder:{on:(event,fn)=>hooks[event]=fn,removeListener:event=>delete hooks[event]}}
});
module.exports.load();
for(const platform of [{platform:'mini-game',actualPlatform:'wechatgame'},{platform:'wechatgame'}]){
const f=buildFixture(t);let calls=0;
hooks['build-finished']({...platform,dest:f.root},error=>{assert.ifError(error);calls++;});
assert.equal(calls,1);assert(fs.existsSync(path.join(f.root,'cloud-rise-package-config.js')));
}
let skipped=0;hooks['build-finished']({platform:'mini-game',actualPlatform:'bytedance'},()=>skipped++);
assert.equal(skipped,1);assert.equal(logs.length,2);module.exports.unload();
});
function loaderFixture(build,settings={}){
const built=build.pack(),config=configFor(build);
const state={response:{version:built.version},...settings};
const downloads=[],requests=[],loads=[],scripts=[],warnings=[],timers=[],storage=new Map(),engineCache=new Map();
const wx={
request:o=>{
requests.push(o);
if(!state.hangRequest)queueMicrotask(()=>{
if(state.requestError)o.fail(Error('offline version'));
else o.success({statusCode:200,data:state.response});
});
return {abort:()=>{state.aborted=true;}};
},
downloadFile:()=>{throw Error('Custom downloadFile must not be used');},
getFileSystemManager:()=>{throw Error('Custom unzip or filesystem caching must not be used');}
};
const handlers={},window={};
const originals={
bundle:(file,options,done)=>{loads.push({file,options});done(null,{name:file});},
'.json':(url,options,done)=>read(url,options,done,true),
'.png':(url,options,done)=>read(url,options,done,false)
};
function read(url,options,done,json){
loads.push({file:url,options});
if(!url.startsWith(config.baseUrl)){done(null,url);return;}
try{
let data=engineCache.get(url);
if(!data){
downloads.push(url);
const relative=url.slice(config.baseUrl.length).split('?')[0];
if(state.failBundle===relative.split('/')[0])throw Error('offline bundle');
const bytes=fs.readFileSync(path.join(built.publishDir,relative));
data=json?JSON.parse(bytes):bytes.toString();
engineCache.set(url,data);
}
done(null,json?{...data}:data);
}catch(error){done(error);}
}
const cache={cacheQueue:{},tempFiles:{remove(){}},removeCache:key=>engineCache.delete(key),makeBundleFolder(){}};
const cc={sys:{localStorage:{getItem:k=>storage.get(k)||null,setItem:(k,v)=>storage.set(k,v)}},
assetManager:{cacheManager:cache,downloader:{_downloaders:handlers,register:(kind,fn)=>handlers[kind]=fn}}};
const context={window,cc,wx,console:{warn:(...a)=>warnings.push(a)},require:()=>config,
__cocos_require__:file=>{assert(fs.existsSync(path.join(build.root,file)));scripts.push(file);},
setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout:()=>{}};
const restart=()=>{Object.assign(handlers,originals);vm.runInNewContext(runtime,{...context});};
restart();
return {state,requests,downloads,loads,scripts,timers,storage,engineCache,restart,
prepare:()=>window.__prepareCloudRiseVersion(),
load:(name,options={})=>new Promise((resolve,reject)=>handlers.bundle(name,options,(error,data)=>error?reject(error):resolve(data))),
asset:(relative,type='.png',options={})=>new Promise((resolve,reject)=>handlers[type](config.baseUrl+relative,options,(error,data)=>error?reject(error):resolve(data)))
};
}
test('version preparation downloads no bundles; matching/art leave stages untouched',async t=>{
const f=loaderFixture(buildFixture(t));
assert.equal(await f.prepare(),'1.0.0');assert.equal(f.loads.length,0);
await Promise.all([f.load('matching'),f.load('art')]);
assert.equal(f.requests.length,1);assert.equal(f.downloads.length,2);
assert(f.loads.every(l=>/\/(matching|art)\/config\.json\?v=1\.0\.0$/.test(l.file)));
const stage=await f.load('stage2',{version:'stale-client-hash'});
assert.equal(stage.name,'stage2');assert(stage.base.endsWith('/cloud_rise/stage2/'));
assert.equal(f.loads.at(-1).options.cacheEnabled,true);
assert(!f.loads.some(l=>/\/stage[13]\//.test(l.file)));
assert.equal(f.scripts.length,3);
await f.load('cloud_rise_home');assert.equal(f.loads.at(-1).file,'cloud_rise_home');
await f.load('https://other.example.com/art');assert.equal(f.loads.at(-1).file,'https://other.example.com/art');
});
test('same version reuses cache across restart, and missing files download individually',async t=>{
const f=loaderFixture(buildFixture(t));
await f.load('art');await f.asset('art/native/image.png');
f.restart();await f.load('art');await f.asset('art/native/image.png');assert.equal(f.downloads.length,2);
await f.load('stage2');assert.equal(f.downloads.length,3);
f.engineCache.delete('https://cdn.example.com/game/remote/cloud_rise/art/native/image.png?v=1.0.0');
await f.asset('art/native/image.png');assert.equal(f.downloads.length,4);assert.equal(f.requests.length,2);
});
test('changed version refreshes both config and unchanged image paths, with independent stage downloads',async t=>{
const build=buildFixture(t),f=loaderFixture(build);
await f.load('art');assert.equal(await f.asset('art/native/image.png'),'image-art');
build.release('1.0.1');fs.writeFileSync(build.root+'/remote/art/native/image.png','new image');build.pack();
f.state.response={version:'1.0.1'};
assert.equal(await f.prepare(),'1.0.0'); // Don't mix releases inside an already-running game.
f.restart();await f.load('art');assert.equal(await f.asset('art/native/image.png'),'new image');
assert.equal(f.downloads.length,4);
assert(f.downloads.slice(2).every(url=>url.endsWith('?v=1.0.1')));
assert(!f.downloads.some(url=>url.includes('/stage')));
assert.equal(JSON.parse(f.storage.get('cloud_rise_bundle_version_v2')).version,'1.0.1');
});
test('packed JSON and native requests receive version keys, other games are untouched',async t=>{
const build=buildFixture(t),f=loaderFixture(build);await f.prepare();
const folder=build.pack().publishDir+'/stage1/import';fs.mkdirSync(folder);fs.writeFileSync(folder+'/packed.json','{"packed":true}');
assert.deepEqual(await f.asset('stage1/import/packed.json','.json'),{packed:true});
await f.asset('stage1/native/image.png?format=raw');
assert(f.loads.at(-1).file.endsWith('?format=raw&v=1.0.0'));
// Outside the five owned directories, preserve the request URL and options.
await assert.rejects(f.asset('other/image.png'));
assert(f.loads.at(-1).file.endsWith('/other/image.png'));
assert.equal(f.loads.at(-1).options.cacheEnabled,undefined);
});
test('bundle failure is retryable without rechecking version and does not block another bundle',async t=>{
const f=loaderFixture(buildFixture(t),{failBundle:'stage2'});
await f.load('art');await assert.rejects(f.load('stage2'),/offline bundle/);
await f.load('matching');f.state.failBundle='';await f.load('stage2');
assert.equal(f.requests.length,1);assert.equal(f.downloads.length,4);
});
test('failed update retains last successful version record and retries the selected new version',async t=>{
const build=buildFixture(t),f=loaderFixture(build);await f.load('art');
build.release('1.0.1');build.pack();f.state.response={version:'1.0.1'};f.state.failBundle='art';f.restart();
await assert.rejects(f.load('art'),/offline bundle/);
assert.equal(JSON.parse(f.storage.get('cloud_rise_bundle_version_v2')).version,'1.0.0');
assert.equal(await f.prepare(),'1.0.1');f.state.failBundle='';await f.load('art');
assert.equal(JSON.parse(f.storage.get('cloud_rise_bundle_version_v2')).version,'1.0.1');
});
test('version query failure reuses same-CDN cached resources; without history it fails and can retry',async t=>{
const f=loaderFixture(buildFixture(t));await f.load('art');await f.asset('art/native/image.png');
f.state.requestError=true;f.restart();
await f.load('art');await f.asset('art/native/image.png');assert.equal(f.downloads.length,2);
f.storage.set('cloud_rise_bundle_version_v2',JSON.stringify({baseUrl:'https://other.example.com/',version:'9.0.0'}));
f.restart();await assert.rejects(f.prepare(),/offline version/);
f.state.requestError=false;assert.equal(await f.prepare(),'1.0.0');
});
test('invalid version responses and timeout cannot guess a version on first use',async t=>{
for(const response of [null,{},'{broken',{version:'../escape'},{version:123}]){
const f=loaderFixture(buildFixture(t),{response});await assert.rejects(f.prepare());assert.equal(f.loads.length,0);
}
const f=loaderFixture(buildFixture(t),{hangRequest:true}),task=f.prepare();
f.timers[0]();await assert.rejects(task,/timed out/);assert(f.state.aborted);
});
test('stale CDN config cannot be cached under a newer version; retry works after correcting server files',async t=>{
const build=buildFixture(t),f=loaderFixture(build,{response:{version:'1.0.1'}});
await assert.rejects(f.load('art'),/version mismatch/);assert.equal(f.storage.size,0);assert.equal(f.engineCache.size,0);
build.release('1.0.1');build.pack();await f.load('art');
assert.equal(f.downloads.length,2);assert.equal(f.requests.length,1);
});
function serviceFixture(data) {
const ts=require('typescript'),calls=[],window={},exports={};
let ready;window.__prepareCloudRiseVersion=()=>{calls.push('version');return new Promise(resolve=>ready=resolve);};
const cc={fx:{GameConfig:{GM_INFO:{userId:'1',level:200}}},warn(){}};
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/module/Config/CloudRiseService.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText,{exports,cc,window,require:()=>({default:{}})});
const service=exports.default;service.data=()=>data;service.now=()=>100;
service.pagePrefab=async()=>{calls.push('home');};service.pageFragment=async kind=>{calls.push(kind);};
return {service,calls,ready:()=>ready()};
}
test('eligible activity warms matching after version selection without loading any stage or enrolling',async()=>{
const f=serviceFixture({available:true,period:{startsAt:0,endsAt:200,unlockLevel:100}});
f.service.preloadActivity();assert.deepEqual(f.calls,['home','version']);f.ready();await settle();
assert.deepEqual(f.calls,['home','version','matching']);
});
test('closed activity does not preload; personal runs beyond signup closing still select the release',async()=>{
const closed=serviceFixture({available:false,period:{startsAt:0,endsAt:50}});closed.service.preloadActivity();assert.deepEqual(closed.calls,[]);
const active=serviceFixture({available:false,run:{status:'playing',expiresAt:200,stages:[]},period:{startsAt:0,endsAt:50}});
active.service.preloadActivity();active.ready();await settle();assert.deepEqual(active.calls,['home','version']);
});