test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s

This commit is contained in:
2026-04-02 00:55:34 +08:00
parent 7de4ddc3ee
commit ee0bec4a78
32 changed files with 5100 additions and 336 deletions

3
playwright-smoke/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/

View File

@@ -0,0 +1,33 @@
# Playwright 回归测试
## 本地运行
```powershell
cd playwright-smoke
pnpm install
pnpm test
```
或在仓库根目录直接执行:
```powershell
pnpm test:ui
```
## 可用脚本
- `pnpm test`:跑前台 + 后台全量回归
- `pnpm test:frontend`:只跑前台
- `pnpm test:admin`:只跑后台
- `pnpm test:headed`:有界面调试
## 设计说明
- 使用独立 `mock-server.mjs` 提供前台 SSR、前端交互、后台 CRUD 所需的稳定假数据。
- CI 不依赖真实数据库 / Rust 后端,适合做前后台 UI 回归。
- 每条用例开始前都会调用 mock reset避免数据串扰。
- 本地默认优先走已安装的 `msedge` channelCI 仍使用 `playwright install chromium`
- 当前已覆盖:
- 前台首页过滤、文章详情、评论、搜索、AI 问答、友链申请、订阅确认/管理/退订
- 后台:登录、导航、评论审核、友链审核
- 后台深度回归:分类 CRUD、标签 CRUD、订阅 CRUD / 测试发送 / weekly & monthly digest、文章创建/保存/版本恢复/删除、媒体上传/元数据/替换/删除、站点设置保存/AI 重建索引/Provider 连通性/存储连通性、评测 CRUD / AI 润色、评论画像与黑名单管理

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "termi-playwright-smoke",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:frontend": "playwright test --project=frontend",
"test:admin": "playwright test --project=admin",
"test:headed": "playwright test --headed",
"install:browsers": "playwright install --with-deps chromium"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^24.7.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,92 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, devices } from '@playwright/test'
const mockBaseUrl = 'http://127.0.0.1:5159'
const frontendBaseUrl = 'http://127.0.0.1:4321'
const adminBaseUrl = 'http://127.0.0.1:4322'
const isCi = Boolean(process.env.CI)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, '..')
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 12_000,
},
reporter: [['list'], ['html', { open: 'never' }]],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: true,
},
projects: [
{
name: 'frontend',
testMatch: /frontend\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
channel: isCi ? undefined : 'msedge',
baseURL: frontendBaseUrl,
},
},
{
name: 'admin',
testMatch: /admin\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
channel: isCi ? undefined : 'msedge',
baseURL: adminBaseUrl,
},
},
],
webServer: [
{
command: 'node ./mock-server.mjs',
cwd: __dirname,
url: `${mockBaseUrl}/__playwright/health`,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PLAYWRIGHT_MOCK_PORT: '5159',
PLAYWRIGHT_FRONTEND_ORIGIN: frontendBaseUrl,
PLAYWRIGHT_ADMIN_ORIGIN: adminBaseUrl,
},
},
{
command: 'pnpm dev --host 127.0.0.1 --port 4321',
cwd: path.resolve(repoRoot, 'frontend'),
url: frontendBaseUrl,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PUBLIC_API_BASE_URL: `${mockBaseUrl}/api`,
INTERNAL_API_BASE_URL: `${mockBaseUrl}/api`,
PUBLIC_IMAGE_ALLOWED_HOSTS: '127.0.0.1:5159,127.0.0.1',
},
},
{
command: 'pnpm dev --host 127.0.0.1 --port 4322',
cwd: path.resolve(repoRoot, 'admin'),
url: adminBaseUrl,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
VITE_API_BASE: mockBaseUrl,
VITE_FRONTEND_BASE_URL: frontendBaseUrl,
},
},
],
})

77
playwright-smoke/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,77 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.55.0
version: 1.59.0
'@types/node':
specifier: ^24.7.2
version: 24.12.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@playwright/test@1.59.0':
resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
engines: {node: '>=18'}
hasBin: true
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.59.0:
resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.0:
resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@playwright/test@1.59.0':
dependencies:
playwright: 1.59.0
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
fsevents@2.3.2:
optional: true
playwright-core@1.59.0: {}
playwright@1.59.0:
dependencies:
playwright-core: 1.59.0
optionalDependencies:
fsevents: 2.3.2
typescript@5.9.3: {}
undici-types@7.16.0: {}

