From c17a72830c3223195684ab79525fe78fa7805f70 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 23 Mar 2026 09:59:54 +0800 Subject: [PATCH] =?UTF-8?q?R28-1=20R28-4:=20=E6=94=B6=E5=8F=A3=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E9=97=A8=E7=A6=81=E4=B8=8E=E5=85=AC=E7=BD=91=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-e2e.yml | 88 ++++------- .github/workflows/ci-live-smoke.yml | 50 ++++++ client/package.json | 3 + client/playwright.config.ts | 6 +- client/tests/e2e/helpers/liveAuth.ts | 72 +++++++++ client/tests/e2e/helpers/smokeAssertions.ts | 67 ++++++++ client/tests/e2e/live-smoke.spec.ts | 145 ++++++++++++++++++ client/tests/e2e/visual-portal.spec.ts | 46 ++++++ client/tests/e2e/visual-shell.spec.ts | 35 +++++ ...3-22_21-28-38-r28-web-automation-gates.csv | 7 + 10 files changed, 456 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/ci-live-smoke.yml create mode 100644 client/tests/e2e/helpers/liveAuth.ts create mode 100644 client/tests/e2e/helpers/smokeAssertions.ts create mode 100644 client/tests/e2e/live-smoke.spec.ts create mode 100644 client/tests/e2e/visual-portal.spec.ts create mode 100644 client/tests/e2e/visual-shell.spec.ts create mode 100644 issues/2026-03-22_21-28-38-r28-web-automation-gates.csv diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index d653c2e8..0f618c02 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -2,6 +2,9 @@ name: ci-e2e on: pull_request: + push: + branches: + - main workflow_dispatch: schedule: - cron: "10 19 * * *" @@ -9,7 +12,7 @@ on: jobs: e2e: runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 services: postgres: @@ -27,24 +30,26 @@ jobs: --health-retries=10 env: - NODE_ENV: test - PORT: 3000 - HOST: 127.0.0.1 - APP_ORIGIN: http://127.0.0.1:3000 - DATABASE_URL: postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public - JWT_SECRET: ci_e2e_secret_key - OCR_PROVIDER: mock - OCR_MAX_ATTEMPTS: 3 - OCR_POLL_CRON: "*/5 * * * * *" - DELEGATION_REVOKE_CRON: "0 * * * *" - SERVE_CLIENT_DIST: "true" - CLIENT_DIST_DIR: "../client/dist" - E2E_BASE_URL: http://127.0.0.1:3000 + QA_DB_HOST: 127.0.0.1 + QA_DB_PORT: 5432 + QA_DB_USER: hyboa_user + QA_DB_PASSWORD: hyboa_password + QA_DB_ADMIN_DB: postgres + QA_PORT: 3000 + QA_HOST: 127.0.0.1 steps: - name: Checkout uses: actions/checkout@v4 + - name: Select gate mode + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "WEB_GATE_MODE=smoke-local" >> "$GITHUB_ENV" + else + echo "WEB_GATE_MODE=full-local" >> "$GITHUB_ENV" + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -62,61 +67,22 @@ jobs: working-directory: client run: npm ci - - name: Migrate database - working-directory: server - run: npm run prisma:migrate:deploy - - - name: Seed database - working-directory: server - run: npm run seed:dev - - - name: Build client - working-directory: client - run: npm run build + - name: Install PostgreSQL client + run: sudo apt-get update && sudo apt-get install -y postgresql-client - name: Install Playwright browsers working-directory: client run: npx playwright install --with-deps - - name: Start server - working-directory: server - run: | - nohup npm run dev > /tmp/hyb-oa-server.log 2>&1 & - echo $! > /tmp/hyb-oa-server.pid - - - name: Wait for ready - run: | - for i in {1..45}; do - if curl -fsS "http://127.0.0.1:3000/ready" >/dev/null; then - exit 0 - fi - sleep 2 - done - echo "Server readiness check timed out" >&2 - exit 1 - - - name: Run Playwright E2E - working-directory: client - run: npm run test:e2e - - - name: Stop server - if: always() - run: | - if [[ -f /tmp/hyb-oa-server.pid ]]; then - kill "$(cat /tmp/hyb-oa-server.pid)" || true - fi - - - name: Dump server log on failure - if: failure() - run: | - echo "==== /tmp/hyb-oa-server.log ====" - cat /tmp/hyb-oa-server.log || true + - name: Run web gate + run: bash scripts/qa/run-web-gates.sh "$WEB_GATE_MODE" - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report - path: client/playwright-report + name: playwright-report-${{ env.WEB_GATE_MODE }}-${{ github.run_id }} + path: | + client/playwright-report + client/test-results if-no-files-found: ignore - diff --git a/.github/workflows/ci-live-smoke.yml b/.github/workflows/ci-live-smoke.yml new file mode 100644 index 00000000..03fe2aac --- /dev/null +++ b/.github/workflows/ci-live-smoke.yml @@ -0,0 +1,50 @@ +name: ci-live-smoke + +on: + workflow_dispatch: + schedule: + - cron: "20 20 * * *" + +jobs: + live-smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + LIVE_SMOKE_BASE_URL: ${{ secrets.LIVE_SMOKE_BASE_URL }} + LIVE_SMOKE_ADMIN_IDENTIFIER: ${{ secrets.LIVE_SMOKE_ADMIN_IDENTIFIER }} + LIVE_SMOKE_ADMIN_PASSWORD: ${{ secrets.LIVE_SMOKE_ADMIN_PASSWORD }} + LIVE_SMOKE_EMPLOYEE_IDENTIFIER: ${{ secrets.LIVE_SMOKE_EMPLOYEE_IDENTIFIER }} + LIVE_SMOKE_EMPLOYEE_PASSWORD: ${{ secrets.LIVE_SMOKE_EMPLOYEE_PASSWORD }} + LIVE_EXPECT_WECOM_STATUS: ${{ secrets.LIVE_EXPECT_WECOM_STATUS }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Install Playwright browsers + working-directory: client + run: npx playwright install --with-deps + + - name: Run live smoke + run: bash scripts/qa/run-web-gates.sh smoke-live + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: live-smoke-report-${{ github.run_id }} + path: | + client/playwright-report + client/test-results + if-no-files-found: ignore diff --git a/client/package.json b/client/package.json index ce603d03..3714a0d6 100644 --- a/client/package.json +++ b/client/package.json @@ -8,6 +8,9 @@ "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", "test:e2e": "playwright test", + "test:e2e:smoke": "playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts", + "test:e2e:full": "playwright test", + "test:e2e:live": "playwright test tests/e2e/live-smoke.spec.ts", "preview": "vite preview" }, "dependencies": { diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 989fb828..4d5ae47a 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "@playwright/test"; -const baseURL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000"; +const e2eMode = process.env.E2E_MODE === "live" ? "live" : "local"; +const baseURL = e2eMode === "live" + ? process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com" + : process.env.E2E_BASE_URL?.trim() || "http://127.0.0.1:3000"; export default defineConfig({ testDir: "./tests/e2e", @@ -18,4 +21,3 @@ export default defineConfig({ }, reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"], ["html"]], }); - diff --git a/client/tests/e2e/helpers/liveAuth.ts b/client/tests/e2e/helpers/liveAuth.ts new file mode 100644 index 00000000..197ad87f --- /dev/null +++ b/client/tests/e2e/helpers/liveAuth.ts @@ -0,0 +1,72 @@ +import { expect, type Page } from "@playwright/test"; + +import { logout } from "./auth"; + +export type LiveSmokeRole = "admin" | "employee"; + +export type LiveSmokeUser = { + role: LiveSmokeRole; + identifier: string; + password: string; + name: string; +}; + +function requireEnv(key: string) { + const value = process.env[key]?.trim(); + if (!value) { + throw new Error(`缺少 live smoke 环境变量:${key}`); + } + return value; +} + +export function getExpectedWecomStatus() { + return process.env.LIVE_EXPECT_WECOM_STATUS?.trim() || "disabled"; +} + +export function getLiveSmokeUser(role: LiveSmokeRole) { + if (role === "admin") { + return { + role, + identifier: requireEnv("LIVE_SMOKE_ADMIN_IDENTIFIER"), + password: requireEnv("LIVE_SMOKE_ADMIN_PASSWORD"), + }; + } + + return { + role, + identifier: requireEnv("LIVE_SMOKE_EMPLOYEE_IDENTIFIER"), + password: requireEnv("LIVE_SMOKE_EMPLOYEE_PASSWORD"), + }; +} + +async function readCurrentUserName(page: Page): Promise { + const profileTrigger = page.getByTestId("topbar-profile-trigger").first(); + await expect(profileTrigger).toBeVisible(); + const name = (await profileTrigger.textContent())?.trim(); + if (!name) { + throw new Error("无法从顶栏读取当前登录用户名称"); + } + return name; +} + +export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise { + const credentials = getLiveSmokeUser(role); + + await page.goto("/login"); + await expect(page.getByTestId("login-identifier")).toBeVisible(); + await page.getByTestId("login-identifier").fill(credentials.identifier); + await page.getByTestId("login-password").fill(credentials.password); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/portal/); + + const name = await readCurrentUserName(page); + + return { + ...credentials, + name, + }; +} + +export async function logoutLiveUser(page: Page, user?: Pick) { + await logout(page, user?.name); +} diff --git a/client/tests/e2e/helpers/smokeAssertions.ts b/client/tests/e2e/helpers/smokeAssertions.ts new file mode 100644 index 00000000..b8ae6474 --- /dev/null +++ b/client/tests/e2e/helpers/smokeAssertions.ts @@ -0,0 +1,67 @@ +import { expect, type Page } from "@playwright/test"; + +export type WecomEntryConfig = { + enabled: boolean; + entryEnabled: boolean; + oauthEnabled: boolean; + corpId: string; + agentId: string; + loginUrl: string | null; + bindEnabled: boolean; + defaultReturnTo: string; + status: string; + statusReasons: string[]; +}; + +type Envelope = { + data: T; + error?: { + code?: string; + message?: string; + }; +}; + +export async function assertPortalLoaded(page: Page) { + await expect(page).toHaveURL(/\/portal/); + await expect(page.getByRole("heading", { name: "Portal" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Portal" }).first()).toBeVisible(); +} + +export async function assertMainNavVisible(page: Page) { + await expect(page.getByRole("link", { name: "Portal" }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: "团队" }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: "项目" }).first()).toBeVisible(); +} + +export async function assertBackupModulesCollapsed(page: Page) { + const summary = page.locator("summary").filter({ hasText: "备用模块" }).first(); + await expect(summary).toBeVisible(); + const details = summary.locator("xpath=ancestor::details[1]"); + const isOpen = await details.evaluate((node) => (node as HTMLDetailsElement).open); + expect(isOpen).toBe(false); + await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible(); +} + +export async function assertWecomBindingCardVisible(page: Page) { + await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible(); + await expect(page.getByText("接入状态", { exact: true })).toBeVisible(); +} + +export async function fetchWecomConfig(page: Page) { + const response = await page.request.get("/api/auth/wecom/config"); + const text = await response.text(); + let parsed: Envelope | null = null; + + try { + parsed = text ? JSON.parse(text) as Envelope : null; + } catch { + parsed = null; + } + + return { + status: response.status(), + ok: response.ok(), + body: text, + data: parsed?.data ?? null, + }; +} diff --git a/client/tests/e2e/live-smoke.spec.ts b/client/tests/e2e/live-smoke.spec.ts new file mode 100644 index 00000000..efd66b51 --- /dev/null +++ b/client/tests/e2e/live-smoke.spec.ts @@ -0,0 +1,145 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +import { getExpectedWecomStatus, loginLiveUser, logoutLiveUser } from "./helpers/liveAuth"; +import { openProfileSettings } from "./helpers/navigation"; +import { + assertBackupModulesCollapsed, + assertMainNavVisible, + assertPortalLoaded, + assertWecomBindingCardVisible, + fetchWecomConfig, +} from "./helpers/smokeAssertions"; + +async function attachDebugArtifacts(page: Page, testInfo: TestInfo, consoleMessages: string[]) { + await testInfo.attach("current-url.txt", { + body: Buffer.from(page.url() || "about:blank"), + contentType: "text/plain", + }); + + await testInfo.attach("console.log", { + body: Buffer.from(consoleMessages.join("\n") || "(no console output)"), + contentType: "text/plain", + }); + + try { + const wecomConfig = await fetchWecomConfig(page); + await testInfo.attach("wecom-config.json", { + body: Buffer.from(JSON.stringify(wecomConfig, null, 2)), + contentType: "application/json", + }); + } catch (error) { + await testInfo.attach("wecom-config-error.txt", { + body: Buffer.from(String(error)), + contentType: "text/plain", + }); + } +} + +test.describe("live smoke", () => { + test.skip(process.env.E2E_MODE !== "live", "仅在 live smoke 模式下执行"); + test.describe.configure({ mode: "serial" }); + + test("公网基础探活与企微配置状态", async ({ request, page }, testInfo) => { + const consoleMessages: string[] = []; + page.on("console", (message) => { + consoleMessages.push(`[${message.type()}] ${message.text()}`); + }); + + try { + const readyResponse = await request.get("/ready"); + expect(readyResponse.ok()).toBeTruthy(); + + await page.goto("/login"); + await expect(page.getByTestId("login-identifier")).toBeVisible(); + + const wecomConfig = await fetchWecomConfig(page); + expect(wecomConfig.status).toBe(200); + expect(wecomConfig.data?.status).toBe(getExpectedWecomStatus()); + } finally { + await attachDebugArtifacts(page, testInfo, consoleMessages); + } + }); + + test("管理员只读网页 smoke", async ({ page }, testInfo) => { + const consoleMessages: string[] = []; + page.on("console", (message) => { + consoleMessages.push(`[${message.type()}] ${message.text()}`); + }); + + let userName = "管理员"; + try { + const admin = await loginLiveUser(page, "admin"); + userName = admin.name; + + await assertPortalLoaded(page); + await assertMainNavVisible(page); + await assertBackupModulesCollapsed(page); + + await page.goto("/team"); + await expect(page.getByRole("heading", { name: "团队状态", exact: true })).toBeVisible(); + + await page.goto("/projects"); + await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible(); + + await page.goto("/announcements"); + await expect(page.getByRole("heading", { name: "公告", exact: true })).toBeVisible(); + + await page.goto("/system/workspace"); + await expect(page.getByRole("heading", { name: "工作区设置", exact: true })).toBeVisible(); + + await page.goto("/system/feature-flags"); + await expect(page.getByRole("heading", { name: "功能开关", exact: true })).toBeVisible(); + + await page.goto("/audit-logs"); + await expect(page.getByRole("heading", { name: "审计工作台", exact: true })).toBeVisible(); + + await openProfileSettings(page, userName); + await assertWecomBindingCardVisible(page); + + await logoutLiveUser(page, { name: userName }); + } finally { + await attachDebugArtifacts(page, testInfo, consoleMessages); + } + }); + + test("员工只读网页 smoke", async ({ page }, testInfo) => { + const consoleMessages: string[] = []; + page.on("console", (message) => { + consoleMessages.push(`[${message.type()}] ${message.text()}`); + }); + + let userName = "员工"; + try { + const employee = await loginLiveUser(page, "employee"); + userName = employee.name; + + await assertPortalLoaded(page); + await assertMainNavVisible(page); + + await page.goto("/team"); + await expect(page.getByRole("heading", { name: "团队状态", exact: true })).toBeVisible(); + + await page.goto("/projects"); + await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible(); + + await page.goto("/announcements"); + await expect(page.getByRole("heading", { name: "公告", exact: true })).toBeVisible(); + + await page.goto("/system/workspace"); + await expect(page.getByRole("heading", { name: "工作区设置", exact: true })).toBeVisible(); + + await page.goto("/system/feature-flags"); + await expect(page.getByRole("heading", { name: "功能开关", exact: true })).toBeVisible(); + + await page.goto("/audit-logs"); + await expect(page.getByRole("heading", { name: "审计工作台", exact: true })).toBeVisible(); + + await openProfileSettings(page, userName); + await assertWecomBindingCardVisible(page); + + await logoutLiveUser(page, { name: userName }); + } finally { + await attachDebugArtifacts(page, testInfo, consoleMessages); + } + }); +}); diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts new file mode 100644 index 00000000..ad9e0513 --- /dev/null +++ b/client/tests/e2e/visual-portal.spec.ts @@ -0,0 +1,46 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +import { EMPLOYEE, login, logout, type E2EUser } from "./helpers/auth"; + +const ADMIN: E2EUser = { + identifier: "admin@hyinda.com", + password: "AdminPassword1234", + name: "Admin", +}; + +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }); +} + +test.describe("visual portal", () => { + test("员工 Portal 视觉基线", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1200 }); + await login(page, EMPLOYEE); + + await expect(page.locator('[aria-label="portal-card-my_todos"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible(); + await expect(page.getByText("我的申请与资产")).toBeVisible(); + + await attachScreenshot(page, testInfo, "portal-employee.png"); + }); + + test("管理员 Portal 视觉基线", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1200 }); + await login(page, ADMIN); + + await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible(); + await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible(); + await expect(page.getByText("今日管理重点")).toBeVisible(); + await expect(page.getByText("试点观测摘要")).toBeVisible(); + + await attachScreenshot(page, testInfo, "portal-admin.png"); + await logout(page, ADMIN.name); + }); +}); diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts new file mode 100644 index 00000000..919cd8d7 --- /dev/null +++ b/client/tests/e2e/visual-shell.spec.ts @@ -0,0 +1,35 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +import { EMPLOYEE, login } from "./helpers/auth"; + +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }); +} + +test.describe("visual shell", () => { + test("登录页入口布局基线", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1024 }); + await page.goto("/login"); + + await expect(page.getByRole("heading", { name: "登录海音达工作台" })).toBeVisible(); + await expect(page.getByText("统一入口").first()).toBeVisible(); + await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); + + await attachScreenshot(page, testInfo, "login-shell.png"); + }); + + test("登录后壳层布局基线", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1024 }); + await login(page, EMPLOYEE); + + await expect(page.locator("aside")).toBeVisible(); + await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible(); + await expect(page.locator("aside").getByRole("link", { name: "Portal", exact: true })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "备用模块" })).toBeVisible(); + + await attachScreenshot(page, testInfo, "app-shell.png"); + }); +}); diff --git a/issues/2026-03-22_21-28-38-r28-web-automation-gates.csv b/issues/2026-03-22_21-28-38-r28-web-automation-gates.csv new file mode 100644 index 00000000..6f7f999d --- /dev/null +++ b/issues/2026-03-22_21-28-38-r28-web-automation-gates.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +R28-0,执行合同建档与范围冻结,为网页自动化测试分层与公网 smoke 建立执行合同并冻结本轮范围,Issue CSV 校验通过 且范围锁定为本地 smoke 全量 E2E 公网 smoke CI 与文档,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_21-28-38-r28-web-automation-gates.csv,manual,DONE,DONE,DONE,issues/2026-03-22_21-28-38-r28-web-automation-gates.csv,[],none +R28-1,本地 smoke 与 full 测试入口,新增本地 smoke full 两层 Playwright 运行模式与脚本入口,client 支持 test:e2e:smoke test:e2e:full 且 scripts/qa/run-web-gates.sh 可稳定执行 smoke-local full-local,cd client && npm run typecheck && bash scripts/qa/run-web-gates.sh smoke-local,manual,DONE,DONE,DONE,client/package.json|client/playwright.config.ts|scripts/qa/run-web-gates.sh,"[""R28-0""]",固定使用本地临时库与显式 env 启动 server +R28-2,公网 live smoke 用例与 helper,新增只读公网 smoke spec 与 live 登录 helper/断言 helper,client 支持 test:e2e:live 且 live-smoke 仅执行只读检查并校验 wecom config 状态,cd client && E2E_MODE=live npm run test:e2e:live,manual,DONE,DONE,DONE,client/tests/e2e/live-smoke.spec.ts|client/tests/e2e/helpers/liveAuth.ts|client/tests/e2e/helpers/smokeAssertions.ts,"[""R28-1""]",默认依赖 LIVE_SMOKE_* 环境变量和 smoke 账号 +R28-3,CI 分层与定时巡检接入,调整现有 ci-e2e 并新增 ci-live-smoke 让 PR 夜间与公网 smoke 分层运行,PR 跑 smoke 主分支和夜间跑 full 且新增 ci-live-smoke 支持 workflow_dispatch 与 schedule,.github/workflows/ci-e2e.yml + .github/workflows/ci-live-smoke.yml 静态审阅 + git diff --check,manual,DONE,DONE,DONE,.github/workflows/ci-e2e.yml|.github/workflows/ci-live-smoke.yml,"[""R28-1"",""R28-2""]",沿用现有 ci-regression 不推翻结构 +R28-4,部署与试点文档补强,把网页自动化门禁和 live smoke 步骤写入 trialrun 手册与 checklist,发布前后和试点检查单中可按文档执行 smoke-local full-local smoke-live,git diff --check,manual,DONE,DONE,DONE,deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|docs/admin-handbook.md,"[""R28-1"",""R28-2"",""R28-3""]",文档需明确公网 smoke 禁止写操作 +R28-5,回归验证与收口,执行本地 smoke 本地 full 与必要静态检查并完成 git 提交,本地 smoke full typecheck build 通过 且 issue 状态同步完成,bash scripts/qa/run-web-gates.sh smoke-local && bash scripts/qa/run-web-gates.sh full-local && cd client && npm run build,manual,DONE,DONE,DONE,issues/2026-03-22_21-28-38-r28-web-automation-gates.csv|scripts/qa/run-web-gates.sh,"[""R28-3"",""R28-4""]",live smoke 因依赖真实 smoke 账号 默认仅提供可执行入口与 workflow