import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import vm from 'node:vm'; import { test } from 'node:test'; import ts from 'typescript'; const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; const tick = () => new Promise(setImmediate); /** 加载真实配置、请求、认证、App 与切换流程,仅模拟微信运行时和服务端响应。 */ function fixture(storage = new Map()) { let version = 'develop'; let app; let pages = []; let prefetchCalls = 0; let navigationCount = 0; let loginCount = 0; let handler = ({ url, environment }) => { if (url.endsWith('/auth/wechat/login')) { return { accessToken: `token-${environment}`, tokenType: 'Bearer', expiresIn: 86400, expiresAt: '2099-01-01T00:00:00Z', user: { id: `user-${environment}`, nickName: environment, avatarUrl: null, avatarUrlExpiresAt: null, createdAt: '2026-10-19T00:00:00Z', }, }; } return { home: { marker: environment } }; }; const requests = []; const wx = { getAccountInfoSync: () => ({ miniProgram: { appId: 'wx-env-test', envVersion: version }, }), getStorageSync: (key) => storage.get(key), setStorageSync: (key, value) => storage.set(key, structuredClone(value)), removeStorageSync: (key) => storage.delete(key), login({ success }) { loginCount++; success({ code: `code-${loginCount}` }); }, request(options) { send(options); }, uploadFile(options) { send(options, true); }, getBackgroundFetchData({ success }) { prefetchCalls++; success({ fetchedData: JSON.stringify({ home: { marker: 'legacy-prefetch' }, }), }); }, reLaunch({ url, success }) { assert.equal(url, '/pages/home/home'); navigationCount++; const page = { route: 'pages/home/home', data: {}, initData(config) { this.data.config = config; }, }; pages = [page]; void app.getPageConfig().then((config) => page.initData(config)); success(); }, }; function send(options, upload = false) { const request = { ...options, environment: load('config/api').getApiConfig().environment, }; requests.push(request); Promise.resolve() .then(() => handler(request)) .then( (data) => { const body = { code: 0, data }; options.success({ statusCode: 200, data: upload ? JSON.stringify(body) : body, }); }, () => options.fail({}), ); } const modules = new Map(); const context = vm.createContext({ wx, console, Error, getApp: () => app, getCurrentPages: () => pages, App(value) { app = value; }, }); function load(file) { file = resolve('miniprogram', file); if (!file.endsWith('.ts')) file += '.ts'; if (modules.has(file)) return modules.get(file); const exports = {}; modules.set(file, exports); const code = ts.transpileModule(readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, }, }).outputText; vm.runInContext(`(function(require,exports){${code}\n})`, context)( (name) => load(resolve(dirname(file), name)), exports, ); return exports; } load('app'); return { config: load('config/api'), auth: load('utils/auth'), api: load('services/api-request'), member: load('services/member-api'), switcher: load('services/environment-switch'), activity: load('services/api-activity'), app, wx, storage, requests, handle(fn) { handler = fn; }, version(value) { version = value; }, pages(value) { pages = value; }, get currentPages() { return pages; }, get prefetchCalls() { return prefetchCalls; }, get navigationCount() { return navigationCount; }, get loginCount() { return loginCount; }, }; } test('同址三环境仍隔离凭证与缓存;切换重建首页和个人状态,切回复用会话', async () => { const f = fixture( new Map([['pageConfig', { home: { marker: 'legacy-storage' } }]]), ); await f.app.loadPageConfig(); await f.auth.getUser(); assert.equal(f.app.globalData.pageConfig.home.marker, 'local'); const localConfig = f.config.getApiConfig(); f.app.globalData.pendingCategoryTabId = 'math'; for (const target of ['staging', 'production', 'local']) { await f.switcher.switchApiEnvironment(target); await tick(); assert.equal(f.app.globalData.user.id, `user-${target}`); assert.equal(f.app.globalData.pageConfig.home.marker, target); assert.equal(f.currentPages[0].data.config.home.marker, target); assert.equal(f.app.globalData.pendingCategoryTabId, undefined); } assert.equal(f.loginCount, 3); assert.equal(f.navigationCount, 3); assert.equal(f.prefetchCalls, 0); assert.equal(f.config.isCurrentApiConfig(localConfig), false); const cacheKeys = [...f.storage.keys()].filter((key) => key.startsWith('doodle_page_config_v2:'), ); assert.equal(cacheKeys.length, 3); const authKeys = [...f.storage.keys()].filter((key) => key.startsWith('doodle_auth_v2:'), ); assert.equal(authKeys.length, 3); const restored = fixture(f.storage); assert.equal(restored.config.getApiConfig().environment, 'local'); f.config.selectApiEnvironment('staging'); assert.equal( fixture(f.storage).config.getApiConfig().environment, 'staging', ); }); test('trial/release/未知包固定 production,不能通过缓存或切换入口覆盖;保留原预拉取', async () => { const f = fixture(); f.config.selectApiEnvironment('staging'); for (const version of ['trial', 'release', 'unknown']) { f.version(version); assert.equal(f.config.getApiConfig().environment, 'production'); await assert.rejects( f.switcher.switchApiEnvironment('local'), /仅开发版/, ); assert.throws(() => f.config.selectApiEnvironment('local'), /仅开发版/); } f.version('trial'); await f.app.loadPageConfig(); assert.equal(f.prefetchCalls, 1); assert.equal(f.app.globalData.pageConfig.home.marker, 'legacy-prefetch'); }); test('公开接口旧响应在 A→B→A 后仍作废;不会作为当前请求成功返回', async () => { const f = fixture(); const gate = deferred(); f.handle(() => gate.promise); const pending = f.api.apiRequest('/categories', { auth: 'none' }); const rejected = assert.rejects(pending, /环境已变化/); await tick(); f.config.selectApiEnvironment('staging'); f.config.selectApiEnvironment('local'); gate.resolve([]); await rejected; }); test('写请求、上传及登录等待全过程禁止切换,完成后可切换', async () => { const f = fixture(); await f.auth.getUser(); const gate = deferred(); f.handle(() => gate.promise); const pending = f.member.uploadAsset('/tmp/test.jpg', 'preview'); await tick(); assert.equal(f.activity.hasPendingApiWrites(), true); await assert.rejects( f.switcher.switchApiEnvironment('staging'), /等待当前保存或上传/, ); assert.equal(f.config.getApiConfig().environment, 'local'); gate.resolve({ id: 'test-preview', url: 'https://example.test/preview.jpg', }); await pending; assert.equal(f.activity.hasPendingApiWrites(), false); f.pages([{ data: { debugPublishLoading: true } }]); await assert.rejects( f.switcher.switchApiEnvironment('staging'), /等待当前保存或上传/, ); }); test('未配置地址与缓存写入失败不改变当前环境;目标连接失败不回退、不展示旧内容', async () => { const f = fixture(); f.config.apiConfig.stagingBaseUrl = ''; assert.equal(f.config.getApiEnvironmentOptions()[1].available, false); await assert.rejects( f.switcher.switchApiEnvironment('staging'), /配置 staging/, ); assert.equal(f.config.getApiConfig().environment, 'local'); f.config.apiConfig.stagingBaseUrl = f.config.apiConfig.localBaseUrl; const original = f.wx.setStorageSync; f.wx.setStorageSync = () => { throw new Error('storage full'); }; await assert.rejects( f.switcher.switchApiEnvironment('staging'), /storage full/, ); assert.equal(f.config.getApiConfig().environment, 'local'); f.wx.setStorageSync = original; f.app.globalData.pageConfig = { home: { marker: 'local-old' } }; f.handle(() => { throw new Error('offline'); }); await assert.rejects( f.switcher.switchApiEnvironment('staging'), /目标接口连接失败/, ); assert.equal(f.config.getApiConfig().environment, 'staging'); assert.equal(f.app.globalData.pageConfig, undefined); assert.equal(f.currentPages[0].data.config, null); }); test('旧首页初始化与登录迟到响应不污染切换后的共享状态,旧等待者被释放', async () => { const f = fixture(); const gate = deferred(); f.handle(({ environment, url }) => { if (environment === 'local') return gate.promise; if (url.endsWith('/page-config')) return { home: { marker: 'staging' } }; throw new Error('skip login'); }); const waiter = f.app.getPageConfig(); const pendingConfig = f.app.loadPageConfig(); const pendingLogin = f.auth.getUser(); const rejected = assert.rejects(pendingLogin, /环境已变化|登录状态已变化/); await tick(); await f.switcher.switchApiEnvironment('staging'); assert.equal(await waiter, null); gate.resolve({ home: { marker: 'old-local' } }); await pendingConfig; await rejected; assert.equal(f.app.globalData.pageConfig.home.marker, 'staging'); assert.equal(f.app.globalData.user, null); }); test('配置变更使当前地址失效时仍能切走;写请求等待首次登录期间也禁止切换', async () => { const f = fixture(); f.config.selectApiEnvironment('staging'); f.config.apiConfig.stagingBaseUrl = ''; assert.throws(() => f.config.getApiConfig(), /配置 staging/); const waiting = f.app.getPageConfig(); assert.equal(await f.app.loadPageConfig(), false); assert.equal(await waiting, null); await f.switcher.switchApiEnvironment('local'); assert.equal(f.config.getApiConfig().environment, 'local'); const g = fixture(); const gate = deferred(); g.handle(({ url }) => url.endsWith('/auth/wechat/login') ? gate.promise : {}, ); const write = g.api.apiRequest('/me/profile', { method: 'PUT', data: { nickName: 'test' }, }); await tick(); assert.equal( g.requests.filter((row) => row.url.endsWith('/me/profile')).length, 0, ); await assert.rejects( g.switcher.switchApiEnvironment('staging'), /等待当前保存或上传/, ); gate.resolve({ accessToken: 'token-local', tokenType: 'Bearer', expiresIn: 86400, expiresAt: '2099-01-01T00:00:00Z', user: { id: 'user-local', nickName: null, avatarUrl: null, avatarUrlExpiresAt: null, createdAt: '2026-10-19T00:00:00Z', }, }); await write; assert.equal(g.activity.hasPendingApiWrites(), false); }); test('Debug 页面调用统一切换入口,失败时隐藏 loading 后显示错误;非开发包拒绝操作', async () => { const f = fixture(); let definition; let toasts = []; let loading = false; f.wx.showLoading = () => { loading = true; }; f.wx.hideLoading = () => { loading = false; }; f.wx.showToast = (options) => { assert.equal(loading, false); toasts.push(options.title); }; const source = ts.transpileModule( readFileSync('miniprogram/supportPages/debug/debug.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, }, }, ).outputText; vm.runInNewContext(source, { exports: {}, wx: f.wx, Error, getCurrentPages: () => [definition], require(name) { return name === '../../config/api' ? f.config : f.switcher; }, Page(value) { definition = { ...value, setData(value) { Object.assign(this.data, value); }, }; }, }); definition.onLoad(); definition.onShow(); assert.equal(definition.data.currentEnvironment, 'local'); f.config.apiConfig.stagingBaseUrl = ''; await definition.onSelectEnvironment({ currentTarget: { dataset: { environment: 'staging' } }, }); assert.match(toasts.at(-1), /配置 staging/); assert.equal(definition.data.isSwitchingEnvironment, false); f.version('trial'); definition.onLoad(); definition.onShow(); assert.equal(definition.data.isDevEnv, false); assert.equal(definition.data.currentEnvironment, 'production'); toasts = []; await definition.onSelectEnvironment({ currentTarget: { dataset: { environment: 'local' } }, }); assert.equal(toasts.length, 0); });