B1-B8 建立项目级流程与QA门禁
这个提交包含在:
@@ -25,6 +25,21 @@ import { toast } from "sonner";
|
||||
|
||||
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
|
||||
|
||||
function batchStatusLabel(value: string) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "reviewed") return "已复核";
|
||||
if (value === "executed") return "已执行";
|
||||
if (value === "failed") return "执行失败";
|
||||
if (value === "cancelled") return "已取消";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function entityTypeLabel(value: string) {
|
||||
if (value === "reimbursement") return "报销";
|
||||
if (value === "travel_order") return "差旅订单";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
return isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
@@ -342,8 +357,8 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
||||
<div>状态:{selectedBatch.status}</div>
|
||||
<div>实体:{selectedBatch.entityType}</div>
|
||||
<div>状态:{batchStatusLabel(selectedBatch.status)}</div>
|
||||
<div>实体:{entityTypeLabel(selectedBatch.entityType)}</div>
|
||||
<div>总金额:{selectedBatch.totalAmount}</div>
|
||||
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
|
||||
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</div>
|
||||
|
||||
@@ -7,6 +7,40 @@ import { resolveReimbursementDisplayCode } from "../../lib/reimbursementDisplay"
|
||||
|
||||
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可用",
|
||||
bound: "已绑定",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const REIMBURSEMENT_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
paid: "已支付",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const TRAVEL_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
submitted: "已提交",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
confirmed: "已确认",
|
||||
paid: "已支付",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function statusLabel(status: string | null | undefined, labels: Record<string, string>) {
|
||||
if (!status) return "未知状态";
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
export type InvoiceBindCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
|
||||
@@ -73,7 +107,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择票据</option>
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} ({item.status})
|
||||
{`${item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -88,7 +122,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择报销单</option>
|
||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{resolveReimbursementDisplayCode(item)} ({item.status})
|
||||
{resolveReimbursementDisplayCode(item)} ({statusLabel(item.status, REIMBURSEMENT_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -106,7 +140,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择差旅申请</option>
|
||||
{(travelRequestQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.requestNo ?? item.purpose} ({item.status})
|
||||
{item.requestNo ?? item.purpose} ({statusLabel(item.status, TRAVEL_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -121,7 +155,7 @@ export function InvoiceBindCard({
|
||||
<option value="">选择差旅订单</option>
|
||||
{bindableTravelOrders.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.externalOrderNo} ({item.status})
|
||||
{item.externalOrderNo} ({statusLabel(item.status, TRAVEL_STATUS_LABEL)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -306,7 +306,7 @@ export function InvoiceDetailPanel({
|
||||
onChange={(event) => setEditSetAvailable(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="text-zinc-700">将状态标记为 available(人工确认可用)</span>
|
||||
<span className="text-zinc-700">将状态标记为可用(人工确认可用)</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await expect(page.getByTestId("asset-name")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-category-reset").click();
|
||||
await page.getByTestId("asset-category-code").fill(categoryCode);
|
||||
|
||||
@@ -56,8 +56,14 @@ export async function openInvoicePool(page: Page) {
|
||||
export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: string, timeout = 180_000): Promise<LiveInvoiceOcrResult> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const uploadedInvoiceId = uploadPayload.data?.invoicePoolId ?? null;
|
||||
|
||||
const detailPanel = page.getByTestId("invoice-detail-panel");
|
||||
await expect(detailPanel).toBeVisible({ timeout: 30_000 });
|
||||
@@ -74,7 +80,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
||||
} catch (error) {
|
||||
const detailText = await detailPanel.innerText().catch(() => "");
|
||||
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
|
||||
const invoiceId = extractField(detailText, "票据ID:");
|
||||
const invoiceId = uploadedInvoiceId ?? extractField(detailText, "票据ID:");
|
||||
throw new Error(
|
||||
[
|
||||
"live 腾讯 OCR 等待终态超时",
|
||||
@@ -100,7 +106,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
|
||||
detailText,
|
||||
bannerText,
|
||||
durationMs: Date.now() - startedAt,
|
||||
invoiceId: extractField(detailText, "票据ID:"),
|
||||
invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"),
|
||||
invoiceCode,
|
||||
invoiceNumber,
|
||||
sellerName: extractField(detailText, "销售方:"),
|
||||
|
||||
@@ -23,7 +23,7 @@ async function createBlankReimbursement(page: import("@playwright/test").Page) {
|
||||
return reimbursementId;
|
||||
}
|
||||
|
||||
test("E2E 轻交互 OCR:报销详情上传后自动可绑定并提交", async ({ page, reimbursementDetail }) => {
|
||||
test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
const reimbursementId = await createBlankReimbursement(page);
|
||||
@@ -34,21 +34,18 @@ test("E2E 轻交互 OCR:报销详情上传后自动可绑定并提交", async
|
||||
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
|
||||
|
||||
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
|
||||
const invoiceFileName = `r73-success-${Date.now()}.png`;
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
|
||||
name: `r73-success-${Date.now()}.png`,
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const text = await page.getByText(/已选 \d+ 张发票/).first().textContent().catch(() => "");
|
||||
return text ?? "";
|
||||
}, { timeout: 90_000 }).toContain("已选 1 张发票");
|
||||
|
||||
await page.getByRole("button", { name: "绑定选中发票" }).click();
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).not.toContainText("暂无关联票据", { timeout: 30_000 });
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).toContainText(invoiceFileName, { timeout: 90_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0);
|
||||
|
||||
await reimbursementDetail.submitReimbursement();
|
||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
|
||||
@@ -71,7 +68,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:ocr_failed", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
|
||||
|
||||
await page.getByTestId("invoice-edit-open").click();
|
||||
@@ -83,7 +80,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
await page.getByTestId("invoice-edit-seller-name").fill("北京悦海堂餐饮管理有限公司");
|
||||
await page.getByTestId("invoice-edit-save").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("北京悦海堂餐饮管理有限公司");
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { expect, test } from "@playwright/test";
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
||||
|
||||
function envelope(data: unknown) {
|
||||
|
||||
@@ -3,14 +3,6 @@ import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
||||
test.setTimeout(240_000);
|
||||
@@ -38,22 +30,29 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
// 2) employee: upload invoice and wait OCR
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const invoiceFileName = `e2e-${Date.now()}.png`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `e2e-${Date.now()}.png`,
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
// 预览(popup 存在即可)
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first();
|
||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceFileName }).first();
|
||||
await expect(invoiceRow).toBeVisible();
|
||||
await invoiceRow.getByRole("button").click();
|
||||
await page.getByTestId("invoice-preview-button").click();
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { devices, expect, test } from "@playwright/test";
|
||||
import { createAuthenticatedApiClient, login, MANAGER } from "./helpers/auth";
|
||||
import { createAuthenticatedApiClient, MANAGER, type E2EUser } from "./helpers/auth";
|
||||
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
|
||||
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow";
|
||||
|
||||
async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/mobile$/);
|
||||
}
|
||||
|
||||
async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, taskId: string) {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -56,7 +64,7 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
entityType: "travel_request",
|
||||
});
|
||||
|
||||
await login(page, MANAGER);
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
@@ -67,12 +75,12 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByText("当前离线")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待同步 1");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过");
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无草稿", { timeout: 30_000 });
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await Promise.all([created.client.dispose(), managerApi.dispose()]);
|
||||
@@ -92,7 +100,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
entityType: "travel_request",
|
||||
});
|
||||
|
||||
await login(page, MANAGER);
|
||||
await loginMobile(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${task.id}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||
@@ -103,7 +111,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待同步 1");
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("驳回");
|
||||
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
@@ -30,6 +30,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const title = `Playwright 采购 ${Date.now()}`;
|
||||
let requestNo = "";
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "采购申请")).click();
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
@@ -51,15 +52,15 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
|
||||
|
||||
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]');
|
||||
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]:visible');
|
||||
if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) {
|
||||
await page.getByTestId("purchase-add-item").click();
|
||||
}
|
||||
|
||||
await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 });
|
||||
await itemDescriptionInputs.first().fill("Playwright Laptop");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("100");
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
import { ADMIN, EMPLOYEE, MANAGER, createAuthenticatedApiClient, type E2EUser } from "./helpers/auth";
|
||||
|
||||
type MatrixRow = {
|
||||
Domain: string;
|
||||
Tier: string;
|
||||
PC_Routes: string;
|
||||
Mobile_WeCom_Routes: string;
|
||||
};
|
||||
|
||||
type VisualFinding = {
|
||||
severity: "P1" | "P2";
|
||||
route: string;
|
||||
domain: string;
|
||||
viewport: "pc" | "mobile-wecom";
|
||||
category: string;
|
||||
detail: string;
|
||||
screenshot?: string;
|
||||
};
|
||||
|
||||
type PreparedRoutes = {
|
||||
reimbursementId: string;
|
||||
projectId: string | null;
|
||||
workflowTaskId: string | null;
|
||||
employeeId: string;
|
||||
};
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const MATRIX_PATH = path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/project-wide-visual");
|
||||
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/qa-flow/project-wide-visual");
|
||||
const WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
||||
|
||||
const findings: VisualFinding[] = [];
|
||||
|
||||
function outputPath(dir: string, fileName: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, fileName);
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string) {
|
||||
const cells: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (quoted && char === "\"" && next === "\"") {
|
||||
current += "\"";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === ",") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseMatrix() {
|
||||
const lines = readFileSync(MATRIX_PATH, "utf8").split(/\r?\n/).filter((line) => line.trim());
|
||||
const header = parseCsvLine(lines[0] ?? "");
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = parseCsvLine(line);
|
||||
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])) as MatrixRow;
|
||||
});
|
||||
}
|
||||
|
||||
function splitRoutes(value: string) {
|
||||
return value.split("|").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function sanitizeRoute(route: string) {
|
||||
return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root";
|
||||
}
|
||||
|
||||
function addFinding(finding: VisualFinding) {
|
||||
findings.push({
|
||||
...finding,
|
||||
detail: finding.detail.replace(UUID_RE, "[uuid]"),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceDynamicRoute(route: string, prepared: PreparedRoutes) {
|
||||
if (route.includes(":reimbursementId")) return route.replace(":reimbursementId", prepared.reimbursementId);
|
||||
if (route.includes("/reimbursements/:id")) return route.replace(":id", prepared.reimbursementId);
|
||||
if (route.includes(":projectId")) return prepared.projectId ? route.replace(":projectId", prepared.projectId) : null;
|
||||
if (route.includes(":taskId")) return prepared.workflowTaskId ? route.replace(":taskId", prepared.workflowTaskId) : null;
|
||||
if (route.includes(":employeeId")) return route.replace(":employeeId", prepared.employeeId);
|
||||
return route;
|
||||
}
|
||||
|
||||
async function loginForVisual(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function newWecomContext(browser: Browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: WECOM_UA,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function prepareRoutes() {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const managerClient = await createAuthenticatedApiClient(MANAGER);
|
||||
const adminClient = await createAuthenticatedApiClient(ADMIN);
|
||||
try {
|
||||
const created = await employeeClient.api.post("/api/reimbursements", {
|
||||
data: { description: "项目级视觉巡检草稿" },
|
||||
});
|
||||
const createdJson = await created.json() as { data?: { id?: string } };
|
||||
if (!created.ok() || !createdJson.data?.id) {
|
||||
throw new Error(`创建视觉巡检报销草稿失败:HTTP ${created.status()}`);
|
||||
}
|
||||
|
||||
const projectsResponse = await adminClient.api.get("/api/projects?page=1&pageSize=1");
|
||||
const projectsJson = await projectsResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null;
|
||||
const projectId = projectsResponse.ok() ? projectsJson?.data?.items?.[0]?.id ?? null : null;
|
||||
|
||||
const tasksResponse = await managerClient.api.get("/api/workflow/tasks?status=pending&page=1&pageSize=1");
|
||||
const tasksJson = await tasksResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null;
|
||||
const workflowTaskId = tasksResponse.ok() ? tasksJson?.data?.items?.[0]?.id ?? null : null;
|
||||
|
||||
return {
|
||||
reimbursementId: createdJson.data.id,
|
||||
projectId,
|
||||
workflowTaskId,
|
||||
employeeId: employeeClient.me.id,
|
||||
};
|
||||
} finally {
|
||||
await Promise.all([employeeClient.dispose(), managerClient.dispose(), adminClient.dispose()]);
|
||||
}
|
||||
}
|
||||
|
||||
function userForRoute(route: string): E2EUser {
|
||||
if (route.startsWith("/mobile/approvals") || route.startsWith("/workflow/tasks")) return MANAGER;
|
||||
if (
|
||||
route.startsWith("/system") ||
|
||||
route.startsWith("/people") ||
|
||||
route.startsWith("/organization") ||
|
||||
route.startsWith("/audit-logs") ||
|
||||
route.startsWith("/payroll") ||
|
||||
route.startsWith("/performance")
|
||||
) {
|
||||
return ADMIN;
|
||||
}
|
||||
return EMPLOYEE;
|
||||
}
|
||||
|
||||
async function auditPage(page: Page, input: {
|
||||
route: string;
|
||||
domain: string;
|
||||
viewport: "pc" | "mobile-wecom";
|
||||
}) {
|
||||
const responses: Array<{ status: number; url: string }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 500) {
|
||||
responses.push({ status: response.status(), url: response.url() });
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource: the server responded with a status of 4\d\d/i.test(text)) {
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(input.route);
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const screenshot = outputPath(
|
||||
SCREENSHOT_DIR,
|
||||
`${input.viewport}-${sanitizeRoute(input.route)}.png`,
|
||||
);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const text = document.body?.innerText ?? "";
|
||||
const root = document.documentElement;
|
||||
const width = window.innerWidth;
|
||||
const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
|
||||
const visibleTables = Array.from(document.querySelectorAll("table")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
||||
}).length;
|
||||
const sidebarLike = Array.from(document.querySelectorAll("aside, nav")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const textContent = element.textContent ?? "";
|
||||
return rect.width > 160 && textContent.includes("备用模块");
|
||||
}).length;
|
||||
const clipped = Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
const style = getComputedStyle(element);
|
||||
const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY);
|
||||
return overflowHidden && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
testid: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60),
|
||||
}));
|
||||
return { text, width, scrollWidth, visibleTables, sidebarLike, clipped };
|
||||
});
|
||||
|
||||
if (responses.length) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "http",
|
||||
detail: JSON.stringify(responses.slice(0, 5)),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (consoleErrors.length) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "console",
|
||||
detail: consoleErrors.slice(0, 5).join("\n"),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.scrollWidth > metrics.width + 2) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "responsive",
|
||||
detail: `viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.viewport === "mobile-wecom") {
|
||||
if (metrics.visibleTables > 0) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "mobile-table",
|
||||
detail: `移动页存在可见 table:${metrics.visibleTables}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (metrics.sidebarLike > 0) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "mobile-pc-shell",
|
||||
detail: "企业微信移动端出现 PC 导航壳。",
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rawCodePatterns = [/\bdraft\b/i, /\bbound\b/i, /business_client_meal/i, /expense-type-[a-z0-9-]+/i];
|
||||
const shouldApplyOrdinaryUserCopyGate = (
|
||||
input.route.startsWith("/reimbursements") ||
|
||||
input.route.startsWith("/mobile/reimbursements") ||
|
||||
input.route.startsWith("/mobile/capture")
|
||||
);
|
||||
const bannedTerms = ["绑定整单", "不绑定明细", "发票池"];
|
||||
if (shouldApplyOrdinaryUserCopyGate && (rawCodePatterns.some((pattern) => pattern.test(metrics.text)) || bannedTerms.some((term) => metrics.text.includes(term)))) {
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "copy",
|
||||
detail: "普通用户页面出现原始码值或内部术语。",
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.clipped.length) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
route: input.route,
|
||||
domain: input.domain,
|
||||
viewport: input.viewport,
|
||||
category: "clipped",
|
||||
detail: JSON.stringify(metrics.clipped),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport: "pc" | "mobile-wecom") {
|
||||
const seen = new Set<string>();
|
||||
const plans: Array<{ route: string; domain: string }> = [];
|
||||
|
||||
for (const row of matrix) {
|
||||
const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes);
|
||||
for (const token of tokens) {
|
||||
const route = replaceDynamicRoute(token, prepared);
|
||||
if (!route || seen.has(route)) continue;
|
||||
seen.add(route);
|
||||
plans.push({ route, domain: row.Domain });
|
||||
}
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
async function auditPlans(browser: Browser, plans: Array<{ route: string; domain: string }>, viewport: "pc" | "mobile-wecom") {
|
||||
const plansByUser = new Map<string, { user: E2EUser; plans: Array<{ route: string; domain: string }> }>();
|
||||
for (const plan of plans) {
|
||||
const user = userForRoute(plan.route);
|
||||
const entry = plansByUser.get(user.identifier) ?? { user, plans: [] };
|
||||
entry.plans.push(plan);
|
||||
plansByUser.set(user.identifier, entry);
|
||||
}
|
||||
|
||||
for (const { user, plans: userPlans } of plansByUser.values()) {
|
||||
const context = viewport === "mobile-wecom"
|
||||
? await newWecomContext(browser)
|
||||
: await browser.newContext({ viewport: { width: 1366, height: 900 }, serviceWorkers: "block" });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await loginForVisual(page, user);
|
||||
for (const plan of userPlans) {
|
||||
await auditPage(page, { ...plan, viewport });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("项目级全页面视觉巡检", () => {
|
||||
test("PC 与企业微信移动端没有已知视觉和文案回归", async ({ browser }) => {
|
||||
const prepared = await prepareRoutes();
|
||||
const matrix = parseMatrix();
|
||||
const pcPlans = buildRoutePlans(matrix, prepared, "pc");
|
||||
const mobilePlans = buildRoutePlans(matrix, prepared, "mobile-wecom");
|
||||
|
||||
await auditPlans(browser, pcPlans, "pc");
|
||||
await auditPlans(browser, mobilePlans, "mobile-wecom");
|
||||
|
||||
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
|
||||
writeFileSync(reportPath, JSON.stringify({
|
||||
checkedAt: new Date().toISOString(),
|
||||
pcRoutes: pcPlans.map((item) => item.route),
|
||||
mobileRoutes: mobilePlans.map((item) => item.route),
|
||||
findings,
|
||||
}, null, 2), "utf8");
|
||||
|
||||
expect(findings.filter((finding) => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -331,7 +331,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("详情页发票默认绑定唯一明细", async ({ page }) => {
|
||||
test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
@@ -341,12 +341,24 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
|
||||
await page.getByTestId("reimbursement-invoice-add").click();
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveValue(itemId);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细");
|
||||
await expect(page.getByText("绑定整单")).toHaveCount(0);
|
||||
await expect(page.getByText("不绑定明细")).toHaveCount(0);
|
||||
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0);
|
||||
const uploadInput = page.getByTestId("reimbursement-invoice-upload-file");
|
||||
await expect(uploadInput).toHaveAttribute("multiple", "");
|
||||
await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible();
|
||||
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await expect(page.getByText("已选 1 张发票")).toBeVisible();
|
||||
|
||||
const bindResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok();
|
||||
});
|
||||
await page.getByRole("button", { name: "添加选中发票" }).click();
|
||||
await bindResponsePromise;
|
||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { devices, expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function browserAgnosticDevice(name: keyof typeof devices) {
|
||||
const device = devices[name];
|
||||
return {
|
||||
@@ -408,8 +410,9 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
|
||||
await page.getByTestId("mobile-nav-capture").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/capture/);
|
||||
await expect(page.getByText("拍照或选择文件", { exact: true })).toBeVisible();
|
||||
await page.getByTestId("mobile-capture-file").setInputFiles({
|
||||
await expect(page.getByRole("heading", { name: "票据上传" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /选择 PDF/ })).toBeVisible();
|
||||
await page.getByTestId("mobile-capture-pdf-file").setInputFiles({
|
||||
name: "r89-invoice.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\n% R89"),
|
||||
@@ -425,22 +428,18 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("R89 小屏桌面菜单兜底", () => {
|
||||
test.describe("R89 小屏移动路由兜底", () => {
|
||||
test.use(browserAgnosticDevice("Pixel 5"));
|
||||
|
||||
test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => {
|
||||
test("非移动路由在小屏场景会转到移动通知页", async ({ page }) => {
|
||||
await mockR89Apis(page);
|
||||
|
||||
await loginByPassword(page);
|
||||
await page.getByTestId("mobile-nav-me").click();
|
||||
await page.getByRole("link", { name: /通知中心/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/notifications/);
|
||||
await page.getByRole("button", { name: "打开菜单" }).click();
|
||||
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Portal" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "关闭菜单" }).click();
|
||||
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden();
|
||||
await expect(page).toHaveURL(/\/mobile\/notifications/);
|
||||
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "打开菜单" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import {
|
||||
ADMIN,
|
||||
apiGetData,
|
||||
createAuthenticatedApiClient,
|
||||
EMPLOYEE,
|
||||
@@ -12,12 +13,37 @@ import {
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
type FeatureFlagItem = {
|
||||
flagKey: string;
|
||||
overrideMode: "inherit" | "force_on" | "force_off";
|
||||
rolloutType: "global" | "department_allowlist";
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
version: number;
|
||||
effectiveEnabled: boolean;
|
||||
};
|
||||
|
||||
async function ensureFeatureFlagOn(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], flagKey: string) {
|
||||
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
|
||||
const item = list.items.find((flag) => flag.flagKey === flagKey);
|
||||
if (!item) {
|
||||
throw new Error(`Missing feature flag: ${flagKey}`);
|
||||
}
|
||||
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
|
||||
return;
|
||||
}
|
||||
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
|
||||
data: {
|
||||
overrideMode: "force_on",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "playwright reimbursement payment batch flow",
|
||||
expectedVersion: item.version,
|
||||
},
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
|
||||
@@ -27,11 +53,15 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const adminApi = await createAuthenticatedApiClient(ADMIN);
|
||||
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
|
||||
const paymentDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1");
|
||||
await ensureFeatureFlagOn(adminApi.api, "erp_sync");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await (await openNavLink(page, "我的报销")).click();
|
||||
@@ -53,17 +83,24 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `r78-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
@@ -125,7 +162,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
|
||||
await expect(batchDetailCard).toBeVisible();
|
||||
await expect(batchDetailCard).toContainText("状态:draft");
|
||||
await expect(batchDetailCard).toContainText("状态:草稿");
|
||||
await expect(batchDetailCard).toContainText("复核进度:0/1");
|
||||
|
||||
await logout(page, FINANCE.name);
|
||||
@@ -149,7 +186,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
await expect(executedBatchDetailCard).toBeVisible();
|
||||
await expect(executedBatchDetailCard).toContainText("复核进度:1/1");
|
||||
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click();
|
||||
await expect(executedBatchDetailCard).toContainText("状态:executed", { timeout: 30_000 });
|
||||
await expect(executedBatchDetailCard).toContainText("状态:已执行", { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
@@ -185,6 +222,6 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
expect(erpPreview.exportState.lastOutboxId).toBeTruthy();
|
||||
expect(erpPreview.exportState.lastOutboxStatus).toBe("pending");
|
||||
} finally {
|
||||
await Promise.all([employeeApi.dispose(), financeMgrApi.dispose()]);
|
||||
await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,14 +2,6 @@ import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -34,18 +26,25 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `progress-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
await page.getByTestId("invoice-bind-submit").click();
|
||||
|
||||
@@ -2,16 +2,8 @@ import { expect, test } from "@playwright/test";
|
||||
import { createAuthenticatedApiClient, EMPLOYEE, FINANCE_MGR, login, logout, MANAGER } from "./helpers/auth";
|
||||
import { createAndSubmitTravelRequestViaUi, importTravelPaymentSeed } from "./helpers/travel";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse ${label} from text: ${text}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function extractInvoiceCode(text: string) {
|
||||
const match = text.match(/发票号:([A-Z0-9]+)-/);
|
||||
const match = text.match(/发票(?:号|号码):([A-Z0-9]+)-/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Failed to parse invoice code from text: ${text}`);
|
||||
}
|
||||
@@ -61,16 +53,25 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
||||
|
||||
await page.goto("/invoice-pool");
|
||||
const invoiceFileName = `travel-payment-${Date.now()}.png`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(`TRAVEL_PAYMENT_${Date.now()}`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
const invoiceId = uploadPayload.data?.invoicePoolId;
|
||||
if (!invoiceId) {
|
||||
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 });
|
||||
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
|
||||
const invoiceId = extractId(invoiceDetailText, "票据ID");
|
||||
const invoiceCode = extractInvoiceCode(invoiceDetailText);
|
||||
|
||||
await page.reload();
|
||||
|
||||
@@ -33,9 +33,9 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
}),
|
||||
@@ -46,6 +46,7 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "wecom-fallback-token",
|
||||
@@ -56,6 +57,20 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "wecom-fallback-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -99,9 +114,9 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
primaryEntryMode: "wecom",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
devicePriority: "mobile",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
@@ -162,10 +177,13 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fportal&wecom=unbound");
|
||||
await page.goto("/login?redirect=%2Fmobile&wecom=unbound");
|
||||
await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定");
|
||||
if (await page.getByTestId("login-identifier").count() === 0) {
|
||||
await page.getByRole("button", { name: "使用账号密码登录" }).click();
|
||||
}
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("ResetA1abc123def456");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page).toHaveURL(/\/mobile/);
|
||||
});
|
||||
|
||||
+9
-1
@@ -32,10 +32,11 @@ cd server && ./scripts/regression-all.sh
|
||||
若当前本地开发库存在 Prisma migration 漂移,禁止直接重置数据库。需要验证发票查重真实 HTTP 链路时,优先使用隔离临时库:
|
||||
|
||||
```bash
|
||||
bash scripts/qa/check-local-migration-drift.sh
|
||||
bash scripts/qa/run-isolated-regression-dedup.sh
|
||||
```
|
||||
|
||||
该脚本会创建临时 QA 数据库、执行迁移和种子、启动临时服务、运行 `server/scripts/regression-dedup.sh`,结束后默认删除临时库。若需要保留现场,可设置 `QA_KEEP_DB=1`。
|
||||
`check-local-migration-drift.sh` 只读执行 `prisma migrate status` 并给出风险提示,不会执行 `migrate reset`。`run-isolated-regression-dedup.sh` 会创建临时 QA 数据库、执行迁移和种子、启动临时服务、运行 `server/scripts/regression-dedup.sh`,结束后默认删除临时库。若需要保留现场,可设置 `QA_KEEP_DB=1`。
|
||||
|
||||
前端检查:
|
||||
|
||||
@@ -78,6 +79,13 @@ cd client && npm run build
|
||||
node scripts/qa/create-real-device-report.mjs
|
||||
```
|
||||
|
||||
公网 live smoke 和真实 OCR 准备按 [`docs/trialrun/live-smoke-readiness.md`](trialrun/live-smoke-readiness.md) 执行:
|
||||
|
||||
```bash
|
||||
node scripts/qa/check-live-smoke-env.mjs
|
||||
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh
|
||||
```
|
||||
|
||||
系统应避免让已绑定用户反复登录。若手机企业微信能进入但仍要求登录,优先检查:
|
||||
|
||||
- 回调 code 是否传到后端。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 国内 OA 与费控系统最佳实践对照
|
||||
|
||||
快照日期:2026-05-08;公开来源二次复核:2026-05-09
|
||||
|
||||
## 调研来源
|
||||
|
||||
- 合思(原易快报)官网:强调从申请、消费、报销、对账、记账、归档、报表的全流程数字化,覆盖商旅、用车、餐饮、办公采购等场景,并把发票识别验真、合规透明、可追踪作为核心能力。来源:<https://www.hosecloud.com/index.html>
|
||||
- 分贝通官网:首页与费用管理产品页强调全流程费控、全场景支付、事前管控、OCR 识别、自动验真、查重、电子档案、三流合一和 OA/HRM/CRM/ERP 连接。来源:<https://www.fenbeitong.com/>、<https://www.fenbeitong.com/product.php>
|
||||
- 每刻科技官网:强调多行业费用管理、AI/OCR 票据识别、智能审核、知识库/制度问答、海外票据识别和合规效率。来源:<https://www.maycur.com/>
|
||||
- 企业微信第三方应用文档:工作台应用应从企业微信直接进入 H5 页面,第三方网页授权通过 OAuth code 获取成员身份,回调与 state 需要明确校验。来源:<https://wdk-docs.github.io/wework-docs/>、<https://wdk-docs.github.io/wework-docs/server/>
|
||||
|
||||
## 可迁移最佳实践
|
||||
|
||||
1. **移动端优先入口**
|
||||
- 企业微信工作台进入后应直接落到移动工作台,不应出现 PC 壳、桌面表格或侧边栏。
|
||||
- 深链要保留目标页面,例如审批待办、票据上传、报销详情。
|
||||
|
||||
2. **报销从“填单”转向“票据驱动”**
|
||||
- 员工上传发票后,应尽快看到“上传成功、识别中、可添加、识别失败、重复/已使用”的状态变化。
|
||||
- 识别失败或重复时,应给出业务语言原因和下一步动作,不应只显示系统码。
|
||||
|
||||
3. **事前规则与事后审计并行**
|
||||
- 费用标准、预算、审批规则应在提交前预检。
|
||||
- 财务复核、例外放行、付款、归档应全程留痕。
|
||||
|
||||
4. **三流合一与电子档案**
|
||||
- 报销单、发票、附件、审批记录、付款批次、ERP 凭证应能互相追溯。
|
||||
- 普通员工侧只看业务文案;财务/管理员侧才出现更细的状态、差错和审计信息。
|
||||
|
||||
5. **自动化验收要覆盖“体验事实”**
|
||||
- 仅验证接口成功不够,还要验证页面文案、状态变化、移动布局、文件选择行为、错误提示和视觉溢出。
|
||||
- 真实 OCR、真实企业微信 OAuth、真机文件选择属于上线前必须有证据的 live smoke。
|
||||
|
||||
## hyb-oa 当前判断
|
||||
|
||||
| 领域 | 当前合理点 | 主要差距 | 优化优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| 企业微信入口 | 已有 `/mobile`、OAuth config、移动底部导航和公网入口配置 | 真机企业微信 OAuth + 真实账号回跳证据不足;仍需防 PC 页面误入 | P0 |
|
||||
| 报销与票据 | 已支持报销、发票上传、OCR、绑定、解绑、查重、付款批次 | OCR 真实耗时、状态变化和失败提示需要 live 证据;票据入口需继续区分拍照/图片/PDF | P0 |
|
||||
| 费用规则 | 已有费用类型、审批规则、预算预检、支付批次 | 普通用户流程还需更强的提交前解释和异常引导 | P1 |
|
||||
| 审批 | PC/移动审批和离线队列已有自动化 | 多人并发、代理审批、真实企业微信 deep link 还缺 live 证据 | P1 |
|
||||
| 电子档案 | 发票、附件、审批、付款、ERP preview 已有链路 | 财务视角归档检索、合同/发票/付款跨域追溯仍偏分散 | P1 |
|
||||
| 全局治理 | 已有 feature matrix、coverage matrix、Issue CSV、回归脚本 | 缺项目级覆盖扫描、全页面视觉巡检和统一 release gate | P0 |
|
||||
|
||||
## 下一步落地策略
|
||||
|
||||
1. 先把现有流程和测试覆盖机器化,避免继续靠人工记忆判断是否“一一核查”。
|
||||
2. 将已知问题固化成静态扫描与 Playwright 断言,包括原始码值、内部术语、移动 PC 化、PDF capture 错误、横向溢出。
|
||||
3. 把真实 OCR、真实企业微信、真机测试作为 live readiness,不具备条件时输出 skipped 和缺项清单。
|
||||
4. 发布前统一执行 project-wide release gate;只有部署和公网检查通过后,才通知可以测试。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 公网 live smoke 与真实 OCR 准备清单
|
||||
|
||||
## 必需环境变量
|
||||
|
||||
- `LIVE_SMOKE_BASE_URL`:默认 `https://oa.hyinda.com`。
|
||||
- `LIVE_SMOKE_ADMIN_IDENTIFIER` / `LIVE_SMOKE_ADMIN_PASSWORD`:管理员 smoke 账号。
|
||||
- `LIVE_SMOKE_EMPLOYEE_IDENTIFIER` / `LIVE_SMOKE_EMPLOYEE_PASSWORD`:员工 smoke 账号。
|
||||
- `LIVE_EXPECT_WECOM_STATUS`:建议设为 `ready`,用于校验企业微信入口状态。
|
||||
|
||||
## 真实 OCR 复核
|
||||
|
||||
- `PLAYWRIGHT_OCR_LIVE_FILE`:单张真实测试发票 PDF/JPG/PNG/WEBP。
|
||||
- 或 `PLAYWRIGHT_OCR_LIVE_DIR`:批量票样目录。
|
||||
- 可选 `PLAYWRIGHT_OCR_LIVE_IDENTIFIER` / `PLAYWRIGHT_OCR_LIVE_PASSWORD`:专用 OCR 测试员工账号;未提供时复用 `LIVE_SMOKE_EMPLOYEE_*`。
|
||||
- 可选 `PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1`:允许重复样本被识别为重复,用于反复测试同一张票。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
node scripts/qa/check-live-smoke-env.mjs
|
||||
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh
|
||||
```
|
||||
|
||||
## 判定
|
||||
|
||||
- 缺少 live smoke 账号或 OCR 样本时,脚本必须输出 `SKIP` 和缺项,不得误报通过真实链路。
|
||||
- 配置完整时,脚本执行公网 `/ready`、公网资源文案扫描、网页 live smoke 和真实 OCR 样本上传。
|
||||
- 脚本不得打印密码、腾讯密钥或第三方 secret。
|
||||
@@ -0,0 +1,11 @@
|
||||
Domain,Tier,Primary_Roles,PC_Routes,Mobile_WeCom_Routes,Server_Routes,Main_Flow,Exception_Flow,Permission_Flow,Current_Coverage,Known_Gaps,Best_Practice_Delta,Next_Action
|
||||
"认证与企业微信入口","P0","employee|manager|admin","/login|/portal|/settings/profile","/mobile|/mobile/me|/mobile/profile","server/src/routes/auth.js|server/src/routes/wecomAuth.js|server/src/routes/me.js","账号登录或企微 OAuth 进入工作台,按设备落到 PC Portal 或移动工作台。","未绑定、会话过期、OAuth 回跳失败、密码错误、首次登录改密。","普通员工仅看个人入口;管理员看系统入口;企业微信回跳只允许安全 returnTo。","login-portal-ux.spec.ts|wecom-entry.spec.ts|wecom-first-bind.spec.ts|r89-mobile-login-navigation.spec.ts|live-smoke.spec.ts","真实企业微信 OAuth 和真机回跳仍需 live smoke;HTTPS cookie 行为缺最终证据。","国内企业微信应用最佳实践要求工作台直达 H5 移动体验,不能露出 PC 壳。","B3/B5:视觉巡检防 PC 壳;live readiness 检查真实账号。"
|
||||
"报销与票据 OCR","P0","employee|manager|finance|finance_mgr|admin","/reimbursements|/reimbursements/:id|/invoice-pool|/payment-batches|/ap-operations","/mobile/reimbursements|/mobile/reimbursements/:id|/mobile/capture","server/src/routes/reimbursements.js|server/src/routes/invoicePool.js|server/src/routes/paymentBatches.js|server/src/routes/apOperations.js","员工建草稿、加明细、上传发票、OCR 识别、添加到报销、提交审批、财务付款归档。","OCR 超时/失败、重复发票、已绑定/已报销、删除后重传、缺金额无法自动明细、付款失败。","员工只看自己的报销和业务文案;经理审批;财务复核付款;管理员配置。","reimbursement-workflow-progress.spec.ts|invoice-ocr-flow.spec.ts|reimbursement-payment-batch-flow.spec.ts|r111-invoice-reimbursement-guard.spec.ts|r125-invoice-reupload-after-delete.spec.ts|regression-dedup.sh","真实腾讯 OCR 耗时和公网文件上传链路需要样本;移动 PDF 选择需真机证据。","合思/分贝通/每刻均强调票据识别、验真、查重、电子档案和状态可追溯。","B3/B5/B7:视觉和文案门禁、真实 OCR readiness、发布门禁。"
|
||||
"审批中心","P0","manager|finance|finance_mgr|admin","/workflow/tasks|/workflow/tasks/:taskId","/mobile/approvals","server/src/routes/workflow.js|server/src/routes/workflowDefinitions.js","审批人查看待办,审批/驳回/退回/转交,申请人查看进度。","任务已处理、权限不足、并发审批、离线审批重放冲突。","只有当前审批人可处理;管理员可治理流程定义;普通员工只看相关单据进度。","workflow-quick-approve.spec.ts|mobile-approvals-offline.spec.ts|r111-invoice-reimbursement-guard.spec.ts","代理审批、多人并发、真实企业微信 deep link 证据不足。","同类系统强调审批全程留痕、风险提示和移动处理效率。","B4/B5:业务流门禁聚合;live smoke 覆盖移动 deep link。"
|
||||
"请假与考勤","P1","employee|manager|admin","/leave-requests|/attendance|/team","/mobile/leave","server/src/routes/leave.js|server/src/routes/leave-types.js|server/src/routes/attendance.js|server/src/routes/presence.js","员工提交请假、经理审批、团队日历/考勤状态更新。","余额不足、附件缺失、节假日/跨日、撤回、补卡。","员工看本人;经理看团队;管理员配置假期和考勤规则。","leave-flow.spec.ts|leave-team-calendar.spec.ts|announcements-presence.spec.ts|r75-automation-gap-fill.spec.ts","复杂排班、跨日班次和真实移动附件未专项重验。","国内 OA 常规要求移动提交、审批联动团队视图。","B4:纳入业务流门禁;B3:移动视觉巡检。"
|
||||
"周报、待办、公告与通知","P1","employee|manager|admin","/weekly-reports|/todos|/announcements|/notifications","/mobile/notifications","server/src/routes/weeklyReports.js|server/src/routes/todos.js|server/src/routes/announcements.js|server/src/routes/notifications.js","员工写周报、生成待办、公告触达、通知已读。","未提交提醒、通知跳转失效、公告权限错误。","管理员发布公告;员工只看可见公告;经理看团队周报汇总。","weekly-todo-flow.spec.ts|announcements-presence.spec.ts|global-search.spec.ts|r75-automation-gap-fill.spec.ts","外发渠道、催办策略和真实消息投递缺专项证据。","同类系统强调制度与知识库同步、通知可追踪。","B4:纳入业务流门禁;后续可补渠道 live smoke。"
|
||||
"人员、组织、HR 与绩效","P1","employee|manager|hr|admin","/people|/people/:employeeId|/people/stats|/organization|/payroll|/performance","/mobile/me|/mobile/profile","server/src/routes/employees.js|server/src/routes/departments.js|server/src/routes/payroll.js|server/src/routes/performance.js","管理员维护人员组织,员工完善档案,经理/HR 查看统计与绩效。","入职/转正/离职、隐私字段缺失、导出、权限越界。","员工看本人;经理看团队;HR/管理员看管理页面。","user-onboarding.spec.ts|r75-automation-gap-fill.spec.ts","离职资产交接、绩效真实评分、敏感字段脱敏证据不足。","国内 OA/HR 常规要求组织数据作为审批和权限事实源。","B2/B4:覆盖扫描标出缺口,业务门禁先跑现有用例。"
|
||||
"项目、合同与涉密边界","P1","manager|project_role|admin","/projects|/projects/:projectId|/contracts-registry","/mobile","server/src/routes/projects.js|server/src/routes/contractsRegistry.js|server/src/routes/confidentialDocRefs.js","项目创建、成员协作、沟通记录、合同台账、涉密引用边界。","项目权限不足、涉密文档不可见、合同缺关联。","项目成员和管理员分级可见;涉密内容最小暴露。","project-workbench.spec.ts|confidential-boundary.spec.ts","真实项目创建协同和合同台账编辑证据不足。","费控最佳实践强调申请/合同/发票/付款互相追溯。","B4:纳入项目与合同只读/写入门禁。"
|
||||
"采购、预算、差旅与对公付款","P1","employee|manager|finance|admin","/purchase-requests|/budgets|/travel-requests|/payment-batches|/ap-operations","/mobile/approvals|/mobile/capture","server/src/routes/purchaseRequests.js|server/src/routes/budgets.js|server/src/routes/travelRequests.js|server/src/routes/travelOrders.js|server/src/routes/travelReconciliation.js|server/src/routes/paymentBatches.js","采购/差旅申请、预算预检、审批、支付、对账和归档。","预算不足、审批拒绝、对账差异、付款异常、票据不匹配。","员工创建;经理审批;财务付款和对账;管理员配置规则。","procurement-flow.spec.ts|travel-payment-flow.spec.ts|travel-reconciliation-flow.spec.ts|reimbursement-payment-batch-flow.spec.ts","高风险两轮复核、大额批次、真实 ERP/银行回执缺证据。","分贝通/合思强调事前管控、企业支付、三流合一和报表分析。","B4/B7:聚合现有业务流,live 外部回执保持 skipped。"
|
||||
"资产与移动盘点","P1","employee|asset_admin|admin","/assets|/inventory-checks","/mobile/assets/scan","server/src/routes/assets.js|server/src/routes/assetMovements.js|server/src/routes/inventoryChecks.js","资产建卡、领用归还、调拨、盘点,移动端扫码盘点。","扫码失败、离线队列、盘点冲突、权限不足。","员工看本人资产;资产管理员管理台账和盘点。","assets-flow.spec.ts","真实摄像头扫码和真机离线恢复缺证据。","移动端应优先真实设备验证摄像头和文件权限。","B3/B5:视觉与真机清单覆盖。"
|
||||
"系统治理、审计与发布","P0","admin|system_operator","/system/workspace|/system/settings|/system/feature-flags|/system/workflow-definitions|/audit-logs|/system/cutover|/system/integrations/erp","/mobile/me","server/src/routes/systemWorkspace.js|server/src/routes/featureFlags.js|server/src/routes/audit.js|server/src/routes/cutover.js|server/src/routes/integrations.js","管理员配置工作区、功能开关、流程定义、审计、切换和集成。","配置漂移、开关 force_off、迁移漂移、外部依赖不可用、发布失败。","仅系统权限可见;普通用户页面不得出现内部码值或调试文案。","settings-governance.spec.ts|live-smoke.spec.ts|smoke-trialrun-runtime.js|check-ui-design-rules.mjs","缺项目级 coverage scan、全页面视觉巡检和统一 release gate。","成熟系统需要可审计、可回滚、可持续回归的发布链路。","B2/B3/B6/B7:补齐覆盖扫描、视觉巡检、漂移诊断和发布门禁。"
|
||||
|
@@ -0,0 +1,9 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
B1,国内最佳实践与全业务流程矩阵,"调研国内 OA、费控、报销、审批系统的流程最佳实践,并与当前 hyb-oa 业务域做流程-角色-页面-接口-测试映射。","形成矩阵,覆盖认证、审批、报销、票据、HR、项目、采购、资产、系统治理;每个域标明主流程、异常流程、权限流程、最佳实践差距和优化建议。","业务流程:全业务流程审计;步骤:读取 docs/feature-matrix.md、docs/trialrun/coverage-matrix.csv、client 路由、server 路由、E2E 文件,并结合公开调研来源,生成 docs/trialrun/project-wide-flow-matrix.csv 和 docs/trialrun/domestic-oa-best-practices.md 后人工审阅。","manual|node|web",DONE,DONE,DONE,"docs/trialrun/project-wide-flow-matrix.csv|docs/trialrun/domestic-oa-best-practices.md","[]","仅新增治理文档,不改变业务逻辑。"
|
||||
B2,测试覆盖索引与缺口扫描,"自动扫描前端路由、页面、后端路由和 E2E 文件,输出覆盖索引和缺口清单。","脚本能列出页面是否有 PC/mobile/WeCom/flow/visual 覆盖,输出 JSON 与 Markdown 摘要;缺口可按风险排序。","业务流程:测试治理;步骤:node scripts/qa/check-flow-coverage.mjs --json;node scripts/qa/check-flow-coverage.mjs --markdown artifacts/qa-flow/flow-coverage.md;确认包含所有 router 页面和关键后端路由。","node",DONE,DONE,DONE,"scripts/qa/check-flow-coverage.mjs|artifacts/qa-flow","[""B1""]","新增只读扫描脚本;artifacts 不纳入提交。"
|
||||
B3,全页面视觉巡检,"构建 Playwright 全页面视觉检查,覆盖 PC、mobile、WeCom UA。","无横向溢出;移动无桌面表格;无 5xx/console error;无原始码值/内部术语;截图归档;无法自动登录的页面必须明确 skipped。","业务流程:PC 与企业微信移动端全页面巡检;步骤:E2E_BASE_URL=<local> npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list。","playwright",DONE,DONE,DONE,"client/tests/e2e/project-wide-visual.spec.ts|output/playwright|artifacts/qa-flow","[""B1"",""B2""]","先覆盖可 mock 或无需后端状态的页面;公网真实登录由 B5 处理。"
|
||||
B4,核心业务流门禁脚本,"聚合现有核心业务 E2E,形成可复用业务流门禁。","认证、请假、审批、报销、票据、周报、项目、采购、资产、系统治理等已存在用例能通过一个脚本按域执行;失败产物自动收集。","业务流程:全业务核心 E2E;步骤:bash scripts/qa/run-business-flow-gates.sh local;必要时传入 E2E_BASE_URL;确认脚本输出每个业务域结果。","bash|playwright",DONE,DONE,DONE,"scripts/qa/run-business-flow-gates.sh|client/tests/e2e","[""B2""]","优先聚合现有 E2E,不在本 Issue 大规模补业务代码。"
|
||||
B5,真实OCR与公网live smoke前置门禁,"把 LIVE_SMOKE_*、真实企业微信入口、真实 OCR 样本和真机测试要求做成可检查的环境门禁。","缺账号或样本时明确 skipped 并说明缺项;配置完整时能执行公网 ready、企业微信配置、真实登录 smoke 和 OCR 样本上传耗时记录。","业务流程:公网登录、企业微信入口、真实发票上传与 OCR;步骤:node scripts/qa/check-live-smoke-env.mjs;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。","node|bash|playwright|curl|manual",DONE,DONE,DONE,"scripts/qa/check-live-smoke-env.mjs|scripts/qa/run-live-smoke-readiness.sh|docs/trialrun/live-smoke-readiness.md","[]","需要测试账号、腾讯 OCR 环境和测试发票样本;不得打印密码或 secret。"
|
||||
B6,本地迁移漂移诊断专项,"在不重置数据库的前提下诊断本地开发库 Prisma migration drift,并给出安全修复路径。","脚本能输出当前迁移状态、漂移风险、推荐动作;不会执行 migrate reset;如需破坏性重建必须提示人工确认。","业务流程:本地真实 HTTP 回归准备;步骤:bash scripts/qa/check-local-migration-drift.sh;bash scripts/qa/run-isolated-regression-dedup.sh;确认本地漂移不会阻塞隔离回归。","bash|prisma|manual",DONE,DONE,DONE,"scripts/qa/check-local-migration-drift.sh|docs/trialrun-runbook.md","[]","短期继续使用隔离 QA 库;本项只做诊断和非破坏建议。"
|
||||
B7,发布门禁project-wide模式,"把静态扫描、覆盖扫描、业务流门禁、视觉巡检、live smoke readiness 聚合到 release gate。","bash scripts/qa/run-release-gate.sh project-wide 可运行完整项目级门禁;缺 live 凭据时只标记 live readiness skipped,不影响本地门禁结论。","业务流程:发布前验证;步骤:bash scripts/qa/run-release-gate.sh project-wide;失败时输出阶段、命令、截图或报告路径。","bash|npm|playwright",DONE,DONE,DONE,"scripts/qa/run-release-gate.sh|scripts/qa/run-web-gates.sh|scripts/qa","[""B2"",""B3"",""B4"",""B5"",""B6""]","不改变既有 all/r66 等模式默认行为。"
|
||||
B8,第二轮回归部署与公网复核,"完成 B1-B7 后执行本地回归、提交、部署和公网复核。","Issue CSV 状态回填;git status 干净;公网 /ready、企业微信 config、public bundle scan、项目级门禁结果明确;用户可测试项已部署。","业务流程:交付闭环;步骤:validate issue csv、client typecheck/build、server prisma validate、project-wide gate、deploy/trialrun/deploy.sh、公网 ready 和 bundle scan。","bash|npm|curl|git",DONE,DONE,DONE,"issues|client|server|scripts|docs","[""B1"",""B2"",""B3"",""B4"",""B5"",""B6"",""B7""]","若 live smoke 缺凭据,最终报告必须列明阻塞项和最小补齐清单。"
|
||||
|
+387
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
|
||||
const navSchemaPath = path.join(rootDir, "client/src/features/navigation/schema.ts");
|
||||
const flowMatrixPath = path.join(rootDir, "docs/trialrun/project-wide-flow-matrix.csv");
|
||||
const serverRoutesDir = path.join(rootDir, "server/src/routes");
|
||||
const e2eDir = path.join(rootDir, "client/tests/e2e");
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [--json] [--markdown <path>] [--fail-on-gaps]
|
||||
|
||||
输出:
|
||||
--json 输出 JSON 到 stdout
|
||||
--markdown <path> 输出 Markdown 摘要到指定路径
|
||||
--fail-on-gaps 存在 P0 missing_e2e 或 missing_visual 时以 1 退出
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed = {
|
||||
json: false,
|
||||
markdownPath: "",
|
||||
failOnGaps: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") {
|
||||
parsed.json = true;
|
||||
} else if (arg === "--markdown") {
|
||||
parsed.markdownPath = args[index + 1] ?? "";
|
||||
index += 1;
|
||||
} else if (arg === "--fail-on-gaps") {
|
||||
parsed.failOnGaps = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
usage();
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数:${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes("--markdown") && !parsed.markdownPath) {
|
||||
throw new Error("--markdown 需要指定输出路径");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
return readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function walkFiles(dir, predicate = () => true) {
|
||||
const output = [];
|
||||
if (!existsSync(dir)) return output;
|
||||
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) {
|
||||
output.push(...walkFiles(file, predicate));
|
||||
} else if (predicate(file)) {
|
||||
output.push(file);
|
||||
}
|
||||
}
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
function toPosix(file) {
|
||||
return file.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (quoted && char === "\"" && next === "\"") {
|
||||
current += "\"";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === ",") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
cells.push(current);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseCsv(source) {
|
||||
const lines = source.split(/\r?\n/).filter((line) => line.trim());
|
||||
const header = parseCsvLine(lines[0] ?? "");
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = parseCsvLine(line);
|
||||
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""]));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
if (!route) return "/";
|
||||
if (route === "*") return "*";
|
||||
const prefixed = route.startsWith("/") ? route : `/${route}`;
|
||||
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
||||
}
|
||||
|
||||
function routeToKeyword(route) {
|
||||
return normalizeRoute(route)
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/:[^/]+/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function routeToRegex(route) {
|
||||
const normalized = normalizeRoute(route);
|
||||
const escaped = normalized
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\\:[^/]+/g, "[^/]+");
|
||||
return new RegExp(`(^|["'\`\\s])${escaped}($|["'\`?#\\s])`);
|
||||
}
|
||||
|
||||
function parseRouterRoutes() {
|
||||
const source = readText(routerPath);
|
||||
const routeRegex = /<Route\s+([^>]*?)>/gs;
|
||||
const routes = [];
|
||||
let match = routeRegex.exec(source);
|
||||
while (match) {
|
||||
const attrs = match[1];
|
||||
const pathMatch = attrs.match(/\bpath=(?:"([^"]+)"|\{`([^`]+)`\})/);
|
||||
const indexRoute = /\bindex\b/.test(attrs);
|
||||
if (pathMatch || indexRoute) {
|
||||
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
|
||||
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
|
||||
if (normalized !== "*" && normalized !== "/403") {
|
||||
routes.push({ path: normalized });
|
||||
}
|
||||
}
|
||||
match = routeRegex.exec(source);
|
||||
}
|
||||
|
||||
return [...new Map(routes.map((route) => [route.path, route])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
function parsePageTitles() {
|
||||
const source = existsSync(navSchemaPath) ? readText(navSchemaPath) : "";
|
||||
const titleRegex = /\{\s*path:\s*"([^"]+)",\s*title:\s*"([^"]+)",\s*group:\s*"([^"]+)"/g;
|
||||
const titles = new Map();
|
||||
let match = titleRegex.exec(source);
|
||||
while (match) {
|
||||
titles.set(normalizeRoute(match[1]), { title: match[2], group: match[3] });
|
||||
match = titleRegex.exec(source);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
function parseSpecs() {
|
||||
return walkFiles(e2eDir, (file) => file.endsWith(".spec.ts")).map((file) => {
|
||||
const relPath = toPosix(path.relative(rootDir, file));
|
||||
const source = readText(file);
|
||||
const lower = `${relPath}\n${source}`.toLowerCase();
|
||||
return {
|
||||
path: relPath,
|
||||
source,
|
||||
lower,
|
||||
isMobile: /mobile|\/mobile|移动/.test(lower),
|
||||
isWecom: /wecom|wxwork|企业微信|weixinwork/.test(lower),
|
||||
isVisual: /visual|screenshot|tohavescreenshot|视觉/.test(lower),
|
||||
isFlow: /login\(|createauthenticatedapiclient|api(post|get|patch)data|workflow|approve|submit|上传|审批|报销|请假/.test(lower),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseServerRoutes() {
|
||||
return walkFiles(serverRoutesDir, (file) => file.endsWith(".js")).map((file) => toPosix(path.relative(rootDir, file)));
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return value.split("|").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function routeTokenMatches(pattern, route) {
|
||||
const normalizedPattern = normalizeRoute(pattern);
|
||||
const normalizedRoute = normalizeRoute(route);
|
||||
if (normalizedPattern === normalizedRoute) return true;
|
||||
if (normalizedPattern.includes(":")) {
|
||||
return routeToRegex(normalizedPattern).test(normalizedRoute);
|
||||
}
|
||||
return normalizedRoute === normalizedPattern || normalizedRoute.startsWith(`${normalizedPattern}/`);
|
||||
}
|
||||
|
||||
function domainForRoute(route, domains) {
|
||||
return domains.find((domain) => {
|
||||
const routeTokens = [
|
||||
...splitList(domain.PC_Routes ?? ""),
|
||||
...splitList(domain.Mobile_WeCom_Routes ?? ""),
|
||||
];
|
||||
return routeTokens.some((token) => routeTokenMatches(token, route));
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function specMatchesRoute(spec, route, domain) {
|
||||
const normalized = normalizeRoute(route);
|
||||
const keyword = routeToKeyword(normalized);
|
||||
const routeRegex = routeToRegex(normalized);
|
||||
if (routeRegex.test(spec.source)) return true;
|
||||
|
||||
if (keyword && keyword.split(" ").some((part) => part.length >= 5 && spec.lower.includes(part.toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!domain) return false;
|
||||
const domainKeywords = [
|
||||
domain.Domain,
|
||||
...splitList(domain.PC_Routes ?? ""),
|
||||
...splitList(domain.Mobile_WeCom_Routes ?? ""),
|
||||
...splitList(domain.Server_Routes ?? "").map((item) => path.basename(item, ".js")),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.split(/[\s/|:_-]+/)
|
||||
.filter((item) => item.length >= 4);
|
||||
|
||||
return domainKeywords.some((part) => spec.lower.includes(part));
|
||||
}
|
||||
|
||||
function classifyRoute(route, specs, domain, titleMeta) {
|
||||
const matchedSpecs = specs.filter((spec) => specMatchesRoute(spec, route.path, domain));
|
||||
const coverage = {
|
||||
pc: matchedSpecs.some((spec) => !spec.isMobile && !route.path.startsWith("/mobile")),
|
||||
mobile: matchedSpecs.some((spec) => spec.isMobile) || route.path.startsWith("/mobile"),
|
||||
wecom: matchedSpecs.some((spec) => spec.isWecom),
|
||||
visual: matchedSpecs.some((spec) => spec.isVisual),
|
||||
flow: matchedSpecs.some((spec) => spec.isFlow),
|
||||
};
|
||||
const gaps = [];
|
||||
const isP0 = domain?.Tier === "P0";
|
||||
if (!matchedSpecs.length) gaps.push("missing_e2e");
|
||||
if (route.path.startsWith("/mobile") && !coverage.wecom) gaps.push("missing_wecom_assertion");
|
||||
if (isP0 && !coverage.visual) gaps.push("missing_visual");
|
||||
if (isP0 && !coverage.flow) gaps.push("missing_flow");
|
||||
|
||||
return {
|
||||
path: route.path,
|
||||
title: titleMeta?.title ?? "",
|
||||
group: titleMeta?.group ?? "",
|
||||
domain: domain?.Domain ?? "未映射",
|
||||
tier: domain?.Tier ?? "unmapped",
|
||||
coverage,
|
||||
specs: matchedSpecs.map((spec) => spec.path),
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(routes, domains, serverRoutes) {
|
||||
const gapCounts = new Map();
|
||||
for (const route of routes) {
|
||||
for (const gap of route.gaps) {
|
||||
gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const domainRows = domains.map((domain) => {
|
||||
const domainRoutes = routes.filter((route) => route.domain === domain.Domain);
|
||||
return {
|
||||
domain: domain.Domain,
|
||||
tier: domain.Tier,
|
||||
routeCount: domainRoutes.length,
|
||||
gapCount: domainRoutes.reduce((total, route) => total + route.gaps.length, 0),
|
||||
routes: domainRoutes.map((route) => route.path),
|
||||
knownGaps: domain.Known_Gaps,
|
||||
nextAction: domain.Next_Action,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
routeCount: routes.length,
|
||||
domainCount: domains.length,
|
||||
serverRouteCount: serverRoutes.length,
|
||||
gapCounts: Object.fromEntries([...gapCounts.entries()].sort()),
|
||||
highRiskGapCount: routes.filter((route) => route.tier === "P0" && route.gaps.length).length,
|
||||
},
|
||||
domains: domainRows,
|
||||
routes,
|
||||
serverRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const gapSummary = Object.entries(report.summary.gapCounts)
|
||||
.map(([gap, count]) => `- ${gap}: ${count}`)
|
||||
.join("\n") || "- none: 0";
|
||||
|
||||
const routeRows = report.routes
|
||||
.map((route) => `| ${route.path} | ${route.domain} | ${route.tier} | ${route.coverage.pc ? "Y" : "N"} | ${route.coverage.mobile ? "Y" : "N"} | ${route.coverage.wecom ? "Y" : "N"} | ${route.coverage.visual ? "Y" : "N"} | ${route.coverage.flow ? "Y" : "N"} | ${route.gaps.join("<br>") || "-"} |`)
|
||||
.join("\n");
|
||||
|
||||
const domainRows = report.domains
|
||||
.map((domain) => `| ${domain.domain} | ${domain.tier} | ${domain.routeCount} | ${domain.gapCount} | ${domain.nextAction || "-"} |`)
|
||||
.join("\n");
|
||||
|
||||
return `# 项目级流程覆盖扫描
|
||||
|
||||
- 生成时间:${report.generatedAt}
|
||||
- 前端路由数:${report.summary.routeCount}
|
||||
- 业务域数:${report.summary.domainCount}
|
||||
- 后端 route 文件数:${report.summary.serverRouteCount}
|
||||
- P0 含缺口路由数:${report.summary.highRiskGapCount}
|
||||
|
||||
## 缺口汇总
|
||||
|
||||
${gapSummary}
|
||||
|
||||
## 业务域
|
||||
|
||||
| Domain | Tier | Routes | Gaps | Next Action |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
${domainRows}
|
||||
|
||||
## 路由覆盖
|
||||
|
||||
| Route | Domain | Tier | PC | Mobile | WeCom | Visual | Flow | Gaps |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
${routeRows}
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs();
|
||||
const domains = parseCsv(readText(flowMatrixPath));
|
||||
const routes = parseRouterRoutes();
|
||||
const titles = parsePageTitles();
|
||||
const specs = parseSpecs();
|
||||
const serverRoutes = parseServerRoutes();
|
||||
const classifiedRoutes = routes.map((route) => {
|
||||
const domain = domainForRoute(route.path, domains);
|
||||
const titleMeta = titles.get(route.path) ?? titles.get(`${route.path}/`);
|
||||
return classifyRoute(route, specs, domain, titleMeta);
|
||||
});
|
||||
const report = summarize(classifiedRoutes, domains, serverRoutes);
|
||||
|
||||
if (args.markdownPath) {
|
||||
const outputPath = path.resolve(rootDir, args.markdownPath);
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, renderMarkdown(report), "utf8");
|
||||
console.error(`[flow-coverage] markdown=${outputPath}`);
|
||||
}
|
||||
|
||||
if (args.json || !args.markdownPath) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
if (args.failOnGaps) {
|
||||
const hasBlockingGap = report.routes.some(
|
||||
(route) => route.tier === "P0" && (route.gaps.includes("missing_e2e") || route.gaps.includes("missing_visual")),
|
||||
);
|
||||
if (hasBlockingGap) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
|
||||
|
||||
function present(key) {
|
||||
return Boolean(process.env[key]?.trim());
|
||||
}
|
||||
|
||||
function maskedPresence(keys) {
|
||||
return Object.fromEntries(keys.map((key) => [key, present(key)]));
|
||||
}
|
||||
|
||||
function existingFileFrom(keys) {
|
||||
const values = keys.map((key) => process.env[key]?.trim()).filter(Boolean);
|
||||
return values.find((value) => existsSync(value)) || "";
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, json, text: text.slice(0, 300) };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function buildStaticChecks() {
|
||||
const liveSmokeKeys = [
|
||||
"LIVE_SMOKE_ADMIN_IDENTIFIER",
|
||||
"LIVE_SMOKE_ADMIN_PASSWORD",
|
||||
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
|
||||
"LIVE_SMOKE_EMPLOYEE_PASSWORD",
|
||||
];
|
||||
const liveSmokeReady = liveSmokeKeys.every(present);
|
||||
const ocrUserReady = (
|
||||
(present("PLAYWRIGHT_OCR_LIVE_IDENTIFIER") && present("PLAYWRIGHT_OCR_LIVE_PASSWORD")) ||
|
||||
(present("LIVE_SMOKE_EMPLOYEE_IDENTIFIER") && present("LIVE_SMOKE_EMPLOYEE_PASSWORD"))
|
||||
);
|
||||
const invoiceFile = existingFileFrom(["PLAYWRIGHT_OCR_LIVE_FILE", "LIVE_OCR_INVOICE_FILE"]);
|
||||
const invoiceBatchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() || "";
|
||||
const invoiceSampleReady = Boolean(invoiceFile) || (Boolean(invoiceBatchDir) && existsSync(invoiceBatchDir));
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
liveSmokeReady,
|
||||
liveSmoke: maskedPresence(liveSmokeKeys),
|
||||
liveSmokeOptional: maskedPresence([
|
||||
"LIVE_EXPECT_WECOM_STATUS",
|
||||
"LIVE_SMOKE_MANAGER_IDENTIFIER",
|
||||
"LIVE_SMOKE_MANAGER_PASSWORD",
|
||||
"LIVE_SMOKE_FINANCE_IDENTIFIER",
|
||||
"LIVE_SMOKE_FINANCE_PASSWORD",
|
||||
]),
|
||||
liveOcrReady: ocrUserReady && invoiceSampleReady,
|
||||
liveOcr: {
|
||||
userReady: ocrUserReady,
|
||||
invoiceSampleReady,
|
||||
invoiceFileProvided: Boolean(invoiceFile),
|
||||
invoiceBatchDirProvided: Boolean(invoiceBatchDir),
|
||||
allowDuplicate: process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1",
|
||||
},
|
||||
missing: [],
|
||||
};
|
||||
}
|
||||
|
||||
function addMissing(checks) {
|
||||
if (!checks.liveSmokeReady) {
|
||||
for (const [key, value] of Object.entries(checks.liveSmoke)) {
|
||||
if (!value) checks.missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!checks.liveOcr.userReady) {
|
||||
checks.missing.push("PLAYWRIGHT_OCR_LIVE_IDENTIFIER/PASSWORD 或 LIVE_SMOKE_EMPLOYEE_*");
|
||||
}
|
||||
if (!checks.liveOcr.invoiceSampleReady) {
|
||||
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const strict = process.argv.includes("--strict");
|
||||
const jsonOnly = process.argv.includes("--json");
|
||||
const checks = buildStaticChecks();
|
||||
addMissing(checks);
|
||||
|
||||
const ready = await fetchJson(`${baseUrl}/ready`);
|
||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
...checks,
|
||||
publicReady: {
|
||||
ok: ready.ok,
|
||||
status: ready.status,
|
||||
},
|
||||
wecomConfig: {
|
||||
ok: wecomConfig.ok,
|
||||
status: wecomConfig.status,
|
||||
statusText: wecomConfig.json?.data?.status ?? null,
|
||||
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
|
||||
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
|
||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
||||
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
if (jsonOnly) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`[live-smoke-env] base=${result.baseUrl}`);
|
||||
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
||||
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
|
||||
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
||||
console.log(`[live-smoke-env] liveOcrReady=${result.liveOcrReady}`);
|
||||
if (result.missing.length) {
|
||||
console.log(`[live-smoke-env] missing=${result.missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[live-smoke-env][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
REPORT_DIR="${MIGRATION_DRIFT_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/migration-drift-$(date +%Y%m%d-%H%M%S)}"
|
||||
STATUS_LOG="$REPORT_DIR/prisma-migrate-status.txt"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
echo ">>> [migration-drift] Prisma migrate status(只读)"
|
||||
set +e
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
npx prisma migrate status
|
||||
) >"$STATUS_LOG" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
cat "$STATUS_LOG"
|
||||
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
echo "[migration-drift][PASS] 当前 DATABASE_URL 对应数据库与迁移目录一致"
|
||||
echo "[migration-drift] report=$STATUS_LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[migration-drift][WARN] Prisma migrate status 返回非 0:$status"
|
||||
echo "[migration-drift][WARN] 未执行 migrate reset,也未修改数据库。"
|
||||
echo "[migration-drift][WARN] 安全替代回归命令:bash scripts/qa/run-isolated-regression-dedup.sh"
|
||||
echo "[migration-drift][WARN] 若确需重建本地开发库,必须先人工确认备份与影响范围。"
|
||||
echo "[migration-drift] report=$STATUS_LOG"
|
||||
|
||||
if [[ "${MIGRATION_DRIFT_STRICT:-0}" == "1" ]]; then
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
MODE="${1:-local}"
|
||||
REPORT_DIR="${BUSINESS_FLOW_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/business-flow-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-business-flow-gates.sh <local|attached|list>
|
||||
|
||||
说明:
|
||||
local 通过 scripts/qa/run-web-gates.sh business-flow-local 准备临时库和服务后运行
|
||||
attached 复用当前 E2E_BASE_URL 直接按业务域执行 Playwright
|
||||
list 仅输出业务域与用例清单
|
||||
|
||||
可选环境变量:
|
||||
BUSINESS_FLOW_GROUPS=auth,reimbursement 仅运行部分业务域
|
||||
E2E_BASE_URL attached 模式下必填或沿用 Playwright 默认
|
||||
USAGE
|
||||
}
|
||||
|
||||
declare -a GROUP_ORDER=(
|
||||
auth
|
||||
reimbursement
|
||||
approval
|
||||
leave
|
||||
collaboration
|
||||
hr
|
||||
project
|
||||
procurement
|
||||
asset
|
||||
travel
|
||||
governance
|
||||
)
|
||||
|
||||
specs_for_group() {
|
||||
case "$1" in
|
||||
auth)
|
||||
printf '%s\n' \
|
||||
tests/e2e/login-portal-ux.spec.ts \
|
||||
tests/e2e/wecom-entry.spec.ts \
|
||||
tests/e2e/wecom-first-bind.spec.ts \
|
||||
tests/e2e/r89-mobile-login-navigation.spec.ts
|
||||
;;
|
||||
reimbursement)
|
||||
printf '%s\n' \
|
||||
tests/e2e/reimbursement-workflow-progress.spec.ts \
|
||||
tests/e2e/invoice-ocr-flow.spec.ts \
|
||||
tests/e2e/reimbursement-payment-batch-flow.spec.ts \
|
||||
tests/e2e/r102-reimbursement-main-flow.spec.ts \
|
||||
tests/e2e/r111-invoice-reimbursement-guard.spec.ts \
|
||||
tests/e2e/r125-invoice-reupload-after-delete.spec.ts
|
||||
;;
|
||||
approval)
|
||||
printf '%s\n' \
|
||||
tests/e2e/workflow-quick-approve.spec.ts \
|
||||
tests/e2e/mobile-approvals-offline.spec.ts
|
||||
;;
|
||||
leave)
|
||||
printf '%s\n' \
|
||||
tests/e2e/leave-flow.spec.ts \
|
||||
tests/e2e/leave-team-calendar.spec.ts
|
||||
;;
|
||||
collaboration)
|
||||
printf '%s\n' \
|
||||
tests/e2e/weekly-todo-flow.spec.ts \
|
||||
tests/e2e/announcements-presence.spec.ts \
|
||||
tests/e2e/global-search.spec.ts
|
||||
;;
|
||||
hr)
|
||||
printf '%s\n' \
|
||||
tests/e2e/user-onboarding.spec.ts \
|
||||
tests/e2e/r75-automation-gap-fill.spec.ts
|
||||
;;
|
||||
project)
|
||||
printf '%s\n' \
|
||||
tests/e2e/project-workbench.spec.ts \
|
||||
tests/e2e/confidential-boundary.spec.ts
|
||||
;;
|
||||
procurement)
|
||||
printf '%s\n' \
|
||||
tests/e2e/procurement-flow.spec.ts
|
||||
;;
|
||||
asset)
|
||||
printf '%s\n' \
|
||||
tests/e2e/assets-flow.spec.ts
|
||||
;;
|
||||
travel)
|
||||
printf '%s\n' \
|
||||
tests/e2e/travel-payment-flow.spec.ts \
|
||||
tests/e2e/travel-reconciliation-flow.spec.ts
|
||||
;;
|
||||
governance)
|
||||
printf '%s\n' \
|
||||
tests/e2e/settings-governance.spec.ts
|
||||
;;
|
||||
*)
|
||||
echo "[business-flow][ERROR] 未知业务域:$1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
selected_groups() {
|
||||
if [[ -n "${BUSINESS_FLOW_GROUPS:-}" ]]; then
|
||||
tr ',' '\n' <<<"$BUSINESS_FLOW_GROUPS" | sed '/^$/d'
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "${GROUP_ORDER[@]}"
|
||||
}
|
||||
|
||||
existing_specs_for_group() {
|
||||
local group="$1"
|
||||
local spec
|
||||
while IFS= read -r spec; do
|
||||
[[ -f "$CLIENT_DIR/$spec" ]] && printf '%s\n' "$spec"
|
||||
done < <(specs_for_group "$group")
|
||||
}
|
||||
|
||||
list_groups() {
|
||||
local group
|
||||
for group in "${GROUP_ORDER[@]}"; do
|
||||
echo "[$group]"
|
||||
existing_specs_for_group "$group" | sed 's/^/ - /'
|
||||
done
|
||||
}
|
||||
|
||||
run_attached() {
|
||||
local group
|
||||
local status=0
|
||||
local summary="$REPORT_DIR/summary.txt"
|
||||
: >"$summary"
|
||||
|
||||
for group in $(selected_groups); do
|
||||
mapfile -t specs < <(existing_specs_for_group "$group")
|
||||
if [[ "${#specs[@]}" -eq 0 ]]; then
|
||||
echo "[business-flow][SKIP] $group:无可执行 spec" | tee -a "$summary"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ">>> [business-flow] $group" | tee -a "$summary"
|
||||
if (
|
||||
cd "$CLIENT_DIR"
|
||||
E2E_MODE=local npx playwright test "${specs[@]}" --workers=1 --reporter=list
|
||||
); then
|
||||
echo "[business-flow][PASS] $group" | tee -a "$summary"
|
||||
else
|
||||
echo "[business-flow][FAIL] $group" | tee -a "$summary"
|
||||
status=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[business-flow] report=$summary"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
list)
|
||||
list_groups
|
||||
;;
|
||||
attached)
|
||||
run_attached
|
||||
;;
|
||||
local)
|
||||
BUSINESS_FLOW_REPORT_DIR="$REPORT_DIR" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" business-flow-local
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
REPORT_DIR="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-readiness-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
has_live_smoke_credentials() {
|
||||
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
|
||||
}
|
||||
|
||||
has_live_ocr_credentials() {
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_IDENTIFIER:-}" && -n "${PLAYWRIGHT_OCR_LIVE_PASSWORD:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] && [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
|
||||
}
|
||||
|
||||
has_live_ocr_sample() {
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_FILE:-}" && -f "$PLAYWRIGHT_OCR_LIVE_FILE" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${LIVE_OCR_INVOICE_FILE:-}" && -f "$LIVE_OCR_INVOICE_FILE" ]]; then
|
||||
export PLAYWRIGHT_OCR_LIVE_FILE="$LIVE_OCR_INVOICE_FILE"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_DIR:-}" && -d "$PLAYWRIGHT_OCR_LIVE_DIR" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ">>> [live-readiness] 环境与公网入口检查"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs"
|
||||
|
||||
echo ">>> [live-readiness] /ready: $PUBLIC_BASE_URL"
|
||||
curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
|
||||
|
||||
echo ">>> [live-readiness] 公网资源文案扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
|
||||
|
||||
if has_live_smoke_credentials; then
|
||||
echo ">>> [live-readiness] 执行公网 live smoke"
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP live smoke:缺少完整 LIVE_SMOKE_* 凭据" | tee "$REPORT_DIR/live-smoke.txt"
|
||||
fi
|
||||
|
||||
if has_live_ocr_credentials && has_live_ocr_sample; then
|
||||
echo ">>> [live-readiness] 执行真实 OCR 样本复核"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
PLAYWRIGHT_MANUAL=1 \
|
||||
PLAYWRIGHT_OCR_LIVE=1 \
|
||||
PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list
|
||||
) | tee "$REPORT_DIR/live-ocr.txt"
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP 真实 OCR:缺少 smoke 员工凭据或 PLAYWRIGHT_OCR_LIVE_FILE/PLAYWRIGHT_OCR_LIVE_DIR" | tee "$REPORT_DIR/live-ocr.txt"
|
||||
fi
|
||||
|
||||
echo "[live-readiness][PASS] readiness 检查完成"
|
||||
echo "[live-readiness] report=$REPORT_DIR"
|
||||
@@ -10,12 +10,13 @@ mkdir -p "$ARTIFACT_ROOT"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|r66-trialrun|help>
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|project-wide|deploy|ci-release|r66-trialrun|help>
|
||||
|
||||
说明:
|
||||
static 运行部署契约、server Prisma 校验、client typecheck/build
|
||||
smoke-local 运行本地网页 smoke 门禁
|
||||
full-local 运行本地全量门禁(server regression + client full E2E)
|
||||
project-wide 项目级门禁:static + 覆盖扫描 + UI/移动契约 + 迁移漂移诊断 + 业务流 + 全页面视觉 + live readiness
|
||||
deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
|
||||
all 依次执行 static -> smoke-local -> full-local -> deploy
|
||||
ci-release 依次执行 static -> full-local -> deploy
|
||||
@@ -25,6 +26,8 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|de
|
||||
RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
|
||||
QA_* 透传给 scripts/qa/run-web-gates.sh
|
||||
LIVE_SMOKE_* 透传给 deploy.sh 与 live smoke
|
||||
PROJECT_WIDE_SKIP_BUSINESS_FLOW=1 跳过业务流 E2E
|
||||
PROJECT_WIDE_SKIP_VISUAL=1 跳过项目级视觉 E2E
|
||||
DEPLOY_SERVER / REMOTE_DIR / REMOTE_ENV_FILE / SYNC_ENV
|
||||
USAGE
|
||||
}
|
||||
@@ -81,6 +84,39 @@ stage_server_regression_all() {
|
||||
)
|
||||
}
|
||||
|
||||
stage_project_wide_static() {
|
||||
local issue_csv="$ROOT_DIR/issues/2026-05-08_23-38-37-project-wide-qa-second-pass.csv"
|
||||
python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py "$issue_csv"
|
||||
node "$ROOT_DIR/scripts/qa/check-ui-design-rules.mjs" --strict
|
||||
node "$ROOT_DIR/scripts/qa/check-mobile-route-contract.mjs"
|
||||
node "$ROOT_DIR/scripts/qa/check-flow-coverage.mjs" --json > "$ARTIFACT_ROOT/flow-coverage.json"
|
||||
node "$ROOT_DIR/scripts/qa/check-flow-coverage.mjs" --markdown "$ARTIFACT_ROOT/flow-coverage.md"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$ARTIFACT_ROOT/live-smoke-env.json"
|
||||
bash "$ROOT_DIR/scripts/qa/check-local-migration-drift.sh"
|
||||
}
|
||||
|
||||
stage_project_wide_business_flow() {
|
||||
if [[ "${PROJECT_WIDE_SKIP_BUSINESS_FLOW:-0}" == "1" ]]; then
|
||||
echo "[release-gate] 跳过业务流 E2E(PROJECT_WIDE_SKIP_BUSINESS_FLOW=1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bash "$ROOT_DIR/scripts/qa/run-business-flow-gates.sh" local
|
||||
}
|
||||
|
||||
stage_project_wide_visual() {
|
||||
if [[ "${PROJECT_WIDE_SKIP_VISUAL:-0}" == "1" ]]; then
|
||||
echo "[release-gate] 跳过项目级视觉 E2E(PROJECT_WIDE_SKIP_VISUAL=1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" project-wide-visual-local
|
||||
}
|
||||
|
||||
stage_project_wide_live_readiness() {
|
||||
LIVE_SMOKE_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh"
|
||||
}
|
||||
|
||||
stage_deploy() {
|
||||
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||
}
|
||||
@@ -98,6 +134,13 @@ case "$MODE" in
|
||||
full-local)
|
||||
run_stage full-local stage_full_local
|
||||
;;
|
||||
project-wide)
|
||||
run_stage static stage_static
|
||||
run_stage project-wide-static stage_project_wide_static
|
||||
run_stage project-wide-business-flow stage_project_wide_business_flow
|
||||
run_stage project-wide-visual stage_project_wide_visual
|
||||
run_stage project-wide-live-readiness stage_project_wide_live_readiness
|
||||
;;
|
||||
deploy)
|
||||
run_stage deploy stage_deploy
|
||||
;;
|
||||
|
||||
+50
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|business-flow-local|project-wide-visual-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
@@ -276,6 +276,7 @@ qa_export_app_env() {
|
||||
export GLOBAL_SEARCH_ENABLED="true"
|
||||
export ANNOUNCEMENTS_ENABLED="true"
|
||||
export PRESENCE_BOARD_ENABLED="true"
|
||||
export ATTENDANCE_ENABLED="${ATTENDANCE_ENABLED:-true}"
|
||||
export WEEKLY_REPORT_ENABLED="true"
|
||||
export EMPLOYEE_LIFECYCLE_ENABLED="true"
|
||||
export TODO_BOARD_ENABLED="true"
|
||||
@@ -285,6 +286,8 @@ qa_export_app_env() {
|
||||
export PROJECT_CLOSURE_CHECKLIST_ENABLED="true"
|
||||
export CONFIDENTIAL_DOC_REFS_ENABLED="true"
|
||||
export TRAVEL_SYNC_ENABLED="true"
|
||||
export ERP_SYNC_ENABLED="${ERP_SYNC_ENABLED:-true}"
|
||||
export PAYMENT_BATCH_ENABLED="${PAYMENT_BATCH_ENABLED:-true}"
|
||||
export IM_NOTIFIER_ENABLED="true"
|
||||
export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT"
|
||||
}
|
||||
@@ -505,6 +508,36 @@ qa_run_r110_mobile_local() {
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_business_flow_local() {
|
||||
qa_select_port
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}"
|
||||
export JOBS_ENABLED="${JOBS_ENABLED:-true}"
|
||||
qa_export_app_env
|
||||
qa_prepare_db
|
||||
qa_build_client
|
||||
qa_start_server
|
||||
|
||||
echo ">>> 执行业务流门禁"
|
||||
E2E_BASE_URL="$QA_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-business-flow-gates.sh" attached
|
||||
}
|
||||
|
||||
qa_run_project_wide_visual_local() {
|
||||
qa_select_port
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}"
|
||||
export JOBS_ENABLED="${JOBS_ENABLED:-true}"
|
||||
qa_export_app_env
|
||||
qa_prepare_db
|
||||
qa_build_client
|
||||
qa_start_server
|
||||
|
||||
echo ">>> 执行项目级全页面视觉巡检"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
rm -rf test-results playwright-report
|
||||
E2E_BASE_URL="$QA_BASE_URL" E2E_MODE=local npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_live_smoke() {
|
||||
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
||||
@@ -535,6 +568,22 @@ case "$MODE" in
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_full_local
|
||||
;;
|
||||
business-flow-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_business_flow_local
|
||||
;;
|
||||
project-wide-visual-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_project_wide_visual_local
|
||||
;;
|
||||
r70-assist-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
|
||||
在新工单中引用
屏蔽一个用户