|
|
@@ -0,0 +1,277 @@
|
|
|
+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);
|
|
|
+ });
|
|
|
+});
|