fix(member): 修复账号注销/退出登录后无法正常跳转到微信原生授权登录的问题
修复背景:
账号注销成功后, 用户被重定向到 native 登录页 (手机号+验证码表单风格, 像 H5),
而不是微信小程序原生授权登录 (ns-login 弹窗 + getPhoneNumber 授权).
此外, 微信小程序下短时间内连发 reLaunch 会触发 native 错误:
appLaunch with non-empty page stack
以及历史遗留的 navigateTo push 登录页 + 旧页面在栈中导致的死循环问题.
主要修复:
1. util.js redirectTo reLaunch 分支 (4 平台兼容):
- H5 平台: window.location.replace (真正清空 history, 避免 history.back + router.replace 假清栈)
- 小程序平台 (mp-weixin/mp-harmony/...): 加 300ms 全局跳转锁 + setTimeout(0) 包裹
解决 appLaunch with non-empty page stack 错误
- APP/其他: 保留原生 uni.reLaunch
2. util.js redirectTo 入口智能默认 (跨平台兜底):
- 目标 == LOGIN_PAGE_URL && mode 是默认 navigateTo && 没传 back 参数
→ 自动改成 reLaunch (避免注销后死循环)
- 显式传 mode 或带 back 参数的场景保持原行为 (不影响登录后回原页面)
3. member-public.js mixin 增强:
- getLoginEntryUrl(): 跨平台登录入口智能分流
微信小程序/鸿蒙/其他小程序 → INDEX_PAGE_URL (首页, 走 ns-login 原生授权弹窗)
H5/快应用/APP → LOGIN_PAGE_URL (原生登录页)
- redirectToLoginOnce(): 统一封装 clearLoginState + 跳登录入口, 带 800ms 组件级防抖锁
- logout / getCancelStatus 兜底 / submitCancelApply 三个地方都改用 redirectToLoginOnce()
4. 5 个注销/退出流程页面改造:
- cancelsuccess.vue / cancelstatus.vue / cancellation.vue / cancelrefuse.vue
/ assets.vue: 跳登录入口全部用 redirectToLoginOnce (不再直接 redirectTo(LOGIN_PAGE_URL))
- cancellation.vue / cancelrefuse.vue: 加 memberPublic mixin (原本没有)
5. 取消了在 pages/index/public/js/index.js 中 onShow 自动弹登录弹窗的尝试
(因触发 Vue 框架的 dataset of null pre-existing 错误, 改由其他途径处理)
测试覆盖 (300+ 测试用例全部通过):
- tests/member-cancel-flow.test.js (28 个测试, 9 个用户故事场景)
- tests/redirectTo-relaunch-h5.test.js (11 个测试, 9 个跨平台场景)
- tests/redirectTo-real.test.js (12 个)
- tests/util.test.js (30 个)
- 其他 8 个测试文件全部通过
This commit is contained in:
@@ -511,6 +511,27 @@ export default {
|
||||
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
||||
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
||||
//
|
||||
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
|
||||
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
|
||||
// 修复场景:
|
||||
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
|
||||
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
|
||||
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
|
||||
// onShow 死循环跳登录页.
|
||||
// 排除场景 (重要, 避免影响):
|
||||
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
|
||||
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
|
||||
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
|
||||
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
|
||||
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
|
||||
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
|
||||
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
|
||||
if (cleanPath === LOGIN_PAGE_URL) {
|
||||
mode = 'reLaunch';
|
||||
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
||||
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
||||
|
||||
@@ -538,18 +559,18 @@ export default {
|
||||
merged.page_params = page_params;
|
||||
}
|
||||
|
||||
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
||||
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||||
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
||||
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
||||
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
||||
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
||||
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
||||
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
||||
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
||||
// 成 top-level 分隔符丢参数)
|
||||
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
||||
// 做 decode roundtrip + encode 一次)
|
||||
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
||||
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||||
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
||||
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
||||
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
||||
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
||||
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
||||
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
||||
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
||||
// 成 top-level 分隔符丢参数)
|
||||
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
||||
// 做 decode roundtrip + encode 一次)
|
||||
const queryStr = Object.keys(merged)
|
||||
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
|
||||
.join('&');
|
||||
@@ -669,6 +690,41 @@ export default {
|
||||
break;
|
||||
case 'reLaunch':
|
||||
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
||||
// H5 平台修复 (2026-07-23):
|
||||
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
|
||||
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
|
||||
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
|
||||
// 注销/退出登录后用户能看到旧会员信息。
|
||||
// location.replace 不产生新历史条目, 直接替换当前条目,
|
||||
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
||||
&& typeof window.location.replace === 'function') {
|
||||
window.location.replace(url);
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
// 小程序平台修复 (2026-07-23):
|
||||
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
|
||||
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
|
||||
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
|
||||
// 解决方案:
|
||||
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
|
||||
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
|
||||
// #ifdef MP
|
||||
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
|
||||
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
|
||||
return;
|
||||
}
|
||||
globalThis.__reLaunchLock = true;
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url });
|
||||
// 300ms 后释放锁
|
||||
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
|
||||
}, 0);
|
||||
return;
|
||||
// #endif
|
||||
// APP / 其他平台: 原生 reLaunch
|
||||
uni.reLaunch({
|
||||
url
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
onShow() {
|
||||
// 获取关注公众号二维码
|
||||
this.getFollowQrcode();
|
||||
|
||||
|
||||
// 重新获取店铺信息
|
||||
this.shopInfo = uni.getStorageSync('shopInfo');
|
||||
},
|
||||
|
||||
@@ -95,7 +95,10 @@ export default {
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getAccountInfo();
|
||||
}
|
||||
@@ -112,8 +115,8 @@ export default {
|
||||
this.fenxiao_info = res.data.fenxiao_info;
|
||||
}
|
||||
} else if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
|
||||
<script>
|
||||
import htmlParser from '@/common/js/html-parser';
|
||||
import memberPublic from './public/js/member-public.js';
|
||||
|
||||
export default {
|
||||
mixins: [memberPublic],
|
||||
data() {
|
||||
return {
|
||||
agreement: {},
|
||||
@@ -31,7 +34,10 @@ export default {
|
||||
if (option.back) this.back = option.back;
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getCancelAgreement();
|
||||
}
|
||||
@@ -45,9 +51,8 @@ export default {
|
||||
this.agreement = res.data;
|
||||
if (this.agreement.content) this.agreement.content = htmlParser(this.agreement.content);
|
||||
} else if (res.code == -1) {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import memberPublic from './public/js/member-public.js';
|
||||
|
||||
export default {
|
||||
mixins: [memberPublic],
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
@@ -26,7 +29,8 @@ export default {
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
@@ -39,9 +43,8 @@ export default {
|
||||
if (res.code >= 0) {
|
||||
this.reason = res.data.reason;
|
||||
} else if (res.code == -1) {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
onLoad(option) {
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
|
||||
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
|
||||
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
@@ -70,8 +73,8 @@
|
||||
}
|
||||
|
||||
if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
state: ''
|
||||
state: '',
|
||||
// 防抖锁: 防止 onLoad 的 getStatus 异步回调 和 用户点击 success 按钮
|
||||
// 短时间内连发 reLaunch, 触发微信小程序 "appLaunch with non-empty page stack" native 错误
|
||||
_navigated: false
|
||||
};
|
||||
},
|
||||
onLoad(option) {
|
||||
@@ -28,32 +31,31 @@
|
||||
|
||||
// 判断登录
|
||||
if (!this.storeToken) {
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
this.getStatus();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatus() {
|
||||
this.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
this.$api.sendRequest({
|
||||
url: '/membercancel/api/membercancel/info',
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data && res.data.status == 1) {
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
// 跟 success 按钮共享防抖, 避免同时触发
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
success() {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
}
|
||||
});
|
||||
},
|
||||
success() {
|
||||
// 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
methods: {
|
||||
// 清除登录态(token / 用户信息 / 购物车 / 本地存储)
|
||||
// 清除登录态(token / 会员信息 / 购物车 / 本地存储)
|
||||
clearLoginState() {
|
||||
this.$store.commit('setToken', '');
|
||||
this.$store.commit('setMemberInfo', '');
|
||||
@@ -9,6 +9,48 @@ export default {
|
||||
uni.removeStorageSync('privacyAgreed');
|
||||
},
|
||||
|
||||
// 跨平台登录入口:小程序下用 ns-login 原生授权弹窗 (走 wx.login / getPhoneNumber),
|
||||
// H5 / 快应用 / APP 下用原生登录页 /pages_tool/login/login (手机号+动态码表单).
|
||||
//
|
||||
// 修复背景: 之前所有跳转统一用 LOGIN_PAGE_URL (/pages_tool/login/login),
|
||||
// 这个原生登录页在微信小程序下是 form 提交 (像 H5), 没有走 wx.login 授权。
|
||||
// 期望行为:
|
||||
// - 微信小程序 / 鸿蒙元服务 / 其他小程序: 跳到首页 /pages/index/index,
|
||||
// 首页挂载了 <ns-login ref="login">, 用户后续操作触发 _requireHomeAuth
|
||||
// 时会自动弹 ns-login 弹窗, 走原生 wx.login / getPhoneNumber 授权.
|
||||
// - H5 / 快应用 / APP: 保持跳 LOGIN_PAGE_URL (原生登录页), 表单流程正常.
|
||||
getLoginEntryUrl() {
|
||||
// #ifdef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
|
||||
return this.$util.INDEX_PAGE_URL;
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
|
||||
return this.$util.LOGIN_PAGE_URL;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 统一的"跳登录入口"封装, 包含组件级防抖锁 + 清登录态 + reLaunch
|
||||
//
|
||||
// 为什么需要防抖: 微信小程序下, 短时间内连发 reLaunch 会触发
|
||||
// "appLaunch with non-empty page stack" native 错误 (页面销毁未完成, 新 launch 时栈非空).
|
||||
// 注销/退出流程中常见并发场景:
|
||||
// - cancelsuccess.vue onLoad 调 getStatus 异步回调 + 用户点 "完成" 按钮
|
||||
// - 多个 submitCancelApply 同时被触发
|
||||
// 防抖策略: 同一组件实例 800ms 内只能跳一次, 之后忽略
|
||||
// (util.js reLaunch 分支还有 300ms 全局锁作为第二道防线)
|
||||
redirectToLoginOnce() {
|
||||
// 组件级防抖: 800ms 内重复调用直接忽略
|
||||
const now = Date.now();
|
||||
if (this._lastLoginRedirectAt && now - this._lastLoginRedirectAt < 800) {
|
||||
console.warn('[member-public] redirectToLoginOnce 防抖: 800ms 内重复调用已忽略');
|
||||
return;
|
||||
}
|
||||
this._lastLoginRedirectAt = now;
|
||||
this.clearLoginState();
|
||||
// 小程序下 getLoginEntryUrl() 返回首页 (走 ns-login 原生授权弹窗),
|
||||
// H5 / APP 下返回原生登录页 (form 提交)
|
||||
this.$util.redirectTo(this.getLoginEntryUrl(), {}, 'reLaunch');
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
uni.showModal({
|
||||
@@ -16,9 +58,8 @@ export default {
|
||||
content: this.$lang('logoutContent'),
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到会员中心等历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -51,9 +92,8 @@ export default {
|
||||
this.$util.redirectTo('/pages_tool/member/cancellation');
|
||||
}
|
||||
} else if (res.code == -1) {
|
||||
this.clearLoginState();
|
||||
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -73,10 +113,12 @@ export default {
|
||||
if (rres.code >= 0) {
|
||||
// 注销申请提交成功后立刻清空登录态,
|
||||
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
|
||||
this.clearLoginState();
|
||||
if (options.clearStateAndRedirect) {
|
||||
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
|
||||
// 跳登录入口: 内部带防抖锁, 避免连发 reLaunch
|
||||
this.redirectToLoginOnce();
|
||||
} else {
|
||||
// 跳 cancelstatus (审核中), 也走 reLaunch 清栈
|
||||
this.clearLoginState();
|
||||
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -119,6 +119,7 @@ function createThisMock({ showModalChoice = 'confirm', apiCode = 0 } = {}) {
|
||||
// $util.redirectTo 接收 (to, param, mode), 我们记原始参数
|
||||
const util = {
|
||||
LOGIN_PAGE_URL: '/pages_tool/login/login',
|
||||
INDEX_PAGE_URL: '/pages/index/index',
|
||||
MEMBER_PAGE_URL: '/pages_tool/member/index',
|
||||
redirectTo(to, param, mode) {
|
||||
calls.redirectTo.push({ to, param: param || {}, mode: mode || 'navigateTo' });
|
||||
@@ -233,7 +234,7 @@ test('场景 A: 注销申请成功 → 立即 clearLoginState() (不等后续页
|
||||
// =============================================================
|
||||
// 场景 B: clearStateAndRedirect=true → reLaunch 到登录页
|
||||
// =============================================================
|
||||
test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登录页', () => {
|
||||
test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登录入口', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
@@ -246,8 +247,9 @@ test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'clearStateAndRedirect 模式跳转必须是 reLaunch');
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
|
||||
'clearStateAndRedirect 模式应跳到登录页');
|
||||
// vm 跑源码默认模拟小程序, 应跳首页 (走 ns-login 原生授权弹窗)
|
||||
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
|
||||
'vm 默认模拟小程序, clearStateAndRedirect 应跳首页, 实际: ' + calls.redirectTo[0].to);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -330,9 +332,9 @@ test('场景 F: clearLoginState 单独被调 → token / memberInfo / cart / sto
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 G (静态分析): member-public.js submitCancelApply 源码里真的用了 'reLaunch'
|
||||
// 场景 G (静态分析): submitCancelApply 跳转逻辑 (重构后调 redirectToLoginOnce)
|
||||
// =============================================================
|
||||
test('场景 G (静态): member-public.js submitCancelApply 源码里 redirectTo 调用必须是 reLaunch 模式', () => {
|
||||
test('场景 G (静态): member-public.js submitCancelApply 源码用 reLaunch 模式跳转', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
@@ -342,55 +344,48 @@ test('场景 G (静态): member-public.js submitCancelApply 源码里 redirectTo
|
||||
assert.ok(m, '应能找到 submitCancelApply 函数定义');
|
||||
const body = m[1];
|
||||
|
||||
// 关键修复: 函数体里所有 redirectTo 都必须用 reLaunch (防止回退到历史页面)
|
||||
// 提取所有 this.$util.redirectTo( 调用
|
||||
const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
|
||||
assert.ok(redirectCalls.length >= 2,
|
||||
'submitCancelApply 至少应有 2 处 redirectTo (login / cancelstatus), 实际 ' + redirectCalls.length);
|
||||
|
||||
// 重构后: clearStateAndRedirect=true 调 redirectToLoginOnce (内部已用 reLaunch)
|
||||
// else 调 clearLoginState + redirectTo(cancelstatus, reLaunch)
|
||||
// 必须包含 redirectToLoginOnce 调用 (clearStateAndRedirect 路径)
|
||||
assert.ok(body.includes('this.redirectToLoginOnce()'),
|
||||
'submitCancelApply clearStateAndRedirect=true 路径应调 this.redirectToLoginOnce()');
|
||||
// 必须包含直接 redirectTo 调 (else 路径, 跳 cancelstatus)
|
||||
const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([\s\S]*?\);/g) || [];
|
||||
assert.ok(redirectCalls.length >= 1,
|
||||
'submitCancelApply 应至少有 1 处直接 redirectTo (else 路径跳 cancelstatus), 实际 ' + redirectCalls.length);
|
||||
// 该处 redirectTo 必须用 reLaunch
|
||||
for (const call of redirectCalls) {
|
||||
assert.match(call, /['"]reLaunch['"]/,
|
||||
'submitCancelApply 内的 redirectTo 必须用 reLaunch 模式, 实际: ' + call.trim());
|
||||
'submitCancelApply 内直接 redirectTo 必须用 reLaunch 模式, 实际: ' + call.replace(/\n/g, '\\n').slice(0, 100));
|
||||
}
|
||||
|
||||
// 关键修复: clearLoginState 必须在 redirectTo 之前调用
|
||||
const clearIdx = body.indexOf('this.clearLoginState()');
|
||||
const firstRedirectIdx = body.indexOf('this.$util.redirectTo(');
|
||||
assert.ok(clearIdx > -1, 'submitCancelApply 必须调用 clearLoginState()');
|
||||
assert.ok(firstRedirectIdx > -1, 'submitCancelApply 必须调用 redirectTo');
|
||||
assert.ok(clearIdx < firstRedirectIdx,
|
||||
'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + firstRedirectIdx);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 H (静态分析): cancelsuccess.vue 所有 redirectTo 都用 'reLaunch'
|
||||
// 场景 H (静态分析): cancelsuccess.vue 不直接调 redirectTo, 而是调 redirectToLoginOnce
|
||||
// (逻辑封装到 mixin, 统一处理 4 平台 + 跳转锁 + 800ms 防抖)
|
||||
// =============================================================
|
||||
test('场景 H (静态): cancelsuccess.vue 跳登录页的 redirectTo 必须全部用 reLaunch', () => {
|
||||
test('场景 H (静态): cancelsuccess.vue 跳登录入口用 mixin 的 redirectToLoginOnce (无直接 redirectTo 调)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancelsuccess.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 提取所有 this.$util.redirectTo( 调用
|
||||
const calls = src.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
|
||||
assert.ok(calls.length >= 2,
|
||||
'cancelsuccess.vue 应至少有 2 处 redirectTo (onLoad 无 token 分支 + getStatus + success), 实际 ' + calls.length);
|
||||
const calls = src.match(/this\.\$util\.redirectTo\s*\([\s\S]*?\);/g) || [];
|
||||
// 重构后: 跳登录入口全部走 mixin 的 redirectToLoginOnce, 不再直接调 redirectTo
|
||||
// 但文件里仍可能保留 success() / getStatus() / onLoad 等调用, 但跳登录的逻辑已封装到 mixin
|
||||
const loginRedirects = calls.filter(c =>
|
||||
c.includes('LOGIN_PAGE_URL') ||
|
||||
c.includes('INDEX_PAGE_URL') ||
|
||||
c.includes('getLoginEntryUrl()')
|
||||
);
|
||||
assert.equal(loginRedirects.length, 0,
|
||||
'cancelsuccess.vue 跳登录入口应走 mixin 的 redirectToLoginOnce, 不应直接 redirectTo, 实际: ' + JSON.stringify(loginRedirects.map(c => c.replace(/\n/g, '\\n').slice(0, 80))));
|
||||
|
||||
// 每处都应该是 reLaunch
|
||||
for (const c of calls) {
|
||||
// 跳登录页的必须是 reLaunch
|
||||
if (c.includes('LOGIN_PAGE_URL')) {
|
||||
assert.match(c, /['"]reLaunch['"]/,
|
||||
'cancelsuccess.vue 跳 LOGIN_PAGE_URL 必须用 reLaunch, 实际: ' + c.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// 同时确认: 全文件没有对 LOGIN_PAGE_URL 的 redirectTo 不带 reLaunch
|
||||
// 用反向断言: 不应该有 LOGIN_PAGE_URL 但没有 reLaunch 的 redirectTo
|
||||
const loginRedirects = calls.filter(c => c.includes('LOGIN_PAGE_URL'));
|
||||
const badOnes = loginRedirects.filter(c => !/['"]reLaunch['"]/.test(c));
|
||||
assert.equal(badOnes.length, 0,
|
||||
'cancelsuccess.vue 跳登录页不能有非 reLaunch 的 redirectTo, 违规: ' + JSON.stringify(badOnes));
|
||||
// 必须包含 redirectToLoginOnce (从 mixin 继承)
|
||||
const m = src.match(/redirectToLoginOnce\s*\(\s*\)/g) || [];
|
||||
assert.ok(m.length >= 2,
|
||||
'cancelsuccess.vue 应至少 2 处调 this.redirectToLoginOnce() (onLoad + getStatus success + 用户点 success), 实际 ' + m.length);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -508,30 +503,34 @@ test('场景 K (行为模拟): cancelsuccess.vue onLoad — storeToken 已空时
|
||||
// =============================================================
|
||||
// 场景 L (回归): 不允许"先 redirectTo 再 clearLoginState" (顺序错会留漏洞)
|
||||
// =============================================================
|
||||
test('场景 L (回归): submitCancelApply 的源码顺序 — clearLoginState 必须早于 redirectTo', () => {
|
||||
test('场景 L (回归): redirectToLoginOnce 内 — clearLoginState 必须早于 redirectTo (核心安全保证)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/);
|
||||
assert.ok(m);
|
||||
const body = m[1];
|
||||
// 找 redirectToLoginOnce 函数体 (匹配到方法体结束的 }, 同缩进级别)
|
||||
// 简化: 用 slice + 找到下一个 "\n\t\t}" 模式 (redirectToLoginOnce 结束于 2 个 tab 缩进)
|
||||
const funcStart = src.indexOf('redirectToLoginOnce() {');
|
||||
assert.ok(funcStart > -1, '应能找到 redirectToLoginOnce 函数定义, 实际源码: ' + src.substring(0, 200));
|
||||
// 找匹配的 }, 必须跟 method 同缩进 (\t\t})
|
||||
const endMarker = '\n\t\t}';
|
||||
const funcEnd = src.indexOf(endMarker, funcStart);
|
||||
assert.ok(funcEnd > funcStart, '应能找到 redirectToLoginOnce 函数结束');
|
||||
const body = src.substring(funcStart, funcEnd + endMarker.length - 1);
|
||||
|
||||
// 拿到所有 this.clearLoginState() 和 this.$util.redirectTo( 的位置
|
||||
const idxClear = [];
|
||||
const idxRedirect = [];
|
||||
const re = /(this\.clearLoginState\(\)|this\.\$util\.redirectTo\()/g;
|
||||
let mm;
|
||||
while ((mm = re.exec(body)) !== null) {
|
||||
if (mm[1].startsWith('this.clearLoginState')) idxClear.push(mm.index);
|
||||
else idxRedirect.push(mm.index);
|
||||
}
|
||||
assert.ok(idxClear.length >= 1, 'submitCancelApply 内至少 1 次 clearLoginState');
|
||||
assert.ok(idxRedirect.length >= 2, 'submitCancelApply 内至少 2 次 redirectTo');
|
||||
// 必须先 clearLoginState 再 redirectTo (登录态清除必须在跳转之前,
|
||||
// 否则页面栈先 push 登录页时, token / memberInfo 还在 store 里,
|
||||
// 跳过去的页面 onShow 会先渲染旧信息, 然后才被清掉, 造成闪烁)
|
||||
const clearIdx = body.indexOf('this.clearLoginState()');
|
||||
const redirectIdx = body.indexOf('this.$util.redirectTo(');
|
||||
assert.ok(clearIdx > -1, 'redirectToLoginOnce 必须调 clearLoginState, body: ' + body);
|
||||
assert.ok(redirectIdx > -1, 'redirectToLoginOnce 必须调 redirectTo, body: ' + body);
|
||||
assert.ok(clearIdx < redirectIdx,
|
||||
'redirectToLoginOnce 内 clearLoginState 必须在 redirectTo 之前, 实际: clear=' + clearIdx + ', redirect=' + redirectIdx);
|
||||
|
||||
// 第一个 clearLoginState 必须早于第一个 redirectTo
|
||||
assert.ok(idxClear[0] < idxRedirect[0],
|
||||
'第一个 clearLoginState(' + idxClear[0] + ') 必须早于第一个 redirectTo(' + idxRedirect[0] + ')');
|
||||
// 必须用 reLaunch 模式 (防回退到历史页面)
|
||||
assert.match(body, /['"]reLaunch['"]/,
|
||||
'redirectToLoginOnce 必须用 reLaunch 模式');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
@@ -543,19 +542,20 @@ test('场景 L (回归): submitCancelApply 的源码顺序 — clearLoginState
|
||||
// 修复: logout / getCancelStatus 兜底分支都用 reLaunch 模式
|
||||
// =============================================================
|
||||
|
||||
// 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页
|
||||
test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 (清栈防回退)', () => {
|
||||
// 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录入口 (清栈防回退)
|
||||
test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录入口 (清栈防回退)', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.logout();
|
||||
|
||||
// ★ 关键修复: 跳登录页必须用 reLaunch (清空页面栈)
|
||||
// ★ 关键修复: 跳登录入口必须用 reLaunch (清空页面栈)
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'退出登录跳转模式必须是 reLaunch (否则按返回键会回到会员中心), 实际: ' + calls.redirectTo[0].mode);
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
|
||||
'退出登录应跳登录页');
|
||||
// 注意: vm 跑源码时 #ifdef MP-WEIXIN 块保留, 走 INDEX_PAGE_URL (小程序分支)
|
||||
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
|
||||
'vm 默认模拟小程序平台, 退出登录应跳首页 (让首页 ns-login 弹窗接管), 实际: ' + calls.redirectTo[0].to);
|
||||
|
||||
// 登录态必须清
|
||||
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
|
||||
@@ -576,12 +576,8 @@ test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 (
|
||||
'退出登录必须清 privacyAgreed localStorage');
|
||||
|
||||
// ★ 关键: clearLoginState 必须在 redirectTo 之前调用 (否则页面栈先 push 登录页)
|
||||
// 找到 clearLoginState 引发的首个 commit (setToken)
|
||||
const tokenIdx = calls.commit.findIndex(([t]) => t === 'setToken');
|
||||
assert.ok(tokenIdx >= 0, '应有 setToken commit');
|
||||
// commit 是同步的, redirectTo 在 commit 之后 (因为 success 回调里先调 clearLoginState 再调 redirectTo)
|
||||
// 但 redirectTo 的调用顺序晚于所有 commit, 这里检查 calls 的总顺序
|
||||
// 通过检查 calls.commit 中最后一个 commit 是否为 setMemberInfo 来佐证
|
||||
assert.equal(calls.commit[calls.commit.length - 1][0], 'setMemberInfo',
|
||||
'setMemberInfo 应是最后一次 commit (clearLoginState 内最后一步)');
|
||||
});
|
||||
@@ -600,8 +596,8 @@ test('场景 N: 退出登录 modal 点取消 → 不清登录态, 不跳转', ()
|
||||
assert.equal(calls.removeStorage.length, 0, '点取消不应清 localStorage');
|
||||
});
|
||||
|
||||
// 场景 O (静态): logout 跳登录页的 redirectTo 必须用 reLaunch
|
||||
test('场景 O (静态): member-public.js logout 内跳登录页的 redirectTo 必须用 reLaunch 模式', () => {
|
||||
// 场景 O (静态): logout 跳登录入口的 redirectTo 必须用 reLaunch
|
||||
test('场景 O (静态): member-public.js logout 调 mixin 的 redirectToLoginOnce (无直接 redirectTo)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
@@ -611,19 +607,39 @@ test('场景 O (静态): member-public.js logout 内跳登录页的 redirectTo
|
||||
assert.ok(m, '应能找到 logout 函数定义');
|
||||
const body = m[1];
|
||||
|
||||
// 提取所有 this.$util.redirectTo( 调用
|
||||
const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
|
||||
assert.ok(redirectCalls.length >= 1,
|
||||
'logout 应至少有 1 处 redirectTo (到登录页), 实际 ' + redirectCalls.length);
|
||||
// logout 内部应调 redirectToLoginOnce (统一封装)
|
||||
assert.ok(body.includes('this.redirectToLoginOnce()'),
|
||||
'logout 应调 this.redirectToLoginOnce() (统一封装, 避免连发 reLaunch), 实际 logout body: ' + body.replace(/\n/g, '\\n').slice(0, 200));
|
||||
|
||||
for (const call of redirectCalls) {
|
||||
assert.match(call, /['"]reLaunch['"]/,
|
||||
'logout 内的 redirectTo 必须用 reLaunch 模式 (防回退看到旧会员信息), 实际: ' + call.trim());
|
||||
}
|
||||
// 不应直接调 redirectTo (避免绕过 mixin 的防抖)
|
||||
const directRedirects = body.match(/this\.\$util\.redirectTo\s*\([\s\S]*?\);/g) || [];
|
||||
assert.equal(directRedirects.length, 0,
|
||||
'logout 不应直接调 redirectTo, 应走 redirectToLoginOnce, 实际: ' + JSON.stringify(directRedirects));
|
||||
});
|
||||
|
||||
// 跳的必须是登录页
|
||||
assert.ok(redirectCalls[0].includes('LOGIN_PAGE_URL'),
|
||||
'logout 跳的目标必须是 LOGIN_PAGE_URL, 实际: ' + redirectCalls[0].trim());
|
||||
// 场景 O 补充 (静态): getLoginEntryUrl 必须包含 MP / 非 MP 两个分支
|
||||
test('场景 O 补充 (静态): member-public.js getLoginEntryUrl 必须有 MP 和 非 MP 两个分支', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 找到 getLoginEntryUrl 函数体
|
||||
const m = src.match(/getLoginEntryUrl\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/);
|
||||
assert.ok(m, '应能找到 getLoginEntryUrl 函数定义');
|
||||
const body = m[1];
|
||||
|
||||
// 必须包含 #ifdef MP-WEIXIN (小程序分支 → 首页)
|
||||
assert.ok(/#ifdef\s+MP-WEIXIN/.test(body),
|
||||
'getLoginEntryUrl 应包含 #ifdef MP-WEIXIN 条件编译 (小程序下走首页)');
|
||||
// 必须包含 #ifndef (非小程序分支 → 原生登录页)
|
||||
assert.ok(/#ifndef/.test(body),
|
||||
'getLoginEntryUrl 应包含 #ifndef 兜底 (非小程序走原生登录页)');
|
||||
// 小程序分支返回 INDEX_PAGE_URL
|
||||
assert.ok(body.includes('INDEX_PAGE_URL'),
|
||||
'getLoginEntryUrl 小程序分支应返回 INDEX_PAGE_URL');
|
||||
// 非小程序分支返回 LOGIN_PAGE_URL
|
||||
assert.ok(body.includes('LOGIN_PAGE_URL'),
|
||||
'getLoginEntryUrl 非小程序分支应返回 LOGIN_PAGE_URL');
|
||||
});
|
||||
|
||||
// 场景 P: getCancelStatus 兜底分支 (res.code == -1) → clearLoginState + reLaunch
|
||||
@@ -648,12 +664,13 @@ test('场景 P: getCancelStatus res.code == -1 → clearLoginState + reLaunch
|
||||
assert.equal(calls.redirectTo.length, 1);
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'getCancelStatus 兜底分支跳转必须是 reLaunch, 实际: ' + calls.redirectTo[0].mode);
|
||||
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
|
||||
'getCancelStatus 兜底分支应跳登录页');
|
||||
// vm 跑源码默认模拟小程序, 应跳首页 (走 ns-login 原生授权弹窗)
|
||||
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
|
||||
'vm 默认模拟小程序平台, 兜底分支应跳首页, 实际: ' + calls.redirectTo[0].to);
|
||||
});
|
||||
|
||||
// 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前
|
||||
test('场景 Q (回归): member-public.js logout 内 clearLoginState 必须早于 redirectTo', () => {
|
||||
// 场景 Q (回归): logout 间接通过 redirectToLoginOnce 保证 clearLoginState → redirectTo 顺序
|
||||
test('场景 Q (回归): member-public.js logout 必须调 redirectToLoginOnce (含顺序保证)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
@@ -662,17 +679,13 @@ test('场景 Q (回归): member-public.js logout 内 clearLoginState 必须早
|
||||
assert.ok(m, '应能找到 logout 函数定义');
|
||||
const body = m[1];
|
||||
|
||||
const clearIdx = body.indexOf('this.clearLoginState()');
|
||||
const redirectIdx = body.indexOf('this.$util.redirectTo(');
|
||||
|
||||
assert.ok(clearIdx > -1, 'logout 必须调用 clearLoginState()');
|
||||
assert.ok(redirectIdx > -1, 'logout 必须调用 redirectTo()');
|
||||
assert.ok(clearIdx < redirectIdx,
|
||||
'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + redirectIdx);
|
||||
// logout 必须调 redirectToLoginOnce (里面已保证 clearLoginState 早于 redirectTo)
|
||||
assert.ok(body.includes('this.redirectToLoginOnce()'),
|
||||
'logout 应调 this.redirectToLoginOnce() (里面已含防抖锁 + clearLoginState + redirectTo 顺序)');
|
||||
});
|
||||
|
||||
// 场景 R (回归): getCancelStatus 兜底分支 也满足顺序
|
||||
test('场景 R (回归): member-public.js getCancelStatus res.code == -1 分支: clearLoginState 必须早于 redirectTo', () => {
|
||||
// 场景 R (回归): getCancelStatus 兜底分支 走 redirectToLoginOnce
|
||||
test('场景 R (回归): member-public.js getCancelStatus res.code == -1 分支: 必须调 redirectToLoginOnce', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
@@ -687,10 +700,190 @@ test('场景 R (回归): member-public.js getCancelStatus res.code == -1 分支:
|
||||
assert.ok(branchMatch, '应能找到 res.code == -1 分支');
|
||||
const branchBody = branchMatch[0];
|
||||
|
||||
const clearIdx = branchBody.indexOf('this.clearLoginState()');
|
||||
const redirectIdx = branchBody.indexOf('this.$util.redirectTo(');
|
||||
assert.ok(clearIdx > -1, 'res.code == -1 分支必须调用 clearLoginState()');
|
||||
assert.ok(redirectIdx > -1, 'res.code == -1 分支必须调用 redirectTo()');
|
||||
assert.ok(clearIdx < redirectIdx,
|
||||
'res.code == -1 分支: clearLoginState 必须在 redirectTo 之前, 实际: clear=' + clearIdx + ', redirect=' + redirectIdx);
|
||||
// 兜底分支必须调 redirectToLoginOnce (里面已含 clearLoginState + 防抖锁 + reLaunch)
|
||||
assert.ok(branchBody.includes('this.redirectToLoginOnce()'),
|
||||
'res.code == -1 分支必须调 this.redirectToLoginOnce() (统一封装)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 S: redirectToLoginOnce 防抖锁 — 避免 "appLaunch with non-empty page stack"
|
||||
//
|
||||
// 修复背景: 微信小程序下短时间内连发 reLaunch 会触发 native 错误.
|
||||
// 注销/退出流程中常见并发场景:
|
||||
// - cancelsuccess.vue onLoad 调 getStatus 异步回调 + 用户点 "完成" 按钮
|
||||
// - 多个 submitCancelApply / getCancelStatus 同时被触发
|
||||
// 防抖策略: 同一组件实例 800ms 内只能跳一次, 之后忽略
|
||||
// =============================================================
|
||||
test('场景 S: redirectToLoginOnce 第一次调用 → clearLoginState + reLaunch', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
mockThis.redirectToLoginOnce();
|
||||
|
||||
assert.equal(calls.redirectTo.length, 1,
|
||||
'第一次 redirectToLoginOnce 应触发 1 次 redirectTo');
|
||||
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
|
||||
'redirectToLoginOnce 必须用 reLaunch');
|
||||
// vm 跑源码默认模拟小程序 → 跳首页
|
||||
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
|
||||
'vm 默认模拟小程序, 应跳首页');
|
||||
|
||||
// 登录态必须清
|
||||
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
|
||||
'redirectToLoginOnce 必须清 token');
|
||||
assert.ok(calls.commit.some(([t]) => t === 'setMemberInfo'),
|
||||
'redirectToLoginOnce 必须清 setMemberInfo');
|
||||
assert.ok(calls.removeStorage.includes('authInfo'),
|
||||
'redirectToLoginOnce 必须清 authInfo localStorage');
|
||||
});
|
||||
|
||||
test('场景 S 补充: redirectToLoginOnce 800ms 内连发 → 只第一次生效, 后续被忽略', () => {
|
||||
const { mockThis, calls } = createThisMock();
|
||||
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
|
||||
|
||||
// 第一次
|
||||
mockThis.redirectToLoginOnce();
|
||||
// 第二次 (800ms 内)
|
||||
mockThis.redirectToLoginOnce();
|
||||
// 第三次 (800ms 内)
|
||||
mockThis.redirectToLoginOnce();
|
||||
|
||||
// 只应该有 1 次 redirectTo (防抖锁生效)
|
||||
assert.equal(calls.redirectTo.length, 1,
|
||||
'redirectToLoginOnce 800ms 内连发 3 次, 只有第一次应触发 redirectTo, 实际 ' + calls.redirectTo.length);
|
||||
});
|
||||
|
||||
test('场景 S 补充 2: 静态检查 — member-public.js 源码包含 redirectToLoginOnce + 800ms 防抖', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 必须有 redirectToLoginOnce 方法
|
||||
assert.ok(/redirectToLoginOnce\s*\([^)]*\)\s*\{/.test(src),
|
||||
'member-public.js 应定义 redirectToLoginOnce 方法');
|
||||
// 必须有 800ms 防抖时间窗口
|
||||
assert.ok(/800/.test(src),
|
||||
'redirectToLoginDate 防抖时间窗口应为 800ms (避免连发 reLaunch)');
|
||||
// 必须用 _lastLoginRedirectAt 实例字段
|
||||
assert.ok(/_lastLoginRedirectAt/.test(src),
|
||||
'redirectToLoginOnce 应使用 _lastLoginRedirectAt 实例字段做防抖');
|
||||
// logout / getCancelStatus 兜底 / submitCancelApply 三个地方都应改用 redirectToLoginOnce
|
||||
const matches = src.match(/redirectToLoginOnce\(\)/g) || [];
|
||||
assert.ok(matches.length >= 3,
|
||||
'logout / getCancelStatus 兜底 / submitCancelApply clearStateAndRedirect 至少 3 处应改用 redirectToLoginOnce, 实际 ' + matches.length);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 T: cancelstatus.vue / cancellation.vue / cancelrefuse.vue / assets.vue
|
||||
// 等"注销流程"页面 — 跳登录入口必须用 redirectToLoginOnce
|
||||
// (不能直接 redirectTo(LOGIN_PAGE_URL), 否则在 mp-weixin 下:
|
||||
// 1. 走的是 native 登录页 (form 风格, 像 H5), 不是 ns-login 弹窗
|
||||
// 2. 没走 mixin 提供的 800ms 防抖锁, 可能与 onShow 等其他跳转并发)
|
||||
//
|
||||
// 修复背景 (2026-07-23):
|
||||
// weixin.log 显示 cancelstatus.vue onLoad 调 redirectTo(LOGIN_PAGE_URL)
|
||||
// (没传 mode) → 智能默认改成 reLaunch, 跳到 native 登录页
|
||||
// 但用户期望: 微信小程序下应走 ns-login 弹窗 (走 wx.login 原生授权)
|
||||
// 修复: 全部用 redirectToLoginOnce (内部用 getLoginEntryUrl 智能分流)
|
||||
//
|
||||
// 注: cancelstatus.vue / assets.vue 调的是 LOGIN_PAGE_URL, 不走
|
||||
// redirectToLoginOnce. 必须修改.
|
||||
// =============================================================
|
||||
test('场景 T 补充 1: cancelstatus.vue 跳登录入口必须用 redirectToLoginOnce (不能直接 redirectTo(LOGIN_PAGE_URL))', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancelstatus.vue'),
|
||||
'utf8',
|
||||
);
|
||||
// cancelstatus.vue 必须包含 redirectToLoginOnce 调用
|
||||
assert.ok(/this\.redirectToLoginOnce\s*\(/.test(src),
|
||||
'cancelstatus.vue 应调 this.redirectToLoginOnce() (不能直接 redirectTo(LOGIN_PAGE_URL), 否则 mp-weixin 下走 native 登录页, 不是 ns-login 弹窗)');
|
||||
// 不能再有直接调 LOGIN_PAGE_URL 的 redirectTo
|
||||
const directRedirects = src.match(/this\.\$util\.redirectTo\s*\(\s*this\.\$util\.LOGIN_PAGE_URL/g) || [];
|
||||
assert.equal(directRedirects.length, 0,
|
||||
'cancelstatus.vue 不能再直接 redirectTo(this.$util.LOGIN_PAGE_URL), 应改用 this.redirectToLoginOnce() (跨平台 + 防抖锁 + ns-login 弹窗)');
|
||||
});
|
||||
|
||||
test('场景 T 补充 2: cancelstatus.vue onLoad 无 token 时调 redirectToLoginOnce, onShow getStatus 兜底也调', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancelstatus.vue'),
|
||||
'utf8',
|
||||
);
|
||||
// 必须有 2 处 redirectToLoginOnce 调用 (onLoad 无 token + onShow getStatus 兜底 res.code == -1)
|
||||
const matches = src.match(/this\.redirectToLoginOnce\s*\(/g) || [];
|
||||
assert.ok(matches.length >= 2,
|
||||
'cancelstatus.vue 应至少 2 处调 this.redirectToLoginOnce() (onLoad + onShow getStatus 兜底), 实际 ' + matches.length);
|
||||
});
|
||||
|
||||
test('场景 T 补充 3: cancellation.vue 必须有 memberPublic mixin + 调 redirectToLoginOnce', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancellation.vue'),
|
||||
'utf8',
|
||||
);
|
||||
// 必须 import memberPublic
|
||||
assert.ok(src.includes("import memberPublic from './public/js/member-public.js'"),
|
||||
'cancellation.vue 必须 import memberPublic (才能用 redirectToLoginOnce)');
|
||||
// 必须 mixin
|
||||
assert.ok(/mixins:\s*\[memberPublic\]/.test(src),
|
||||
'cancellation.vue 必须 mixins: [memberPublic]');
|
||||
// 至少 2 处 redirectToLoginOnce (onLoad + getCancelAgreement 兜底)
|
||||
const matches = src.match(/this\.redirectToLoginOnce\s*\(/g) || [];
|
||||
assert.ok(matches.length >= 2,
|
||||
'cancellation.vue 应至少 2 处调 this.redirectToLoginOnce() (onLoad + getCancelAgreement 兜底), 实际 ' + matches.length);
|
||||
// 不能再有直接 redirectTo(LOGIN_PAGE_URL)
|
||||
const directRedirects = src.match(/this\.\$util\.redirectTo\s*\(\s*this\.\$util\.LOGIN_PAGE_URL/g) || [];
|
||||
assert.equal(directRedirects.length, 0,
|
||||
'cancellation.vue 不能再直接 redirectTo(this.$util.LOGIN_PAGE_URL), 应改用 this.redirectToLoginOnce()');
|
||||
});
|
||||
|
||||
test('场景 T 补充 4: cancelrefuse.vue 必须有 memberPublic mixin + 调 redirectToLoginOnce', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'cancelrefuse.vue'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(src.includes("import memberPublic from './public/js/member-public.js'"),
|
||||
'cancelrefuse.vue 必须 import memberPublic');
|
||||
assert.ok(/mixins:\s*\[memberPublic\]/.test(src),
|
||||
'cancelrefuse.vue 必须 mixins: [memberPublic]');
|
||||
const matches = src.match(/this\.redirectToLoginOnce\s*\(/g) || [];
|
||||
assert.ok(matches.length >= 2,
|
||||
'cancelrefuse.vue 应至少 2 处调 this.redirectToLoginOnce() (onLoad + getStatus 兜底), 实际 ' + matches.length);
|
||||
const directRedirects = src.match(/this\.\$util\.redirectTo\s*\(\s*this\.\$util\.LOGIN_PAGE_URL/g) || [];
|
||||
assert.equal(directRedirects.length, 0,
|
||||
'cancelrefuse.vue 不能再直接 redirectTo(this.$util.LOGIN_PAGE_URL)');
|
||||
});
|
||||
|
||||
test('场景 T 补充 5: assets.vue 调 redirectToLoginOnce (不能直接 redirectTo(LOGIN_PAGE_URL))', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'assets.vue'),
|
||||
'utf8',
|
||||
);
|
||||
// 至少 2 处 redirectToLoginOnce (onLoad + getAccountInfo 兜底)
|
||||
const matches = src.match(/this\.redirectToLoginOnce\s*\(/g) || [];
|
||||
assert.ok(matches.length >= 2,
|
||||
'assets.vue 应至少 2 处调 this.redirectToLoginOnce() (onLoad + getAccountInfo 兜底), 实际 ' + matches.length);
|
||||
// 不能再有直接 redirectTo(LOGIN_PAGE_URL)
|
||||
const directRedirects = src.match(/this\.\$util\.redirectTo\s*\(\s*this\.\$util\.LOGIN_PAGE_URL/g) || [];
|
||||
assert.equal(directRedirects.length, 0,
|
||||
'assets.vue 不能再直接 redirectTo(this.$util.LOGIN_PAGE_URL)');
|
||||
});
|
||||
|
||||
test('场景 T 补充 6 (已回退): 首页 onLoad/onShow 不再有 _pendingAutoOpenLogin (回退原因: 触发 $refs.dataset null 错误)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages', 'index', 'public', 'js', 'index.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 之前尝试让首页自动弹 ns-login 弹窗, 但触发了 vendor.js:2:19113 错误:
|
||||
// at a.get [as $refs] (vendor.js:2:19648)
|
||||
// at a.onShow (vendor.js:15:136889)
|
||||
// at Array.forEach (<anonymous>)
|
||||
// at Gt (vendor.js:2:19085)
|
||||
// 原因: onShow 同步访问 this.$refs.login 时, ref 对应的 DOM 节点是 null.
|
||||
// 修复: 回退 (不在 onShow 自动弹, 改由调用方自己处理)
|
||||
assert.equal(/_pendingAutoOpenLogin/.test(src), false,
|
||||
'pages/index/public/js/index.js 不应再有 _pendingAutoOpenLogin (回退, 避免 onShow 同步访问 null ref)');
|
||||
// 注意: 已存在的 _handleDiyGroupInteractionEvent 函数中
|
||||
// 也调 this.$refs.login?.open() (在用户点 diy 元素时触发, 不在 onShow),
|
||||
// 这是合理的代码, 不应禁止. 只禁止自动弹登录弹窗的标记.
|
||||
assert.ok(src.includes('this.$refs.login?.open'),
|
||||
'index.js 已存在 _handleDiyGroupInteractionEvent 中调 this.$refs.login?.open() (用户点 diy 元素时触发)');
|
||||
});
|
||||
629
tests/redirectTo-relaunch-h5.test.js
Normal file
629
tests/redirectTo-relaunch-h5.test.js
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* util.js redirectTo(..., 'reLaunch') — 4 平台行为测试
|
||||
* ============================================================================
|
||||
*
|
||||
* 修复背景 (2026-07-23):
|
||||
*
|
||||
* 账号注销/退出登录场景用 `redirectTo(LOGIN_PAGE_URL, {}, 'reLaunch')`
|
||||
* 来清空页面栈防止用户回退。但实际在 4 个平台上的行为并不一致:
|
||||
*
|
||||
* - 微信小程序: uni.reLaunch 原生支持, 栈彻底清空, ✅
|
||||
* - 鸿蒙元服务: uni.reLaunch 映射到原生 relaunch, ✅ (但有已知栈管理 bug)
|
||||
* - H5: uni.reLaunch = 多次 history.back() + router.replace,
|
||||
* 并不能真正删除浏览器历史条目 (浏览器安全策略限制)。
|
||||
* 在微信内置浏览器 / Android 定制浏览器中, 按返回键仍可回退。
|
||||
* ⚠️ 风险: 用户退出登录后仍可看到旧会员信息。
|
||||
* - 快应用: 实际是 H5 webview 嵌入, 走 H5 路径, ⚠️ 同上。
|
||||
*
|
||||
* 修复: util.js redirectTo(reLaunch) 在 H5 下用 window.location.replace 兜底,
|
||||
* 真正清空浏览器历史栈。
|
||||
*
|
||||
* 测什么 (按用户故事):
|
||||
*
|
||||
* 场景 1: H5 下 redirectTo(reLaunch) → 必须用 location.replace (不留 history)
|
||||
* 场景 2: 微信小程序下 → 必须用 uni.reLaunch (原生行为正确)
|
||||
* 场景 3: 鸿蒙元服务下 → 必须用 uni.reLaunch (原生行为)
|
||||
* 场景 4: 华为快应用下 → 必须用 location.replace (H5 webview 内嵌)
|
||||
* 场景 5: H5 下 location.replace 不可用时 (兜底) → 走 uni.reLaunch
|
||||
* 场景 6 (静态): util.js 源码里 reLaunch 分支真的有 #ifdef H5 兜底
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/redirectTo-relaunch-h5.test.js
|
||||
*
|
||||
* 实现策略:
|
||||
* util.js 依赖 store/Config/uni 全局, vm 跑会失败。这里采用 "同源复制 +
|
||||
* 跨平台 mock" 策略:
|
||||
* 1. 把 util.js 的 redirectTo(..., 'reLaunch') 分支简化版复制过来, 严格
|
||||
* 对齐原代码(包括 #ifdef H5 分支)
|
||||
* 2. 用 mock 的 uni 和 window 对象, 调用复制版验证 4 平台行为
|
||||
* 3. 加静态分析测试, 兜底校验源码确实包含 #ifdef H5 分支
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// =============================================================
|
||||
// 被测源码区块(与 common/js/util.js redirectTo reLaunch 分支保持同步)
|
||||
//
|
||||
// 这段代码是 util.js switch(mode) 中 'reLaunch' case 的简化复制版,
|
||||
// 保留了 #ifdef H5 / #ifdef MP 兜底 + 跳转锁逻辑。任何对 util.js
|
||||
// 该分支的修改, 必须同步到这里。
|
||||
// =============================================================
|
||||
function redirectToReLaunchBranch(url) {
|
||||
// H5 平台修复 (2026-07-23): uni.reLaunch 在 H5 上不能完全清空浏览器历史栈,
|
||||
// 用 location.replace 真正替换历史条目 (不留 history 记录)
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
||||
&& typeof window.location.replace === 'function') {
|
||||
window.location.replace(url);
|
||||
return 'window.location.replace';
|
||||
}
|
||||
// #endif
|
||||
// 小程序平台修复 (2026-07-23): 微信小程序在以下场景会抛
|
||||
// "appLaunch with non-empty page stack" native 错误:
|
||||
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
|
||||
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
|
||||
// 解决方案: 跳转锁 (300ms) + setTimeout(0) 包裹
|
||||
// #ifdef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
|
||||
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
|
||||
return 'locked';
|
||||
}
|
||||
globalThis.__reLaunchLock = true;
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: url });
|
||||
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
|
||||
}, 0);
|
||||
return 'uni.reLaunch.delayed';
|
||||
// #endif
|
||||
uni.reLaunch({ url: url });
|
||||
return 'uni.reLaunch';
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Mock 工具
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 创建一个干净的全局状态, 用于测试 reLaunch 分支.
|
||||
* 包括 window, location.replace mock, uni.reLaunch mock.
|
||||
*/
|
||||
function createEnv() {
|
||||
const calls = {
|
||||
uniReLaunch: [],
|
||||
locationReplace: [],
|
||||
locationHref: [],
|
||||
};
|
||||
|
||||
// window.location.replace mock
|
||||
const locationObj = {
|
||||
_replaced: '',
|
||||
_href: '',
|
||||
replace(u) {
|
||||
calls.locationReplace.push(u);
|
||||
this._replaced = u;
|
||||
this._href = u;
|
||||
},
|
||||
set href(v) {
|
||||
calls.locationHref.push(v);
|
||||
this._href = v;
|
||||
},
|
||||
get href() { return this._href; },
|
||||
};
|
||||
const windowMock = { location: locationObj };
|
||||
|
||||
// uni mock
|
||||
const uni = {
|
||||
reLaunch(opts) { calls.uniReLaunch.push(opts); },
|
||||
};
|
||||
|
||||
return { calls, window: windowMock, uni };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟不同平台的全局环境.
|
||||
* 平台模拟: 在 vm 沙箱里把 window / uni 注入, 然后跑 redirectToReLaunchBranch
|
||||
*/
|
||||
function runOnPlatform(platform, url) {
|
||||
const vm = require('node:vm');
|
||||
const env = createEnv();
|
||||
|
||||
// 把 env.window 和 env.uni 挂到沙箱全局
|
||||
const sandbox = { window: env.window, uni: env.uni, console };
|
||||
vm.createContext(sandbox);
|
||||
|
||||
// 把被测代码包成一个函数, 注入到沙箱
|
||||
const wrapped = `
|
||||
${redirectToReLaunchBranch.toString()}
|
||||
// 顶层把 redirectToReLaunchBranch 用上 url 参数跑一次
|
||||
// 但 url 是参数, 我们通过 IIFE 传
|
||||
(function() {
|
||||
const _result = redirectToReLaunchBranch(${JSON.stringify(url)});
|
||||
// 把结果塞回 sandbox, 用 closure 让外层拿不到, 改用 process 通信
|
||||
// 简化: 这里就让 sandbox 暴露 result
|
||||
})();
|
||||
`;
|
||||
vm.runInContext(wrapped, sandbox);
|
||||
|
||||
return env.calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的平台注入方法: 直接在测试函数体内调用 redirectToReLaunchBranch,
|
||||
* 控制 window / uni 的存在与否来模拟平台
|
||||
*/
|
||||
function callBranch({ hasWindow, hasLocation, hasReplace, url }) {
|
||||
const calls = { uniReLaunch: [], locationReplace: [] };
|
||||
|
||||
// 在新的 vm 沙箱里设置 window / uni 全局
|
||||
const vm = require('node:vm');
|
||||
const sandbox = { console, result: '' };
|
||||
|
||||
// 只有 hasWindow=true 时才设 window 全局; 否则 sandbox.window 保持 undefined
|
||||
if (hasWindow === true) {
|
||||
const loc = (hasLocation === true) ? {
|
||||
_replaced: '',
|
||||
replace: hasReplace === true ? function (u) {
|
||||
calls.locationReplace.push(u);
|
||||
loc._replaced = u;
|
||||
} : undefined,
|
||||
} : undefined;
|
||||
sandbox.window = { location: loc };
|
||||
} else {
|
||||
// 显式置 undefined, 模拟微信/鸿蒙环境
|
||||
sandbox.window = undefined;
|
||||
}
|
||||
|
||||
sandbox.uni = {
|
||||
reLaunch(opts) { calls.uniReLaunch.push(opts); },
|
||||
};
|
||||
|
||||
// 同步的 setTimeout (用 queueMicrotask 立即 flush)
|
||||
// 让 setTimeout(0) 在 vm.runInContext 同步返回前执行
|
||||
const pendingTimers = [];
|
||||
sandbox.setTimeout = function (fn, delay) {
|
||||
// 把定时器收集起来, runInContext 之后手动同步 flush
|
||||
pendingTimers.push({ fn, delay });
|
||||
return pendingTimers.length - 1; // 返回 timer id (简化)
|
||||
};
|
||||
sandbox.clearTimeout = function () { /* noop */ };
|
||||
sandbox.globalThis = sandbox; // 让源码里的 globalThis.__reLaunchLock 落在 sandbox 上
|
||||
|
||||
vm.createContext(sandbox);
|
||||
const wrapped = `${redirectToReLaunchBranch.toString()}\nresult = redirectToReLaunchBranch(${JSON.stringify(url)});`;
|
||||
vm.runInContext(wrapped, sandbox);
|
||||
|
||||
// 同步 flush 所有 setTimeout: 0ms 立即跑, >0ms 标记但不跑 (这里只关心 0ms 那个)
|
||||
for (const t of pendingTimers) {
|
||||
if (t.delay === 0) t.fn();
|
||||
}
|
||||
|
||||
return { calls, result: sandbox.result };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 场景 1: H5 平台 — redirectTo(reLaunch) 必须用 location.replace
|
||||
// =============================================================
|
||||
test('场景 1: H5 平台下 redirectTo(reLaunch) 必须用 window.location.replace (不留 history)', () => {
|
||||
const { calls, result } = callBranch({
|
||||
hasWindow: true,
|
||||
hasLocation: true,
|
||||
hasReplace: true,
|
||||
url: '/pages_tool/login/login',
|
||||
});
|
||||
|
||||
assert.equal(result, 'window.location.replace',
|
||||
'H5 下 reLaunch 分支必须返回 window.location.replace (真正清空 history)');
|
||||
assert.equal(calls.locationReplace.length, 1,
|
||||
'H5 下必须调一次 location.replace');
|
||||
assert.equal(calls.locationReplace[0], '/pages_tool/login/login',
|
||||
'location.replace 的 url 必须正确');
|
||||
// 关键: 不应再调 uni.reLaunch (避免双重跳转)
|
||||
assert.equal(calls.uniReLaunch.length, 0,
|
||||
'H5 下走 location.replace 后不应再调 uni.reLaunch (会重复 history 操作)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 2: 微信小程序 — 必须用 uni.reLaunch (原生), 但有跳转锁 + setTimeout 包裹
|
||||
// =============================================================
|
||||
test('场景 2: 微信小程序下 redirectTo(reLaunch) 必须用 uni.reLaunch (带跳转锁 + 延迟)', () => {
|
||||
// 微信小程序没有 window 全局, 但有 uni 全局
|
||||
const { calls, result } = callBranch({
|
||||
hasWindow: false,
|
||||
hasLocation: false,
|
||||
hasReplace: false,
|
||||
url: '/pages_tool/login/login',
|
||||
});
|
||||
|
||||
// 同步返回 'uni.reLaunch.delayed' (因为有 setTimeout 包裹)
|
||||
assert.equal(result, 'uni.reLaunch.delayed',
|
||||
'微信小程序下应走 uni.reLaunch + 跳转锁 + setTimeout 包裹路径');
|
||||
// 但同步 flush 后, uni.reLaunch 应已被调过 1 次
|
||||
assert.equal(calls.uniReLaunch.length, 1, '微信下必须调一次 uni.reLaunch (flush 后)');
|
||||
assert.equal(calls.uniReLaunch[0].url, '/pages_tool/login/login',
|
||||
'uni.reLaunch url 必须正确');
|
||||
assert.equal(calls.locationReplace.length, 0,
|
||||
'微信下不应走 location.replace (无 window 全局)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 3: 鸿蒙元服务 — 必须用 uni.reLaunch (带跳转锁 + 延迟)
|
||||
// =============================================================
|
||||
test('场景 3: 鸿蒙元服务下 redirectTo(reLaunch) 必须用 uni.reLaunch (带跳转锁 + 延迟)', () => {
|
||||
// 鸿蒙元服务有 window? 不一定. 但有 uni. 模拟无 window 走 uni 分支
|
||||
const { calls, result } = callBranch({
|
||||
hasWindow: false,
|
||||
hasLocation: false,
|
||||
hasReplace: false,
|
||||
url: '/pages_tool/login/login',
|
||||
});
|
||||
|
||||
assert.equal(result, 'uni.reLaunch.delayed',
|
||||
'鸿蒙元服务下应走 uni.reLaunch + 跳转锁 + setTimeout 包裹');
|
||||
assert.equal(calls.uniReLaunch.length, 1, '鸿蒙下必须调一次 uni.reLaunch (flush 后)');
|
||||
assert.equal(calls.locationReplace.length, 0,
|
||||
'鸿蒙下不应走 location.replace (鸿蒙无 window.location)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 4: 华为快应用 — 必须用 location.replace (H5 webview 内嵌)
|
||||
// =============================================================
|
||||
test('场景 4: 华为快应用下 redirectTo(reLaunch) 必须用 location.replace (H5 webview 嵌入)', () => {
|
||||
// 快应用是 H5 webview 内嵌, 全局有 window.location.replace
|
||||
const { calls, result } = callBranch({
|
||||
hasWindow: true,
|
||||
hasLocation: true,
|
||||
hasReplace: true,
|
||||
url: '/pages_tool/login/login',
|
||||
});
|
||||
|
||||
assert.equal(result, 'window.location.replace',
|
||||
'快应用下应走 location.replace (快应用是 H5 webview 内嵌)');
|
||||
assert.equal(calls.locationReplace.length, 1, '快应用下必须调一次 location.replace');
|
||||
assert.equal(calls.uniReLaunch.length, 0,
|
||||
'快应用下不应调 uni.reLaunch (走 location.replace 后已 return)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 5: H5 下 location.replace 不可用时的兜底 → 走 MP 分支 (有锁 + 延迟)
|
||||
// =============================================================
|
||||
test('场景 5: H5 下 location.replace 不可用 (异常环境) → 走 MP 分支兜底 (有锁 + 延迟)', () => {
|
||||
// 模拟 window 存在但 location 或 replace 缺失 (罕见但要兜底)
|
||||
const vm = require('node:vm');
|
||||
const calls = { uniReLaunch: [], locationReplace: [] };
|
||||
const sandbox = {
|
||||
console,
|
||||
result: '',
|
||||
window: { location: undefined }, // location 为 undefined
|
||||
uni: {
|
||||
reLaunch(opts) { calls.uniReLaunch.push(opts); },
|
||||
},
|
||||
};
|
||||
|
||||
// 同步 setTimeout mock (跟 callBranch 一样)
|
||||
const pendingTimers = [];
|
||||
sandbox.setTimeout = function (fn, delay) {
|
||||
pendingTimers.push({ fn, delay });
|
||||
return pendingTimers.length - 1;
|
||||
};
|
||||
sandbox.clearTimeout = function () { /* noop */ };
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
vm.createContext(sandbox);
|
||||
const wrapped = `${redirectToReLaunchBranch.toString()}\nresult = redirectToReLaunchBranch('/pages_tool/login/login');`;
|
||||
vm.runInContext(wrapped, sandbox);
|
||||
|
||||
// 同步 flush 0ms setTimeout
|
||||
for (const t of pendingTimers) {
|
||||
if (t.delay === 0) t.fn();
|
||||
}
|
||||
|
||||
// location 不可用时: #ifdef H5 块跳过, 落入 #ifdef MP-... 块 (vm 跑源码所有分支都保留)
|
||||
// → 走 MP 锁 + 延迟路径
|
||||
assert.equal(sandbox.result, 'uni.reLaunch.delayed',
|
||||
'H5 下 location 不可用时应走 MP 兜底分支 (有锁 + 延迟)');
|
||||
assert.equal(calls.uniReLaunch.length, 1, '兜底应调一次 uni.reLaunch (flush 后)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 5 补充: 跳转锁 — 同一全局短时间内连发 reLaunch 第二次应被忽略
|
||||
// =============================================================
|
||||
test('场景 5 补充: 小程序跳转锁 — 第二次 redirectTo(reLaunch) 在锁期内应被忽略', () => {
|
||||
// 模拟微信小程序环境
|
||||
const vm = require('node:vm');
|
||||
const calls = { uniReLaunch: [], locationReplace: [] };
|
||||
const sandbox = {
|
||||
console,
|
||||
result: '',
|
||||
window: undefined, // 微信小程序无 window
|
||||
uni: {
|
||||
reLaunch(opts) { calls.uniReLaunch.push(opts); },
|
||||
},
|
||||
};
|
||||
|
||||
const pendingTimers = [];
|
||||
sandbox.setTimeout = function (fn, delay) {
|
||||
pendingTimers.push({ fn, delay });
|
||||
return pendingTimers.length - 1;
|
||||
};
|
||||
sandbox.clearTimeout = function () { /* noop */ };
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
vm.createContext(sandbox);
|
||||
const wrapped = `
|
||||
${redirectToReLaunchBranch.toString()}
|
||||
result1 = redirectToReLaunchBranch('/pages/login/login1');
|
||||
result2 = redirectToReLaunchBranch('/pages/login/login2');
|
||||
// 同步 flush 0ms 定时器
|
||||
for (let i = 0; i < 1; i++) {
|
||||
const t = pendingTimers[i];
|
||||
if (t && t.delay === 0) t.fn();
|
||||
}
|
||||
`;
|
||||
// 把 pendingTimers 同步推入主沙箱 (它需要在 wrapper 里访问)
|
||||
// 简单做法: 把 pendingTimers 注入到 sandbox
|
||||
sandbox.pendingTimers = pendingTimers;
|
||||
vm.runInContext(wrapped, sandbox);
|
||||
|
||||
// 第一次应该走 delayed 路径, 第二次应该被锁拒绝
|
||||
// 注: vm 同步执行时, 第一次 setTimeout(0) 内的 uni.reLaunch 还没跑
|
||||
// 所以 __reLaunchLock 还是 true, 第二次进来直接 return 'locked'
|
||||
// 关键: 第二次调用应返回 'locked', 避免连发 reLaunch 触发 appLaunch 错误
|
||||
// 简化验证: 我们直接看第二次返回 'locked' 即可
|
||||
// (注: wrapper 里 sandbox.result1 / result2 没暴露, 改用单独跑两次)
|
||||
|
||||
// 单独验证: 第二次调用 (锁还在) 应返回 'locked'
|
||||
const wrapped2 = `
|
||||
${redirectToReLaunchBranch.toString()}
|
||||
// 强制把锁打开
|
||||
globalThis.__reLaunchLock = true;
|
||||
result = redirectToReLaunchBranch('/pages/test/locked');
|
||||
`;
|
||||
const sandbox2 = {
|
||||
console,
|
||||
result: '',
|
||||
window: undefined,
|
||||
uni: { reLaunch: () => {} },
|
||||
globalThis: undefined,
|
||||
};
|
||||
sandbox2.globalThis = sandbox2;
|
||||
vm.createContext(sandbox2);
|
||||
vm.runInContext(wrapped2, sandbox2);
|
||||
assert.equal(sandbox2.result, 'locked',
|
||||
'跳转锁打开时 redirectTo(reLaunch) 应返回 locked (避免连发), 实际: ' + sandbox2.result);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 6 (静态): util.js 源码里 reLaunch 分支真的有 #ifdef H5 兜底
|
||||
//
|
||||
// 兜底验证: 即便我们的复制版被改坏了, 也能通过源码静态分析兜住
|
||||
// =============================================================
|
||||
test('场景 6 (静态): util.js redirectTo 内 reLaunch 分支源码包含 #ifdef H5 兜底逻辑', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'common', 'js', 'util.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 找到 reLaunch case 分支
|
||||
const m = src.match(/case\s+'reLaunch':([\s\S]*?)\n\s{4}break;/);
|
||||
assert.ok(m, '应能找到 case \'reLaunch\' 分支');
|
||||
const body = m[1];
|
||||
|
||||
// 必须包含 #ifdef H5
|
||||
assert.ok(/#ifdef\s+H5/.test(body),
|
||||
'reLaunch 分支应包含 #ifdef H5 条件编译');
|
||||
// 必须包含 #endif
|
||||
assert.ok(/#endif/.test(body),
|
||||
'reLaunch 分支应包含 #endif');
|
||||
// 必须包含 window.location.replace 调用
|
||||
assert.ok(/window\.location\.replace\s*\(/.test(body),
|
||||
'reLaunch 分支应包含 window.location.replace 调用');
|
||||
// 必须保留 uni.reLaunch 调用 (兜底路径)
|
||||
// 用 code line (不在 // 注释里) 的 uni.reLaunch
|
||||
const lines = body.split('\n');
|
||||
let uniLineIdx = -1;
|
||||
let h5LineIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// 跳过纯注释行 (以 // 开头)
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//')) continue;
|
||||
if (/uni\.reLaunch\s*\(/.test(line) && uniLineIdx === -1) uniLineIdx = i;
|
||||
if (/window\.location\.replace\s*\(/.test(line) && h5LineIdx === -1) h5LineIdx = i;
|
||||
}
|
||||
assert.ok(uniLineIdx > -1,
|
||||
'reLaunch 分支应保留 uni.reLaunch 调用 (兜底), 在代码行 (非注释)');
|
||||
assert.ok(h5LineIdx > -1,
|
||||
'reLaunch 分支应包含 window.location.replace 调用, 在代码行 (非注释)');
|
||||
// H5 分支应在 uni.reLaunch 之前 (return 早返回)
|
||||
assert.ok(h5LineIdx < uniLineIdx,
|
||||
'#ifdef H5 的 location.replace 必须在 uni.reLaunch 之前 (return 早返回), ' +
|
||||
'实际: h5=' + h5LineIdx + ', uni=' + uniLineIdx);
|
||||
|
||||
// 必须包含 MP 跳转锁 (防 "appLaunch with non-empty page stack" native 错误)
|
||||
assert.ok(/__reLaunchLock/.test(body),
|
||||
'reLaunch 分支应包含 __reLaunchLock 跳转锁 (小程序防连发)');
|
||||
// 必须包含 setTimeout 包裹 (让 native 层先完成销毁)
|
||||
assert.ok(/setTimeout/.test(body),
|
||||
'reLaunch 分支应包含 setTimeout 包裹 (避免 "appLaunch with non-empty page stack")');
|
||||
// 必须包含 #ifdef MP / #ifdef MP-WEIXIN / 等 跳转锁条件编译 (覆盖小程序)
|
||||
assert.ok(/#ifdef\s+MP(-\w+)?(\b|\s)/.test(body),
|
||||
'reLaunch 分支应包含 #ifdef MP[-xxx] 跳转锁条件编译 (覆盖所有小程序平台)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 7 (静态): 跳登录入口的 redirectTo (在 redirectToLoginOnce 内) 必须用 reLaunch
|
||||
// 兜底验证: 即便其他文件改坏, 我们也能捕获
|
||||
//
|
||||
// 注意 (2026-07-23): 跨平台登录入口改为 getLoginEntryUrl() 统一管理,
|
||||
// 小程序下返回 INDEX_PAGE_URL, H5 下返回 LOGIN_PAGE_URL.
|
||||
// 重构后: 跳登录入口的 redirectTo 集中在 mixin 的 redirectToLoginOnce 内.
|
||||
// 这里检查该函数体内的 redirectTo 必须用 reLaunch.
|
||||
// =============================================================
|
||||
test('场景 7 (静态): member-public.js 内 redirectToLoginOnce 函数体的 redirectTo 必须用 reLaunch', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 找 redirectToLoginOnce 函数体 (匹配到方法体结束的 }, 同缩进级别)
|
||||
const funcStart = src.indexOf('redirectToLoginOnce() {');
|
||||
assert.ok(funcStart > -1, '应能找到 redirectToLoginOnce 函数定义');
|
||||
const endMarker = '\n\t\t}';
|
||||
const funcEnd = src.indexOf(endMarker, funcStart);
|
||||
assert.ok(funcEnd > funcStart, '应能找到 redirectToLoginOnce 函数结束');
|
||||
const body = src.substring(funcStart, funcEnd + endMarker.length - 1);
|
||||
|
||||
// 必须调 redirectTo 到登录入口 (getLoginEntryUrl() 统一入口)
|
||||
const allRedirect = body.match(/this\.\$util\.redirectTo\s*\([\s\S]*?\);/g) || [];
|
||||
assert.ok(allRedirect.length >= 1,
|
||||
'redirectToLoginOnce 必须至少有 1 处 redirectTo, 实际 ' + allRedirect.length);
|
||||
|
||||
for (const call of allRedirect) {
|
||||
// 必须用 reLaunch
|
||||
assert.match(call, /['"]reLaunch['"]/,
|
||||
'redirectToLoginOnce 内的 redirectTo 必须用 reLaunch 模式, 实际: ' + call.replace(/\n/g, '\\n').slice(0, 100));
|
||||
// 必须用 getLoginEntryUrl() (跨平台统一入口)
|
||||
assert.ok(call.includes('getLoginEntryUrl()'),
|
||||
'redirectToLoginOnce 内的 redirectTo 必须用 getLoginEntryUrl() (跨平台统一入口), 实际: ' + call.replace(/\n/g, '\\n').slice(0, 100));
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 9: 智能默认的边界 — 带 back 参数时不应改 mode
|
||||
//
|
||||
// 修复背景 (2026-07-23):
|
||||
// 第一版智能默认是"目标 == LOGIN_PAGE_URL && mode == 'navigateTo' 就改 reLaunch",
|
||||
// 但这会破坏"登录后回原页面"的场景 — 比如 refund_detail.vue:
|
||||
// redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
|
||||
// 如果智能改成 reLaunch, 整个页面栈清空, back 参数就失效了.
|
||||
// 修正: 智能默认额外检查 !param?.back, 带 back 时保持 navigateTo 模式
|
||||
// =============================================================
|
||||
test('场景 9 (静态): util.js redirectTo 智能默认 — 必须检查 param?.back (避免影响登录后回原页)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'common', 'js', 'util.js'),
|
||||
'utf8',
|
||||
);
|
||||
// 找 redirectTo 函数体
|
||||
const funcStart = src.indexOf('redirectTo(to, param = {}, mode = \'navigateTo\') {');
|
||||
assert.ok(funcStart > -1, '应能找到 redirectTo 函数定义');
|
||||
const endMarker = '\n\t}';
|
||||
const funcEnd = src.indexOf(endMarker, funcStart);
|
||||
assert.ok(funcEnd > funcStart, '应能找到 redirectTo 函数结束');
|
||||
const body = src.substring(funcStart, funcEnd + endMarker.length - 1);
|
||||
|
||||
// 智能默认条件必须包含 !param?.back
|
||||
assert.ok(/!\s*param\?\.back/.test(body),
|
||||
'智能默认必须检查 !param?.back (带 back 参数时保持 navigateTo, 不影响登录后回原页)');
|
||||
});
|
||||
|
||||
test('场景 9 补充 (行为): 智能默认 — redirectTo(LOGIN_PAGE_URL) 不带 back 改 reLaunch, 带 back 保持 navigateTo', () => {
|
||||
// 复制智能默认逻辑, 在独立 sandbox 里跑
|
||||
function computeMode(to, param, initialMode) {
|
||||
const vm = require('node:vm');
|
||||
const sandbox = {
|
||||
console,
|
||||
result: '',
|
||||
LOGIN_PAGE_URL: '/pages_tool/login/login',
|
||||
_to: to,
|
||||
_param: param || {},
|
||||
_initialMode: initialMode || 'navigateTo',
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
// 复制源码逻辑 (跟 util.js 智能默认块完全一致)
|
||||
const code = `
|
||||
const to = ${JSON.stringify(to)};
|
||||
const param = ${JSON.stringify(param || {})};
|
||||
let mode = ${JSON.stringify(initialMode || 'navigateTo')};
|
||||
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
|
||||
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
|
||||
if (cleanPath === LOGIN_PAGE_URL) {
|
||||
mode = 'reLaunch';
|
||||
}
|
||||
}
|
||||
result = mode;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
return sandbox.result;
|
||||
}
|
||||
|
||||
// case 1: 不带 back → 应改 reLaunch
|
||||
assert.equal(computeMode('/pages_tool/login/login', {}), 'reLaunch',
|
||||
'redirectTo(LOGIN_PAGE_URL, {}) 不带 back 应改 reLaunch (避免注销后死循环)');
|
||||
|
||||
// case 2: 带 back → 应保持 navigateTo
|
||||
assert.equal(
|
||||
computeMode('/pages_tool/login/login', { back: '/pages_tool/order/refund_detail?id=1' }),
|
||||
'navigateTo',
|
||||
'redirectTo(LOGIN_PAGE_URL, { back: ... }) 应保持 navigateTo (登录后回原页)'
|
||||
);
|
||||
|
||||
// case 3: 显式传 'redirectTo' → 不应被智能默认覆盖
|
||||
assert.equal(
|
||||
computeMode('/pages_tool/login/login', {}, 'redirectTo'),
|
||||
'redirectTo',
|
||||
'redirectTo(..., {}, "redirectTo") 显式传 redirectTo 不应被智能默认覆盖'
|
||||
);
|
||||
|
||||
// case 4: 带 query 的 LOGIN_PAGE_URL + 不带 back → 应改 reLaunch
|
||||
assert.equal(
|
||||
computeMode('/pages_tool/login/login?from=diy', {}),
|
||||
'reLaunch',
|
||||
'redirectTo(LOGIN_PAGE_URL 带 query, {}) 不带 back 应改 reLaunch'
|
||||
);
|
||||
|
||||
// case 5: 其他页面 (非 LOGIN_PAGE_URL) → 不应改
|
||||
assert.equal(
|
||||
computeMode('/pages_tool/member/index', {}),
|
||||
'navigateTo',
|
||||
'redirectTo(非 LOGIN_PAGE_URL) 不应被智能默认覆盖'
|
||||
);
|
||||
|
||||
// case 6: 显式传 'reLaunch' → 不变
|
||||
assert.equal(
|
||||
computeMode('/pages_tool/login/login', {}, 'reLaunch'),
|
||||
'reLaunch',
|
||||
'redirectTo(..., {}, "reLaunch") 显式传 reLaunch 不应被覆盖'
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 8 (静态): redirectTo 智能默认 — 跳 LOGIN_PAGE_URL 时自动改用 reLaunch
|
||||
//
|
||||
// 修复背景 (2026-07-23):
|
||||
// 多处业务代码历史遗留 redirectTo(LOGIN_PAGE_URL) 没传 mode (默认 navigateTo).
|
||||
// 这导致 cancelstatus.vue 等页面:
|
||||
// 1. 跳登录页 (navigateTo push 到栈顶)
|
||||
// 2. 旧页面 (cancelstatus) 还在栈中
|
||||
// 3. 用户按返回键回到 cancelstatus → onShow 再次调 getStatus → 接口返回 -1 →
|
||||
// 再次 navigateTo 跳登录页 (死循环, 视觉上"打开登录页"但页面栈混乱)
|
||||
// 修复: util.js redirectTo 入口加智能默认, 跳 LOGIN_PAGE_URL 时自动用 reLaunch
|
||||
// =============================================================
|
||||
test('场景 8 (静态): util.js redirectTo 入口包含 LOGIN_PAGE_URL → reLaunch 智能默认逻辑', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'common', 'js', 'util.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 找 redirectTo 函数体 (跨行匹配, 容忍任意缩进)
|
||||
const funcStart = src.indexOf('redirectTo(to, param = {}, mode = \'navigateTo\') {');
|
||||
assert.ok(funcStart > -1, '应能找到 redirectTo 函数定义');
|
||||
const endMarker = '\n\t}';
|
||||
// 找 redirectTo 函数体的结束 - 取第一个出现的 "\n\t}" 同缩进
|
||||
const funcEnd = src.indexOf(endMarker, funcStart);
|
||||
assert.ok(funcEnd > funcStart, '应能找到 redirectTo 函数结束');
|
||||
const body = src.substring(funcStart, funcEnd + endMarker.length - 1);
|
||||
|
||||
// 必须包含智能默认的注释
|
||||
assert.ok(/智能默认 mode/.test(body),
|
||||
'redirectTo 入口应包含"智能默认 mode"逻辑 (跳 LOGIN_PAGE_URL 自动用 reLaunch)');
|
||||
// 必须包含 LOGIN_PAGE_URL 的判断
|
||||
assert.ok(body.includes('LOGIN_PAGE_URL'),
|
||||
'redirectTo 入口应判断目标是否是 LOGIN_PAGE_URL');
|
||||
// 必须自动改 mode 为 reLaunch
|
||||
assert.match(body, /mode\s*=\s*['"]reLaunch['"]/,
|
||||
'redirectTo 入口应将 mode 自动改为 reLaunch');
|
||||
});
|
||||
Reference in New Issue
Block a user