Files
doodle-mini/tests/environment-switch.test.mjs
2026-10-19 17:49:19 +08:00

426 lines
14 KiB
JavaScript

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);
});