exceptions-list-refresh.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import { test, expect } from '../fixtures/auth';
  2. import type { Page, Request } from '@playwright/test';
  3. /**
  4. * S8-LIST-001 回归:详情操作后返回列表,列表状态应自动刷新且仅触发一次列表查询。
  5. * 不绑定固定单据:A 用例挑"新建"行,B 用例挑"已指派"行,C 用例任取一行。
  6. */
  7. const LIST_URL = '/#/aidop/s8/exceptions';
  8. const LIST_API_RE = /\/api\/aidop\/s8\/exceptions(?:\?|$)/;
  9. const DETAIL_URL_RE = /#\/aidop\/s8\/exceptions\/[\w-]+/;
  10. type Counter = { readonly count: number; dispose: () => void };
  11. function trackListCalls(page: Page): Counter {
  12. let count = 0;
  13. const handler = (req: Request) => {
  14. if (req.method() !== 'GET') return;
  15. const url = req.url();
  16. // 仅统计列表查询,不算 /:id/* 详情子接口
  17. if (LIST_API_RE.test(url) && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(url)) {
  18. count += 1;
  19. }
  20. };
  21. page.on('request', handler);
  22. return {
  23. get count() {
  24. return count;
  25. },
  26. dispose: () => page.off('request', handler),
  27. };
  28. }
  29. async function gotoList(page: Page) {
  30. if (/#\/aidop\/s8\/exceptions(?!\/)/.test(page.url())) {
  31. await page.reload({ waitUntil: 'domcontentloaded' });
  32. } else {
  33. await page.goto(LIST_URL, { waitUntil: 'domcontentloaded' });
  34. }
  35. await page
  36. .waitForResponse((r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET', {
  37. timeout: 30_000,
  38. })
  39. .catch(() => {});
  40. await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
  41. await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
  42. }
  43. async function findRowByStatus(page: Page, status: RegExp) {
  44. const rows = page.locator('table tbody tr');
  45. const n = await rows.count();
  46. for (let i = 0; i < n; i++) {
  47. const text = (await rows.nth(i).innerText()).replace(/\s+/g, ' ');
  48. if (status.test(text)) {
  49. const codeMatch = text.match(/EX-[\w-]+/);
  50. if (!codeMatch) continue;
  51. return { index: i, text, code: codeMatch[0], locator: rows.nth(i) };
  52. }
  53. }
  54. return null;
  55. }
  56. /**
  57. * 通过主动提报 API 种一条"新建"状态的异常,返回单据编号 EX-...。
  58. * 复用 page 上下文的鉴权,避免单独维护 token。
  59. */
  60. async function seedNewException(page: Page): Promise<string | null> {
  61. const baseURL = new URL(page.url()).origin;
  62. // 应用使用 Bearer,token 存在 localStorage["access-token"],外加 Local.ts 自带前缀
  63. const token = await page.evaluate(() => {
  64. for (let i = 0; i < localStorage.length; i++) {
  65. const k = localStorage.key(i)!;
  66. if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
  67. try {
  68. const raw = localStorage.getItem(k)!;
  69. const v = JSON.parse(raw);
  70. return typeof v === 'string' ? v : v?.value ?? raw;
  71. } catch {
  72. return localStorage.getItem(k);
  73. }
  74. }
  75. }
  76. return null;
  77. });
  78. if (!token) return null;
  79. const headers = { Authorization: `Bearer ${token}` };
  80. const optsResp = await page.request.get(
  81. `${baseURL}/api/aidop/s8/reports/form-options?tenantId=1&factoryId=1`,
  82. { headers },
  83. );
  84. if (!optsResp.ok()) return null;
  85. const optsJson = (await optsResp.json()) as any;
  86. const opts = optsJson.data ?? optsJson;
  87. const sceneCode = opts.scenes?.[0]?.value ?? opts.scenes?.[0]?.code;
  88. const deptId = opts.departments?.[0]?.value ?? opts.departments?.[0]?.id;
  89. if (!sceneCode || !deptId) return null;
  90. const createResp = await page.request.post(`${baseURL}/api/aidop/s8/reports`, {
  91. headers,
  92. data: {
  93. tenantId: 1,
  94. factoryId: 1,
  95. title: `E2E 自动种子 ${new Date().toISOString()}`,
  96. description: 'created by playwright for REG-S8-LIST-001-A',
  97. sceneCode,
  98. severity: 'P3',
  99. occurrenceDeptId: deptId,
  100. responsibleDeptId: deptId,
  101. },
  102. });
  103. if (!createResp.ok()) return null;
  104. const created = (await createResp.json()) as any;
  105. const code =
  106. created?.exceptionCode ??
  107. created?.data?.exceptionCode ??
  108. created?.data?.code ??
  109. created?.code ??
  110. null;
  111. return code;
  112. }
  113. async function rowTextByCode(page: Page, code: string): Promise<string> {
  114. const row = page.locator('table tbody tr', { hasText: code }).first();
  115. await expect(row).toBeVisible({ timeout: 10_000 });
  116. return (await row.innerText()).replace(/\s+/g, ' ');
  117. }
  118. async function openDetailOfRow(page: Page, rowIndex: number) {
  119. const row = page.locator('table tbody tr').nth(rowIndex);
  120. const detailWait = page.waitForURL(DETAIL_URL_RE, { timeout: 15_000 });
  121. const link = row.getByRole('link').first();
  122. if (await link.isVisible().catch(() => false)) {
  123. await link.click();
  124. } else {
  125. await row.locator('td').first().click();
  126. }
  127. await detailWait;
  128. await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
  129. }
  130. async function clickAndWait(
  131. page: Page,
  132. name: RegExp,
  133. apiRe: RegExp,
  134. opts: { needAssignee?: boolean } = {},
  135. ) {
  136. const btn = page.getByRole('button', { name }).first();
  137. await expect(btn).toBeEnabled({ timeout: 10_000 });
  138. await btn.click();
  139. // 操作面板按钮会打开 el-dialog,需要在弹窗内填表 + 确定
  140. const dialog = page.locator('.el-dialog:visible').first();
  141. await expect(dialog).toBeVisible({ timeout: 10_000 });
  142. if (opts.needAssignee) {
  143. const select = dialog.locator('.el-select').first();
  144. await select.locator('.el-select__wrapper').click({ force: true });
  145. const option = page
  146. .locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)')
  147. .first();
  148. await expect(option).toBeVisible({ timeout: 10_000 });
  149. await option.click();
  150. }
  151. const respPromise = page.waitForResponse(
  152. (r) => apiRe.test(r.url()) && r.request().method() === 'POST',
  153. { timeout: 15_000 },
  154. );
  155. await dialog.getByRole('button', { name: /确\s*定/ }).click();
  156. const resp = await respPromise;
  157. expect(resp.ok(), `${name} 接口返回非 2xx`).toBeTruthy();
  158. await expect(dialog).toBeHidden({ timeout: 10_000 });
  159. await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
  160. }
  161. async function backToList(page: Page) {
  162. const back = page.getByRole('button', { name: /返\s*回/ }).first();
  163. if (await back.isVisible().catch(() => false)) {
  164. await back.click();
  165. } else {
  166. await page.goBack();
  167. }
  168. await page.waitForURL(/#\/aidop\/s8\/exceptions(?!\/)/, { timeout: 15_000 }).catch(() => {});
  169. await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
  170. }
  171. test.describe.configure({ mode: 'serial' });
  172. test.describe('S8-LIST-001 详情操作返回列表自动刷新', () => {
  173. test('REG-S8-LIST-001-A 认领后返回列表,目标行状态变为已指派', async ({ authedPage }) => {
  174. await gotoList(authedPage);
  175. let target = await findRowByStatus(authedPage, /新建/);
  176. if (!target) {
  177. const seeded = await seedNewException(authedPage);
  178. test.skip(!seeded, '无可用"新建"记录,且通过主动提报 API 种子失败');
  179. await gotoList(authedPage);
  180. target = await findRowByStatus(authedPage, /新建/);
  181. test.skip(!target, '种子已创建但列表未刷出"新建"行');
  182. test.info().annotations.push({ type: 'seeded', description: seeded! });
  183. }
  184. test.info().annotations.push({ type: 'before', description: target!.text });
  185. await openDetailOfRow(authedPage, target!.index);
  186. await clickAndWait(authedPage, /^认\s*领$/, /\/exceptions\/[\w-]+\/claim/, { needAssignee: true });
  187. const counter = trackListCalls(authedPage);
  188. const listResp = authedPage.waitForResponse(
  189. (r) =>
  190. LIST_API_RE.test(r.url()) &&
  191. r.request().method() === 'GET' &&
  192. !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
  193. { timeout: 15_000 },
  194. );
  195. await backToList(authedPage);
  196. await listResp.catch(() => {});
  197. await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
  198. const row = authedPage.locator('table tbody tr', { hasText: target!.code }).first();
  199. await expect(row, '返回列表后该行应在合理时间内变为"已指派"').toContainText(/已指派/, {
  200. timeout: 8_000,
  201. });
  202. const after = (await row.innerText()).replace(/\s+/g, ' ');
  203. counter.dispose();
  204. test.info().annotations.push({ type: 'after', description: after });
  205. test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
  206. expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1);
  207. });
  208. test('REG-S8-LIST-001-B 开始处理后返回列表,目标行状态变为处理中', async ({ authedPage }) => {
  209. await gotoList(authedPage);
  210. const target = await findRowByStatus(authedPage, /已指派/);
  211. test.skip(!target, '当前列表无"已指派"状态记录可供 B 用例使用');
  212. test.info().annotations.push({ type: 'before', description: target!.text });
  213. await openDetailOfRow(authedPage, target!.index);
  214. await clickAndWait(authedPage, /^开始处理$/, /\/exceptions\/[\w-]+\/start-progress/);
  215. const counter = trackListCalls(authedPage);
  216. const listResp = authedPage.waitForResponse(
  217. (r) =>
  218. LIST_API_RE.test(r.url()) &&
  219. r.request().method() === 'GET' &&
  220. !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
  221. { timeout: 15_000 },
  222. );
  223. await backToList(authedPage);
  224. await listResp.catch(() => {});
  225. await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
  226. const after = await rowTextByCode(authedPage, target!.code);
  227. counter.dispose();
  228. test.info().annotations.push({ type: 'after', description: after });
  229. test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
  230. expect(after, '返回列表后行状态应包含"处理中"').toMatch(/处理中/);
  231. expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1);
  232. });
  233. test('REG-S8-LIST-001-C 空操作返回列表不应造成请求风暴', async ({ authedPage }) => {
  234. await gotoList(authedPage);
  235. await openDetailOfRow(authedPage, 0);
  236. const counter = trackListCalls(authedPage);
  237. const listResp = authedPage.waitForResponse(
  238. (r) =>
  239. LIST_API_RE.test(r.url()) &&
  240. r.request().method() === 'GET' &&
  241. !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
  242. { timeout: 15_000 },
  243. );
  244. await backToList(authedPage);
  245. await listResp.catch(() => {});
  246. await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
  247. counter.dispose();
  248. test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
  249. expect(counter.count, '空操作返回列表,列表查询次数应 ≤ 1').toBeLessThanOrEqual(1);
  250. });
  251. });