300 lines
18 KiB
JavaScript
300 lines
18 KiB
JavaScript
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');
|
|
}
|
|
});
|