Files
MatchMaster/tools/test-client-update.cjs
T
2026-10-16 15:02:14 +08:00

228 lines
12 KiB
JavaScript

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.endsWith('/ServerEnvironment') ? { default: { current: () => 'production' } }
: 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;/);
});