Просмотр исходного кода

test(s0): A2 DELETE 引用检查 E2E 回归脚本

引入 Playwright E2E 基础设施 + A2 回归脚本:
- playwright.config.ts / global-setup.ts / fixtures/auth.ts (storageState + authedPage fixture)
- smoke.spec.ts 验证登录态可达
- s0/a2-delete-refcheck.spec.ts 实测 Department / Location DELETE 在存在下游引用时返回 409
- package.json 增加 @playwright/test 与 vitest 依赖
- .gitignore 排除 test-results / playwright-report / .auth 缓存
YY968XX 1 месяц назад
Родитель
Сommit
d8cb07e0c6

+ 6 - 1
Web/.gitignore

@@ -23,4 +23,9 @@ pnpm-debug.log*
 *.sw?
 
 pnpm-lock.yaml
-package-lock.json
+package-lock.json
+
+# Playwright
+/test-results/
+/playwright-report/
+/tests/e2e/.auth/

+ 4 - 3
Web/package.json

@@ -87,6 +87,7 @@
 	"devDependencies": {
 		"@eslint/eslintrc": "^3.3.5",
 		"@eslint/js": "^10.0.1",
+		"@playwright/test": "^1.59.1",
 		"@plugin-web-update-notification/vite": "^2.0.2",
 		"@rollup/pluginutils": "^5.3.0",
 		"@types/lodash-es": "^4.17.12",
@@ -106,6 +107,7 @@
 		"eslint": "^10.0.3",
 		"eslint-plugin-vue": "^10.8.0",
 		"globals": "^17.4.0",
+		"jsdom": "^26.1.0",
 		"less": "^4.6.4",
 		"prettier": "^3.8.1",
 		"rollup-plugin-visualizer": "^7.0.1",
@@ -117,10 +119,9 @@
 		"vite-plugin-cdn-import": "^1.0.1",
 		"vite-plugin-compression2": "^2.5.1",
 		"vite-plugin-vue-setup-extend": "^0.4.0",
+		"vitest": "^3.2.4",
 		"vue-eslint-parser": "^10.4.0",
-		"vue-tsc": "^3.2.6",
-		"jsdom": "^26.1.0",
-		"vitest": "^3.2.4"
+		"vue-tsc": "^3.2.6"
 	},
 	"pnpm": {
 		"onlyBuiltDependencies": [

+ 33 - 0
Web/playwright.config.ts

@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const BASE_URL = process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888';
+const STORAGE_STATE = path.resolve(__dirname, 'tests/e2e/.auth/storage-state.json');
+
+export default defineConfig({
+  testDir: './tests/e2e',
+  testIgnore: ['**/.auth/**', '**/fixtures/**'],
+  timeout: 120_000,
+  expect: { timeout: 15_000 },
+  fullyParallel: false,
+  workers: 1,
+  reporter: [['list']],
+  globalSetup: './tests/e2e/global-setup.ts',
+  use: {
+    baseURL: BASE_URL,
+    storageState: STORAGE_STATE,
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+    video: 'retain-on-failure',
+    navigationTimeout: 60_000,
+    actionTimeout: 15_000,
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+});

+ 126 - 0
Web/tests/e2e/fixtures/auth.ts

@@ -0,0 +1,126 @@
+import { test as base, expect, type Page } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export interface AuthCreds {
+  account: string;
+  password: string;
+}
+
+export const DEFAULT_CREDS: AuthCreds = {
+  account: process.env.AIDOP_E2E_ACCOUNT ?? 'superAdmin.NET',
+  password: process.env.AIDOP_E2E_PASSWORD ?? '1234567890dop',
+};
+
+export const STORAGE_STATE_PATH = path.resolve(
+  __dirname,
+  '../.auth/storage-state.json',
+);
+
+async function selectFirstTenantIfPresent(page: Page): Promise<void> {
+  const tenantSelect = page.locator('.login-content-form .el-select').first();
+  const visible = await tenantSelect.isVisible().catch(() => false);
+  if (!visible) return;
+  const wrapper = tenantSelect.locator('.el-select__wrapper').first();
+  await 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: 15000 });
+  await option.click();
+}
+
+/**
+ * Drive the login page end-to-end. Leaves `page` on /#/dashboard/home with a valid session.
+ * Does NOT navigate further — callers decide where to go next.
+ */
+export async function performLogin(page: Page, creds: AuthCreds = DEFAULT_CREDS): Promise<void> {
+  await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => {});
+
+  const loginButton = page.getByRole('button', { name: '登 录' });
+  await expect(loginButton).toBeVisible({ timeout: 30000 });
+
+  await page.getByPlaceholder('请输入账号').fill(creds.account);
+  await page.getByPlaceholder('请输入密码').fill(creds.password);
+  await selectFirstTenantIfPresent(page);
+
+  const loginRespPromise = page
+    .waitForResponse(
+      (r) => r.url().includes('/api/sysAuth/login') && r.request().method() === 'POST',
+      { timeout: 30000 },
+    )
+    .catch(() => null);
+
+  await loginButton.click();
+
+  const rotateVerifyVisible = await page
+    .locator('.el-dialog')
+    .filter({ hasText: '请按住滑块拖动' })
+    .isVisible()
+    .catch(() => false);
+  if (rotateVerifyVisible) {
+    throw new Error('登录触发了旋转验证,fixture 未处理该交互');
+  }
+
+  const loginResp = await loginRespPromise;
+  if (!loginResp) {
+    throw new Error('点击登录后 30s 内未捕获到 /api/sysAuth/login');
+  }
+  if (loginResp.status() !== 200) {
+    throw new Error(`登录失败,HTTP ${loginResp.status()}: ${await loginResp.text()}`);
+  }
+
+  await page.waitForURL(/#\/(dashboard|)/, { timeout: 30000 }).catch(() => {});
+  await page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => {});
+}
+
+/**
+ * Ensure storageState file exists. If not, drive a real browser login once and persist it.
+ * Called by global-setup; tests themselves just consume `authedPage`.
+ */
+export async function ensureStorageState(
+  browserFactory: () => Promise<import('@playwright/test').Browser>,
+): Promise<string> {
+  if (fs.existsSync(STORAGE_STATE_PATH)) {
+    return STORAGE_STATE_PATH;
+  }
+  fs.mkdirSync(path.dirname(STORAGE_STATE_PATH), { recursive: true });
+  const browser = await browserFactory();
+  try {
+    const ctx = await browser.newContext({ baseURL: 'http://localhost:8888' });
+    const page = await ctx.newPage();
+    await performLogin(page);
+    await ctx.storageState({ path: STORAGE_STATE_PATH });
+    await ctx.close();
+  } finally {
+    await browser.close();
+  }
+  return STORAGE_STATE_PATH;
+}
+
+type AuthFixtures = {
+  authedPage: Page;
+};
+
+/**
+ * `test` extended with an `authedPage` fixture. `authedPage` is a page that has been
+ * navigated to '/' under the shared storageState — the session has already been hydrated.
+ */
+export const test = base.extend<AuthFixtures>({
+  authedPage: async ({ page }, use) => {
+    // storageState is applied at project level via playwright.config.ts.
+    await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60000 });
+    await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
+    // If storageState expired and we land on /#/login, re-login inline to self-heal.
+    if (/\/#\/login/.test(page.url())) {
+      await performLogin(page);
+    }
+    await use(page);
+  },
+});
+
+export { expect };

