426 lines
14 KiB
JavaScript
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);
|
|
});
|