import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import vm from 'node:vm'; import { beforeEach, describe, expect, test, vi } from 'vitest'; const repoRoot = process.cwd(); const shellScriptPath = join( repoRoot, 'miniprogram', 'shell', 'webView.js', ); const webViewHostBridgePath = join( repoRoot, 'miniprogram', 'host-bridge', 'webView.js', ); type MiniProgramPage = { data: Record; setData: (patch: Record) => void; onLoad: (query?: Record) => Promise; onShareAppMessage: () => Record; onShareTimeline: () => Record; onShow: () => void; consumePayResult: () => void; handleGetPhoneNumber: (event: { detail?: Record; }) => Promise; }; function createWxMock() { return { getAccountInfoSync: vi.fn(() => ({ miniProgram: { envVersion: 'release' }, })), getStorageSync: vi.fn(() => ''), getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })), login: vi.fn(), navigateBack: vi.fn(), removeStorageSync: vi.fn(), request: vi.fn(), showShareMenu: vi.fn(), setStorageSync: vi.fn(), }; } function loadWebViewPage( wxMock: ReturnType, configOverrides: Record = {}, ) { (globalThis as unknown as { wx: ReturnType }).wx = wxMock; const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {}); const shellModule = loadCommonJsModule(shellScriptPath, { '../config': { API_BASE_URL: 'https://www.genarrative.world/', DEV_API_BASE_URL: 'https://dev.genarrative.world/', DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', MINI_PROGRAM_APP_ID: 'wx-test-app', MINI_PROGRAM_ENV: 'release', WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', WEB_VIEW_SOURCE_QUERY: { clientType: 'mini_program', clientRuntime: 'wechat_mini_program', }, ...configOverrides, }, '../host-bridge/webView': webViewBridge, }) as { createWechatWebViewPage: () => Record; }; const pageConfig = shellModule.createWechatWebViewPage(); const page = { ...pageConfig, data: { ...(pageConfig.data as Record) }, setData(patch: Record) { Object.assign(this.data, patch); }, } as MiniProgramPage; return page; } function loadCommonJsModule( filePath: string, requireMap: Record, ) { const source = readFileSync(filePath, 'utf8'); const module = { exports: {} as Record }; const sandbox = { console, getCurrentPages: () => [], module, exports: module.exports, setTimeout(callback: () => void) { callback(); return 1; }, require(requestPath: string) { if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) { return requireMap[requestPath]; } throw new Error(`Unexpected require: ${requestPath}`); }, wx: globalThis.wx, }; vm.runInNewContext(source, sandbox, { filename: filePath }); return module.exports; } describe('mini-program web-view auth page', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}); }); test('默认进入时不预登录,直接打开未登录 web-view', async () => { const wxMock = createWxMock(); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); }); wxMock.request.mockImplementation(({ success }) => { success({ statusCode: 200, data: { token: 'jwt-active-wechat', bindingStatus: 'active', }, }); }); const page = loadWebViewPage(wxMock); await page.onLoad({}); expect(wxMock.login).not.toHaveBeenCalled(); expect(wxMock.request).not.toHaveBeenCalled(); expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(false); expect(page.data.webViewUrl).toBe( 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', ); expect(wxMock.showShareMenu).toHaveBeenCalledWith({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'], }); }); test('默认进入时即便微信新身份待绑手机号,也不弹出绑定手机号页', async () => { const wxMock = createWxMock(); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); }); wxMock.request.mockImplementation(({ success }) => { success({ statusCode: 200, data: { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', }, }); }); const page = loadWebViewPage(wxMock); await page.onLoad({}); expect(wxMock.login).not.toHaveBeenCalled(); expect(wxMock.request).not.toHaveBeenCalled(); expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(false); expect(page.data.webViewUrl).toBe( 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', ); }); test('web-view 页面分享好友和朋友圈都回到小程序 web-view 入口', () => { const wxMock = createWxMock(); const page = loadWebViewPage(wxMock); expect(page.onShareAppMessage()).toEqual({ title: '陶泥儿', path: '/pages/web-view/index', }); expect(page.onShareTimeline()).toEqual({ title: '陶泥儿', query: '', }); }); test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => { const wxMock = createWxMock(); const page = loadWebViewPage(wxMock, { API_BASE_URL: '', }); await page.onLoad({}); expect(wxMock.login).not.toHaveBeenCalled(); expect(wxMock.request).not.toHaveBeenCalled(); expect(page.data.errorMessage).toBe(''); expect(page.data.webViewUrl).toBe( 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', ); }); test('体验版自动切到 dev 子域名并透传 trial 环境', async () => { const wxMock = createWxMock(); wxMock.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'trial' }, }); const page = loadWebViewPage(wxMock); await page.onLoad({}); expect(page.data.webViewUrl).toBe( 'https://dev.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial', ); }); test('开发版自动切到 dev 子域名并把 develop 规整为 dev', async () => { const wxMock = createWxMock(); wxMock.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' }, }); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); }); wxMock.request.mockImplementation(({ success }) => { success({ statusCode: 200, data: { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', }, }); }); const page = loadWebViewPage(wxMock); await page.onLoad({ authAction: 'login', returnTo: 'previous' }); expect(wxMock.request).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://dev.genarrative.world/api/auth/wechat/miniprogram-login', header: expect.objectContaining({ 'x-mini-program-env': 'dev', }), }), ); }); test('onShow 二次检查支付结果并写回 web-view hash', () => { const wxMock = createWxMock(); wxMock.getStorageSync.mockImplementation((key) => key === 'genarrative:wechat-pay-result' ? 'request-1:success:order-1' : '', ); const page = loadWebViewPage(wxMock); page.data.webViewUrl = 'https://www.genarrative.world/?clientType=mini_program#tab=profile'; page.onShow(); expect(wxMock.removeStorageSync).toHaveBeenCalledWith( 'genarrative:wechat-pay-result', ); expect(page.data.webViewUrl).toBe( 'https://www.genarrative.world/?clientType=mini_program#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1', ); }); test('H5 请求登录时才启动微信小程序登录并进入手机号授权态', async () => { const wxMock = createWxMock(); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); }); wxMock.request.mockImplementation(({ success }) => { success({ statusCode: 200, data: { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', user: { displayName: '陶泥玩家', publicUserCode: 'SY-12345678', }, }, }); }); const page = loadWebViewPage(wxMock); await page.onLoad({ authAction: 'login', returnTo: 'previous' }); expect(wxMock.login).toHaveBeenCalledTimes(1); expect(wxMock.request).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login', method: 'POST', data: { code: 'wx-login-code' }, }), ); expect(page.data.webViewUrl).toBe(''); expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(true); }); test('微信登录失败不向页面透出原生错误', async () => { const wxMock = createWxMock(); const loginError = { errMsg: 'login:fail private native detail' }; wxMock.login.mockImplementation(({ fail }) => { fail(loginError); }); const page = loadWebViewPage(wxMock); await page.onLoad({ authAction: 'login', returnTo: 'previous' }); expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。'); expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed'); expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed'); expect(console.error.mock.calls.flat()).not.toContain(loginError); expect(page.data.phoneBindingRequired).toBe(false); }); test('绑定手机号失败不向页面透出后端错误体', async () => { const wxMock = createWxMock(); const page = loadWebViewPage(wxMock); page.data.authResult = { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', }; wxMock.request.mockImplementation(({ success }) => { success({ statusCode: 500, data: { error: { message: 'private backend detail', }, }, }); }); await page.handleGetPhoneNumber({ detail: { code: 'wechat-phone-code', }, }); expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。'); expect(console.error).toHaveBeenCalledWith( '[web-view] mini program bind phone failed', ); expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed'); expect(console.error.mock.calls.flat()).not.toContain('private backend detail'); }); test('拒绝手机号授权不向页面透出微信原生错误', async () => { const wxMock = createWxMock(); const page = loadWebViewPage(wxMock); page.data.authResult = { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', }; const authDeclined = { errMsg: 'getPhoneNumber:fail private native detail', }; await page.handleGetPhoneNumber({ detail: authDeclined, }); expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。'); expect(page.data.errorMessage).not.toContain('private native detail'); expect(console.error).toHaveBeenCalledWith( '[web-view] bind phone auth declined', ); expect(console.error.mock.calls.flat()).not.toContain(authDeclined); }); });