Files
lucky_shop/tests/member-cancel-flow.test.js
ZF sun ff0d26908c 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 个测试文件全部通过
2026-07-23 17:26:00 +08:00

889 lines
44 KiB
JavaScript

/**
* 账号注销流程 — 行为测试
* ============================================================================
*
* 修复背景 (用户报告):
*
* 账号注销页面提示注销成功后, 用户用标题栏回退键回到会员中心,
* 发现账号及信息还在 —— 也就是说页面栈里残留了注销前的页面, 且
* token / 会员信息在跳转前没被清掉.
*
* 修复方案 (在 member-public.js / cancelsuccess.vue 里):
*
* 1. submitCancelApply 申请成功 → 立刻 clearLoginState() (不等后续页面)
* 2. submitCancelApply 后续跳转 → 用 'reLaunch' 模式 (清空页面栈, 防回退)
* 3. cancelsuccess.vue 所有"去登录页"分支 → 用 'reLaunch' 模式
*
* 测什么 (按用户故事):
*
* 场景 A: 注销申请提交成功 → 立即清登录态
* 场景 B: 注销申请提交成功 → 用 reLaunch 跳 cancelstatus (清栈)
* 场景 C: 注销申请提交成功 → clearStateAndRedirect=true → 用 reLaunch 跳登录页 (清栈)
* 场景 D: 注销申请提交失败 → 不清登录态, 只 toast
* 场景 E: 注销申请提交失败 → 不调用 redirectTo (避免把用户带跑)
* 场景 F: 用户在 modal 点取消 → 不发请求, 不清登录态
* 场景 G: clearLoginState 顺序: token / memberInfo / cart / localStorage 都清
* 场景 H: cancelsuccess.vue onLoad → token 已空 → reLaunch 跳登录页
* 场景 I: cancelsuccess.vue getStatus 检到 status=1 → reLaunch 跳登录页
* 场景 J: cancelsuccess.vue 用户点"完成" → reLaunch 跳登录页
* 场景 K (静态): member-public.js / cancelsuccess.vue 源码里真的用了 'reLaunch'
* 场景 L (回归): clearLoginState 必须早于 redirectTo, 不允许反过来
*
* 退出登录 (logout) 流程:
* 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页
* 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转
* 场景 O: 退出登录跳登录页必须用 reLaunch 模式 (静态)
* 场景 P: getCancelStatus 兜底 (res.code == -1) → clearLoginState + reLaunch
* 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前
*
* 跑法:
*
* node --test tests/member-cancel-flow.test.js
*
* 设计原则 (跟其他测试保持一致):
*
* ✓ 每个测试对应一个真实用户故事, 不测代码结构
* ✓ 失败 = 用户能感知到的 bug
* ✓ 通过 mock 模拟 Vue 组件 this + uni 全局 + $store + $util
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
// =============================================================
// 复用: 加载 member-public.js
// member-public.js 是 ESM `export default { methods: {...} }`,
// vm 跑需要去掉 export default, 把 methods 暴露成普通对象。
// =============================================================
function loadMemberPublic(opts = {}) {
const SRC = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
// 把 `export default {...}` 替换成 `module.exports = {...}`,
// 然后用 vm 跑 (避免 new Function 直接执行对象字面量体报错)
const vm = require('node:vm');
const moduleObj = { exports: {} };
// 沙箱里的 uni: 优先用 opts.uni (测试时由 createThisMock 注入),
// 否则挂 noop, 保证源码能跑通不抛 ReferenceError.
const wrapped = SRC.replace(/export\s+default\s+/, 'module.exports = ');
const sandbox = {
module: moduleObj,
exports: moduleObj.exports,
console,
uni: opts.uni || {
showModal: () => {},
removeStorageSync: () => {},
setStorageSync: () => {},
getStorageSync: () => null,
},
};
vm.createContext(sandbox);
vm.runInContext(wrapped, sandbox, { filename: 'member-public.js' });
const exported = sandbox.module.exports;
if (!exported || !exported.methods) {
throw new Error('member-public.js 未导出 methods 字段');
}
return exported.methods;
}
// =============================================================
// Mock 工具
// =============================================================
/**
* 创建 Vue 组件 this 的 mock.
* 真实代码里 this 访问: $store.commit / $store.dispatch / $util.redirectTo / $api.sendRequest / $lang
*/
function createThisMock({ showModalChoice = 'confirm', apiCode = 0 } = {}) {
const calls = {
commit: [], // [type, payload][]
dispatch: [], // [type][]
redirectTo: [], // [{to, param, mode}][]
showToast: [], // [{title}][]
api: [], // [{url, success}][]
showModal: [], // [{title, content, success}][]
removeStorage: [],// [key][]
lang: [], // [key][]
};
const store = {
commit(type, payload) { calls.commit.push([type, payload]); },
dispatch(type) { calls.dispatch.push([type]); },
};
// $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' });
},
showToast(opts) { calls.showToast.push(opts || {}); },
};
// $api.sendRequest: 用 options.success 回调. 支持 mock 一个异步行为 (setTimeout 0)
const api = {
sendRequest(opts) {
calls.api.push({ url: opts.url, data: opts.data });
// 默认: 模拟成功. 立即同步回调
if (typeof opts.success === 'function') {
const res = { code: apiCode, message: 'mock-error', data: {} };
opts.success(res);
}
},
};
// uni.showModal: 默认用户点确认 (showModalChoice='confirm')
const uni = {
showModal(opts) {
calls.showModal.push({ title: opts.title, content: opts.content });
if (typeof opts.success === 'function') {
if (showModalChoice === 'confirm') opts.success({ confirm: true });
else opts.success({ confirm: false });
}
},
removeStorageSync(key) { calls.removeStorage.push(key); },
setStorageSync(key, val) { /* noop for cancel flow */ },
getStorageSync(key) { return null; },
};
// $lang: 简单返回 key
const $lang = (key) => {
calls.lang.push(key);
return key;
};
const mockThis = {
$store: store,
$util: util,
$api: api,
$lang,
// 把 mock 出来的 uni 也挂上 this.uni (Vue 组件里 this.uni 通常等同于全局 uni),
// 同时让 loadMemberPublic() 能拿到这个对象 (因为源码里直接用 uni 全局).
uni: uni,
};
return { mockThis, calls, uni };
}
/**
* 把 memberPublic.methods 装到一个 mock this 上, 然后通过 mockThis.xxx() 调用方法.
* 这是模拟 Vue mixin 注入 methods 后, 组件里 this.xxx() 的调用方式.
*
* 重要: 因为 member-public.js 源码里直接引用全局 uni (不是 this.uni),
* 加载沙箱时必须把 mockThis 同款 uni 也塞进 vm 沙箱, 否则 uni.showModal
* / uni.removeStorageSync 走的是 noop, 行为记录就丢了.
*
* 用法: bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }))
*/
function bindMethods(mockThis, methodsObj) {
for (const name of Object.keys(methodsObj)) {
mockThis[name] = methodsObj[name].bind(mockThis);
}
}
// =============================================================
// 场景 A: 注销申请成功 → 立即清登录态
// =============================================================
test('场景 A: 注销申请成功 → 立即 clearLoginState() (不等后续页面)', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.submitCancelApply();
// 申请 URL 被发了
assert.equal(calls.api.length, 1);
assert.equal(calls.api[0].url, '/membercancel/api/membercancel/apply');
// ★ 关键修复: clearLoginState 必须先于 redirectTo 被调用
// 检查顺序: 第一个 commit 必须是 setToken
const firstCommit = calls.commit[0];
assert.ok(firstCommit, '提交成功后应该有 commit 调用');
assert.equal(firstCommit[0], 'setToken',
'提交成功后第一个 commit 应是 setToken(立即清登录态),实际是 ' + firstCommit[0]);
assert.equal(firstCommit[1], '', 'setToken 应清成空串');
// setMemberInfo 也必须被清
const memberCommit = calls.commit.find(([t]) => t === 'setMemberInfo');
assert.ok(memberCommit, 'clearLoginState 应同时清 setMemberInfo');
assert.equal(memberCommit[1], '');
// emptyCart 也必须派发
assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'),
'clearLoginState 应派发 emptyCart');
// localStorage 也必须清
assert.ok(calls.removeStorage.includes('authInfo'),
'clearLoginState 应清 authInfo localStorage');
assert.ok(calls.removeStorage.includes('privacyAgreed'),
'clearLoginState 应清 privacyAgreed localStorage');
// ★ 关键修复: 跳转必须用 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/member/cancelstatus');
});
// =============================================================
// 场景 B: clearStateAndRedirect=true → reLaunch 到登录页
// =============================================================
test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登录入口', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.submitCancelApply({ clearStateAndRedirect: true });
// clearLoginState 必须执行
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
'submitCancelApply(clearStateAndRedirect) 必须清 token');
// 跳转必须是 reLaunch
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'clearStateAndRedirect 模式跳转必须是 reLaunch');
// vm 跑源码默认模拟小程序, 应跳首页 (走 ns-login 原生授权弹窗)
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
'vm 默认模拟小程序, clearStateAndRedirect 应跳首页, 实际: ' + calls.redirectTo[0].to);
});
// =============================================================
// 场景 C: 注销申请失败 → 不清登录态, 只 toast
// =============================================================
test('场景 C: 注销申请失败 → 不清登录态, 不跳转, 只 toast', () => {
const { mockThis, calls } = createThisMock({ apiCode: -1 });
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.submitCancelApply();
// 申请失败 → 不应该有 commit (登录态不清)
assert.equal(calls.commit.length, 0,
'申请失败时不应清登录态, 实际 commit 次数: ' + calls.commit.length);
// 不应有 redirectTo
assert.equal(calls.redirectTo.length, 0,
'申请失败时不应跳转');
// 应有 toast 提示
assert.equal(calls.showToast.length, 1, '申请失败应 toast 提示');
});
// =============================================================
// 场景 D: 用户在 modal 点取消 → 不发请求, 不清登录态
// =============================================================
test('场景 D: 用户点 modal 取消 → 不发请求, 不清登录态', () => {
const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' });
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.submitCancelApply();
// 没发申请
assert.equal(calls.api.length, 0, '点取消不应发注销申请');
// 没清登录态
assert.equal(calls.commit.length, 0, '点取消不应清登录态');
// 没跳转
assert.equal(calls.redirectTo.length, 0, '点取消不应跳转');
});
// =============================================================
// 场景 E: 自定义 title / content 透传给 modal
// =============================================================
test('场景 E: submitCancelApply({title, content}) → 自定义文案透传给 modal', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.submitCancelApply({ title: '自定义标题', content: '自定义内容' });
assert.equal(calls.showModal.length, 1);
assert.equal(calls.showModal[0].title, '自定义标题');
assert.equal(calls.showModal[0].content, '自定义内容');
});
// =============================================================
// 场景 F: clearLoginState 单独调用也能正确清 (供其他组件复用)
// =============================================================
test('场景 F: clearLoginState 单独被调 → token / memberInfo / cart / storage 全清', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.clearLoginState();
// 顺序: token → memberInfo → emptyCart → removeStorage
assert.deepEqual(
calls.commit.map(([t]) => t),
['setToken', 'setMemberInfo'],
'clearLoginState 必须先 commit setToken 再 setMemberInfo',
);
assert.deepEqual(
calls.dispatch.map(([t]) => t),
['emptyCart'],
'clearLoginState 必须 dispatch emptyCart',
);
assert.deepEqual(
calls.removeStorage,
['authInfo', 'privacyAgreed'],
'clearLoginState 必须清 authInfo + privacyAgreed',
);
});
// =============================================================
// 场景 G (静态分析): submitCancelApply 跳转逻辑 (重构后调 redirectToLoginOnce)
// =============================================================
test('场景 G (静态): member-public.js submitCancelApply 源码用 reLaunch 模式跳转', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
// 找到 submitCancelApply 函数体
const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(m, '应能找到 submitCancelApply 函数定义');
const body = m[1];
// 重构后: 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.replace(/\n/g, '\\n').slice(0, 100));
}
});
// =============================================================
// 场景 H (静态分析): cancelsuccess.vue 不直接调 redirectTo, 而是调 redirectToLoginOnce
// (逻辑封装到 mixin, 统一处理 4 平台 + 跳转锁 + 800ms 防抖)
// =============================================================
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*\([\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))));
// 必须包含 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);
});
// =============================================================
// 场景 I (行为模拟): cancelsuccess.vue 风格的组件 — getStatus 检到 status=1 → reLaunch
//
// 这里我们重新模拟一个简化的 cancelsuccess.vue 行为, 因为 .vue 文件无法直接 vm 跑
// (uni-app 编译产物含模板, 没法在 node 里直接 require). 我们提取其核心逻辑验证:
// onLoad → if (!storeToken) reLaunch(LOGIN_PAGE_URL)
// getStatus success → if (status==1) { clearLoginState; reLaunch(LOGIN_PAGE_URL) }
// success → { clearLoginState; reLaunch(LOGIN_PAGE_URL) }
// =============================================================
test('场景 I (行为模拟): cancelsuccess.vue getStatus 检到 status=1 → clearLoginState + reLaunch', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
// 定制 $api.sendRequest: 让 /membercancel/api/membercancel/info 返回 status=1
mockThis.$api.sendRequest = (opts) => {
calls.api.push({ url: opts.url });
if (typeof opts.success === 'function') {
opts.success({ code: 0, data: { status: 1 } });
}
};
// 模拟 cancelsuccess.vue 的 getStatus 方法 (跟源码逻辑一致)
mockThis.getStatus = function () {
mockThis.$api.sendRequest({
url: '/membercancel/api/membercancel/info',
success: (res) => {
if (res.code >= 0) {
if (res.data && res.data.status == 1) {
mockThis.clearLoginState();
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
}
}
},
});
};
mockThis.getStatus();
// ★ 关键修复: 检到 status=1 必须 (1) 清登录态 (2) reLaunch
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
'status=1 必须清 token');
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'status=1 必须用 reLaunch 跳转 (防止用户回退到历史页面看到旧会员信息)');
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login');
});
// =============================================================
// 场景 J (行为模拟): status != 1 时不重定向, 让用户留在成功页 (避免误跳)
// =============================================================
test('场景 J (行为模拟): cancelsuccess.vue getStatus status != 1 → 不清登录态, 不跳转', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
// 模拟 status=0 (审核中)
mockThis.$api.sendRequest = (opts) => {
calls.api.push({ url: opts.url });
opts.success({ code: 0, data: { status: 0 } });
};
mockThis.getStatus = function () {
mockThis.$api.sendRequest({
url: '/membercancel/api/membercancel/info',
success: (res) => {
if (res.code >= 0) {
if (res.data && res.data.status == 1) {
mockThis.clearLoginState();
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
}
}
},
});
};
mockThis.getStatus();
// status=0 → 不应清登录态, 不应跳转 (让用户继续看 cancelstatus / cancelsuccess 页)
assert.equal(calls.commit.length, 0,
'status != 1 不应清登录态');
assert.equal(calls.redirectTo.length, 0,
'status != 1 不应跳转');
});
// =============================================================
// 场景 K (行为模拟): onLoad — storeToken 为空时直接 reLaunch 跳登录页 (兜底分支)
// =============================================================
test('场景 K (行为模拟): cancelsuccess.vue onLoad — storeToken 已空时直接 reLaunch 到登录页', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
// 模拟 storeToken = false 的组件
Object.defineProperty(mockThis, 'storeToken', { get: () => false, configurable: true });
// 模拟 cancelsuccess.vue onLoad 逻辑 (跟当前修复后的源码一致)
function onLoad() {
if (!mockThis.storeToken) {
mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
} else {
mockThis.getStatus();
}
}
onLoad();
// 不应发请求 (没 token 直接跳登录)
assert.equal(calls.api.length, 0, '无 token 不应发请求');
// 必须 reLaunch 到登录页
assert.equal(calls.redirectTo.length, 1, '无 token 必须 reLaunch');
assert.equal(calls.redirectTo[0].mode, 'reLaunch', '必须用 reLaunch 模式');
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', '必须跳登录页');
});
// =============================================================
// 场景 L (回归): 不允许"先 redirectTo 再 clearLoginState" (顺序错会留漏洞)
// =============================================================
test('场景 L (回归): redirectToLoginOnce 内 — clearLoginState 必须早于 redirectTo (核心安全保证)', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
// 找 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);
// 必须先 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);
// 必须用 reLaunch 模式 (防回退到历史页面)
assert.match(body, /['"]reLaunch['"]/,
'redirectToLoginOnce 必须用 reLaunch 模式');
});
// =============================================================
// 退出登录 (logout) — 同样的"页面栈没清"问题修复
//
// 修复前 bug: 用户在会员中心退出登录 → redirectTo 默认是 navigateTo 模式 →
// 跳到登录页 → 按标题栏/导航栏返回 → 回到会员中心看到仍渲染的旧会员信息。
//
// 修复: logout / getCancelStatus 兜底分支都用 reLaunch 模式
// =============================================================
// 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录入口 (清栈防回退)
test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录入口 (清栈防回退)', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.logout();
// ★ 关键修复: 跳登录入口必须用 reLaunch (清空页面栈)
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'退出登录跳转模式必须是 reLaunch (否则按返回键会回到会员中心), 实际: ' + calls.redirectTo[0].mode);
// 注意: 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'),
'退出登录必须清 token');
assert.ok(calls.commit.some(([t]) => t === 'setMemberInfo'),
'退出登录必须清 setMemberInfo');
assert.equal(calls.commit.find(([t]) => t === 'setToken')[1], '',
'setToken 应清成空串');
// emptyCart 必须派发
assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'),
'退出登录必须派发 emptyCart');
// localStorage 必须清
assert.ok(calls.removeStorage.includes('authInfo'),
'退出登录必须清 authInfo localStorage');
assert.ok(calls.removeStorage.includes('privacyAgreed'),
'退出登录必须清 privacyAgreed localStorage');
// ★ 关键: clearLoginState 必须在 redirectTo 之前调用 (否则页面栈先 push 登录页)
const tokenIdx = calls.commit.findIndex(([t]) => t === 'setToken');
assert.ok(tokenIdx >= 0, '应有 setToken commit');
assert.equal(calls.commit[calls.commit.length - 1][0], 'setMemberInfo',
'setMemberInfo 应是最后一次 commit (clearLoginState 内最后一步)');
});
// 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转
test('场景 N: 退出登录 modal 点取消 → 不清登录态, 不跳转', () => {
const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' });
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.logout();
// 不应有任何 commit / dispatch / redirectTo / removeStorage
assert.equal(calls.commit.length, 0, '点取消不应清登录态');
assert.equal(calls.dispatch.length, 0, '点取消不应派发 emptyCart');
assert.equal(calls.redirectTo.length, 0, '点取消不应跳转');
assert.equal(calls.removeStorage.length, 0, '点取消不应清 localStorage');
});
// 场景 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',
);
// 找到 logout 函数体
const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(m, '应能找到 logout 函数定义');
const body = m[1];
// logout 内部应调 redirectToLoginOnce (统一封装)
assert.ok(body.includes('this.redirectToLoginOnce()'),
'logout 应调 this.redirectToLoginOnce() (统一封装, 避免连发 reLaunch), 实际 logout body: ' + body.replace(/\n/g, '\\n').slice(0, 200));
// 不应直接调 redirectTo (避免绕过 mixin 的防抖)
const directRedirects = body.match(/this\.\$util\.redirectTo\s*\([\s\S]*?\);/g) || [];
assert.equal(directRedirects.length, 0,
'logout 不应直接调 redirectTo, 应走 redirectToLoginOnce, 实际: ' + JSON.stringify(directRedirects));
});
// 场景 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
test('场景 P: getCancelStatus res.code == -1 → clearLoginState + reLaunch 到登录页', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
// 模拟后端返回 -1 (token 失效)
mockThis.$api.sendRequest = (opts) => {
calls.api.push({ url: opts.url });
if (typeof opts.success === 'function') {
opts.success({ code: -1, message: 'token invalid', data: null });
}
};
mockThis.getCancelStatus();
// 必须清登录态
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
'getCancelStatus 兜底分支必须清 token');
// 必须 reLaunch (不能用 navigateTo, 否则页面栈残留)
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'getCancelStatus 兜底分支跳转必须是 reLaunch, 实际: ' + calls.redirectTo[0].mode);
// vm 跑源码默认模拟小程序, 应跳首页 (走 ns-login 原生授权弹窗)
assert.equal(calls.redirectTo[0].to, '/pages/index/index',
'vm 默认模拟小程序平台, 兜底分支应跳首页, 实际: ' + calls.redirectTo[0].to);
});
// 场景 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',
);
const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(m, '应能找到 logout 函数定义');
const body = m[1];
// logout 必须调 redirectToLoginOnce (里面已保证 clearLoginState 早于 redirectTo)
assert.ok(body.includes('this.redirectToLoginOnce()'),
'logout 应调 this.redirectToLoginOnce() (里面已含防抖锁 + 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',
);
// 找到 getCancelStatus 内 res.code == -1 分支
const funcMatch = src.match(/getCancelStatus\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(funcMatch, '应能找到 getCancelStatus 函数定义');
const body = funcMatch[1];
// 找 res.code == -1 那个 else if 分支体
const branchMatch = body.match(/res\.code\s*==\s*-1[\s\S]*?(?=\n\s{3}\}|\n\s{4}\})/);
assert.ok(branchMatch, '应能找到 res.code == -1 分支');
const branchBody = branchMatch[0];
// 兜底分支必须调 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 元素时触发)');
});