235 lines
16 KiB
JavaScript
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']);
|
|
});
|