From ee0bec4a782b86f290b49a9a9c21b2b5ab60cc77 Mon Sep 17 00:00:00 2001 From: limitcool Date: Thu, 2 Apr 2026 00:55:34 +0800 Subject: [PATCH] test: add full playwright ui regression coverage --- .gitea/workflows/ui-regression.yml | 167 ++ README.md | 17 +- admin/src/App.tsx | 62 + admin/src/components/ui/select.tsx | 13 +- admin/src/lib/post-draft-window.ts | 34 +- admin/src/pages/categories-page.tsx | 7 +- admin/src/pages/comments-page.tsx | 4 + admin/src/pages/media-page.tsx | 45 +- admin/src/pages/post-compare-page.tsx | 39 +- admin/src/pages/post-preview-page.tsx | 19 +- admin/src/pages/posts-page.tsx | 286 +- admin/src/pages/reviews-page.tsx | 8 + admin/src/pages/revisions-page.tsx | 9 +- admin/src/pages/site-settings-page.tsx | 9 +- admin/src/pages/subscriptions-page.tsx | 14 +- admin/src/pages/tags-page.tsx | 7 +- frontend/src/lib/reviews.ts | 92 + frontend/src/lib/utils/data.ts | 194 -- frontend/src/pages/reviews/[id].astro | 384 +++ frontend/src/pages/reviews/index.astro | 137 +- frontend/src/pages/sitemap.xml.ts | 10 +- package.json | 5 + playwright-smoke/.gitignore | 3 + playwright-smoke/README.md | 33 + playwright-smoke/mock-server.mjs | 3211 +++++++++++++++++++++++ playwright-smoke/package.json | 17 + playwright-smoke/playwright.config.ts | 92 + playwright-smoke/pnpm-lock.yaml | 77 + playwright-smoke/tests/admin.spec.ts | 309 +++ playwright-smoke/tests/frontend.spec.ts | 89 + playwright-smoke/tests/helpers.ts | 29 + playwright-smoke/tsconfig.json | 14 + 32 files changed, 5100 insertions(+), 336 deletions(-) create mode 100644 .gitea/workflows/ui-regression.yml create mode 100644 frontend/src/lib/reviews.ts delete mode 100644 frontend/src/lib/utils/data.ts create mode 100644 frontend/src/pages/reviews/[id].astro create mode 100644 playwright-smoke/.gitignore create mode 100644 playwright-smoke/README.md create mode 100644 playwright-smoke/mock-server.mjs create mode 100644 playwright-smoke/package.json create mode 100644 playwright-smoke/playwright.config.ts create mode 100644 playwright-smoke/pnpm-lock.yaml create mode 100644 playwright-smoke/tests/admin.spec.ts create mode 100644 playwright-smoke/tests/frontend.spec.ts create mode 100644 playwright-smoke/tests/helpers.ts create mode 100644 playwright-smoke/tsconfig.json diff --git a/.gitea/workflows/ui-regression.yml b/.gitea/workflows/ui-regression.yml new file mode 100644 index 0000000..e09241e --- /dev/null +++ b/.gitea/workflows/ui-regression.yml @@ -0,0 +1,167 @@ +name: ui-regression + +on: + push: + branches: + - main + - master + paths: + - admin/** + - frontend/** + - playwright-smoke/** + - .gitea/workflows/ui-regression.yml + pull_request: + paths: + - admin/** + - frontend/** + - playwright-smoke/** + - .gitea/workflows/ui-regression.yml + workflow_dispatch: + +jobs: + playwright-regression: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: | + frontend/pnpm-lock.yaml + admin/pnpm-lock.yaml + playwright-smoke/pnpm-lock.yaml + + - name: Install frontend deps + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Install admin deps + working-directory: admin + run: pnpm install --frozen-lockfile + + - name: Install Playwright deps + working-directory: playwright-smoke + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: playwright-smoke + run: pnpm exec playwright install --with-deps chromium + + - name: Typecheck Playwright suite + working-directory: playwright-smoke + run: pnpm exec tsc -p tsconfig.json --noEmit + + - name: Prepare Playwright artifact folders + run: | + rm -rf playwright-smoke/.artifacts + mkdir -p playwright-smoke/.artifacts/frontend + mkdir -p playwright-smoke/.artifacts/admin + + - name: Run frontend UI regression suite + id: ui_frontend + working-directory: playwright-smoke + continue-on-error: true + run: pnpm test:frontend + + - name: Collect frontend Playwright artifacts + if: always() + run: | + if [ -d playwright-smoke/playwright-report ]; then + cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report + fi + if [ -d playwright-smoke/test-results ]; then + cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results + fi + rm -rf playwright-smoke/playwright-report playwright-smoke/test-results + + - name: Run admin UI regression suite + id: ui_admin + working-directory: playwright-smoke + continue-on-error: true + run: pnpm test:admin + + - name: Collect admin Playwright artifacts + if: always() + run: | + if [ -d playwright-smoke/playwright-report ]; then + cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report + fi + if [ -d playwright-smoke/test-results ]; then + cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results + fi + + - name: Upload frontend HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-html-report-frontend + path: playwright-smoke/.artifacts/frontend/playwright-report + retention-days: 14 + if-no-files-found: ignore + + - name: Upload admin HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-html-report-admin + path: playwright-smoke/.artifacts/admin/playwright-report + retention-days: 14 + if-no-files-found: ignore + + - name: Upload frontend raw results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-raw-results-frontend + path: playwright-smoke/.artifacts/frontend/test-results + retention-days: 14 + if-no-files-found: ignore + + - name: Upload admin raw results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-raw-results-admin + path: playwright-smoke/.artifacts/admin/test-results + retention-days: 14 + if-no-files-found: ignore + + - name: Upload frontend failure screenshots / videos / traces + if: steps.ui_frontend.outcome != 'success' + uses: actions/upload-artifact@v4 + with: + name: playwright-failure-artifacts-frontend + path: | + playwright-smoke/.artifacts/frontend/test-results/**/*.png + playwright-smoke/.artifacts/frontend/test-results/**/*.webm + playwright-smoke/.artifacts/frontend/test-results/**/*.zip + playwright-smoke/.artifacts/frontend/test-results/**/error-context.md + retention-days: 21 + if-no-files-found: ignore + + - name: Upload admin failure screenshots / videos / traces + if: steps.ui_admin.outcome != 'success' + uses: actions/upload-artifact@v4 + with: + name: playwright-failure-artifacts-admin + path: | + playwright-smoke/.artifacts/admin/test-results/**/*.png + playwright-smoke/.artifacts/admin/test-results/**/*.webm + playwright-smoke/.artifacts/admin/test-results/**/*.zip + playwright-smoke/.artifacts/admin/test-results/**/error-context.md + retention-days: 21 + if-no-files-found: ignore + + - name: Mark workflow failed when any suite failed + if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success' + run: exit 1 diff --git a/README.md b/README.md index 464736b..43473ab 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Monorepo for the Termi blog system. From the repository root: ```powershell -npm run dev +pnpm dev ``` This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs. @@ -30,13 +30,14 @@ This starts `frontend + admin + backend` in a single Windows Terminal window wit Common shortcuts: ```powershell -npm run dev:mcp -npm run dev:frontend -npm run dev:admin -npm run dev:backend -npm run dev:mcp-only -npm run stop -npm run restart +pnpm dev:mcp +pnpm dev:frontend +pnpm dev:admin +pnpm dev:backend +pnpm dev:mcp-only +pnpm stop +pnpm restart +pnpm test:ui ``` ### PowerShell entrypoint diff --git a/admin/src/App.tsx b/admin/src/App.tsx index bb51203..aee3dbf 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -38,6 +38,18 @@ const PostsPage = lazy(async () => { const mod = await import('@/pages/posts-page') return { default: mod.PostsPage } }) +const PostPreviewPage = lazy(async () => { + const mod = await import('@/pages/post-preview-page') + return { default: mod.PostPreviewPage } +}) +const PostComparePage = lazy(async () => { + const mod = await import('@/pages/post-compare-page') + return { default: mod.PostComparePage } +}) +const PostPolishPage = lazy(async () => { + const mod = await import('@/pages/post-polish-page') + return { default: mod.PostPolishPage } +}) const CategoriesPage = lazy(async () => { const mod = await import('@/pages/categories-page') return { default: mod.CategoriesPage } @@ -223,6 +235,56 @@ function AppRoutes() { return ( } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> +type NativeSelectProps = React.ComponentProps<'select'> & { + 'data-testid'?: string +} type SelectOption = { value: string @@ -78,8 +80,11 @@ function getNextEnabledIndex(options: SelectOption[], currentIndex: number, dire const Select = React.forwardRef( ( { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, children, className, + 'data-testid': dataTestId, defaultValue, disabled = false, id, @@ -434,6 +439,9 @@ const Select = React.forwardRef( className="pointer-events-none absolute h-0 w-0 opacity-0" defaultValue={defaultValue} disabled={disabled} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} + data-testid={dataTestId} id={id} onBlur={onBlur} onFocus={onFocus} @@ -454,8 +462,11 @@ const Select = React.forwardRef( @@ -388,6 +392,7 @@ export function CategoriesPage() { variant="ghost" onClick={() => void handleDelete()} disabled={!selectedItem || deleting} + data-testid="category-delete" className="text-rose-600 hover:text-rose-600" > diff --git a/admin/src/pages/comments-page.tsx b/admin/src/pages/comments-page.tsx index d6c2d8a..be47221 100644 --- a/admin/src/pages/comments-page.tsx +++ b/admin/src/pages/comments-page.tsx @@ -898,6 +898,7 @@ export function CommentsPage() { setManualMatcherValue('') }} disabled={!manualMatcherValue.trim()} + data-testid="comment-blacklist-add" > 新增 @@ -908,6 +909,7 @@ export function CommentsPage() { {blacklist.map((item) => (
@@ -929,6 +931,7 @@ export function CommentsPage() { size="sm" variant="outline" disabled={actingBlacklistId === item.id} + data-testid={`blacklist-toggle-${item.id}`} onClick={async () => { try { setActingBlacklistId(item.id) @@ -959,6 +962,7 @@ export function CommentsPage() { size="sm" variant="danger" disabled={actingBlacklistId === item.id} + data-testid={`blacklist-delete-${item.id}`} onClick={async () => { if (!window.confirm('确定删除这条黑名单规则吗?')) { return diff --git a/admin/src/pages/media-page.tsx b/admin/src/pages/media-page.tsx index 88b3f43..85e2f56 100644 --- a/admin/src/pages/media-page.tsx +++ b/admin/src/pages/media-page.tsx @@ -58,6 +58,24 @@ const defaultMetadataForm: MediaMetadataFormState = { notes: '', } +function normalizeMediaTags(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) +} + +function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse { + return { + ...item, + tags: normalizeMediaTags(item.tags), + } +} + function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState { if (!item) { return defaultMetadataForm @@ -67,7 +85,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor title: item.title ?? '', altText: item.alt_text ?? '', caption: item.caption ?? '', - tags: item.tags.join(', '), + tags: normalizeMediaTags(item.tags).join(', '), notes: item.notes ?? '', } } @@ -111,8 +129,9 @@ export function MediaPage() { } const prefix = prefixFilter === 'all' ? undefined : prefixFilter const result = await adminApi.listMediaObjects({ prefix, limit: 200 }) + const normalizedItems = result.items.map(normalizeMediaItem) startTransition(() => { - setItems(result.items) + setItems(normalizedItems) setProvider(result.provider) setBucket(result.bucket) }) @@ -219,6 +238,7 @@ export function MediaPage() {
- @@ -1919,6 +2168,7 @@ export function PostsPage() {
navigate(`/posts/${post.slug}`)} className={cn( 'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all', @@ -2099,7 +2350,7 @@ export function PostsPage() {
- @@ -2148,6 +2399,7 @@ export function PostsPage() { setEditor((current) => @@ -2439,6 +2691,18 @@ export function PostsPage() { {generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'} + + + -
+ + +