enterprise-mini.page.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import { TIMEOUTS } from '../../utils/timeouts';
  2. import { Page, Locator, expect } from '@playwright/test';
  3. /**
  4. * 企业小程序 H5 URL
  5. */
  6. const MINI_BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8080';
  7. const MINI_LOGIN_URL = `${MINI_BASE_URL}/mini`;
  8. /**
  9. * 企业小程序 Page Object
  10. *
  11. * 用于企业小程序 E2E 测试
  12. * H5 页面路径: /mini
  13. *
  14. * 主要功能:
  15. * - 小程序登录(手机号 + 密码)
  16. * - Token 管理
  17. * - 页面导航和验证
  18. *
  19. * @example
  20. * ```typescript
  21. * const miniPage = new EnterpriseMiniPage(page);
  22. * await miniPage.goto();
  23. * await miniPage.login('13800138000', 'password123');
  24. * await miniPage.expectLoginSuccess();
  25. * ```
  26. */
  27. export class EnterpriseMiniPage {
  28. readonly page: Page;
  29. // ===== 页面级选择器 =====
  30. /** 登录页面容器 */
  31. readonly loginPage: Locator;
  32. /** 页面标题 */
  33. readonly pageTitle: Locator;
  34. // ===== 登录表单选择器 =====
  35. /** 手机号输入框 */
  36. readonly phoneInput: Locator;
  37. /** 密码输入框 */
  38. readonly passwordInput: Locator;
  39. /** 登录按钮 */
  40. readonly loginButton: Locator;
  41. // ===== 主页选择器(登录后) =====
  42. /** 用户信息显示区域 */
  43. readonly userInfo: Locator;
  44. /** 退出登录按钮 */
  45. readonly logoutButton: Locator;
  46. constructor(page: Page) {
  47. this.page = page;
  48. // 初始化登录页面选择器
  49. // Taro 组件在 H5 渲染时会传递 data-testid 到 DOM (使用 taro-view-core 等组件)
  50. this.loginPage = page.getByTestId('mini-login-page');
  51. this.pageTitle = page.getByTestId('mini-page-title');
  52. // 登录表单选择器 - 使用 data-testid
  53. this.phoneInput = page.getByTestId('mini-phone-input');
  54. this.passwordInput = page.getByTestId('mini-password-input');
  55. this.loginButton = page.getByTestId('mini-login-button');
  56. // 主页选择器(登录后可用)
  57. this.userInfo = page.getByTestId('mini-user-info');
  58. // 退出登录按钮 - 使用 getByText 而非 getByRole
  59. this.logoutButton = page.getByText('退出登录');
  60. }
  61. // ===== 导航和基础验证 =====
  62. /**
  63. * 移除开发服务器的覆盖层 iframe(防止干扰测试)
  64. */
  65. private async removeDevOverlays(): Promise<void> {
  66. await this.page.evaluate(() => {
  67. // 移除 react-refresh-overlay 和 webpack-dev-server-client-overlay
  68. const overlays = document.querySelectorAll('#react-refresh-overlay, #webpack-dev-server-client-overlay');
  69. overlays.forEach(overlay => overlay.remove());
  70. // 移除 vConsole 开发者工具覆盖层
  71. const vConsole = document.querySelector('#__vconsole');
  72. if (vConsole) {
  73. vConsole.remove();
  74. }
  75. });
  76. }
  77. /**
  78. * 导航到企业小程序 H5 登录页面
  79. */
  80. async goto(): Promise<void> {
  81. await this.page.goto(MINI_LOGIN_URL);
  82. // 移除开发服务器的覆盖层
  83. await this.removeDevOverlays();
  84. // 使用 auto-waiting 机制,等待页面容器可见
  85. await this.expectToBeVisible();
  86. }
  87. /**
  88. * 验证登录页面关键元素可见
  89. */
  90. async expectToBeVisible(): Promise<void> {
  91. // 等待页面容器可见
  92. await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  93. // 验证页面标题
  94. await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  95. }
  96. // ===== 登录功能方法 =====
  97. /**
  98. * 填写手机号
  99. * @param phone 手机号(11位数字)
  100. *
  101. * 注意:使用 fill() 方法并添加验证步骤确保密码输入完整
  102. * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
  103. */
  104. async fillPhone(phone: string): Promise<void> {
  105. // 先移除覆盖层,确保输入可操作
  106. await this.removeDevOverlays();
  107. // 点击聚焦,然后清空(使用 Ctrl+A + Backspace 模拟用户操作)
  108. await this.phoneInput.click();
  109. // 等待元素聚焦
  110. await this.page.waitForTimeout(100);
  111. // 使用 type 方法输入,会自动覆盖现有内容
  112. await this.phoneInput.type(phone, { delay: 50 });
  113. // 等待表单验证更新
  114. await this.page.waitForTimeout(200);
  115. }
  116. /**
  117. * 填写密码
  118. * @param password 密码(6-20位)
  119. *
  120. * 注意:使用 click + type 方法触发自然的用户输入事件
  121. * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
  122. */
  123. async fillPassword(password: string): Promise<void> {
  124. await this.removeDevOverlays();
  125. await this.passwordInput.click();
  126. await this.page.waitForTimeout(100);
  127. // 使用 fill 方法一次性填充
  128. await this.passwordInput.fill(password);
  129. await this.page.waitForTimeout(300);
  130. // 验证输入值
  131. const actualValue = await this.passwordInput.inputValue();
  132. if (actualValue !== password) {
  133. // 使用 JS 直接设置
  134. await this.passwordInput.evaluate((el, val) => {
  135. const input = el as HTMLInputElement;
  136. input.value = val;
  137. input.dispatchEvent(new Event('input', { bubbles: true }));
  138. input.dispatchEvent(new Event('change', { bubbles: true }));
  139. }, password);
  140. await this.page.waitForTimeout(200);
  141. }
  142. }
  143. /**
  144. * 点击登录按钮
  145. */
  146. async clickLoginButton(): Promise<void> {
  147. // 使用 force: true 避免被开发服务器的覆盖层阻止
  148. await this.loginButton.click({ force: true });
  149. }
  150. /**
  151. * 执行登录操作(完整流程)
  152. * @param phone 手机号
  153. * @param password 密码
  154. */
  155. async login(phone: string, password: string): Promise<void> {
  156. await this.fillPhone(phone);
  157. await this.fillPassword(password);
  158. await this.clickLoginButton();
  159. }
  160. /**
  161. * 验证登录成功
  162. *
  163. * 登录成功后应该跳转到主页或显示用户信息
  164. */
  165. async expectLoginSuccess(): Promise<void> {
  166. // 使用 auto-waiting 机制,等待 URL 变化或用户信息显示
  167. // 小程序登录成功后会跳转到 dashboard 页面
  168. // 等待 URL 变化,使用 Promise.race 实现超时
  169. const urlChanged = await this.page.waitForURL(
  170. url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
  171. { timeout: TIMEOUTS.PAGE_LOAD }
  172. ).then(() => true).catch(() => false);
  173. // 如果 URL 没有变化,检查 token 是否被存储
  174. if (!urlChanged) {
  175. const token = await this.getToken();
  176. if (!token) {
  177. throw new Error('登录失败:URL 未跳转且 token 未存储');
  178. }
  179. }
  180. }
  181. /**
  182. * 验证登录失败(错误提示显示)
  183. * @param expectedErrorMessage 预期的错误消息(可选)
  184. */
  185. async expectLoginError(expectedErrorMessage?: string): Promise<void> {
  186. // 等待一下,让后端响应或前端验证生效
  187. await this.page.waitForTimeout(1000);
  188. // 验证仍然在登录页面(未跳转)
  189. const currentUrl = this.page.url();
  190. expect(currentUrl).toContain('/mini');
  191. // 验证登录页面容器仍然可见
  192. await expect(this.loginPage).toBeVisible();
  193. // 如果提供了预期的错误消息,尝试验证
  194. if (expectedErrorMessage) {
  195. // 尝试查找错误消息(可能在 Toast、Modal 或表单验证中)
  196. const errorElement = this.page.getByText(expectedErrorMessage, { exact: false }).first();
  197. await errorElement.isVisible().catch(() => false);
  198. // 不强制要求错误消息可见,因为后端可能不会返回错误
  199. }
  200. }
  201. // ===== Token 管理方法 =====
  202. /**
  203. * 获取当前存储的 token
  204. * @returns token 字符串,如果不存在则返回 null
  205. *
  206. * 注意:Taro.getStorageSync 在 H5 环境下映射到 localStorage
  207. * Taro.setStorageSync 会将数据包装为 JSON 格式:{"data":"VALUE"}
  208. * 因此需要解析 JSON 并提取 data 字段
  209. *
  210. * Taro H5 可能使用以下键名格式:
  211. * - 直接键名: 'enterprise_token'
  212. * - 带前缀: 'taro_app_storage_key'
  213. * - 或者其他变体
  214. */
  215. async getToken(): Promise<string | null> {
  216. const result = await this.page.evaluate(() => {
  217. // 尝试各种可能的键名
  218. // 1. 直接键名 - Taro 的 setStorageSync 将数据包装为 {"data":"VALUE"}
  219. const token = localStorage.getItem('enterprise_token');
  220. if (token) {
  221. try {
  222. // Taro 格式: {"data":"JWT_TOKEN"}
  223. const parsed = JSON.parse(token);
  224. if (parsed.data) {
  225. return parsed.data;
  226. }
  227. return token;
  228. } catch {
  229. return token;
  230. }
  231. }
  232. // 2. 获取所有 localStorage 键,查找可能的 token
  233. const keys = Object.keys(localStorage);
  234. const prefixedKeys = keys.filter(k => k.includes('token') || k.includes('auth'));
  235. for (const key of prefixedKeys) {
  236. const value = localStorage.getItem(key);
  237. if (value) {
  238. try {
  239. // 尝试解析 Taro 格式
  240. const parsed = JSON.parse(value);
  241. if (parsed.data && parsed.data.length > 20) { // JWT token 通常很长
  242. return parsed.data;
  243. }
  244. } catch {
  245. // 不是 JSON 格式,直接使用
  246. if (value.length > 20) {
  247. return value;
  248. }
  249. }
  250. }
  251. }
  252. // 3. 其他常见键名
  253. const otherTokens = [
  254. localStorage.getItem('token'),
  255. localStorage.getItem('auth_token'),
  256. sessionStorage.getItem('token'),
  257. sessionStorage.getItem('auth_token')
  258. ].filter(Boolean);
  259. for (const t of otherTokens) {
  260. if (t) {
  261. try {
  262. const parsed = JSON.parse(t);
  263. if (parsed.data) return parsed.data;
  264. } catch {
  265. if (t.length > 20) return t;
  266. }
  267. }
  268. }
  269. return null;
  270. });
  271. return result;
  272. }
  273. /**
  274. * 设置 token(用于测试前置条件)
  275. * @param token token 字符串
  276. */
  277. async setToken(token: string): Promise<void> {
  278. await this.page.evaluate((t) => {
  279. localStorage.setItem('token', t);
  280. localStorage.setItem('auth_token', t);
  281. }, token);
  282. }
  283. /**
  284. * 清除所有认证相关的存储
  285. */
  286. async clearAuth(): Promise<void> {
  287. await this.page.evaluate(() => {
  288. // 清除企业小程序相关的认证数据
  289. localStorage.removeItem('enterprise_token');
  290. localStorage.removeItem('enterpriseUserInfo');
  291. // 清除其他常见 token 键
  292. localStorage.removeItem('token');
  293. localStorage.removeItem('auth_token');
  294. sessionStorage.removeItem('token');
  295. sessionStorage.removeItem('auth_token');
  296. });
  297. }
  298. // ===== 主页元素验证方法 =====
  299. /**
  300. * 验证主页元素可见(登录后)
  301. * 根据实际小程序主页结构调整
  302. */
  303. async expectHomePageVisible(): Promise<void> {
  304. // 使用 auto-waiting 机制,等待主页元素可见
  305. // 注意:此方法将在 Story 12.5 E2E 测试中使用,当前仅提供基础结构
  306. // 根据实际小程序主页的 data-testid 调整
  307. const dashboard = this.page.getByTestId('mini-dashboard');
  308. await dashboard.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  309. }
  310. /**
  311. * 获取用户信息显示的文本
  312. * @returns 用户信息文本
  313. */
  314. async getUserInfoText(): Promise<string | null> {
  315. const userInfo = this.userInfo;
  316. const count = await userInfo.count();
  317. if (count === 0) {
  318. return null;
  319. }
  320. return await userInfo.textContent();
  321. }
  322. // ===== 退出登录方法 =====
  323. /**
  324. * 退出登录
  325. */
  326. async logout(): Promise<void> {
  327. // 点击退出登录按钮
  328. await this.logoutButton.click();
  329. // 等待页面加载完成
  330. await this.page.waitForLoadState('domcontentloaded');
  331. }
  332. /**
  333. * 验证已退出登录(返回登录页面)
  334. */
  335. async expectLoggedOut(): Promise<void> {
  336. // 验证返回到登录页面
  337. await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  338. }
  339. }