diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml new file mode 100644 index 0000000..578cbdb --- /dev/null +++ b/.gitea/workflows/e2e.yml @@ -0,0 +1,63 @@ +name: Frontend E2E + +on: + pull_request: + push: + branches: + - main + - master + workflow_dispatch: {} + +jobs: + playwright: + runs-on: ubuntu-22.04 + container: + image: mcr.microsoft.com/playwright:v1.63.0-noble + permissions: + contents: read + defaults: + run: + shell: sh + + steps: + - name: Checkout code + env: + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + GIT_USERNAME: ${{ github.actor }} + GIT_TOKEN: ${{ github.token }} + run: | + case "$SERVER_URL" in + http://*) + AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}" + ;; + https://*) + AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}" + ;; + *) + AUTH_SERVER_URL="$SERVER_URL" + ;; + esac + + if [ ! -d .git ]; then + git init . + fi + + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + else + git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + fi + + git fetch --depth=1 origin "$COMMIT_SHA" + git checkout --force --detach FETCH_HEAD + git clean -ffdx + + - name: Install dependencies + run: npm ci + + - name: Run Playwright tests + env: + CI: "true" + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 8dd8a9c..b65d8de 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # testing /coverage +/playwright-report/ +/test-results/ +/e2e/.auth/ # next.js /.next/ diff --git a/README.MD b/README.MD index f938430..9f79aac 100644 --- a/README.MD +++ b/README.MD @@ -44,6 +44,7 @@ npm run dev npm run lint npm test npm run test:coverage +npm run test:e2e npm run build npm run start docker build -t tjwater-frontend:local . @@ -52,6 +53,7 @@ docker build -t tjwater-frontend:local . - `npm run lint`:运行 ESLint。 - `npm test`:运行 Jest。 - `npm run test:coverage`:生成测试覆盖率。 +- `npm run test:e2e`:启动本地 Next.js 与 Playwright Chromium 烟测。 - `npm run build`:生成生产构建。 - `npm run start`:启动生产模式服务。 @@ -86,6 +88,36 @@ npm run build Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。 +### Playwright E2E + +首次运行先安装 Chromium: + +```bash +npm run e2e:install +npm run test:e2e +``` + +本地测试会自动构建生产版本并启动隔离的 `http://127.0.0.1:3100`,生成仅用于测试的 NextAuth +会话,并模拟后端 API,因此不要求启动 Keycloak、Server 或 Agent。失败时可通过 +`npm run test:e2e:report` 查看 HTML 报告,交互调试可使用 +`npm run test:e2e:ui` 或 `npm run test:e2e:debug`。 + +在不能访问 Playwright CDN 的内网环境,可设置 +`E2E_CHROMIUM_PATH=/absolute/path/to/chrome` 复用预装的 Chromium/Chrome。 + +若要对已部署环境运行测试,请传入环境地址和预先保存的管理员 Playwright 登录状态: + +```bash +E2E_BASE_URL=https://example.test \ +E2E_STORAGE_STATE=/absolute/path/to/storage-state.json \ +E2E_USE_REAL_SERVICES=true \ +npm run test:e2e +``` + +`E2E_BASE_URL` 会关闭本地开发服务器;`E2E_STORAGE_STATE` 避免在仓库中保存账号或 +会话;`E2E_USE_REAL_SERVICES=true` 会关闭 API 模拟。Gitea 的 +`.gitea/workflows/e2e.yml` 在分支推送和 PR 中运行同一组 Chromium 测试。 + ## 安全规则 不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。 diff --git a/e2e/authenticated.spec.ts b/e2e/authenticated.spec.ts new file mode 100644 index 0000000..505f573 --- /dev/null +++ b/e2e/authenticated.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; + +import { mockBackend } from "./support/mockBackend"; + +test.beforeEach(async ({ page }) => { + await mockBackend(page); +}); + +test("已登录管理员可以打开审计日志页面", async ({ page }) => { + await page.goto("/audit-logs"); + + await expect( + page.getByRole("heading", { name: "审计日志", exact: true }), + ).toBeVisible(); + await expect(page.getByText("系统管理员权限已验证")).toBeVisible(); + await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible(); +}); diff --git a/e2e/globalSetup.ts b/e2e/globalSetup.ts new file mode 100644 index 0000000..b0017cc --- /dev/null +++ b/e2e/globalSetup.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { encode } from "next-auth/jwt"; + +import { + AUTH_STATE_PATH, + E2E_AUTH_SECRET, + E2E_BASE_URL, +} from "./support/environment"; + +const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; + +export default async function globalSetup() { + if (process.env.E2E_STORAGE_STATE) return; + + const now = Date.now(); + const baseUrl = new URL(E2E_BASE_URL); + const secure = baseUrl.protocol === "https:"; + const token = await encode({ + secret: E2E_AUTH_SECRET, + maxAge: SESSION_MAX_AGE_SECONDS, + token: { + sub: "playwright-user", + username: "playwright", + name: "E2E 测试用户", + email: "playwright@example.invalid", + accessToken: "playwright-access-token", + accessTokenIssuedAt: now, + accessTokenExpires: now + SESSION_MAX_AGE_SECONDS * 1000, + sessionExpiresAt: now + SESSION_MAX_AGE_SECONDS * 1000, + }, + }); + + await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true }); + await fs.writeFile( + AUTH_STATE_PATH, + JSON.stringify( + { + cookies: [ + { + name: secure + ? "__Secure-next-auth.session-token" + : "next-auth.session-token", + value: token, + domain: baseUrl.hostname, + path: "/", + expires: Math.floor(now / 1000) + SESSION_MAX_AGE_SECONDS, + httpOnly: true, + secure, + sameSite: "Lax", + }, + ], + origins: [ + { + origin: baseUrl.origin, + localStorage: [ + { name: "active_project", value: "playwright-project" }, + { name: "MAP_WORKSPACE", value: "tjwater_e2e" }, + { name: "NETWORK_NAME", value: "tjwater_e2e" }, + { + name: "MAP_EXTENT", + value: "13508801.93,3608163.35,13555650.64,3633685.14", + }, + ], + }, + ], + }, + null, + 2, + ), + ); +} diff --git a/e2e/support/environment.ts b/e2e/support/environment.ts new file mode 100644 index 0000000..42a3cab --- /dev/null +++ b/e2e/support/environment.ts @@ -0,0 +1,15 @@ +import path from "node:path"; + +export const E2E_BASE_URL = + process.env.E2E_BASE_URL || "http://127.0.0.1:3100"; + +export const E2E_AUTH_SECRET = + process.env.E2E_NEXTAUTH_SECRET || + "tjwater-playwright-local-secret-at-least-32-characters"; + +export const AUTH_STATE_PATH = path.join( + process.cwd(), + "e2e", + ".auth", + "user.json", +); diff --git a/e2e/support/mockBackend.ts b/e2e/support/mockBackend.ts new file mode 100644 index 0000000..0723df0 --- /dev/null +++ b/e2e/support/mockBackend.ts @@ -0,0 +1,63 @@ +import type { Page, Route } from "@playwright/test"; + +const json = (route: Route, body: unknown) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + +export const mockBackend = async (page: Page) => { + if (process.env.E2E_USE_REAL_SERVICES === "true") return; + + await page.route("**/api/v1/**", async (route) => { + const url = new URL(route.request().url()); + + if (url.pathname === "/api/v1/access-context") { + return json(route, { + user_id: "playwright-user", + username: "playwright", + system_role: "system_admin", + is_system_admin: true, + project_id: "playwright-project", + project_role: "project_admin", + permissions: [ + "webgis.view", + "simulation.view", + "simulation.run", + "scada.clean", + "risk.run", + "optimization.run", + "burst.run", + "audit.view", + "environment.manage", + ], + }); + } + + if (url.pathname === "/api/v1/projects/current") { + return json(route, { + project_id: "playwright-project", + code: "tjwater_e2e", + gs_workspace: "tjwater_e2e", + map_extent: { + bbox: [13508801.93, 3608163.35, 13555650.64, 3633685.14], + }, + }); + } + + if (url.pathname === "/api/v1/audit-logs/count") { + return json(route, { count: 0 }); + } + + if ( + url.pathname === "/api/v1/audit-logs" || + url.pathname === "/api/v1/admin/users" || + url.pathname === "/api/v1/admin/projects" + ) { + return json(route, []); + } + + return json(route, {}); + }); +}; diff --git a/e2e/unauthenticated.spec.ts b/e2e/unauthenticated.spec.ts new file mode 100644 index 0000000..e1c7bc5 --- /dev/null +++ b/e2e/unauthenticated.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test"; + +test("未登录访问受保护页面时返回登录重定向", async ({ request }) => { + const sessionResponse = await request.get("/api/auth/session"); + expect(await sessionResponse.json()).toEqual({}); + + const response = await request.get("/audit-logs", { maxRedirects: 0 }); + const body = await response.text(); + + expect(response.status()).toBe(200); + expect(body).toContain("NEXT_REDIRECT;replace;/login;307;"); +}); diff --git a/package-lock.json b/package-lock.json index cd4ef35..393296e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -5710,6 +5711,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -19080,6 +19097,35 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 7213498..3c9b9aa 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,12 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:report": "playwright show-report", + "e2e:install": "playwright install chromium", + "e2e:serve": "node scripts/startE2eServer.mjs", "api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts", "api:check": "node scripts/check-api-contracts.mjs", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" @@ -64,6 +70,7 @@ "sharp": "0.35.3" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..398dd3c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,66 @@ +import { defineConfig, devices } from "@playwright/test"; + +import { + AUTH_STATE_PATH, + E2E_AUTH_SECRET, + E2E_BASE_URL, +} from "./e2e/support/environment"; + +const externalServer = Boolean(process.env.E2E_BASE_URL); +const storageState = process.env.E2E_STORAGE_STATE || AUTH_STATE_PATH; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + expect: { timeout: 30_000 }, + reporter: process.env.CI + ? [["line"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + outputDir: "test-results", + globalSetup: "./e2e/globalSetup.ts", + use: { + baseURL: E2E_BASE_URL, + locale: "zh-CN", + timezoneId: "Asia/Shanghai", + launchOptions: process.env.E2E_CHROMIUM_PATH + ? { executablePath: process.env.E2E_CHROMIUM_PATH } + : undefined, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium-public", + testMatch: /unauthenticated\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "chromium-authenticated", + testIgnore: /unauthenticated\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + storageState, + }, + }, + ], + webServer: externalServer + ? undefined + : { + command: + "npm run runtime:config && npm run build && npm run e2e:serve", + url: E2E_BASE_URL, + reuseExistingServer: false, + timeout: 120_000, + env: { + NEXTAUTH_URL: E2E_BASE_URL, + NEXTAUTH_SECRET: E2E_AUTH_SECRET, + KEYCLOAK_CLIENT_ID: "tjwater-e2e", + KEYCLOAK_CLIENT_SECRET: "tjwater-e2e", + KEYCLOAK_ISSUER: "http://127.0.0.1:8180/realms/tjwater-e2e", + }, + }, +}); diff --git a/scripts/startE2eServer.mjs b/scripts/startE2eServer.mjs new file mode 100644 index 0000000..3d9e75a --- /dev/null +++ b/scripts/startE2eServer.mjs @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const projectDir = process.cwd(); +const standaloneDir = path.join(projectDir, ".next", "standalone"); +const standalonePublicDir = path.join(standaloneDir, "public"); +const standaloneStaticDir = path.join(standaloneDir, ".next", "static"); + +fs.rmSync(standalonePublicDir, { recursive: true, force: true }); +fs.rmSync(standaloneStaticDir, { recursive: true, force: true }); +fs.cpSync(path.join(projectDir, "public"), standalonePublicDir, { + recursive: true, +}); +fs.mkdirSync(path.dirname(standaloneStaticDir), { recursive: true }); +fs.cpSync(path.join(projectDir, ".next", "static"), standaloneStaticDir, { + recursive: true, +}); + +const server = spawn(process.execPath, [path.join(standaloneDir, "server.js")], { + cwd: standaloneDir, + env: { + ...process.env, + HOSTNAME: "127.0.0.1", + PORT: "3100", + }, + stdio: "inherit", +}); + +const stopServer = (signal) => { + if (!server.killed) server.kill(signal); +}; + +process.once("SIGINT", () => stopServer("SIGINT")); +process.once("SIGTERM", () => stopServer("SIGTERM")); +server.once("exit", (code, signal) => { + process.exitCode = signal ? 1 : (code ?? 1); +});