172 lines
13 KiB
JavaScript
172 lines
13 KiB
JavaScript
const test = require('node:test'), assert = require('node:assert/strict');
|
|
const fs = require('node:fs'), vm = require('node:vm');
|
|
const source = fs.readFileSync('build-templates/wechatgame/memory-diagnostics.js', 'utf8');
|
|
const ROOT = '/user/memory-diagnostics/';
|
|
function fixture(files = new Map(), envVersion = 'trial', now = 1800000000000, initiallyFull = false) {
|
|
const handlers = {}, timers = [], modals = [], sheets = [], copies = [], calls = [];
|
|
let failWrite = initiallyFull, failCopy = false;
|
|
const fileSystem = {
|
|
accessSync() {}, mkdirSync() {},
|
|
readdirSync() { return [...files.keys()].filter(k => k.startsWith(ROOT)).map(k => k.slice(ROOT.length)); },
|
|
readFileSync(p) { if (!files.has(p)) throw Error('missing'); return files.get(p); },
|
|
writeFileSync(p, data) { if (failWrite) throw Error('full'); calls.push(['write', p]); files.set(p, data); },
|
|
appendFileSync(p, data) { if (failWrite) throw Error('full'); calls.push(['append', p]); files.set(p, (files.get(p) || '') + data); },
|
|
unlinkSync(p) { calls.push(['unlink', p]); files.delete(p); }
|
|
};
|
|
const wx = { env: { USER_DATA_PATH: '/user' },
|
|
getAccountInfoSync() { if (envVersion === 'throws') throw Error(); return { miniProgram: { envVersion, version: '1.2.3' } }; },
|
|
getFileSystemManager() { calls.push(['fs']); return fileSystem; },
|
|
getDeviceInfo: () => ({ model: 'iPhone 8 Plus', platform: 'ios', system: 'iOS 16.7.10', memorySize: 3072, deviceId: 'secret' }),
|
|
getAppBaseInfo: () => ({ version: '8.0', SDKVersion: '3.0' }),
|
|
showModal(o) { modals.push(o); }, showActionSheet(o) { sheets.push(o); }, showToast() {},
|
|
setClipboardData(o) { if (failCopy) o.fail(); else { copies.push(o.data); o.success(); } }
|
|
};
|
|
for (const name of ['onMemoryWarning', 'onHide', 'onShow', 'onError', 'onUnhandledRejection']) wx[name] = fn => { handlers[name] = fn; };
|
|
class Clock extends Date { static now() { return now; } }
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(source, { module, Date: Clock, setInterval: fn => { timers.push(fn); return timers.length; }, clearInterval() {} });
|
|
const api = module.exports.create(wx, { id: 'build-123', builtAt: '2026-10-26', flags: { iOSHighPerformance: true, 'iOSHighPerformance+': true } });
|
|
const rows = () => [...files.values()].flatMap(s => s.split('\n').filter(Boolean).flatMap(line => { try { return [JSON.parse(line)]; } catch (_) { return []; } }));
|
|
return { api, wx, files, handlers, timers, modals, sheets, copies, calls, rows,
|
|
full() { failWrite = true; }, copyFailure(value) { failCopy = value; }, tick() { now += 5000; timers.forEach(fn => fn()); } };
|
|
}
|
|
function fakeEngine() {
|
|
const events = {}, calls = [];
|
|
class Asset {} class Texture2D extends Asset {} class Prefab extends Asset {}
|
|
class Bundle {
|
|
constructor() { this.name = 'stage1'; }
|
|
load(...args) { calls.push({ receiver: this, args }); return 42; }
|
|
releaseAll() { calls.push({ release: this }); return 43; }
|
|
}
|
|
const cc = { Asset, Texture2D, Prefab, AssetManager: { Bundle }, ENGINE_VERSION: '2.4.15', game: { renderType: 1 },
|
|
assetManager: { assets: new Map([['texture', new Texture2D()], ['prefab', new Prefab()]]), loadBundle() {} },
|
|
director: { on: (event, fn) => { events[event] = fn; }, getScene: () => ({ name: 'HomeScene', children: [{ children: [] }] }) },
|
|
Director: { EVENT_BEFORE_SCENE_LOADING: 'load', EVENT_BEFORE_SCENE_LAUNCH: 'before', EVENT_AFTER_SCENE_LAUNCH: 'after' }, find: () => null };
|
|
return { cc, events, calls, Bundle };
|
|
}
|
|
test('release/unknown/unavailable channel performs no persistence, listeners, timers or engine patching', () => {
|
|
for (const env of ['release', null, 'unknown', 'throws']) {
|
|
const f = fixture(new Map(), env), e = fakeEngine(), original = e.Bundle.prototype.load;
|
|
f.api.attachEngine(e.cc); f.api.record('x'); let launched = 0; f.api.beforeBoot(() => launched++);
|
|
assert.equal(f.api.enabled, false); assert.equal(launched, 1); assert.equal(e.Bundle.prototype.load, original);
|
|
assert.equal(f.calls.length, 0); assert.equal(f.timers.length, 0); assert.equal(Object.keys(f.handlers).length, 0);
|
|
}
|
|
});
|
|
test('test sessions persist startup metadata and exact warning counters before restarting', () => {
|
|
const f = fixture(), e = fakeEngine(); f.api.attachEngine(e.cc);
|
|
f.handlers.onMemoryWarning({ level: 10 });
|
|
const start = f.rows().find(r => r.event === 'session.start'), warning = f.rows().find(r => r.event === 'memory.warning');
|
|
assert.equal(start.packageVersion, '1.2.3'); assert.equal(start.model, 'iPhone 8 Plus'); assert.equal(start.buildId, 'build-123');
|
|
assert.equal(start.requestedHighPerformancePlus, true); assert.match(start.actualRuntimeMode, /unknown/);
|
|
assert.equal(warning.detail.count, 1); assert.equal(warning.counts.textures, 1); assert.equal(warning.counts.prefabs, 1);
|
|
assert.equal(warning.counts.cacheEntries, 2); assert.equal(warning.counts.sceneNodes, 2);
|
|
assert(!JSON.stringify(f.rows()).includes('secret'));
|
|
const next = fixture(f.files, 'develop', 1800000010000);
|
|
let boot = 0; next.api.beforeBoot(() => boot++);
|
|
assert.equal(boot, 0); assert.equal(next.modals.length, 1);
|
|
next.modals.shift().success({ confirm: false }); assert.equal(boot, 1);
|
|
assert.equal(next.rows().filter(r => r.event === 'memory.warning').length, 1);
|
|
});
|
|
test('logs stay bounded, preserve startup and newest events, and prune only their own old sessions', () => {
|
|
const f = fixture();
|
|
f.files.set(ROOT + 'unrelated.txt', 'keep'); f.files.set('/user/activity-bundles/cache.zip', 'keep');
|
|
for (let i = 0; i < 4000; i++) f.api.record('activity.page', { index: i, name: '金字塔'.repeat(50), token: 'secret', object: { huge: true } });
|
|
const segments = [...f.files].filter(([k]) => /\.[01]\.jsonl$/.test(k));
|
|
assert.equal(segments.length, 2); segments.forEach(([, v]) => assert(Buffer.byteLength(v) <= 65536));
|
|
assert.equal(f.rows().filter(r => r.event === 'session.start').length, 1);
|
|
assert(f.rows().some(r => r.detail?.index === 3999)); assert(!f.rows().some(r => r.detail?.index === 0));
|
|
assert(!JSON.stringify(f.rows()).includes('secret'));
|
|
for (let i = 1; i <= 7; i++) fixture(f.files, 'trial', 1800000000000 + i * 10000);
|
|
assert.equal([...f.files.keys()].filter(k => k.endsWith('.start.jsonl')).length, 5);
|
|
assert.equal(f.files.get(ROOT + 'unrelated.txt'), 'keep'); assert.equal(f.files.get('/user/activity-bundles/cache.zip'), 'keep');
|
|
});
|
|
test('batched asset callbacks flush on warning; disk failure cannot break warning or launch', () => {
|
|
const f = fixture(); f.api.record('resource.load.start', { operation: 1 });
|
|
assert(!f.rows().some(r => r.event === 'resource.load.start'));
|
|
f.handlers.onMemoryWarning({}); assert(f.rows().some(r => r.event === 'resource.load.start'));
|
|
f.full(); assert.doesNotThrow(() => f.handlers.onMemoryWarning({}));
|
|
assert.doesNotThrow(() => f.tick()); let boot = 0; f.api.beforeBoot(() => boot++); assert.equal(boot, 1);
|
|
});
|
|
test('initial storage failure leaves the game launchable with no hooks or timer', () => {
|
|
const f = fixture(new Map(), 'trial', 1800000000000, true); let boot = 0;
|
|
f.api.beforeBoot(() => boot++); assert.equal(boot, 1); assert.equal(f.api.enabled, false);
|
|
assert.equal(f.timers.length, 0); assert.equal(Object.keys(f.handlers).length, 0);
|
|
});
|
|
test('rotated export is chronological and preserves the warning count even after its original event rotates out', () => {
|
|
const f = fixture(); f.handlers.onMemoryWarning({});
|
|
for (let i = 0; i < 2000; i++) f.api.record('activity.page', { index: i, name: 'x'.repeat(100) });
|
|
const next = fixture(f.files, 'trial', 1800000010000); next.api.exportPrevious(); next.sheets.shift().success({ tapIndex: 0 });
|
|
while (next.modals.length) next.modals.shift().success({ confirm: true });
|
|
const rows = next.copies.map(s => s.slice(s.indexOf('\n') + 1)).join('').trim().split('\n').map(JSON.parse);
|
|
assert.equal(rows[0].event, 'session.start'); assert.equal(rows.at(-1).detail.index, 1999);
|
|
assert.equal(rows.at(-1).memoryWarnings, 1);
|
|
assert(rows.every((row, i) => !i || row.seq > rows[i - 1].seq));
|
|
});
|
|
test('previous export survives cancellation, retries clipboard failure, and reconstructs all segments in order', () => {
|
|
const old = fixture(); for (let i = 0; i < 80; i++) old.api.record('activity.page', { index: i, name: 'a'.repeat(100) });
|
|
const before = new Map(old.files), f = fixture(old.files, 'trial', 1800000010000);
|
|
let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().fail(); assert.equal(done, 1);
|
|
f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 });
|
|
f.copyFailure(true); f.modals.shift().success({ confirm: true });
|
|
assert.match(f.modals[0].content, /复制失败/); assert.equal(f.copies.length, 0);
|
|
f.copyFailure(false);
|
|
while (f.modals.length) f.modals.shift().success({ confirm: true });
|
|
assert.equal(done, 2); assert(f.copies.length > 1);
|
|
const exported = f.copies.map(s => s.slice(s.indexOf('\n') + 1)).join('');
|
|
const rows = exported.trim().split('\n').map(JSON.parse);
|
|
assert.equal(rows[0].session, JSON.parse([...before.values()][0].split('\n')[0]).session);
|
|
assert.equal(rows.filter(r => r.event === 'activity.page').length, 80);
|
|
assert(rows.every((r, i) => !i || r.seq > rows[i - 1].seq));
|
|
for (const [k, v] of before) assert.equal(f.files.get(k), v, 'export does not delete or rewrite the old session');
|
|
});
|
|
test('pre-engine export does not launch the game until the user finishes; native UI failure fails open once', () => {
|
|
const first = fixture(), f = fixture(first.files, 'trial', 1800000010000); let boot = 0;
|
|
f.api.beforeBoot(() => boot++); const prompt = f.modals.shift(); prompt.success({ confirm: true });
|
|
assert.equal(boot, 0); f.sheets.shift().fail(); assert.equal(boot, 1); prompt.fail(); assert.equal(boot, 1);
|
|
});
|
|
test('load observers preserve overloads, callback receiver/results/exceptions, and release semantics', () => {
|
|
const f = fixture(), e = fakeEngine(); f.api.attachEngine(e.cc); const b = new e.Bundle();
|
|
let received; function complete(...args) { received = { self: this, args }; return 99; }
|
|
assert.equal(b.load('prefab/stage', e.cc.Prefab, complete), 42);
|
|
assert.equal(e.calls[0].receiver, b); assert.equal(e.calls[0].args[1], e.cc.Prefab);
|
|
const asset = {}; const receiver = {}; assert.equal(e.calls[0].args[2].call(receiver, null, asset), 99);
|
|
assert.equal(received.self, receiver); assert.equal(received.args[1], asset);
|
|
b.load('prefab/stage', e.cc.Prefab); assert.equal(e.calls[1].args[1], e.cc.Prefab, 'asset constructor is not a completion callback');
|
|
b.load('prefab/stage', () => { throw Error('user callback'); });
|
|
assert.throws(() => e.calls[2].args[1](null, asset), /user callback/);
|
|
assert.equal(b.releaseAll(), 43); assert.equal(e.calls[3].release, b);
|
|
f.tick(); assert(f.rows().some(r => r.event === 'resource.load.complete' && r.detail.ok));
|
|
assert(f.rows().some(r => r.event === 'resource.release.complete'));
|
|
});
|
|
test('scene hooks and background sampling record meaningful boundaries without counting hidden periodic samples', () => {
|
|
const f = fixture(), e = fakeEngine(); f.api.attachEngine(e.cc);
|
|
e.events.load('GameScene'); e.events.before({ name: 'GameScene' }); e.events.after();
|
|
f.handlers.onHide(); const count = f.rows().length; f.tick(); assert.equal(f.rows().length, count);
|
|
f.handlers.onShow(); f.tick(); assert(f.rows().some(r => r.event === 'sample'));
|
|
assert(f.rows().some(r => r.event === 'scene.load.start' && r.detail.scene === 'GameScene'));
|
|
});
|
|
test('the scene export button is created once and opens the previous-session picker', () => {
|
|
const old = fixture(), f = fixture(old.files, 'trial', 1800000010000), e = fakeEngine();
|
|
class Node {
|
|
constructor(name) { this.name = name; this.children = []; this.handlers = {}; this.width = 750; this.height = 1334; }
|
|
addChild(node) { this.children.push(node); }
|
|
getChildByName(name) { return this.children.find(n => n.name === name); }
|
|
setContentSize() {} setPosition() {} addComponent(Type) { return new Type(); }
|
|
on(event, callback) { this.handlers[event] = callback; }
|
|
}
|
|
Node.EventType = { TOUCH_END: 'touchend' }; const canvas = new Node('Canvas');
|
|
Object.assign(e.cc, { Node, Label: class {}, find: () => canvas });
|
|
f.api.attachEngine(e.cc); e.events.after(); e.events.after();
|
|
assert.equal(canvas.children.length, 1); let stopped = false;
|
|
canvas.children[0].handlers.touchend({ stopPropagation() { stopped = true; } });
|
|
assert(stopped); assert.equal(f.sheets.length, 1);
|
|
});
|
|
test('bootstrap keeps the recovery prompt before adapter/engine initialization and the build-hook insertion point', () => {
|
|
const boot = fs.readFileSync('build-templates/wechatgame/game.js', 'utf8');
|
|
const calls = [], context = { wx: {}, window: { boot: () => calls.push('boot') }, cc: { view: {}, sys: { platform: 1, WECHAT_GAME_SUB: 2 }, macro: {} },
|
|
requirePlugin: () => calls.push('engine'), __globalAdapter: { init() {}, adaptEngine() {} },
|
|
require: name => name === './memory-diagnostics' ? { create: () => ({ enabled: false, beforeBoot: next => { context.resume = next; }, attachEngine() {} }) } : {} };
|
|
vm.runInNewContext(boot, context); assert.deepEqual(calls, []); context.resume(); assert.deepEqual(calls, ['engine', 'boot']);
|
|
assert(boot.includes('window.boot();'));
|
|
});
|