228 lines
12 KiB
JavaScript
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;/);
|
|
});
|