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');"){ 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']); });