const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const root = path.resolve(__dirname, '..'); const tick = () => new Promise(resolve => setTimeout(resolve, 5)); const compile = source => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.CommonJS, experimentalDecorators: true, } }).outputText; function fixture(version = 'develop', store = new Map(), options = {}) { const requests = [], dialogs = [], timers = new Set(); const state = { restarts: 0, paused: false, resumed: 0, flushes: 0, loading: false }; const scene = { name: 'HomeScene', pauseSystemEvents() { state.paused = true; }, resumeSystemEvents() { state.paused = false; } }; const info = { uid: 'user', token: 'token', version: '1.0.84' }; const cc = { sys: { localStorage: { getItem: key => store.has(key) ? store.get(key) : null, setItem: (key, value) => { if (options.storageFailure) throw Error('quota'); store.set(key, String(value)); }, removeItem: key => store.delete(key), } }, director: { getScene: () => scene }, isValid: () => true, game: { resume: () => state.resumed++ }, audioEngine: { resumeAll() {} }, fx: { GameConfig: { GM_INFO: info }, GameTool: { getWechatGameVersion: () => ({develop:'开发版',trial:'体验版',release:'正式版'})[version] || '未知版本' } }, loader: { getXMLHttpRequest: () => ({ open(method, url) { this.url = url; }, setRequestHeader(key, value) { this.contentType = value; }, send(body) { this.body = body; requests.push(this); }, respond(data) { this.readyState = 4; this.status = 200; this.responseText = JSON.stringify(data); this.onreadystatechange(); }, }) }, }; const wx = { getAccountInfoSync: () => { if (options.versionFailure) throw Error('unavailable'); return { miniProgram: { envVersion: version } }; }, showModal: value => dialogs.push(value), showLoading: () => state.loading = true, hideLoading: () => state.loading = false, }; const clientUpdate = { blocked: false, handleResponse: value => value, onShow() {} }; const tracker = { stopOnline() {}, startOnline() {}, flushNow() { state.flushes++; } }; const sdk = { API: { showToast() {}, reboot(_path, failed) { state.restarts++; state.restartFailed = failed; if (options.restartFailure) failed(); } } }; const cache = new Map(); function load(relative) { const file = path.resolve(root, relative); if (cache.has(file)) return cache.get(file); const exports = {}; cache.set(file, exports); let source = fs.readFileSync(file, 'utf8'); if (file.endsWith('Utils.ts')) source = source.split(' static getGacha')[0] + '\n}'; vm.runInNewContext(compile(source), { exports, cc, ...(options.web ? {} : { wx }), console, setTimeout(fn, ms) { const timer = setTimeout(() => { timers.delete(timer); fn(); }, options.fastTimeouts && ms >= 8000 ? 30 : ms); timers.add(timer); return timer; }, clearTimeout(timer) { timers.delete(timer); clearTimeout(timer); }, require(name) { if (name.includes('MiniGameSdk')) return { MiniGameSdk: sdk }; if (name.endsWith('/ClientUpdate')) return { default: clientUpdate }; if (name.endsWith('/DailyTaskProgressTracker')) return { default: tracker }; if (/BattlePass/.test(name)) return {}; return load(path.resolve(path.dirname(file), name + '.ts')); }, }, { filename: file }); return exports; } const Environment = load('assets/Script/module/Config/ServerEnvironment.ts').default; const Storage = load('assets/Script/module/Storage/Storage.ts').StorageMessage; const Utils = load('assets/Script/module/Pay/Utils.ts').default; const Switch = load('assets/Script/module/Config/ServerSwitch.ts').default; const SevenDay = load('assets/Script/seven_day_gift/SevenDayGiftBootstrapApi.ts').default; cc.fx.StorageMessage = Storage; return { Environment, Storage, Utils, Switch, SevenDay, cc, wx, scene, info, requests, dialogs, state, tracker, store, load, answer(confirm = true) { const dialog = dialogs.shift(); assert.ok(dialog); dialog.success?.({ confirm }); dialog.complete?.(); }, cleanup() { timers.forEach(clearTimeout); }, }; } test('develop/trial default to test; release/unknown/API failure ignore a saved test choice', () => { for (const version of ['develop', 'trial', 'release', 'unknown']) { const f = fixture(version); const allowed = version === 'develop' || version === 'trial'; assert.equal(f.Environment.canSwitch(), allowed); assert.equal(f.Utils.apiBaseUrl(), allowed ? f.Utils.testHttpip : f.Utils.httpip); if (!allowed) assert.throws(() => f.Environment.saveNext('test')); } const store = new Map(), dev = fixture('develop', store); dev.Environment.saveNext('test'); for (const f of [fixture('release', store), fixture('develop', store, {versionFailure:true})]) { assert.equal(f.Environment.canSwitch(), false); assert.equal(f.Utils.apiBaseUrl(), f.Utils.httpip); assert.equal(f.Environment.next(), 'production'); } }); test('saved choice only applies after a full launch, to form/JSON requests and cache namespaces together', () => { const store = new Map(), old = fixture('trial', store); old.Environment.saveNext('production'); for (const f of [old, fixture('trial', store)]) { const expected = f === old ? f.Utils.testHttpip : f.Utils.httpip; f.Utils.POST('userData', {action:'read'}, () => {}); f.Utils.POSTJSON('activityConfig/list', {}, () => {}, false); assert.equal(f.requests[0].url, expected + 'userData'); assert.equal(f.requests[1].url, expected + 'activityConfig/list'); assert.equal(new URLSearchParams(f.requests[0].body).get('isDebug'), 'false', 'analytics flag still describes package version'); assert.equal(JSON.parse(f.requests[1].body).token, undefined); f.Storage.setStorage('coin', {coin: f === old ? 900 : 12}); } assert.equal(old.Storage.getStorage('coin').coin, 900); const prod = fixture('trial', store); assert.equal(prod.Storage.getStorage('coin').coin, 12); prod.Environment.saveNext('test'); assert.equal(fixture('trial', store).Storage.getStorage('coin').coin, 900); }); test('legacy gameplay data stays backed up and cannot seed either server; music stays shared', () => { const store = new Map([['level', JSON.stringify({level:9999})], ['uid', '"old-test-uid"'], ['music', '{"musicOpen":false}']]); const testServer = fixture('develop', store); assert.equal(testServer.Storage.getStorage('level'), null); assert.equal(testServer.Storage.getStorage('uid'), null); assert.equal(testServer.Storage.getStorage('music').musicOpen, false); testServer.Storage.setStorage('level', {level:100}); testServer.Storage.setStorage('pendingOrder', 'test-order'); testServer.Environment.saveNext('production'); const prod = fixture('develop', store); assert.equal(prod.Storage.getStorage('level'), null); assert.equal(prod.Storage.getStorage('pendingOrder'), null); prod.Storage.removeStorage('level'); assert.equal(testServer.Storage.getStorage('level').level, 100); assert.equal(JSON.parse(store.get('level')).level, 9999); prod.Storage.setStorage('music', {musicOpen:true}); assert.equal(testServer.Storage.getStorage('music').musicOpen, true); assert.equal(fixture('web', store, {web:true}).Storage.getStorage('level').level, 9999, 'web preview is unchanged'); }); test('test activity controls follow the actual server, including development pointing at production', () => { const store = new Map(), dev = fixture('develop', store); assert.equal(dev.SevenDay.getTestEnvironment().allowed, true); dev.Environment.saveNext('production'); const prod = fixture('develop', store); assert.equal(prod.SevenDay.getTestEnvironment().allowed, false); assert.equal(prod.SevenDay.getTestEnvironment().baseUrl, prod.Utils.httpip); assert.equal(fixture('release', store).SevenDay.getTestEnvironment().allowed, false); dev.Utils.testHttpip = dev.Utils.httpip; assert.equal(dev.SevenDay.getTestEnvironment().allowed, false); }); test('confirmation cancel, same selection, release and game scene never restart or persist a change', () => { const f = fixture(); f.Switch.choose('test', () => {}); assert.equal(f.dialogs.length, 0); f.Switch.choose('production', () => {}); f.answer(false); assert.equal(f.Environment.next(), 'test'); assert.equal(f.state.restarts, 0); f.scene.name = 'GameScene'; f.Switch.choose('production', () => {}); assert.equal(f.dialogs.length, 0); const release = fixture('release'); release.Switch.choose('test', () => {}); assert.equal(release.dialogs.length, 0); }); test('switch waits for nested saves and coin queue, blocks later transports, then restarts with old session intact', async t => { const f = fixture(); t.after(() => f.cleanup()); f.Storage.setStorage('uid', 'old-uid'); f.Utils.POST('userCoin', {action:'save'}, () => { f.Utils.withCoinSave(() => new Promise(resolve => f.Utils.POSTJSON('userProp', {action:'save'}, resolve))); }); f.Switch.choose('production', () => {}); f.answer(); await tick(); assert.equal(f.state.restarts, 0); assert.equal(f.Environment.next(), 'test'); f.requests[0].respond({code:1}); await tick(); assert.equal(f.requests.length, 2); assert.equal(f.state.restarts, 0); f.requests[1].respond({code:1}); await tick(); await tick(); assert.equal(f.state.restarts, 1); assert.equal(f.state.paused, true); assert.equal(f.Environment.current(), 'test'); assert.equal(f.Environment.next(), 'production'); assert.equal(f.Storage.getStorage('uid'), 'old-uid'); let rejected; f.Utils.POST('userData', {}, value => rejected = value); await tick(); assert.equal(f.requests.length, 2); assert.equal(rejected.errorCode, 'SERVER_SWITCHING'); }); test('uncertain saves and save timeout cancel switching; late responses cannot re-block requests', async t => { for (const timeout of [false, true]) { const f = fixture('develop', new Map(), {fastTimeouts:true}); t.after(() => f.cleanup()); f.Utils.POST('userCoin', {action:'save'}, () => {}); f.Switch.choose('production', () => {}); f.answer(); if (!timeout) f.requests[0].onerror(); await new Promise(resolve => setTimeout(resolve, 60)); assert.equal(f.Environment.next(), 'test'); assert.equal(f.state.restarts, 0); assert.equal(f.Switch.inProgress, false); assert.equal(f.state.paused, false); if (timeout) { f.requests[0].respond({code:1}); await tick(); } f.Utils.POST('userData', {action:'read'}, () => {}); assert.equal(f.requests.length, 2); } }); test('restart failure keeps old server usable and saved target pending; user can cancel the pending change', async t => { const f = fixture('trial', new Map(), {restartFailure:true}); t.after(() => f.cleanup()); f.Switch.choose('production', () => {}); f.answer(); await tick(); await tick(); assert.equal(f.state.restarts, 1); assert.equal(f.state.paused, false); assert.equal(f.Switch.inProgress, false); assert.equal(f.Environment.current(), 'test'); assert.equal(f.Environment.next(), 'production'); f.Utils.POST('userData', {}, () => {}); assert.equal(f.requests[0].url, f.Utils.testHttpip + 'userData'); f.answer(false); f.Switch.choose('test', () => {}); f.answer(); assert.equal(f.Environment.next(), 'test'); }); test('storage write failure does not restart or change active environment', async t => { const f = fixture('trial', new Map(), {storageFailure:true}); t.after(() => f.cleanup()); f.Switch.choose('production', () => {}); f.answer(); await tick(); await tick(); assert.equal(f.state.restarts, 0); assert.equal(f.Environment.current(), 'test'); assert.equal(f.state.paused, false); assert.equal(f.Switch.inProgress, false); }); test('restart with no callback times out and allows retry, without a hot switch', async t => { const f = fixture('trial', new Map(), {fastTimeouts:true}); t.after(() => f.cleanup()); f.Switch.choose('production', () => {}); f.answer(); await new Promise(resolve => setTimeout(resolve, 70)); assert.equal(f.state.restarts, 1); assert.equal(f.Switch.inProgress, false); assert.equal(f.Environment.current(), 'test'); assert.equal(f.Environment.next(), 'production'); f.answer(true); await tick(); await tick(); assert.equal(f.state.restarts, 2); }); test('settings creates radio controls only in develop/trial HomeScene, with a single current selection', () => { const source = fs.readFileSync(path.join(root, 'assets/Script/setUi.ts'), 'utf8'); const ast = ts.createSourceFile('setUi.ts', source, 99, true); const method = ast.statements.find(ts.isClassDeclaration).members.find(n => n.name?.getText(ast) === 'onLoad').getText(ast); for (const [version, sceneName] of [['develop','HomeScene'], ['trial','HomeScene'], ['release','HomeScene'], ['develop','GameScene']]) { const f = fixture(version); f.scene.name = sceneName; class Node { constructor(name) { this.name = name; this.children = []; this.components = []; this.events = {}; this.active = true; } set parent(value) { this._parent = value; value.children.push(this); } setPosition(x,y) { this.x = x; this.y = y; } setContentSize(width,height) { this.width = width; this.height = height; } getChildByName(name) { return this.children.find(n => n.name === name); } addComponent(Type) { const value = new Type(); value.node = this; this.components.push(value); value.onLoad?.(); if (this.active) value.onEnable?.(); return value; } on(name, callback) { this.events[name] = callback; } } class Graphics { clear() { this.circles = []; } circle(x,y,radius) { this.circles.push(radius); } fill() {} stroke() {} } class Label { onEnable() { // Cocos 2.4 immediately lays out a newly enabled empty Label in NONE mode. if (this.overflow === undefined) this.node.setContentSize(0, 40); } } Label.HorizontalAlign = Label.VerticalAlign = {CENTER:1}; Label.Overflow = {SHRINK:2}; Object.assign(f.cc, { Node, Graphics, Label, Button:class {}, Component:class {}, color:(r,g,b)=>({r,g,b}), _decorator:{ccclass: Type=>Type} }); const Selector = f.load('assets/Script/ServerSelector.ts').default; const context = { cc:f.cc, ServerEnvironment:f.Environment, ServerSelector:Selector }; vm.runInNewContext(compile('class Settings {' + method + '} globalThis.Settings = Settings;'), context); const settings = new context.Settings(); settings.node = new Node('Pause'); const panel = new Node('pause'); panel.parent = settings.node; for (const key of ['music','effect','vibrate']) settings[key] = {children:[{},{}]}; settings.onLoad(); const allowed = version !== 'release' && sceneName === 'HomeScene'; assert.equal(panel.children.length, allowed ? 1 : 0); if (!allowed) continue; const node = panel.children[0], selector = node.components[0]; assert.equal(node.y, -520); assert.ok(node.y - 75 >= -666 && node.y + 75 < -400, 'controls fit the HomeScene footer'); assert.equal(selector.radios.filter(r => r.graphics.circles.includes(12)).length, 1); assert.equal(selector.radios.find(r => r.graphics.circles.includes(12)).target, 'test'); node.getChildByName('production').events.click(); assert.equal(f.dialogs.length, 1); f.answer(false); assert.equal(selector.hint.string, '切换后将重启游戏'); const labels = node.children.flatMap(n => n.components.filter(c => c instanceof Label)); assert.deepEqual(labels.map(label => label.string), ['服务器', '正式服', '测试服', '切换后将重启游戏']); for (const label of labels) { assert.equal(label.node.active, true); assert.ok(label.node.width >= 150 && label.node.height >= label.fontSize + 12, 'text retains its bounds after Label initialization'); assert.equal(label.useSystemFont, true); assert.equal(label.enableWrapText, false); } assert.equal(labels[0].enableBold, true); f.Environment.saveNext('production'); selector.onEnable(); assert.match(selector.hint.string, /当前测试服.*重启后切至正式服/); } }); test('SDK restart reports missing API, thrown exceptions and platform failures to the caller', () => { const source = fs.readFileSync(path.join(root, 'assets/Script/Sdk/MiniGameSdk.ts'), 'utf8'); const start = source.indexOf(' static reboot('), end = source.indexOf(' /**', start); const code = compile('class API {' + source.slice(start, end) + '} globalThis.API = API;'); for (const mode of ['missing', 'throws', 'fails', 'success', 'unsupported']) { let failures = 0, receivedPath; const wx = mode === 'missing' ? {} : {restartMiniProgram(options) { receivedPath = options.path; if (mode === 'throws') throw Error('unsupported'); if (mode === 'fails') options.fail(); else options.success(); }}; const context = {wx, console:{log(){}},isWechat:()=>mode!=='unsupported',isBytedance:()=>false}; vm.runInNewContext(code, context); context.API.reboot('?resume=1', () => failures++); assert.equal(failures, mode === 'success' ? 0 : 1); if (mode === 'success') assert.equal(receivedPath, '?resume=1'); } });