+ 6 - 0
Web/tests/e2e/global-setup.ts

@@ -0,0 +1,6 @@
+import { chromium } from '@playwright/test';
+import { ensureStorageState } from './fixtures/auth';
+
+export default async function globalSetup(): Promise<void> {
+  await ensureStorageState(() => chromium.launch());
+}

+ 121 - 0
Web/tests/e2e/s0/a2-delete-refcheck.spec.ts

@@ -0,0 +1,121 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page } from '@playwright/test';
+
+/**
+ * A2 实测:S0 四类主数据 DELETE 是否在存在下游引用时拒绝。
+ * 不修改任何数据:每条用例先用 list 找到一条"应当被引用"的记录,仅尝试 DELETE,
+ * 期望 4xx + 业务错误码;若意外返回 2xx,则真的存在 BUG。
+ */
+
+async function token(page: Page) {
+  return 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)) {
+        const raw = localStorage.getItem(k)!;
+        try {
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return raw;
+        }
+      }
+    }
+    return null;
+  });
+}
+async function get(page: Page, url: string) {
+  const t = await token(page);
+  const r = await page.request.get(`${new URL(page.url()).origin}${url}`, {
+    headers: { Authorization: `Bearer ${t!}` },
+  });
+  return { status: r.status(), json: r.ok() ? ((await r.json()) as any) : null };
+}
+async function del(page: Page, url: string) {
+  const t = await token(page);
+  const r = await page.request.delete(`${new URL(page.url()).origin}${url}`, {
+    headers: { Authorization: `Bearer ${t!}` },
+  });
+  let body: any = null;
+  try { body = await r.json(); } catch { body = await r.text().catch(() => null); }
+  return { status: r.status(), body };
+}
+
+function rowsOf(j: any): any[] {
+  return j?.list ?? j?.data?.list ?? j?.data?.items ?? j?.data ?? j?.items ?? [];
+}
+
+/** 取主表与子表全量,按 keyField 求交集,返回主表中 key 出现在子表的第一条。 */
+async function pickReferenced(
+  page: Page,
+  parentUrl: string,
+  childUrl: string,
+  parentField: string,
+  childField: string,
+): Promise<{ id: number; key: string; childCount: number } | null> {
+  const parents = rowsOf((await get(page, parentUrl)).json);
+  const children = rowsOf((await get(page, childUrl)).json);
+  const childCounts = new Map<string, number>();
+  for (const c of children) {
+    const k = c[childField];
+    if (k) childCounts.set(String(k), (childCounts.get(String(k)) ?? 0) + 1);
+  }
+  for (const p of parents) {
+    const key = String(p[parentField] ?? '');
+    if (key && childCounts.has(key)) {
+      return { id: Number(p.id), key, childCount: childCounts.get(key)! };
+    }
+  }
+  return null;
+}
+
+test.describe('A2 DELETE 引用检查实测', () => {
+  test.beforeEach(async ({ authedPage }) => {
+    await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  });
+
+  test('Department 删除带 Employee 的部门 → 应 4xx', async ({ authedPage }) => {
+    const target = await pickReferenced(
+      authedPage,
+      '/api/s0/warehouse/departments?page=1&pageSize=200&tenantId=1&factoryId=1',
+      '/api/s0/warehouse/employees?page=1&pageSize=500&tenantId=1&factoryId=1',
+      'department',
+      'department',
+    );
+    test.skip(!target, '无可用"被员工引用的部门"样本');
+    const resp = await del(
+      authedPage,
+      `/api/s0/warehouse/departments/${target!.id}?tenantId=1&factoryId=1`,
+    );
+    test.info().annotations.push({
+      type: 'department',
+      description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`,
+    });
+    expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400);
+  });
+
+  test('Location 删除带 Shelf 的库位 → 应 4xx', async ({ authedPage }) => {
+    // 库位下游覆盖 LocationShelfMaster + ItemMaster.Location;前者无 S0 列表 API,
+    // 改用 materials.location 求交集(命中其一即触发拒绝)
+    const target = await pickReferenced(
+      authedPage,
+      '/api/s0/warehouse/locations?page=1&pageSize=200&tenantId=1&factoryId=1',
+      '/api/s0/sales/materials?page=1&pageSize=2000&tenantId=1&factoryId=1',
+      'location',
+      'location',
+    );
+    test.skip(!target, '无可用"被物料/货架引用的库位"样本');
+    const resp = await del(
+      authedPage,
+      `/api/s0/warehouse/locations/${target!.id}?tenantId=1&factoryId=1`,
+    );
+    test.info().annotations.push({
+      type: 'location',
+      description: `target=${JSON.stringify(target)} resp=${resp.status} body=${JSON.stringify(resp.body).slice(0, 200)}`,
+    });
+    expect(resp.status, '应返回 4xx 拒绝').toBeGreaterThanOrEqual(400);
+  });
+
+  // Material / Supplier 的运行态 DELETE 验证:未在 S0 路由下挂 SRM/Routing 子表 API,
+  // 无法稳妥挑选"必有引用"的样本而不踩到孤儿。本轮仅做代码审查,不做运行态删除。
+});

+ 7 - 0
Web/tests/e2e/smoke.spec.ts

@@ -0,0 +1,7 @@
+import { test, expect } from './fixtures/auth';
+
+test('fixture smoke: authedPage reaches dashboard without re-login', async ({ authedPage }) => {
+  await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  await authedPage.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
+  expect(authedPage.url()).not.toMatch(/\/#\/login/);
+});