talent-mini.page.ts 12 KB

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