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, enabled = true) { const handlers = {}, timers = [], modals = [], sheets = [], calls = [], logins = [], requests = [], timeouts = new Map(); let timeoutId = 0; let failWrite = initiallyFull; 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() {}, showLoading() {}, hideLoading() {}, login(o) { logins.push(o); }, request(o) { requests.push(o); return { abort() { calls.push(['abort']); } }; }, setClipboardData() { calls.push(['clipboard']); throw Error('clipboard must not be used'); } }; 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: id => { timers[id - 1] = null; }, setTimeout: fn => { timeouts.set(++timeoutId, fn); return timeoutId; }, clearTimeout: id => timeouts.delete(id) }); const api = module.exports.create(wx, { id: 'build-123', builtAt: '2026-10-26', flags: { iOSHighPerformance: true, 'iOSHighPerformance+': true } }, enabled); 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, calls, rows, logins, requests, timeouts, full() { failWrite = true; }, advance(ms) { now += ms; }, tick() { now += 5000; timers.filter(Boolean).forEach(fn => fn()); } }; } function startUpload(f, code = 'one-use-code') { assert.equal(f.modals[0].title, '上传诊断'); f.modals.shift().success({ confirm: true }); f.logins.shift().success({ code }); const request = f.requests.shift(); assert.equal(request.url, 'https://sor779u2w8.sealoshzh.site/memoryDiagnostics'); assert.equal(request.data.action, 'upload'); assert.equal(request.data.code, code); return request; } function completeUpload(f, request) { request.success({ statusCode: 200, data: { code: 1, data: { id: 'a'.repeat(24), session: request.data.session, bytes: Buffer.byteLength(request.data.content), } } }); assert.equal(f.modals[0].title, '上传成功'); f.modals.shift().success({ confirm: true }); assert(!f.calls.some(call => call[0] === 'clipboard')); assert.equal(f.timeouts.size, 0); } 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', EVENT_AFTER_UPDATE: 'frame' }, find: () => null }; return { cc, events, calls, Bundle }; } test('diagnostics default to off before accessing WeChat APIs', () => { const module = { exports: {} }; vm.runInNewContext(source, { module }); const wx = new Proxy({}, { get() { throw Error('WeChat API accessed while disabled'); } }); for (const enabled of [undefined, false, null, 0, 1, 'true']) { assert.equal(module.exports.create(wx, {}, enabled).enabled, false); } assert.equal(module.exports.create(wx).enabled, false); }); test('master switch disables diagnostics in every channel without side effects', () => { for (const env of ['develop', 'trial', 'release']) { const files = new Map([[ROOT + '1800000000000-abcdefgh.start.jsonl', '{"event":"session.start"}\n']]); const before = [...files]; const f = fixture(files, env, 1800000010000, false, false), e = fakeEngine(), original = e.Bundle.prototype.load; let launched = 0, exported = 0; f.api.attachEngine(e.cc); f.api.record('x'); f.api.beforeBoot(() => launched++); f.api.exportPrevious(() => exported++); assert.equal(f.api.enabled, false); assert.equal(launched, 1); assert.equal(exported, 1); assert.equal(e.Bundle.prototype.load, original); assert.deepEqual([...files], before); for (const entries of [f.calls, f.timers, f.modals, f.sheets, f.logins, f.requests, Object.keys(f.handlers), Object.keys(e.events)]) assert.equal(entries.length, 0); assert.equal(f.timeouts.size, 0); } }); 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 }); const request = startUpload(next), rows = request.data.content.trim().split('\n').map(JSON.parse); completeUpload(next, request); 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('upload survives cancellation and sends every retained character without using the clipboard', () => { 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.modals.shift().success({ confirm: false }); assert.equal(done, 2); f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); assert.equal(done, 2); assert.equal(f.logins.length, 0); assert.equal(f.requests.length, 0, 'no automatic upload'); const request = startUpload(f), exported = request.data.content; completeUpload(f, request); assert.equal(done, 3); assert.equal(exported, [...before.values()].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('upload preserves warning details and a damaged tail, and waits for its receipt before booting', () => { const old = fixture(), engine = fakeEngine(); old.api.attachEngine(engine.cc); old.handlers.onMemoryWarning({ level: 10 }); old.tick(); old.api.record('activity.close', { activity: 'pyramid' }, true); const segment = [...old.files.keys()].find(k => k.endsWith('.0.jsonl')); old.files.set(segment, old.files.get(segment) + '{partial'); const before = new Map(old.files), f = fixture(old.files, 'trial', 1800000010000); let boot = 0; f.api.beforeBoot(() => boot++); f.modals.shift().success({ confirm: true }); f.sheets.shift().success({ tapIndex: 0 }); assert.equal(boot, 0); const queued = f.modals[0]; f.tick(); assert.equal(f.modals[0], queued); assert.equal(boot, 0); const request = startUpload(f), content = request.data.content; assert.equal(boot, 0); completeUpload(f, request); assert.equal(content, [...before.values()].join('')); assert.match(content, /iPhone 8 Plus/); assert.match(content, /build-123/); assert.match(content, /"memoryWarnings":1/); assert.match(content, /memory.warning/); assert.match(content, /"textures":1/); assert.match(content, /"sceneNodes":2/); assert.match(content, /activity.close/); assert.match(content, /pyramid/); assert(content.endsWith('{partial')); assert.equal(boot, 1); for (const [key, value] of before) assert.equal(f.files.get(key), value); }); test('modal failure or cancellation releases the export lock and continues the game once', () => { for (const mode of ['fail', 'throw', 'cancel']) { const old = fixture(), f = fixture(old.files, 'trial', 1800000010000); const showModal = f.wx.showModal; if (mode === 'throw') f.wx.showModal = () => { throw Error('native modal failed'); }; let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); if (mode !== 'throw') { const page = f.modals.shift(); if (mode === 'cancel') page.success({ confirm: false }); page.fail(); } assert.equal(done, 1); f.wx.showModal = showModal; f.api.exportPrevious(() => done++); assert.equal(f.sheets.length, 1); f.sheets.shift().fail(); assert.equal(done, 2); } }); test('network failure keeps local bytes and retry gets a fresh login code', () => { const old = fixture(), before = new Map(old.files), f = fixture(old.files, 'trial', 1800000010000); let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); const first = startUpload(f); first.fail({ errMsg: 'request:fail' }); assert.equal(f.modals[0].title, '上传未完成'); assert.equal(done, 0); f.modals.shift().success({ confirm: true }); f.logins.shift().success({ code: 'fresh-code' }); const retry = f.requests.shift(); assert.equal(retry.data.code, 'fresh-code'); assert.equal(retry.data.content, first.data.content); first.success(undefined); assert.equal(f.modals.length, 0, 'late first response ignored'); completeUpload(f, retry); assert.equal(done, 1); for (const [k, v] of before) assert.equal(f.files.get(k), v); }); test('HTTP/business errors and malformed receipts cannot report upload success', () => { for (const response of [undefined, { statusCode: 503 }, { statusCode: 200, data: { code: 0, errorCode: 'DIAGNOSTICS_DISABLED' } }, { statusCode: 200, data: { code: 1, data: {} } }, { statusCode: 200, data: { code: 1, data: { id: 'a'.repeat(24), session: 'wrong', bytes: 1 } } }]) { const old = fixture(), f = fixture(old.files, 'trial', 1800000010000); let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); startUpload(f).success(response); assert.equal(f.modals[0].title, '上传未完成'); f.modals.shift().success({ confirm: false }); assert.equal(done, 1); assert.equal(f.timeouts.size, 0); } }); test('login and request timeouts release the game and ignore late callbacks', () => { for (const stage of ['login', 'request']) { const old = fixture(), f = fixture(old.files, 'trial', 1800000010000); let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); f.modals.shift().success({ confirm: true }); const login = f.logins.shift(); if (stage === 'request') login.success({ code: 'test' }); [...f.timeouts.values()][0](); assert.equal(f.modals[0].title, '上传未完成'); if (stage === 'request') assert(f.calls.some(c => c[0] === 'abort')); f.modals.shift().success({ confirm: false }); assert.equal(done, 1); if (stage === 'login') { login.success({ code: 'late' }); assert.equal(f.requests.length, 0); } else f.requests.shift().success({ statusCode: 200, data: { code: 1 } }); assert.equal(f.modals.length, 0); assert.equal(done, 1); assert.equal(f.timeouts.size, 0); } }); test('unavailable login/request and unreadable source files leave recovery usable', () => { for (const stage of ['login', 'request', 'read']) { const old = fixture(), f = fixture(old.files, 'trial', 1800000010000); if (stage === 'read') f.wx.getFileSystemManager().readFileSync = () => { throw Error('disk'); }; else f.wx[stage] = () => { throw Error('native unavailable'); }; let done = 0; f.api.exportPrevious(() => done++); f.sheets.shift().success({ tapIndex: 0 }); if (stage !== 'read') { f.modals.shift().success({ confirm: true }); if (stage === 'request') f.logins.shift().success({ code: 'test' }); assert.equal(f.modals[0].title, '上传未完成'); f.modals.shift().fail(); } assert.equal(done, 1); assert.equal(f.requests.length, 0); f.api.exportPrevious(() => done++); f.sheets.shift().fail(); assert.equal(done, 2); } }); 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('frames keep sampling and flushing resources when the interval stops, with one shared cadence', () => { const f = fixture(), e = fakeEngine(); f.api.attachEngine(e.cc); assert.equal(f.timers.filter(Boolean).length, 1, 'engine attachment replaces the bootstrap timer'); f.tick(); e.events.frame(); assert.equal(f.rows().filter(r => r.event === 'sample').length, 1); f.api.record('resource.load.start', { operation: 999 }); f.advance(5000); e.events.frame(); const samples = f.rows().filter(r => r.event === 'sample'); assert.equal(samples.length, 2); assert.equal(samples[1].detail.source, 'frame'); assert.equal(samples[1].detail.timerAgeMs, 5000); assert.equal(samples[1].detail.sampleAgeMs, 5000); assert(f.rows().some(r => r.detail?.operation === 999)); for (let i = 0; i < 300; i++) e.events.frame(); assert.equal(f.rows().filter(r => r.event === 'sample').length, 2, 'no per-frame cache scans/log writes'); f.advance(20000); e.events.frame(); assert.equal(f.rows().filter(r => r.event === 'sample').at(-1).detail.sampleAgeMs, 20000); f.handlers.onHide(); const count = f.rows().length; f.tick(); e.events.frame(); assert.equal(f.rows().length, count); f.handlers.onShow(); f.handlers.onShow(); assert.equal(f.timers.filter(Boolean).length, 1); f.tick(); assert.equal(f.rows().at(-1).event, 'sample'); }); test('export dialogs are recorded and do not suppress live samples or mutate the previous log', () => { const old = fixture(), before = new Map(old.files), f = fixture(old.files, 'trial', 1800000010000); f.api.exportPrevious(); f.sheets.shift().success({ tapIndex: 0 }); for (let i = 0; i < 6; i++) f.tick(); const samples = f.rows().filter(r => r.event === 'sample'); assert.equal(samples.length, 6); assert(samples.every(r => r.detail.exporting === true)); f.modals.shift().success({ confirm: false }); f.tick(); assert.equal(f.rows().at(-1).detail.exporting, false); assert(f.rows().some(r => r.event === 'diagnostic.export.start')); assert(f.rows().some(r => r.event === 'diagnostic.export.finish')); for (const [key, value] of before) assert.equal(f.files.get(key), value); }); test('load sources survive asynchronous completion and nested scopes restore even after errors', () => { const f = fixture(), e = fakeEngine(); f.api.attachEngine(e.cc); const bundle = new e.Bundle(); const receiver = {}; let received; assert.equal(f.api.withSource('career.city.spine', () => { bundle.load('spine/action03/skeleton', function (...args) { received = { receiver: this, args }; }); assert.throws(() => f.api.withSource('rank.item.remote', () => { bundle.load('head'); throw Error('user'); }), /user/); return bundle.load('spine/action09/skeleton'); }), 42); bundle.load('outside'); f.api.withSource('https://example.com/?token=secret', () => bundle.load('redacted')); const asset = {}; e.calls[0].args[1].call(receiver, null, asset); f.tick(); const starts = f.rows().filter(r => r.event === 'resource.load.start'); assert.deepEqual(starts.map(r => r.detail.source), ['career.city.spine', 'rank.item.remote', 'career.city.spine', 'unknown', 'unknown']); assert.equal(f.rows().find(r => r.event === 'resource.load.complete').detail.source, 'career.city.spine'); assert.equal(received.receiver, receiver); assert.equal(received.args[1], asset); assert(!JSON.stringify(f.rows()).includes('secret')); }); test('source helper preserves return values and exceptions with diagnostics absent, old or enabled', () => { const ts = require('typescript'); const compiled = ts.transpileModule(fs.readFileSync('assets/Script/module/Tool/MemoryDiagnosticSource.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 }, }).outputText; for (const diagnostics of [undefined, { enabled: true }, { enabled: false }, fixture().api]) { const exports = {}; vm.runInNewContext(compiled, { exports, window: { __memoryDiagnostics: diagnostics } }); let calls = 0; const value = {}; assert.equal(exports.withDiagnosticSource('home.avatar.remote', () => { calls++; return value; }), value); assert.throws(() => exports.withDiagnosticSource('home.avatar.remote', () => { calls++; throw Error('original'); }), /original/); assert.equal(calls, 2); } }); 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: (wx, build, enabled) => { assert.equal(enabled, false); return { 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();')); });