s0-org-id-sampling.spec.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. /**
  2. * S0 组织 ID 类型一致性|运行时取样(简化版)
  3. *
  4. * 目标:不做 Vue 内部反射,只靠「UI 驱动 + 网络请求体 + 可见错误文案」留证。
  5. * 每个样本页面分别记录:
  6. * - 打开新增弹窗后拦截到的 GET /api/sysOrg/list 响应体中 id 的 JSON 原文类型
  7. * - 选完公司/工厂后,点保存捕获的 POST/PUT 请求体里 companyRefId / factoryRefId 的 JSON 原文类型
  8. * - 可见错误提示文案(form-item error 或 el-message)
  9. *
  10. * 本轮只收集证据;结论写入 artifacts/S0-org-id-runtime-sampling-report.md。
  11. */
  12. import { test, expect } from './fixtures/auth';
  13. import type { Page, Request, Response } from '@playwright/test';
  14. interface Capture {
  15. orgListIdSample: string | null; // raw JSON substring, e.g. `"id":"1300000000001"` vs `"id":1300000000001`
  16. saveRequestBody: string | null; // raw JSON body
  17. saveStatus: number | null;
  18. saveResponseBody: string | null;
  19. visibleErrors: string[];
  20. consoleErrors: string[];
  21. }
  22. function attachCaptures(page: Page, cap: Capture): void {
  23. page.on('response', async (resp: Response) => {
  24. try {
  25. const url = resp.url();
  26. if (/\/api\/sysOrg\/list/.test(url) && cap.orgListIdSample === null) {
  27. const txt = await resp.text();
  28. const m = txt.match(/"id":\s*("[^"]+"|\d+)/);
  29. if (m) cap.orgListIdSample = m[0];
  30. }
  31. } catch {}
  32. });
  33. page.on('request', async (req: Request) => {
  34. const url = req.url();
  35. const method = req.method();
  36. if (!/\/api\/s0\//.test(url)) return;
  37. if (!['POST', 'PUT'].includes(method)) return;
  38. if (cap.saveRequestBody === null) cap.saveRequestBody = req.postData() ?? '';
  39. });
  40. page.on('console', (m) => {
  41. if (m.type() === 'error') cap.consoleErrors.push(m.text());
  42. });
  43. page.on('pageerror', (e) => cap.consoleErrors.push(`pageerror: ${e.message}`));
  44. }
  45. async function clickAddButtonInContent(page: Page, name: string): Promise<boolean> {
  46. // Scope to the active tab content, not global — avoids picking home-page buttons.
  47. const scope = page.locator('.el-main, .layout-view-bg-white, .app-main, main').first();
  48. const scoped = scope.isVisible().catch(() => false);
  49. const search = (await scoped) ? scope : page;
  50. const btn = search.getByRole('button', { name }).first();
  51. if (!(await btn.isVisible({ timeout: 10_000 }).catch(() => false))) return false;
  52. await btn.click();
  53. await page.waitForTimeout(700);
  54. return true;
  55. }
  56. async function pickFirstInSelect(page: Page, selectLocator: string, nth: number): Promise<string | null> {
  57. const select = page.locator(selectLocator).nth(nth);
  58. if (!(await select.isVisible().catch(() => false))) return null;
  59. const wrapper = select.locator('.el-select__wrapper, .select-trigger').first();
  60. await wrapper.click({ force: true });
  61. await page.waitForTimeout(300);
  62. const opt = page
  63. .locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)')
  64. .first();
  65. if (!(await opt.isVisible({ timeout: 5000 }).catch(() => false))) return null;
  66. const text = (await opt.innerText()).trim();
  67. await opt.click({ force: true }).catch(() => {});
  68. await page.waitForTimeout(400);
  69. return text;
  70. }
  71. const SAMPLES: Array<{ name: string; hash: string; addBtnName: RegExp | string }> = [
  72. { name: 'sales/CustomerList (Pattern A, baseline)', hash: '#/aidop/s0/sales/customer', addBtnName: /^新增客户$/ },
  73. { name: 'sales/MaterialList (Pattern B+C, form default=0)', hash: '#/aidop/s0/sales/material', addBtnName: /新 ?增/ },
  74. { name: 'sales/OrderReviewCycleList (Pattern B+C, form default=0)', hash: '#/aidop/s0/sales/order-review-cycle', addBtnName: /^新 ?增$/ },
  75. { name: 'manufacturing/ProductionLineList (cascade)', hash: '#/aidop/s0/manufacturing/production-line', addBtnName: /^新 ?增$/ },
  76. { name: 'supply/SupplierList (cross-domain)', hash: '#/aidop/s0/supply/supplier', addBtnName: /新 ?增/ },
  77. ];
  78. for (const s of SAMPLES) {
  79. test(`runtime sample: ${s.name}`, async ({ authedPage }) => {
  80. const page = authedPage;
  81. const cap: Capture = {
  82. orgListIdSample: null,
  83. saveRequestBody: null,
  84. saveStatus: null,
  85. saveResponseBody: null,
  86. visibleErrors: [],
  87. consoleErrors: [],
  88. };
  89. attachCaptures(page, cap);
  90. await page.goto(`/${s.hash}`, { waitUntil: 'domcontentloaded' });
  91. await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
  92. await page.waitForTimeout(1000);
  93. const opened = await clickAddButtonInContent(
  94. page,
  95. typeof s.addBtnName === 'string' ? s.addBtnName : (s.addBtnName as any),
  96. );
  97. console.log(`[${s.name}] dialog opened: ${opened}`);
  98. await page.waitForTimeout(800);
  99. if (opened) {
  100. // Pick first and second el-select inside the visible dialog.
  101. const selInDialog = '.el-dialog:visible .el-select, .el-drawer:visible .el-select';
  102. const company = await pickFirstInSelect(page, selInDialog, 0);
  103. const factory = await pickFirstInSelect(page, selInDialog, 1);
  104. console.log(`[${s.name}] picked company=${company} factory=${factory}`);
  105. // Attempt to click Save. Response listener is already attached via attachCaptures.
  106. const saveBtn = page
  107. .locator('.el-dialog:visible, .el-drawer:visible')
  108. .getByRole('button', { name: /^(确 ?定|保 ?存|提 ?交)$/ })
  109. .first();
  110. if (await saveBtn.isVisible().catch(() => false)) {
  111. const respPromise = page
  112. .waitForResponse(
  113. (r) => /\/api\/s0\//.test(r.url()) && ['POST', 'PUT'].includes(r.request().method()),
  114. { timeout: 6000 },
  115. )
  116. .catch(() => null);
  117. await saveBtn.click();
  118. const resp = await respPromise;
  119. if (resp) {
  120. cap.saveStatus = resp.status();
  121. cap.saveResponseBody = (await resp.text()).slice(0, 400);
  122. }
  123. await page.waitForTimeout(600);
  124. const errs = await page
  125. .locator('.el-form-item__error, .el-message--error, .el-message__content')
  126. .allInnerTexts()
  127. .catch(() => []);
  128. cap.visibleErrors = errs.map((x) => x.trim()).filter(Boolean);
  129. } else {
  130. console.log(`[${s.name}] save button not visible`);
  131. }
  132. }
  133. console.log(`\n=== EVIDENCE: ${s.name} ===\n${JSON.stringify(cap, null, 2)}\n`);
  134. expect(true).toBe(true);
  135. });
  136. }