| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- 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<string | null> {
- 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<string> {
- 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);
- });
- });
|