import { test, expect } from '../fixtures/auth'; import type { Page, Request } from '@playwright/test'; /** * S8-LIST-001 回归:详情操作后返回列表,列表状态应自动刷新且仅触发一次列表查询。 * 不绑定固定单据:A 用例挑"新建"行,B 用例挑"已指派"行,C 用例任取一行。 */ const LIST_URL = '/#/aidop/s8/exceptions'; const LIST_API_RE = /\/api\/aidop\/s8\/exceptions(?:\?|$)/; const DETAIL_URL_RE = /#\/aidop\/s8\/exceptions\/[\w-]+/; type Counter = { readonly count: number; dispose: () => void }; function trackListCalls(page: Page): Counter { let count = 0; const handler = (req: Request) => { if (req.method() !== 'GET') return; const url = req.url(); // 仅统计列表查询,不算 /:id/* 详情子接口 if (LIST_API_RE.test(url) && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(url)) { count += 1; } }; page.on('request', handler); return { get count() { return count; }, dispose: () => page.off('request', handler), }; } async function gotoList(page: Page) { if (/#\/aidop\/s8\/exceptions(?!\/)/.test(page.url())) { await page.reload({ waitUntil: 'domcontentloaded' }); } else { await page.goto(LIST_URL, { waitUntil: 'domcontentloaded' }); } await page .waitForResponse((r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET', { timeout: 30_000, }) .catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 }); } async function findRowByStatus(page: Page, status: RegExp) { const rows = page.locator('table tbody tr'); const n = await rows.count(); for (let i = 0; i < n; i++) { const text = (await rows.nth(i).innerText()).replace(/\s+/g, ' '); if (status.test(text)) { const codeMatch = text.match(/EX-[\w-]+/); if (!codeMatch) continue; return { index: i, text, code: codeMatch[0], locator: rows.nth(i) }; } } return null; } /** * 通过主动提报 API 种一条"新建"状态的异常,返回单据编号 EX-...。 * 复用 page 上下文的鉴权,避免单独维护 token。 */ async function seedNewException(page: Page): Promise { const baseURL = new URL(page.url()).origin; // 应用使用 Bearer,token 存在 localStorage["access-token"],外加 Local.ts 自带前缀 const token = await page.evaluate(() => { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i)!; if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) { try { const raw = localStorage.getItem(k)!; const v = JSON.parse(raw); return typeof v === 'string' ? v : v?.value ?? raw; } catch { return localStorage.getItem(k); } } } return null; }); if (!token) return null; const headers = { Authorization: `Bearer ${token}` }; const optsResp = await page.request.get( `${baseURL}/api/aidop/s8/reports/form-options?tenantId=1&factoryId=1`, { headers }, ); if (!optsResp.ok()) return null; const optsJson = (await optsResp.json()) as any; const opts = optsJson.data ?? optsJson; const sceneCode = opts.scenes?.[0]?.value ?? opts.scenes?.[0]?.code; const deptId = opts.departments?.[0]?.value ?? opts.departments?.[0]?.id; if (!sceneCode || !deptId) return null; const createResp = await page.request.post(`${baseURL}/api/aidop/s8/reports`, { headers, data: { tenantId: 1, factoryId: 1, title: `E2E 自动种子 ${new Date().toISOString()}`, description: 'created by playwright for REG-S8-LIST-001-A', sceneCode, severity: 'P3', occurrenceDeptId: deptId, responsibleDeptId: deptId, }, }); if (!createResp.ok()) return null; const created = (await createResp.json()) as any; const code = created?.exceptionCode ?? created?.data?.exceptionCode ?? created?.data?.code ?? created?.code ?? null; return code; } async function rowTextByCode(page: Page, code: string): Promise { const row = page.locator('table tbody tr', { hasText: code }).first(); await expect(row).toBeVisible({ timeout: 10_000 }); return (await row.innerText()).replace(/\s+/g, ' '); } async function openDetailOfRow(page: Page, rowIndex: number) { const row = page.locator('table tbody tr').nth(rowIndex); const detailWait = page.waitForURL(DETAIL_URL_RE, { timeout: 15_000 }); const link = row.getByRole('link').first(); if (await link.isVisible().catch(() => false)) { await link.click(); } else { await row.locator('td').first().click(); } await detailWait; await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); } async function clickAndWait( page: Page, name: RegExp, apiRe: RegExp, opts: { needAssignee?: boolean } = {}, ) { const btn = page.getByRole('button', { name }).first(); await expect(btn).toBeEnabled({ timeout: 10_000 }); await btn.click(); // 操作面板按钮会打开 el-dialog,需要在弹窗内填表 + 确定 const dialog = page.locator('.el-dialog:visible').first(); await expect(dialog).toBeVisible({ timeout: 10_000 }); if (opts.needAssignee) { const select = dialog.locator('.el-select').first(); await select.locator('.el-select__wrapper').click({ force: true }); const option = page .locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)') .first(); await expect(option).toBeVisible({ timeout: 10_000 }); await option.click(); } const respPromise = page.waitForResponse( (r) => apiRe.test(r.url()) && r.request().method() === 'POST', { timeout: 15_000 }, ); await dialog.getByRole('button', { name: /确\s*定/ }).click(); const resp = await respPromise; expect(resp.ok(), `${name} 接口返回非 2xx`).toBeTruthy(); await expect(dialog).toBeHidden({ timeout: 10_000 }); await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); } async function backToList(page: Page) { const back = page.getByRole('button', { name: /返\s*回/ }).first(); if (await back.isVisible().catch(() => false)) { await back.click(); } else { await page.goBack(); } await page.waitForURL(/#\/aidop\/s8\/exceptions(?!\/)/, { timeout: 15_000 }).catch(() => {}); await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 }); } test.describe.configure({ mode: 'serial' }); test.describe('S8-LIST-001 详情操作返回列表自动刷新', () => { test('REG-S8-LIST-001-A 认领后返回列表,目标行状态变为已指派', async ({ authedPage }) => { await gotoList(authedPage); let target = await findRowByStatus(authedPage, /新建/); if (!target) { const seeded = await seedNewException(authedPage); test.skip(!seeded, '无可用"新建"记录,且通过主动提报 API 种子失败'); await gotoList(authedPage); target = await findRowByStatus(authedPage, /新建/); test.skip(!target, '种子已创建但列表未刷出"新建"行'); test.info().annotations.push({ type: 'seeded', description: seeded! }); } test.info().annotations.push({ type: 'before', description: target!.text }); await openDetailOfRow(authedPage, target!.index); await clickAndWait(authedPage, /^认\s*领$/, /\/exceptions\/[\w-]+\/claim/, { needAssignee: true }); const counter = trackListCalls(authedPage); const listResp = authedPage.waitForResponse( (r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET' && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()), { timeout: 15_000 }, ); await backToList(authedPage); await listResp.catch(() => {}); await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {}); const row = authedPage.locator('table tbody tr', { hasText: target!.code }).first(); await expect(row, '返回列表后该行应在合理时间内变为"已指派"').toContainText(/已指派/, { timeout: 8_000, }); const after = (await row.innerText()).replace(/\s+/g, ' '); counter.dispose(); test.info().annotations.push({ type: 'after', description: after }); test.info().annotations.push({ type: 'list-calls', description: String(counter.count) }); expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1); }); test('REG-S8-LIST-001-B 开始处理后返回列表,目标行状态变为处理中', async ({ authedPage }) => { await gotoList(authedPage); const target = await findRowByStatus(authedPage, /已指派/); test.skip(!target, '当前列表无"已指派"状态记录可供 B 用例使用'); test.info().annotations.push({ type: 'before', description: target!.text }); await openDetailOfRow(authedPage, target!.index); await clickAndWait(authedPage, /^开始处理$/, /\/exceptions\/[\w-]+\/start-progress/); const counter = trackListCalls(authedPage); const listResp = authedPage.waitForResponse( (r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET' && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()), { timeout: 15_000 }, ); await backToList(authedPage); await listResp.catch(() => {}); await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {}); const after = await rowTextByCode(authedPage, target!.code); counter.dispose(); test.info().annotations.push({ type: 'after', description: after }); test.info().annotations.push({ type: 'list-calls', description: String(counter.count) }); expect(after, '返回列表后行状态应包含"处理中"').toMatch(/处理中/); expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1); }); test('REG-S8-LIST-001-C 空操作返回列表不应造成请求风暴', async ({ authedPage }) => { await gotoList(authedPage); await openDetailOfRow(authedPage, 0); const counter = trackListCalls(authedPage); const listResp = authedPage.waitForResponse( (r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET' && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()), { timeout: 15_000 }, ); await backToList(authedPage); await listResp.catch(() => {}); await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {}); counter.dispose(); test.info().annotations.push({ type: 'list-calls', description: String(counter.count) }); expect(counter.count, '空操作返回列表,列表查询次数应 ≤ 1').toBeLessThanOrEqual(1); }); });