const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); const read = file => fs.readFileSync(file, 'utf8'); const transpile = source => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.CommonJS, experimentalDecorators: true, } }).outputText; const required = () => ({ code: 0, errorCode: 'CLIENT_UPDATE_REQUIRED', msg: '需要更新', data: { version: '1.0.84', minVersion: '1.0.83' } }); function fixture({ managerAvailable = true } = {}) { const events = {}, requests = [], dialogs = [], timers = new Map(); const state = { initialized: 0, applied: 0, exits: 0, paused: 0, resumed: 0, inputPaused: 0, audioPaused: 0, loading: [] }; let id = 0; const manager = { onCheckForUpdate: cb => { events.check = cb; }, onUpdateReady: cb => { events.ready = cb; }, onUpdateFailed: cb => { events.failed = cb; }, applyUpdate: () => { state.applied++; }, }; const wx = { getUpdateManager: managerAvailable ? () => { state.initialized++; return manager; } : undefined, showLoading: options => state.loading.push(options), hideLoading() {}, showModal: options => dialogs.push(options), showToast() {}, exitMiniProgram: () => { state.exits++; }, login: options => options.success({ code: 'wx-code' }), }; const cc = { game: { pause: () => { state.paused++; }, resume: () => { state.resumed++; } }, audioEngine: { pauseAll: () => { state.audioPaused++; }, resumeAll() {} }, director: { getScene: () => ({ pauseSystemEvents: recursive => { assert.equal(recursive, true); state.inputPaused++; } }) }, fx: { GameConfig: { GM_INFO: { version: '1.0.82', token: 'token', gameState: true } }, GameTool: { getWechatGameVersion: () => '正式版', trackErrorToShushu() {} }, }, loader: { getXMLHttpRequest: () => ({ open(method, url) { this.url = url; }, setRequestHeader(name, value) { this.contentType = value; }, send(body) { this.body = body; requests.push(this); }, respond(value) { this.readyState = 4; this.status = 200; this.responseText = JSON.stringify(value); this.onreadystatechange(); }, }) }, }; const context = vm.createContext({ cc, wx, console: { log() {}, error() {} }, exports: {}, setTimeout: (fn, ms) => { const key = ++id; timers.set(key, { fn, ms }); return key; }, clearTimeout: key => timers.delete(key), }); vm.runInContext(transpile(read('assets/Script/module/Config/ClientUpdate.ts')), context); const ClientUpdate = context.exports.default; context.exports = {}; const sdk = { isWechat: () => true, isBytedance: () => false, API: { showToast() {} } }; context.require = name => name.endsWith('/ClientUpdate') ? { default: ClientUpdate } : name.includes('MiniGameSdk') ? { MiniGameSdk: sdk } : {}; // Existing Utils contains an unrelated U+200C method name beyond the request helpers. const transport = read('assets/Script/module/Pay/Utils.ts').split(' static getGacha')[0] + '\n}'; vm.runInContext(transpile(transport), context); const Utils = context.exports.default; const runTimers = ms => { for (const [key, timer] of Array.from(timers)) if (ms === undefined || timer.ms === ms) { timers.delete(key); timer.fn(); } }; const answer = (dialog, confirm = true) => { dialog.success({ confirm }); dialog.complete(); }; return { ClientUpdate, Utils, cc, wx, manager, events, requests, dialogs, state, timers, context, runTimers, answer }; } test('all form/JSON/authenticated/public/external business requests report the configured version', () => { const f = fixture(); const input = { version: 'spoofed', action: 'read' }; f.Utils.POST('login', input, () => {}); f.Utils.POST('userData', input, () => {}, { json: true }); f.Utils.POSTJSON('cloudRise/index', input, () => {}); f.Utils.POSTJSON('activityConfig/list', input, () => {}, false); f.Utils.POST('getRank', input, () => {}); f.Utils.POST('getAddressRankOther', input, () => {}); assert.equal(f.requests.length, 6); for (const xhr of f.requests) { const body = xhr.contentType === 'application/json' ? JSON.parse(xhr.body) : Object.fromEntries(new URLSearchParams(xhr.body)); assert.equal(body.version, '1.0.82'); } assert.equal(input.version, 'spoofed', 'caller object is not mutated'); assert.equal(JSON.parse(f.requests[3].body).token, undefined); assert.match(f.requests[4].url, /bx7gi3d4oq/); assert.match(f.requests[5].url, /laf.nika4games.com/); }); test('ordinary available updates do not interrupt a compatible player', () => { const f = fixture(); f.ClientUpdate.initialize(); f.ClientUpdate.initialize(); f.events.check({ hasUpdate: true }); f.events.ready(); const response = { code: 1, data: {} }; assert.equal(f.ClientUpdate.handleResponse(response), response); assert.equal(f.state.initialized, 1); assert.equal(f.ClientUpdate.blocked, false); assert.equal(f.dialogs.length, 0); assert.equal(f.state.applied, 0); }); test('required update freezes gameplay, waits for readiness, and applies only on restart confirmation', () => { const f = fixture(); f.ClientUpdate.initialize(); f.events.check({ hasUpdate: true }); f.ClientUpdate.handleResponse(required()); assert.equal(f.ClientUpdate.blocked, true); assert.equal(f.cc.fx.GameConfig.GM_INFO.gameState, false); assert.equal(f.state.paused, 1); assert.equal(f.state.inputPaused, 1); assert.equal(f.dialogs.length, 0); assert.equal(f.state.applied, 0); f.events.ready(); assert.equal(f.dialogs.length, 1); assert.equal(f.dialogs[0].showCancel, false); assert.equal(f.dialogs[0].confirmText, '重启更新'); f.ClientUpdate.handleResponse(required()); f.ClientUpdate.onShow(); assert.equal(f.dialogs.length, 1, 'parallel responses/foreground events cannot duplicate the modal'); f.answer(f.dialogs[0]); assert.equal(f.state.applied, 1); assert.equal(f.ClientUpdate.blocked, true); assert.equal(f.state.resumed, 0); assert.equal(f.timers.size, 0); }); test('an already downloaded update is offered immediately after a server rejection', () => { const f = fixture(); f.ClientUpdate.initialize(); f.events.ready(); f.ClientUpdate.handleResponse(required()); assert.equal(f.dialogs[0].confirmText, '重启更新'); assert.equal(f.state.applied, 0); }); test('late successes are converted to the gate error and further requests never reach the network', () => { const f = fixture(), callbacks = []; f.Utils.POST('login', {}, value => callbacks.push(value)); f.Utils.POSTJSON('userCoin', {}, value => callbacks.push(value)); f.requests[0].respond(required()); f.requests[1].respond({ code: 1, data: { coin: 999 } }); f.Utils.POST('userData', {}, value => callbacks.push(value)); f.Utils.POSTJSON('cloudRise/index', {}, value => callbacks.push(value)); f.runTimers(0); assert.equal(f.requests.length, 2); assert.equal(callbacks.length, 4); assert(callbacks.every(value => value.errorCode === 'CLIENT_UPDATE_REQUIRED')); assert.equal(f.Utils.pendingRequests.size, 0, 'existing request bookkeeping must still settle'); f.requests[0].onerror(); assert.equal(callbacks.length, 4, 'transport completion remains once-only'); }); test('a JSON upgrade response activates the same global gate', () => { const f = fixture(); let response; f.Utils.POSTJSON('cloudRise/index', {}, value => { response = value; }); f.requests[0].respond(required()); assert.equal(response.errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.equal(f.ClientUpdate.blocked, true); }); test('unavailable, failed, timed-out and unsupported updates never unblock or apply an unready package', () => { for (const mode of ['unavailable', 'failed', 'timeout', 'unsupported']) { const f = fixture({ managerAvailable: mode !== 'unsupported' }); f.ClientUpdate.initialize(); if (mode === 'unavailable') f.events.check({ hasUpdate: false }); if (mode === 'failed') f.events.failed(); f.ClientUpdate.handleResponse(required()); if (mode === 'timeout') f.runTimers(30000); assert.equal(f.dialogs.length, 1, mode); assert.equal(f.dialogs[0].confirmText, '退出游戏', mode); f.answer(f.dialogs[0]); assert.equal(f.state.exits, 1); assert.equal(f.state.applied, 0); assert.equal(f.ClientUpdate.blocked, true); } }); test('readiness while an unavailable modal is open replaces the prompt before any action', () => { const f = fixture(); f.ClientUpdate.initialize(); f.events.failed(); f.ClientUpdate.handleResponse(required()); f.events.ready(); assert.equal(f.dialogs.length, 1); f.answer(f.dialogs[0]); f.runTimers(0); assert.equal(f.state.exits, 0); assert.equal(f.state.applied, 0); assert.equal(f.dialogs[1].confirmText, '重启更新'); f.answer(f.dialogs[1]); assert.equal(f.state.applied, 1); }); test('configuration errors remain ordinary retryable service errors and do not activate update UI', () => { const f = fixture(); const response = { code: 0, errorCode: 'CLIENT_UPDATE_CONFIG_UNAVAILABLE', msg: '服务暂不可用' }; assert.equal(f.ClientUpdate.handleResponse(response), response); assert.equal(f.ClientUpdate.blocked, false); assert.equal(f.dialogs.length, 0); }); test('a failed applyUpdate remains blocked and offers exit instead of repeatedly applying', () => { const f = fixture(); f.ClientUpdate.initialize(); f.events.ready(); f.ClientUpdate.handleResponse(required()); f.manager.applyUpdate = () => { throw Error('platform restart failed'); }; f.answer(f.dialogs[0]); f.runTimers(0); assert.equal(f.ClientUpdate.blocked, true); assert.equal(f.dialogs[1].confirmText, '退出游戏'); assert.equal(f.state.resumed, 0); }); test('login polling stops after upgrade, and a fresh process can log in normally', () => { const f = fixture(); let result; f.Utils.getUserInfo(value => { result = value; }); assert.equal(f.requests.length, 1); f.requests[0].respond(required()); f.runTimers(6000); assert.equal(result.errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.equal(f.requests.length, 1); const restarted = fixture(); let loggedIn = false; restarted.Utils.getUserInfo(value => { loggedIn = value.code === 1; }); restarted.requests[0].respond({ code: 1, data: { _id: 'u', openid: 'o', session_key: 's' } }); assert.equal(loggedIn, true); assert.equal(restarted.ClientUpdate.blocked, false); }); test('GameManager guards foreground resume, login retry entry, load completion and scene transition', () => { const f = fixture(); const source = ts.createSourceFile('GameManager.ts', read('assets/Script/GameManager.ts'), 99, true); const cls = source.statements.find(ts.isClassDeclaration); const methods = ['onShow', 'readUserData', 'update'].map(name => cls.members.find(member => member.name?.getText(source) === name).getText(source)).join('\n'); f.context.ClientUpdate = f.ClientUpdate; vm.runInContext(transpile(`class Manager { ${methods} }; globalThis.Manager = Manager;`), f.context); f.ClientUpdate.handleResponse(required()); const instance = new f.context.Manager(); instance.onShow(); instance.readUserData(); instance.update(1); assert.equal(f.state.resumed, 0); assert.equal(f.requests.length, 0); const text = read('assets/Script/GameManager.ts'); assert.match(text, /if \(!ClientUpdate\.blocked && cc\.isValid\(this\.node\)\) cc\.director\.loadScene/); assert.match(text, /const attemptUserInfo = \(\) => \{\s*if \(ClientUpdate\.blocked\) return;/); });