feat: 增加环境切换
This commit is contained in:
+15
-14
@@ -52,7 +52,7 @@ beforeEach(() => {
|
||||
requests = [];
|
||||
navigations = [];
|
||||
apiConfig.developmentEnvironment = 'local';
|
||||
apiConfig.onlineBaseUrl = '';
|
||||
apiConfig.productionBaseUrl = 'http://127.0.0.1:8800/api/v1';
|
||||
handler = () => ({ data: [] });
|
||||
globalThis.wx = {
|
||||
getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
|
||||
@@ -152,22 +152,23 @@ function useRows(rows, selectedCategories = categories) {
|
||||
};
|
||||
}
|
||||
|
||||
test('环境配置:开发版默认本地,正式/体验/未知环境不回退本地,线上要求 HTTPS', () => {
|
||||
assert.equal(
|
||||
resolveApiConfig('develop').baseUrl,
|
||||
'http://127.0.0.1:8800/api/v1',
|
||||
);
|
||||
for (const version of ['release', 'trial', 'unknown'])
|
||||
assert.throws(() => resolveApiConfig(version), /线上/);
|
||||
apiConfig.onlineBaseUrl = 'https://api.example.test/api/v1/';
|
||||
for (const version of ['release', 'trial'])
|
||||
test('环境配置:体验/正式/未知包固定 production,暂允许指定的本机占位地址', () => {
|
||||
assert.equal(resolveApiConfig('develop').environment, 'local');
|
||||
for (const version of ['release', 'trial', 'unknown']) {
|
||||
assert.equal(resolveApiConfig(version).environment, 'production');
|
||||
assert.equal(
|
||||
resolveApiConfig(version).baseUrl,
|
||||
'https://api.example.test/api/v1',
|
||||
'http://127.0.0.1:8800/api/v1',
|
||||
);
|
||||
apiConfig.developmentEnvironment = 'online';
|
||||
assert.equal(resolveApiConfig('develop').environment, 'online');
|
||||
apiConfig.onlineBaseUrl = 'http://api.example.test/api/v1';
|
||||
}
|
||||
apiConfig.productionBaseUrl = '';
|
||||
assert.throws(() => resolveApiConfig('release'), /production/);
|
||||
apiConfig.productionBaseUrl = 'https://api.example.test/api/v1/';
|
||||
assert.equal(
|
||||
resolveApiConfig('release').baseUrl,
|
||||
'https://api.example.test/api/v1',
|
||||
);
|
||||
apiConfig.productionBaseUrl = 'http://api.example.test/api/v1';
|
||||
assert.throws(() => resolveApiConfig('release'), /配置/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
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);
|
||||
});
|
||||
@@ -95,6 +95,12 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
|
||||
require(name) {
|
||||
if (name === './services/site-api')
|
||||
return { fetchPageConfig: async () => old };
|
||||
if (name === './config/api')
|
||||
return {
|
||||
getApiConfig: () => ({ version: 'release' }),
|
||||
apiContextKey: () => 'test-production',
|
||||
isCurrentApiConfig: () => true,
|
||||
};
|
||||
if (name === './config/assets')
|
||||
return { normalizePreviewImages };
|
||||
return {};
|
||||
@@ -112,7 +118,7 @@ test('首页配置:预拉取、旧 storage 缓存及新后端兜底均写回
|
||||
return entry === 'storage' ? old : null;
|
||||
},
|
||||
setStorageSync(key, value) {
|
||||
assert.equal(key, 'pageConfig');
|
||||
assert.equal(key, 'doodle_page_config_v2:test-production');
|
||||
stored = value;
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user