feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s

This commit is contained in:
2026-04-02 03:43:37 +08:00
parent ee0bec4a78
commit a516be2e91
37 changed files with 3890 additions and 879 deletions

View File

@@ -897,6 +897,12 @@ function buildReviewRecord(entry, id) {
}
}
function isPublicReviewVisible(review) {
return ['published', 'completed', 'done'].includes(
normalizeText(review?.status).toLowerCase(),
)
}
function createSubscriptionRecord(id, overrides = {}) {
return {
created_at: iso(-id * 6),
@@ -1097,6 +1103,7 @@ function createInitialState() {
],
comment_persona_logs: [],
deliveries: [],
worker_jobs: [],
ai_events: [],
content_events: [],
captcha_tokens: new Map(),
@@ -1111,6 +1118,7 @@ function createInitialState() {
audit: 1,
revision: 1,
delivery: 1,
worker_job: 1,
blacklist: 2,
persona_log: 1,
ai_event: 1,
@@ -1224,6 +1232,156 @@ function createNotificationDelivery({
}
}
function createWorkerJob({
job_kind = 'worker',
worker_name,
display_name = null,
status = 'queued',
queue_name = null,
requested_by = VALID_LOGIN.username,
requested_source = 'mock-admin',
trigger_mode = 'manual',
payload = null,
result = null,
error_text = null,
tags = [],
related_entity_type = null,
related_entity_id = null,
parent_job_id = null,
attempts_count = status === 'queued' ? 0 : 1,
max_attempts = 1,
cancel_requested = false,
queued_at = iso(-1),
started_at = status === 'queued' ? null : iso(-1),
finished_at = status === 'queued' || status === 'running' ? null : iso(-1),
} = {}) {
const record = {
created_at: iso(-1),
updated_at: iso(-1),
id: nextId('worker_job'),
parent_job_id,
job_kind,
worker_name,
display_name,
status,
queue_name,
requested_by,
requested_source,
trigger_mode,
payload,
result,
error_text,
tags: [...tags],
related_entity_type,
related_entity_id: related_entity_id === null ? null : String(related_entity_id),
attempts_count,
max_attempts,
cancel_requested,
queued_at,
started_at,
finished_at,
}
state.worker_jobs.unshift(record)
return record
}
function canCancelWorkerJob(job) {
return !job.cancel_requested && (job.status === 'queued' || job.status === 'running')
}
function canRetryWorkerJob(job) {
return ['failed', 'cancelled', 'succeeded'].includes(job.status)
}
function normalizeWorkerJob(job) {
return {
...clone(job),
can_cancel: canCancelWorkerJob(job),
can_retry: canRetryWorkerJob(job),
}
}
function buildWorkerOverview() {
const jobs = state.worker_jobs
const counters = {
total_jobs: jobs.length,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
}
const grouped = new Map()
const catalog = [
['worker.download_media', 'worker', '远程媒体下载', '抓取远程图片 / PDF 到媒体库,并回写媒体元数据。', 'media'],
['worker.notification_delivery', 'worker', '通知投递', '执行订阅通知、测试通知与 digest 投递。', 'notifications'],
['task.retry_deliveries', 'task', '重试待投递通知', '扫描 retry_pending 的通知记录并重新入队。', 'maintenance'],
['task.send_weekly_digest', 'task', '发送周报', '根据近期内容生成周报,并为活跃订阅目标入队。', 'digests'],
['task.send_monthly_digest', 'task', '发送月报', '根据近期内容生成月报,并为活跃订阅目标入队。', 'digests'],
].map(([worker_name, job_kind, label, description, queue_name]) => ({
worker_name,
job_kind,
label,
description,
queue_name,
supports_cancel: true,
supports_retry: true,
}))
for (const job of jobs) {
if (Object.hasOwn(counters, job.status)) {
counters[job.status] += 1
}
const existing =
grouped.get(job.worker_name) ||
{
worker_name: job.worker_name,
job_kind: job.job_kind,
label: catalog.find((item) => item.worker_name === job.worker_name)?.label || job.worker_name,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
last_job_at: null,
}
if (Object.hasOwn(existing, job.status)) {
existing[job.status] += 1
}
existing.last_job_at ||= job.created_at
grouped.set(job.worker_name, existing)
}
counters.active_jobs = counters.queued + counters.running
return {
...counters,
worker_stats: Array.from(grouped.values()),
catalog,
}
}
function enqueueNotificationDeliveryJob(delivery, options = {}) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.notification_delivery',
display_name: `${delivery.event_type}${delivery.target}`,
status: options.status || 'succeeded',
queue_name: 'notifications',
payload: {
delivery_id: delivery.id,
job_id: null,
},
result: options.status === 'failed' ? null : { delivery_id: delivery.id },
error_text: options.status === 'failed' ? options.error_text || 'mock delivery failed' : null,
tags: ['notifications', 'delivery'],
related_entity_type: 'notification_delivery',
related_entity_id: delivery.id,
parent_job_id: options.parent_job_id ?? null,
})
}
function sanitizeFilename(value, fallback = 'upload.bin') {
const normalized = String(value || '').split(/[\\/]/).pop() || fallback
return normalized.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || fallback
@@ -1249,6 +1407,38 @@ function makeMediaRecordFromUpload(key, file) {
}
}
function queueDownloadWorkerJob(payload, mediaRecord) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: normalizeText(payload.title) || `download ${normalizeText(payload.source_url)}`,
status: 'succeeded',
queue_name: 'media',
payload: {
source_url: payload.source_url,
prefix: payload.prefix || null,
title: payload.title || null,
alt_text: payload.alt_text || null,
caption: payload.caption || null,
tags: Array.isArray(payload.tags) ? payload.tags : [],
notes: payload.notes || null,
job_id: null,
},
result: mediaRecord
? {
key: mediaRecord.key,
url: mediaRecord.url,
size_bytes: mediaRecord.size_bytes,
source_url: normalizeText(payload.source_url),
content_type: mediaRecord.content_type,
}
: null,
tags: ['media', 'download'],
related_entity_type: 'media_download',
related_entity_id: normalizeText(payload.source_url),
})
}
function upsertPostFromPayload(current, payload) {
const next = current ? { ...current } : {}
const title = normalizeText(payload.title) || normalizeText(current?.title) || '未命名文章'
@@ -1549,6 +1739,7 @@ function latestDebugState() {
filters: clone(item.filters),
})),
deliveries: state.deliveries.map((item) => clone(item)),
worker_jobs: state.worker_jobs.map((item) => clone(item)),
media: state.media.map((item) => ({
key: item.key,
title: item.title,
@@ -1765,7 +1956,10 @@ const server = createServer(async (req, res) => {
}
if (pathname === '/api/reviews' && req.method === 'GET') {
json(res, 200, state.reviews.map((item) => clone(item)))
const items = isAuthenticated(req)
? state.reviews
: state.reviews.filter((item) => isPublicReviewVisible(item))
json(res, 200, items.map((item) => clone(item)))
return
}
@@ -1801,7 +1995,7 @@ const server = createServer(async (req, res) => {
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop())
const review = state.reviews.find((item) => item.id === id)
if (!review) {
if (!review || (!isAuthenticated(req) && !isPublicReviewVisible(review))) {
notFound(res, '评测不存在。')
return
}
@@ -2836,10 +3030,11 @@ const server = createServer(async (req, res) => {
status: 'queued',
})
state.deliveries.unshift(delivery)
const job = enqueueNotificationDeliveryJob(delivery)
record.last_delivery_status = delivery.status
record.last_notified_at = iso(-1)
addAuditLog('subscription.test', 'subscription', record.target, record.id)
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id })
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id, job_id: job.id })
return
}
@@ -2855,14 +3050,14 @@ const server = createServer(async (req, res) => {
const period = normalizeText(payload.period) || 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => {
state.deliveries.unshift(
createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
}),
)
const delivery = createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
})
state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
subscription.last_delivery_status = 'queued'
subscription.last_notified_at = iso(-1)
})
@@ -2876,6 +3071,205 @@ const server = createServer(async (req, res) => {
return
}
if (pathname === '/api/admin/workers/overview' && req.method === 'GET') {
json(res, 200, buildWorkerOverview())
return
}
if (pathname === '/api/admin/workers/jobs' && req.method === 'GET') {
const status = normalizeText(searchParams.get('status'))
const jobKind = normalizeText(searchParams.get('job_kind'))
const workerName = normalizeText(searchParams.get('worker_name'))
const keyword = normalizeText(searchParams.get('search'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
let items = [...state.worker_jobs]
if (status) {
items = items.filter((item) => item.status === status)
}
if (jobKind) {
items = items.filter((item) => item.job_kind === jobKind)
}
if (workerName) {
items = items.filter((item) => item.worker_name === workerName)
}
if (keyword) {
items = items.filter((item) =>
[item.worker_name, item.display_name, item.related_entity_type, item.related_entity_id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword.toLowerCase())),
)
}
const total = items.length
if (limit > 0) {
items = items.slice(0, limit)
}
json(res, 200, { total, jobs: items.map((item) => normalizeWorkerJob(item)) })
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop())
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/cancel$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
job.cancel_requested = true
if (job.status === 'queued') {
job.status = 'cancelled'
job.finished_at = iso(-1)
job.error_text = 'job cancelled before start'
}
job.updated_at = iso(-1)
addAuditLog('worker.cancel', 'worker_job', job.worker_name, job.id)
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/retry$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
let nextJob = null
if (job.worker_name === 'task.send_weekly_digest' || job.worker_name === 'task.send_monthly_digest') {
const period = job.worker_name === 'task.send_monthly_digest' ? 'monthly' : 'weekly'
nextJob = createWorkerJob({
job_kind: 'task',
worker_name: job.worker_name,
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: state.subscriptions.filter((item) => item.status === 'active').length,
skipped: state.subscriptions.filter((item) => item.status !== 'active').length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else if (job.worker_name === 'worker.download_media') {
const payload = job.payload || {}
nextJob = createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: job.display_name,
status: 'succeeded',
queue_name: 'media',
payload,
result: job.result,
tags: ['media', 'download'],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else {
nextJob = createWorkerJob({
job_kind: job.job_kind,
worker_name: job.worker_name,
display_name: job.display_name,
status: 'succeeded',
queue_name: job.queue_name,
payload: clone(job.payload),
result: clone(job.result),
tags: Array.isArray(job.tags) ? job.tags : [],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
}
addAuditLog('worker.retry', 'worker_job', nextJob.worker_name, nextJob.id, { source_job_id: job.id })
json(res, 200, { queued: true, job: normalizeWorkerJob(nextJob) })
return
}
if (pathname === '/api/admin/workers/tasks/retry-deliveries' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const limit = Number.parseInt(String(payload.limit || '80'), 10) || 80
const retryable = state.deliveries
.filter((item) => item.status === 'retry_pending')
.slice(0, limit)
retryable.forEach((delivery) => {
delivery.status = 'queued'
delivery.updated_at = iso(-1)
delivery.next_retry_at = null
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: 'task.retry_deliveries',
display_name: '重试待投递通知',
status: 'succeeded',
queue_name: 'maintenance',
payload: { limit },
result: { limit, queued: retryable.length },
tags: ['maintenance', 'retry'],
related_entity_type: 'notification_delivery',
related_entity_id: null,
})
addAuditLog('worker.task.retry_deliveries', 'worker_job', job.worker_name, job.id, { limit })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/workers/tasks/digest' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const period = normalizeText(payload.period) === 'monthly' ? 'monthly' : 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => {
const delivery = createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
})
state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: period === 'monthly' ? 'task.send_monthly_digest' : 'task.send_weekly_digest',
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: activeSubscriptions.length,
skipped: state.subscriptions.length - activeSubscriptions.length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
})
addAuditLog('worker.task.digest', 'worker_job', job.worker_name, job.id, { period })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/storage/media' && req.method === 'GET') {
const prefix = normalizeText(searchParams.get('prefix'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
@@ -2970,6 +3364,42 @@ const server = createServer(async (req, res) => {
return
}
if (pathname === '/api/admin/storage/media/download' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const sourceUrl = normalizeText(payload.source_url)
if (!sourceUrl) {
badRequest(res, '缺少远程素材地址。')
return
}
const prefix = normalizeText(payload.prefix) || 'uploads/'
const fileName = sanitizeFilename(sourceUrl.split('/').pop(), 'remote-asset.svg')
const key = `${prefix}${Date.now()}-${fileName}`
const title = normalizeText(payload.title) || sanitizeFilename(fileName, 'remote-asset')
const record = {
key,
url: `${MOCK_ORIGIN}/media/${encodeURIComponent(key)}`,
size_bytes: 2048,
last_modified: iso(-1),
title,
alt_text: normalizeText(payload.alt_text) || null,
caption: normalizeText(payload.caption) || null,
tags: Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : [],
notes: normalizeText(payload.notes) || `downloaded from ${sourceUrl}`,
body: `<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="72" y="200" fill="#f8fafc" font-family="monospace" font-size="40">${title}</text><text x="72" y="280" fill="#94a3b8" font-family="monospace" font-size="24">${sourceUrl}</text></svg>`,
content_type: CONTENT_TYPES.svg,
}
state.media.unshift(record)
const job = queueDownloadWorkerJob(payload, record)
addAuditLog('media.download', 'media', sourceUrl, job.id, { key })
json(res, 200, {
queued: true,
job_id: job.id,
status: job.status,
})
return
}
if (pathname === '/api/admin/storage/media/metadata' && req.method === 'PATCH') {
const { json: payload } = await parseRequest(req)
const key = normalizeText(payload.key)

View File

@@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import { getDebugState, loginAdmin, resetMockState } from './helpers'
import { getDebugState, loginAdmin, MOCK_BASE_URL, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => {
await resetMockState(request)
@@ -39,6 +39,7 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
{ label: '评测', url: /\/reviews$/, text: '《漫长的季节》' },
{ label: '媒体库', url: /\/media$/, text: '漫长的季节封面' },
{ label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' },
{ label: 'Workers', url: /\/workers$/, text: '异步 Worker 控制台' },
{ label: '审计', url: /\/audit$/, text: 'playwright-smoke' },
{ label: '设置', url: /\/settings$/, text: 'InitCool' },
]
@@ -50,6 +51,17 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
}
})
test('后台 dashboard worker 健康卡片可跳转到带筛选的 workers', async ({ page }) => {
await loginAdmin(page)
await expect(page.locator('main')).toContainText('Worker 活动')
await page.getByTestId('dashboard-worker-card-failed').click()
await expect(page).toHaveURL(/\/workers\?status=failed$/)
await expect(page.locator('main')).toContainText('异步 Worker 控制台')
await expect(page.locator('main')).toContainText('failed')
})
test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => {
await loginAdmin(page)
@@ -136,8 +148,21 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
await expect(row).toContainText('Deep Regression Updated')
await row.getByTestId(/subscription-test-/).click()
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
await page.getByTestId('subscriptions-last-job').click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('subscription.test')
await page.getByRole('link', { name: '订阅' }).click()
await page.getByTestId('subscriptions-send-weekly').click()
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
await page.getByTestId('subscriptions-send-monthly').click()
await expect(page.getByTestId(/^subscription-delivery-job-/).first()).toBeVisible()
await page.getByTestId(/^subscription-delivery-job-/).first().click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('worker.notification_delivery')
await page.getByRole('link', { name: '订阅' }).click()
let state = await getDebugState(request)
expect(
@@ -146,6 +171,9 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
).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()
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'worker.notification_delivery'),
).toBeTruthy()
await row.getByTestId(/subscription-delete-/).click()
await expect(row).toHaveCount(0)
@@ -156,6 +184,33 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
).toBeFalsy()
})
test('后台可查看 worker 控制台并执行 digest / retry / job 重跑', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: 'Workers' }).click()
await page.getByTestId('workers-run-weekly').click()
await expect(page.locator('main')).toContainText('task.send_weekly_digest')
await page.getByTestId('workers-retry-job').click()
let state = await getDebugState(request)
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.send_weekly_digest'),
).toBeTruthy()
expect(
state.worker_jobs.some(
(item: { worker_name: string; parent_job_id: number | null }) =>
item.worker_name === 'task.send_weekly_digest' && item.parent_job_id !== null,
),
).toBeTruthy()
await page.getByTestId('workers-run-retry').click()
state = await getDebugState(request)
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.retry_deliveries'),
).toBeTruthy()
})
test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '文章' }).click()
@@ -209,6 +264,15 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
await loginAdmin(page)
await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-remote-url').fill(`${MOCK_BASE_URL}/media-files/remote-playwright.svg`)
await page.getByTestId('media-remote-title').fill('Remote Playwright Cover')
await page.getByTestId('media-remote-download').click()
await expect(page.getByTestId('media-last-remote-job')).toBeVisible()
await page.getByTestId('media-last-remote-job').click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('worker.download_media')
await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-upload-input').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-upload'),
])
@@ -222,6 +286,13 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
await page.getByTestId('media-save-metadata').click()
let state = await getDebugState(request)
expect(
state.media.some(
(item: { title: string; key: string }) =>
item.title === 'Remote Playwright Cover' &&
String(item.key || '').includes('post-covers/'),
),
).toBeTruthy()
expect(
state.media.some(
(item: { title: string; alt_text: string; tags: string[] }) =>

View File

@@ -24,6 +24,36 @@ test('首页过滤、热门区和文章详情链路可用', async ({ page }) =>
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()
await page.goto('/categories/frontend-engineering')
await expect(page.getByRole('heading', { name: '前端工程' })).toBeVisible()
await expect(page.getByText('Astro 终端博客信息架构实战')).toBeVisible()
await page.goto('/tags/playwright')
await expect(page.getByRole('heading', { name: 'Playwright', exact: true })).toBeVisible()
await expect(page.getByText('Playwright 回归工作流设计')).toBeVisible()
await page.goto('/reviews')
await expect(page.getByText('《宇宙探索编辑部》')).toHaveCount(0)
await page.goto('/reviews/4')
await expect(page.getByRole('heading', { name: '评价不存在' })).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '#年度最佳' }).click()
await expect(page).toHaveURL(/\/reviews\?tag=%E5%B9%B4%E5%BA%A6%E6%9C%80%E4%BD%B3$/)
await expect(page.getByText('《漫长的季节》')).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '动画' }).click()
await expect(page).toHaveURL(/\/reviews\?type=anime$/)
await expect(page.locator('#reviews-subtitle')).toContainText('动画')
await expect(page.getByText('《漫长的季节》')).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '已完成' }).click()
await expect(page).toHaveURL(/\/reviews\?status=completed$/)
await expect(page.locator('#reviews-subtitle')).toContainText('已完成')
await expect(page.getByText('《漫长的季节》')).toBeVisible()
})
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
@@ -64,16 +94,27 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
await page.goto('/')
await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户')
await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com')
await page.locator('[data-subscribe-form] button[type="submit"]').click()
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
await page.locator('[data-subscription-popup-open]').click()
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
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 inlineRecord = subscriptionState.subscriptions.find(
(item: { target: string; display_name: string }) => item.target === 'inline-subscriber@example.com',
)
expect(inlineRecord?.display_name).toBe('首页订阅用户')
const latest = subscriptionState.subscriptions.find(
(item: { target: string }) => item.target === 'playwright-subscriber@example.com',
(item: { target: string; display_name: string }) => item.target === 'playwright-subscriber@example.com',
)
expect(latest).toBeTruthy()
expect(latest.display_name).toBe('弹窗订阅用户')
await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`)
await expect(page.getByText('订阅已确认')).toBeVisible()