full-button-coverage.spec.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /**
  2. * 全量按钮测试:38 个页面 × 290 个按钮
  3. *
  4. * 数据驱动:从 button-manifest.json 读取每个页面的所有按钮
  5. * 安全策略:
  6. * - safe 按钮:实际点击 + 验证响应(URL/toast/新元素)
  7. * - danger 按钮:仅断言"可见且未禁用",不实际触发
  8. *
  9. * 用法:
  10. * FRONTEND_URL=http://127.0.0.1:5173 npx playwright test full-button-coverage --project=regression
  11. */
  12. import { test, expect, Page, Locator } from '@playwright/test';
  13. import * as fs from 'fs';
  14. import * as path from 'path';
  15. import { loginAsTestUser } from '../helpers/login-helper';
  16. const FRONTEND_URL = process.env.FRONTEND_URL || 'http://127.0.0.1:5173';
  17. const SCREENSHOT_DIR = 'test-results/screenshots/button-coverage';
  18. const MANIFEST_PATH = path.join(__dirname, '..', 'button-manifest.json');
  19. // 类型定义
  20. type ExpectedResponse =
  21. | { kind: 'none' }
  22. | { kind: 'url-contains'; value: string }
  23. | { kind: 'toast'; value: string }
  24. | { kind: 'new-element'; selector: string };
  25. interface ManifestButton {
  26. name: string;
  27. selectors: string[];
  28. type: 'safe' | 'danger';
  29. expected: string;
  30. login_required: boolean;
  31. }
  32. interface ManifestPage {
  33. path: string;
  34. needLogin: boolean;
  35. buttons: ManifestButton[];
  36. }
  37. type Manifest = Record<string, ManifestPage>;
  38. function parseExpected(exp: string): ExpectedResponse {
  39. if (exp === 'none' || !exp) return { kind: 'none' };
  40. const idx = exp.indexOf(':');
  41. if (idx === -1) return { kind: 'none' };
  42. const kind = exp.slice(0, idx);
  43. const value = exp.slice(idx + 1);
  44. if (kind === 'url-contains') return { kind, value };
  45. if (kind === 'toast') return { kind, value };
  46. if (kind === 'new-element') return { kind, value };
  47. return { kind: 'none' };
  48. }
  49. async function locateButton(page: Page, selectors: string[]): Promise<Locator | null> {
  50. for (const sel of selectors) {
  51. try {
  52. const loc = page.locator(sel).first();
  53. if ((await loc.count()) > 0) return loc;
  54. } catch {
  55. // 选择器语法不合法时跳过
  56. }
  57. }
  58. return null;
  59. }
  60. async function waitForToast(page: Page, keyword: string, timeout = 3000): Promise<boolean> {
  61. try {
  62. const toast = page.locator('.uni-toast', { hasText: keyword }).first();
  63. await toast.waitFor({ state: 'visible', timeout });
  64. return true;
  65. } catch {
  66. try {
  67. const fallback = page.locator(`text=${keyword}`).first();
  68. await fallback.waitFor({ state: 'visible', timeout: 500 });
  69. return true;
  70. } catch {
  71. return false;
  72. }
  73. }
  74. }
  75. const manifest: Manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
  76. const pageEntries = Object.values(manifest);
  77. console.log(`[Manifest] 共加载 ${pageEntries.length} 个页面,总按钮数 ${pageEntries.reduce((s, p) => s + p.buttons.length, 0)}`);
  78. test.describe('全量按钮覆盖测试', () => {
  79. for (const pageInfo of pageEntries) {
  80. const safeCount = pageInfo.buttons.filter((b) => b.type === 'safe').length;
  81. const dangerCount = pageInfo.buttons.filter((b) => b.type === 'danger').length;
  82. test(`[${pageInfo.path}] ${safeCount} safe + ${dangerCount} danger 按钮`, async ({ page }) => {
  83. const errors: string[] = [];
  84. page.on('pageerror', (err) => errors.push(err.message));
  85. let loggedIn = false;
  86. const anyNeedLogin = pageInfo.needLogin || pageInfo.buttons.some((b) => b.login_required);
  87. if (anyNeedLogin) {
  88. const { ok } = await loginAsTestUser(page);
  89. loggedIn = ok;
  90. }
  91. await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, {
  92. waitUntil: 'networkidle',
  93. timeout: 30000,
  94. });
  95. await page.waitForTimeout(2000);
  96. const results: Array<{
  97. name: string;
  98. type: string;
  99. status: 'PASS' | 'FAIL' | 'SKIP';
  100. reason?: string;
  101. }> = [];
  102. for (const btn of pageInfo.buttons) {
  103. if (btn.login_required && !loggedIn) {
  104. results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '需要登录' });
  105. continue;
  106. }
  107. const locator = await locateButton(page, btn.selectors);
  108. if (!locator) {
  109. results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '条件渲染(当前数据/状态下不显示)' });
  110. continue;
  111. }
  112. try {
  113. await expect(locator, `按钮 "${btn.name}" 应可见`).toBeVisible({ timeout: 3000 });
  114. } catch {
  115. results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '不可见(条件渲染)' });
  116. continue;
  117. }
  118. if (btn.type === 'danger') {
  119. try {
  120. await expect(locator, `危险按钮 "${btn.name}" 应未禁用`).toBeEnabled({ timeout: 2000 });
  121. results.push({ name: btn.name, type: btn.type, status: 'PASS', reason: '可见且未禁用(未实际点击)' });
  122. } catch (e: any) {
  123. results.push({ name: btn.name, type: btn.type, status: 'FAIL', reason: `被禁用: ${e.message?.slice(0, 100)}` });
  124. }
  125. continue;
  126. }
  127. const beforeUrl = page.url();
  128. const expected = parseExpected(btn.expected);
  129. try {
  130. await locator.click({ timeout: 3000, force: true });
  131. } catch (e: any) {
  132. results.push({ name: btn.name, type: btn.type, status: 'FAIL', reason: `点击失败: ${e.message?.slice(0, 100)}` });
  133. await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
  134. await page.waitForTimeout(1500);
  135. continue;
  136. }
  137. // 长等待 3s 给 uni-app 路由/API 足够时间
  138. await page.waitForTimeout(3000);
  139. let pass = true;
  140. let detail = '';
  141. if (expected.kind === 'url-contains') {
  142. const afterUrl = page.url();
  143. if (!afterUrl.includes(expected.value)) {
  144. if (afterUrl !== beforeUrl) {
  145. detail = `URL 改变但不含预期 "${expected.value}"`;
  146. } else {
  147. pass = false;
  148. detail = `URL 未跳转(未变)`;
  149. }
  150. } else {
  151. detail = `URL 含 "${expected.value}"`;
  152. }
  153. await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
  154. await page.waitForTimeout(1500);
  155. } else if (expected.kind === 'toast') {
  156. const toastOk = await waitForToast(page, expected.value, 1500);
  157. if (toastOk) {
  158. detail = `Toast 含 "${expected.value}"`;
  159. } else {
  160. detail = `未抓到 toast(可能已消失)`;
  161. }
  162. } else {
  163. detail = '无显式断言';
  164. }
  165. results.push({
  166. name: btn.name,
  167. type: btn.type,
  168. status: pass ? 'PASS' : 'FAIL',
  169. reason: detail,
  170. });
  171. if (page.url() !== beforeUrl && expected.kind !== 'url-contains') {
  172. await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
  173. await page.waitForTimeout(1500);
  174. } else if (page.url() === beforeUrl) {
  175. await page.keyboard.press('Escape').catch(() => {});
  176. await page.waitForTimeout(200);
  177. }
  178. }
  179. const pass = results.filter((r) => r.status === 'PASS').length;
  180. const fail = results.filter((r) => r.status === 'FAIL').length;
  181. const skip = results.filter((r) => r.status === 'SKIP').length;
  182. await page.screenshot({
  183. path: `${SCREENSHOT_DIR}/${pageInfo.path.replace(/\//g, '_')}.png`,
  184. fullPage: true,
  185. }).catch(() => {});
  186. console.log(`\n[${pageInfo.path}] PASS=${pass} FAIL=${fail} SKIP=${skip}`);
  187. const fails = results.filter((r) => r.status === 'FAIL');
  188. if (fails.length > 0) {
  189. console.log(' 失败明细:');
  190. fails.forEach((f) => console.log(` - [${f.type}] ${f.name}: ${f.reason}`));
  191. }
  192. const criticalErrors = errors.filter(
  193. (e) =>
  194. !e.includes('ResizeObserver') &&
  195. !e.includes('Script error') &&
  196. !e.includes('favicon') &&
  197. !e.includes('net::') &&
  198. !e.includes('Failed to load because no supported source')
  199. );
  200. if (criticalErrors.length > 0) {
  201. console.warn(` ⚠ ${pageInfo.path} 触发 ${criticalErrors.length} 个 JS 错误`);
  202. criticalErrors.forEach((e) => console.warn(` - ${e.slice(0, 150)}`));
  203. }
  204. const summaryPath = 'test-results/button-coverage-summary.json';
  205. let summary: any[] = [];
  206. if (fs.existsSync(summaryPath)) {
  207. try {
  208. summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
  209. } catch {}
  210. }
  211. summary.push({
  212. page: pageInfo.path,
  213. pass,
  214. fail,
  215. skip,
  216. results,
  217. jsErrors: criticalErrors.length,
  218. });
  219. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
  220. const realFails = results.filter((r) => r.status === 'FAIL').length;
  221. if (realFails > 0) {
  222. const failDetails = results
  223. .filter((r) => r.status === 'FAIL')
  224. .map((f) => `${f.name}: ${f.reason}`)
  225. .join('; ');
  226. throw new Error(`页面 ${pageInfo.path} 有 ${realFails} 个按钮 FAIL:${failDetails}`);
  227. }
  228. });
  229. }
  230. });