import { expect, test } from "@playwright/test"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { ADMIN, EMPLOYEE, apiPostData, createAuthenticatedApiClient, login, logout } from "../helpers/auth"; import { openNavLink } from "../helpers/navigation"; import { TEST_IDS } from "../helpers/testIds"; type StepStatus = "PASS" | "FAIL"; type TrialStep = { title: string; status: StepStatus; detail: string; screenshot: string; }; const rootDir = path.resolve(process.cwd(), ".."); const reportPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-report.md"); const issueCandidatesPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-issues.csv"); const screenshotDir = path.join(rootDir, "deploy/trialrun/screenshots/r70-auto-trialrun-deep"); function formatWeekStart(offsetDays: number) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offsetDays); return value.toISOString().slice(0, 10); } function csvCell(value: string) { return `"${value.replaceAll('"', '""')}"`; } function safeName(value: string) { return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80); } function writeReports(steps: TrialStep[]) { mkdirSync(path.dirname(reportPath), { recursive: true }); const failures = steps.filter((item) => item.status === "FAIL"); const now = new Date().toISOString(); const lines = [ "# R70 深层自动辅助试运行报告", "", `生成时间:${now}`, "", "## 结论", "", failures.length ? `深层自动辅助试运行发现 ${failures.length} 个失败步骤,需继续人工确认。` : "深层自动辅助试运行未发现阻断步骤;仍需人工判断整体体验是否顺手。", "", "## 步骤结果", "", "| Step | Status | Screenshot | Detail |", "| --- | --- | --- | --- |", ...steps.map((item) => `| ${item.title} | ${item.status} | ${item.screenshot || "N/A"} | ${item.detail.replaceAll("|", "\\|")} |`), ]; writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8"); const issueHeader = [ "ID", "Role", "Severity", "Module", "Title", "Steps", "Expected", "Actual", "Screenshot", "Owner", "Disposition", "Status", "Notes", ]; const issueRows = failures.map((item, index) => [ `R70-DEEP-${String(index + 1).padStart(3, "0")}`, "employee", "P1", item.title, `深层自动辅助试运行失败:${item.title}`, item.title, "真实环境业务链路可执行且关键断言通过", item.detail, item.screenshot || "N/A", "TBD", "needs review", "OPEN", "由 R70 深层自动辅助试运行生成,需人工复核严重级别。", ].map(csvCell).join(","), ); writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8"); } test("R70 深层自动辅助试运行:周报待办、公告团队状态、真实搜索", async ({ page }) => { test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); test.skip(process.env.R70_TRIALRUN_DEEP_ASSIST !== "1", "仅在显式 deep assist 模式下执行"); test.setTimeout(240_000); mkdirSync(screenshotDir, { recursive: true }); const steps: TrialStep[] = []; let stepIndex = 0; const suffix = Date.now(); const uniqueToken = `Playwright 深层联动 ${suffix}`; const weekStart = formatWeekStart(280 + (suffix % 70)); const announcementTitle = `Playwright 深层公告 ${suffix}`; const presenceNote = `深层辅助复核 ${suffix}`; const adminClient = await createAuthenticatedApiClient(ADMIN); async function runStep(title: string, action: () => Promise) { stepIndex += 1; const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${safeName(title)}.png`); const relativeScreenshot = path.relative(rootDir, screenshot); try { const detail = (await action()) ?? "ok"; await page.screenshot({ path: screenshot, fullPage: true }); steps.push({ title, status: "PASS", detail, screenshot: relativeScreenshot }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); steps.push({ title, status: "FAIL", detail, screenshot: relativeScreenshot }); } } try { await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/announcements", { title: announcementTitle, summary: "用于深层自动辅助试运行验证真实公告、Portal 与搜索链路。", enabled: true, priority: 1, }); await login(page, EMPLOYEE); await runStep("周报保存-生成待办-提交-完成闭环", async () => { await (await openNavLink(page, "周报")).click(); await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible(); const weeklyLoadResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok(); }, { timeout: 30_000 }); await page.getByLabel("周报归档周").fill(weekStart); await weeklyLoadResponse; await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`); await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`); await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`); const saveResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok(); }, { timeout: 30_000 }); await page.getByTestId(TEST_IDS.weeklyReport.save).click(); await saveResponse; await expect(page.getByText("周报已保存")).toBeVisible(); const createTodoResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok(); }, { timeout: 30_000 }); await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click(); await createTodoResponse; await expect(page.getByText("已从周报生成待办")).toBeVisible(); const submitResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok(); }, { timeout: 30_000 }); await page.getByTestId(TEST_IDS.weeklyReport.submit).click(); await submitResponse; await expect(page.getByText("周报已提交")).toBeVisible(); await page.goto("/todos"); const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first(); await expect(todoRow).toBeVisible({ timeout: 30_000 }); await todoRow.getByTestId(TEST_IDS.todo.complete).click(); await expect(todoRow).toContainText("done", { timeout: 30_000 }); return `weekStart=${weekStart}; token=${uniqueToken}`; }); await runStep("公告-团队状态-Portal 汇总联动", async () => { await page.goto("/announcements"); await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); await page.goto("/team"); await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); await page.locator("select.select-std").first().selectOption("remote"); await page.getByPlaceholder("可选备注,如下午外出办事").fill(presenceNote); await page.getByRole("button", { name: "保存状态" }).click(); await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await page.goto("/portal"); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); await expect(page.getByText(announcementTitle)).not.toBeVisible(); return announcementTitle; }); await runStep("真实全局搜索命中公告并跳转", async () => { await page.goto("/portal"); await page.getByTestId("global-search-trigger").click(); await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible(); const searchResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "GET" && url.pathname === "/api/search" && response.ok(); }, { timeout: 30_000 }); await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill(String(suffix)); await searchResponse; const resultButton = page.getByRole("button", { name: new RegExp(announcementTitle) }).first(); await expect(resultButton).toBeVisible({ timeout: 30_000 }); await resultButton.click(); await expect(page).toHaveURL(/\/announcements$/); await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); return announcementTitle; }); } finally { writeReports(steps); await adminClient.dispose(); await logout(page, EMPLOYEE.name).catch(() => undefined); } const failures = steps.filter((item) => item.status === "FAIL"); expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); });