View File

@@ -0,0 +1,309 @@
import { expect, test, type Page } from '@playwright/test'
import { getDebugState, loginAdmin, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => {
await resetMockState(request)
})
function acceptNextDialog(page: Page) {
page.once('dialog', async (dialog) => {
await dialog.accept()
})
}
function buildSvgPayload(name: string, label: string) {
return {
name,
mimeType: 'image/svg+xml',
buffer: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><rect width="1200" height="675" fill="#111827"/><text x="80" y="180" fill="#f8fafc" font-family="monospace" font-size="40">${label}</text></svg>`,
'utf8',
),
}
}
test('后台登录、导航与关键模块页面可加载', async ({ page }) => {
await loginAdmin(page)
const routes = [
{ label: '概览', url: /\/$/, text: 'Astro 终端博客信息架构实战' },
{ label: '数据分析', url: /\/analytics$/, text: 'playwright' },
{ label: '文章', url: /\/posts$/, text: 'playwright-regression-workflow' },
{ label: '分类', url: /\/categories$/, text: '前端工程' },
{ label: '标签', url: /\/tags$/, text: 'Playwright' },
{ label: '备份', url: /\/backups$/, text: '导出' },
{ label: '版本', url: /\/revisions$/, text: 'astro-terminal-blog' },
{ label: '评论', url: /\/comments$/, text: 'Carol' },
{ label: '友链', url: /\/friend-links$/, text: 'Pending Link Review' },
{ label: '评测', url: /\/reviews$/, text: '《漫长的季节》' },
{ label: '媒体库', url: /\/media$/, text: '漫长的季节封面' },
{ label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' },
{ label: '审计', url: /\/audit$/, text: 'playwright-smoke' },
{ label: '设置', url: /\/settings$/, text: 'InitCool' },
]
for (const route of routes) {
await page.getByRole('link', { name: route.label }).click()
await expect(page).toHaveURL(route.url)
await expect(page.locator('main')).toContainText(route.text)
}
})
test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '评论' }).click()
await expect(page.locator('main')).toContainText('Carol')
await page.getByRole('button', { name: '通过' }).first().click()
await page.getByRole('link', { name: '友链' }).click()
await expect(page.locator('main')).toContainText('Pending Link Review')
await page.getByRole('button', { name: '通过' }).first().click()
await page.getByRole('link', { name: '设置' }).click()
await expect(page.locator('main')).toContainText('InitCool')
await expect(page.getByTestId('site-settings-save')).toBeVisible()
})
test('后台可完成分类与标签的创建、更新、删除', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '分类' }).click()
await page.getByTestId('category-name-input').fill('Playwright 深回归分类')
await page.getByTestId('category-slug-input').fill('playwright-deep-category')
await page.getByPlaceholder('介绍这个分类主要收录哪些内容。').fill('用于后台深度回归的分类。')
await page.getByTestId('category-save').click()
await expect(page.getByTestId('category-item-playwright-deep-category')).toBeVisible()
await page.getByTestId('category-item-playwright-deep-category').click()
await page.getByPlaceholder('前端工程专题 - Termi').fill('Playwright 深回归分类 SEO')
await page.getByTestId('category-save').click()
let state = await getDebugState(request)
expect(
state.categories.some(
(item: { slug: string; seo_title: string }) =>
item.slug === 'playwright-deep-category' &&
item.seo_title === 'Playwright 深回归分类 SEO',
),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('category-delete').click()
await expect(page.getByTestId('category-item-playwright-deep-category')).toHaveCount(0)
await page.getByRole('link', { name: '标签' }).click()
await page.getByTestId('tag-name-input').fill('Playwright 深回归标签')
await page.getByTestId('tag-slug-input').fill('playwright-deep-tag')
await page.getByPlaceholder('介绍这个标签常见主题、适合谁看。').fill('用于后台深度回归的标签。')
await page.getByTestId('tag-save').click()
await expect(page.getByTestId('tag-item-playwright-deep-tag')).toBeVisible()
await page.getByTestId('tag-item-playwright-deep-tag').click()
await page.getByPlaceholder('Astro 相关文章 - Termi').fill('Playwright 深回归标签 SEO')
await page.getByTestId('tag-save').click()
state = await getDebugState(request)
expect(
state.tags.some(
(item: { slug: string; seo_title: string }) =>
item.slug === 'playwright-deep-tag' && item.seo_title === 'Playwright 深回归标签 SEO',
),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('tag-delete').click()
await expect(page.getByTestId('tag-item-playwright-deep-tag')).toHaveCount(0)
})
test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '订阅' }).click()
await page.getByPlaceholder('name@example.com').fill('deep-regression@example.com')
await page.getByPlaceholder('例如 站长邮箱 / Discord 运维群').fill('Deep Regression')
await page.getByTestId('subscriptions-save').click()
const row = page
.locator('[data-testid^="subscription-row-"]')
.filter({ hasText: 'deep-regression@example.com' })
await expect(row).toBeVisible()
await row.getByTestId(/subscription-edit-/).click()
await page.getByPlaceholder('例如 站长邮箱 / Discord 运维群').fill('Deep Regression Updated')
await page.getByTestId('subscriptions-save').click()
await expect(row).toContainText('Deep Regression Updated')
await row.getByTestId(/subscription-test-/).click()
await page.getByTestId('subscriptions-send-weekly').click()
await page.getByTestId('subscriptions-send-monthly').click()
let state = await getDebugState(request)
expect(
state.deliveries.some((item: { event_type: string; target: string }) =>
item.event_type === 'subscription.test' && item.target === 'deep-regression@example.com'),
).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.weekly')).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.monthly')).toBeTruthy()
await row.getByTestId(/subscription-delete-/).click()
await expect(row).toHaveCount(0)
state = await getDebugState(request)
expect(
state.subscriptions.some((item: { target: string }) => item.target === 'deep-regression@example.com'),
).toBeFalsy()
})
test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '文章' }).click()
await page.getByTestId('posts-open-create').click()
await page.getByTestId('post-create-title').fill('Playwright 深回归文章')
await page.getByTestId('post-create-slug').fill('playwright-deep-post')
await page.getByTestId('post-create-submit').click()
await expect(page).toHaveURL(/\/posts\/playwright-deep-post$/)
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章')
await page.getByTestId('post-editor-title').fill('Playwright 深回归文章(已更新)')
await page.getByTestId('post-editor-save').click()
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章(已更新)')
let state = await getDebugState(request)
expect(
state.posts.some(
(item: { slug: string; title: string }) =>
item.slug === 'playwright-deep-post' && item.title === 'Playwright 深回归文章(已更新)',
),
).toBeTruthy()
await page.getByTestId('post-editor-close').click()
state = await getDebugState(request)
const createRevision = state.post_revisions.find(
(item: { id: number; post_slug: string; operation: string }) =>
item.post_slug === 'playwright-deep-post' && item.operation === 'create',
)
expect(createRevision).toBeTruthy()
await page.getByRole('link', { name: '版本' }).click()
await page.getByTestId('revisions-slug-filter').fill('playwright-deep-post')
await page.getByTestId(`revision-open-${createRevision.id}`).click()
await page.getByTestId('revision-restore-full').click()
await page.getByRole('link', { name: '文章' }).click()
await page.getByTestId('post-item-playwright-deep-post').click()
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章')
acceptNextDialog(page)
await page.getByTestId('post-editor-delete').click()
await expect(page).toHaveURL(/\/posts$/)
state = await getDebugState(request)
expect(state.posts.some((item: { slug: string }) => item.slug === 'playwright-deep-post')).toBeFalsy()
})
test('后台可完成媒体库上传/元数据/替换/删除,并执行设置页关键动作', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-upload-input').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-upload'),
])
await page.getByTestId('media-upload').click()
await expect(page.getByTestId('media-item-0')).toContainText('deep-regression-cover.svg')
await page.getByTestId('media-edit-0').click()
await page.getByPlaceholder('文章封面 / 站点横幅').fill('Deep Regression Cover')
await page.getByPlaceholder('夜色下的终端风格博客封面').fill('Deep Regression Alt')
await page.getByPlaceholder('cover, astro, terminal').fill('playwright, regression')
await page.getByTestId('media-save-metadata').click()
let state = await getDebugState(request)
expect(
state.media.some(
(item: { title: string; alt_text: string; tags: string[] }) =>
item.title === 'Deep Regression Cover' &&
item.alt_text === 'Deep Regression Alt' &&
item.tags.includes('playwright'),
),
).toBeTruthy()
await page.getByTestId('media-replace-input-0').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-replaced'),
])
acceptNextDialog(page)
await page.getByTestId('media-delete-0').click()
state = await getDebugState(request)
expect(state.media.some((item: { title: string }) => item.title === 'Deep Regression Cover')).toBeFalsy()
await page.getByRole('link', { name: '设置' }).click()
await page.getByTestId('site-settings-site-name').fill('InitCool Deep Regression')
await page.getByTestId('site-settings-popup-title').fill('订阅深回归')
await page.getByTestId('site-settings-save').click()
await page.getByTestId('site-settings-reindex').click()
await page.getByTestId('site-settings-test-provider').click()
await page.getByTestId('site-settings-test-image-provider').click()
await page.getByTestId('site-settings-test-storage').click()
state = await getDebugState(request)
expect(state.site_settings.site_name).toBe('InitCool Deep Regression')
expect(state.site_settings.subscription_popup_title).toBe('订阅深回归')
expect(state.site_settings.ai_chunks_count).toBeGreaterThan(128)
})
test('后台可完成评测 CRUD、AI 润色,以及评论画像/黑名单管理', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '评测' }).click()
await page.getByTestId('review-title').fill('Playwright 深评测')
await page.getByTestId('review-date').fill('2026-04-01')
await page.getByTestId('review-description').fill('这是一段用于深度回归的评测简介。')
await page.getByTestId('review-save').click()
await expect(page.locator('main')).toContainText('Playwright 深评测')
await page.getByTestId('review-ai-polish').click()
await expect(page.getByText('AI 点评润色对比')).toBeVisible()
await page.getByTestId('review-ai-adopt').click()
await page.getByTestId('review-save').click()
let state = await getDebugState(request)
expect(
state.reviews.some((item: { title: string }) => item.title === 'Playwright 深评测'),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('review-delete').click()
state = await getDebugState(request)
expect(
state.reviews.some((item: { title: string }) => item.title === 'Playwright 深评测'),
).toBeFalsy()
await page.getByRole('link', { name: '评论' }).click()
await page.getByRole('button', { name: 'AI 分析' }).click()
await expect(page.locator('main')).toContainText('建议保持观察')
await page.getByPlaceholder('输入要封禁的值').fill('203.0.113.55')
await page.getByPlaceholder('原因(可选)').first().fill('playwright deep regression')
await page.getByTestId('comment-blacklist-add').click()
state = await getDebugState(request)
const createdRule = state.comment_blacklist.find(
(item: { id: number; matcher_value: string }) => item.matcher_value === '203.0.113.55',
)
expect(createdRule).toBeTruthy()
await page.getByTestId(`blacklist-toggle-${createdRule.id}`).click()
await page.getByTestId(`blacklist-toggle-${createdRule.id}`).click()
acceptNextDialog(page)
await page.getByTestId(`blacklist-delete-${createdRule.id}`).click()
state = await getDebugState(request)
expect(
state.comment_blacklist.some((item: { matcher_value: string }) => item.matcher_value === '203.0.113.55'),
).toBeFalsy()
})

View File

@@ -0,0 +1,89 @@
import { expect, test } from '@playwright/test'
import { getDebugState, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => {
await resetMockState(request)
})
test('首页过滤、热门区和文章详情链路可用', async ({ page }) => {
await page.goto('/')
await expect(page.locator('#home-results-count')).toContainText(/条结果/)
await page.locator('[data-home-category-filter="测试体系"]').click()
await expect(page.locator('#home-active-category-text')).toHaveText('测试体系')
await page.locator('[data-home-tag-filter="Playwright"]').click()
await expect(page.locator('#home-active-tag-text')).toHaveText('Playwright')
await page.locator('[data-home-popular-range="30d"]').click()
await expect(page.locator('#home-stats-window-pill')).toHaveText('30d')
await page.locator('a[href="/articles/playwright-regression-workflow"]').first().click()
await expect(page).toHaveURL(/\/articles\/playwright-regression-workflow$/)
await expect(page.getByRole('heading', { name: 'Playwright 回归工作流设计' })).toBeVisible()
await expect(page.locator('.paragraph-comment-marker').first()).toBeVisible()
})
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
await page.goto('/articles/astro-terminal-blog')
await page.locator('#toggle-comment-form').click()
await page.locator('#comment-form input[name="nickname"]').fill('Playwright Visitor')
await page.locator('#comment-form input[name="email"]').fill('visitor@example.com')
await page.locator('#comment-form textarea[name="content"]').fill('这是一条来自回归测试的新评论。')
await page.locator('#comment-form input[name="captchaAnswer"]').fill('7')
await page.getByRole('button', { name: '提交' }).click()
await expect(page.locator('#comment-message')).toContainText('提交')
const commentState = await getDebugState(request)
expect(commentState.comments.some((item: { author: string }) => item.author === 'Playwright Visitor')).toBeTruthy()
await page.goto('/search?q=playwright')
await expect(page.getByText('Playwright 回归工作流设计')).toBeVisible()
await page.goto('/ask')
await page.locator('#ai-question').fill('这个博客主要写什么内容?')
await page.locator('#ai-submit').click()
await expect(page.locator('#ai-answer')).toContainText('Playwright 回归工作流')
})
test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, request }) => {
await page.goto('/friends')
await page.locator('input[name="siteName"]').fill('Playwright Friend')
await page.locator('input[name="siteUrl"]').fill('https://playwright-friend.example')
await page.locator('textarea[name="description"]').fill('回归测试用的友链申请。')
await page.locator('label', { hasText: '[其他]' }).click()
await page.locator('#has-reciprocal').check()
await page.getByRole('button', { name: '提交申请' }).click()
await expect(page.locator('#form-message')).toContainText('提交')
const friendState = await getDebugState(request)
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
await page.goto('/')
await page.locator('[data-subscription-popup-open]').click()
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅')
const subscriptionState = await getDebugState(request)
const latest = subscriptionState.subscriptions.find(
(item: { target: string }) => item.target === 'playwright-subscriber@example.com',
)
expect(latest).toBeTruthy()
await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`)
await expect(page.getByText('订阅已确认')).toBeVisible()
await page.goto(`/subscriptions/manage?token=${encodeURIComponent(latest.manage_token)}`)
await page.getByRole('textbox', { name: '称呼' }).fill('回归通知')
await page.getByRole('button', { name: '保存偏好' }).click()
await expect(page.locator('[data-manage-status]')).toContainText('偏好已保存')
await page.goto(`/subscriptions/unsubscribe?token=${encodeURIComponent(latest.manage_token)}`)
await page.getByRole('button', { name: '确认退订' }).click()
await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订')
})

View File

@@ -0,0 +1,29 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test'
export const MOCK_BASE_URL = 'http://127.0.0.1:5159'
export const ADMIN_COOKIE = {
name: 'termi_admin_session',
value: 'mock-admin-session',
domain: '127.0.0.1',
path: '/',
}
export async function resetMockState(request: APIRequestContext) {
const response = await request.post(`${MOCK_BASE_URL}/__playwright/reset`)
expect(response.ok()).toBeTruthy()
}
export async function getDebugState(request: APIRequestContext) {
const response = await request.get(`${MOCK_BASE_URL}/__playwright/state`)
expect(response.ok()).toBeTruthy()
return response.json()
}
export async function loginAdmin(page: Page) {
await page.goto('/login')
await page.getByLabel('用户名').fill('admin')
await page.getByLabel('密码').fill('admin123')
await page.getByRole('button', { name: '进入后台' }).click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByText('当前登录admin')).toBeVisible()
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"types": ["node", "@playwright/test"],
"strict": true,
"noEmit": true,
"allowJs": false,
"resolveJsonModule": true
},
"include": ["playwright.config.ts", "tests/**/*.ts"]
}