const { chromium } = require('playwright') const BASE = 'http://localhost:5173' const USER = 'ui_test' const PASS = 'UiTest@123' const results = [] let passed = 0 let failed = 0 function report(id, name, ok, detail = '') { results.push({ id, name, ok, detail }) if (ok) passed++ else failed++ } async function login(page) { await page.goto(BASE + '/login') await page.waitForSelector('#username', { timeout: 10000 }) await page.fill('#username', USER) await page.fill('#password', PASS) await page.click('button[type=submit]') await page.waitForURL('**/admin/**', { timeout: 10000 }) } async function step(browser, id, name, fn) { const page = await browser.newPage() page.setDefaultTimeout(10000) page.on('pageerror', (e) => console.log(`[${id} pageerror]`, e.message)) try { await fn(page) report(id, name, true) } catch (e) { report(id, name, false, e.message.split('\n')[0]) } finally { await page.close() } } ;(async () => { const browser = await chromium.launch() await step(browser, '2.5.1', '后台布局: 侧边栏+顶部栏', async (page) => { await login(page) await page.waitForSelector('.sidebar') await page.waitForSelector('.topbar') const nav = await page.locator('.sidebar-nav').textContent() if (!nav.includes('文章管理') || !nav.includes('标签管理')) throw new Error('侧边栏缺少菜单') }) await step(browser, '2.5.2', '文章列表表格', async (page) => { await login(page) await page.waitForSelector('.article-table') const headers = await page.locator('.article-table th').allTextContents() if (!headers.some((h) => h.includes('标题'))) throw new Error('缺标题列') if (!headers.some((h) => h.includes('状态'))) throw new Error('缺状态列') if (!headers.some((h) => h.includes('操作'))) throw new Error('缺操作列') const rows = await page.locator('.article-table tbody tr').count() if (rows < 1) throw new Error('无文章行') }) await step(browser, '2.5.3', '状态标签颜色', async (page) => { await login(page) await page.waitForSelector('.article-table') const badges = await page.evaluate(() => { const arr = [] document.querySelectorAll('.status-badge').forEach((el) => { arr.push({ text: el.textContent, cls: el.className }) }) return arr }) if (badges.length < 1) throw new Error('无状态徽章') for (const b of badges) { const t = b.text if (t === '草稿' && !b.cls.includes('draft')) throw new Error('草稿样式错误') if (t === '已发布' && !b.cls.includes('published')) throw new Error('已发布样式错误') if (t === '已归档' && !b.cls.includes('archived')) throw new Error('已归档样式错误') } }) await step(browser, '2.5.4', '创建文章', async (page) => { await login(page) await page.waitForSelector('.article-table') const unique = '验收新建文章-' + Date.now() await page.click('text=新建文章') await page.waitForSelector('#title') await page.fill('#title', unique) await page.fill('#content', '这是通过 UI 创建的内容。') await page.click('button[type=submit]') await page.waitForURL('**/admin/articles') await page.waitForTimeout(500) await page.selectOption('.filter-select', { label: '草稿' }) await page.waitForTimeout(1000) const body = await page.locator('.article-table').textContent() if (!body.includes(unique)) throw new Error('列表未出现新文章') }) await step(browser, '2.5.5', '编辑文章(预填充+更新)', async (page) => { await login(page) await page.waitForSelector('.article-table') const firstTitle = (await page.locator('.article-table tbody tr td').first().textContent()).trim() await page.locator('.article-table tbody tr').first().locator('button', { hasText: '编辑' }).click() await page.waitForSelector('#title') const prefill = await page.locator('#title').inputValue() if (prefill !== firstTitle) throw new Error('预填充标题不一致: ' + prefill + ' vs ' + firstTitle) const edited = prefill + '-编辑' + Date.now() await page.fill('#title', edited) await page.click('button[type=submit]') await page.waitForURL('**/admin/articles') await page.waitForTimeout(800) const body = await page.locator('.article-table').textContent() if (!body.includes(edited)) throw new Error('更新后标题未出现在列表') }) await step(browser, '2.5.6', '发布文章', async (page) => { await login(page) await page.waitForSelector('.filter-select') await page.selectOption('.filter-select', { label: '草稿' }) await page.waitForTimeout(1000) const hasDraft = await page.locator('.article-table tbody tr', { hasText: '草稿' }).count() if (hasDraft < 1) throw new Error('无草稿文章可发布') const row = page.locator('.article-table tbody tr', { hasText: '草稿' }).first() const title = (await row.locator('td').first().textContent()).trim() await row.locator('button', { hasText: '发布' }).click() await page.waitForTimeout(1500) const updated = await page.evaluate(async (t) => { const r = await fetch('/api/articles?page=1&size=50&status=PUBLISHED') const j = await r.json() const hit = j.data.list.find((a) => a.title === t) return hit ? hit.status : null }, title) if (updated !== 'PUBLISHED') throw new Error('发布后状态=' + updated) }) await step(browser, '2.5.7', '归档文章', async (page) => { await login(page) await page.waitForSelector('.article-table') const hasPub = await page.locator('.article-table tbody tr', { hasText: '已发布' }).count() if (hasPub < 1) throw new Error('无已发布文章可归档') const row = page.locator('.article-table tbody tr', { hasText: '已发布' }).first() const title = (await row.locator('td').first().textContent()).trim() await row.locator('button', { hasText: '归档' }).click() await page.waitForTimeout(1500) const updated = await page.evaluate(async (t) => { const r = await fetch('/api/articles?page=1&size=50&status=ARCHIVED') const j = await r.json() const hit = j.data.list.find((a) => a.title === t) return hit ? hit.status : null }, title) if (updated !== 'ARCHIVED') throw new Error('归档后状态=' + updated) }) await step(browser, '2.5.8', '删除文章', async (page) => { await login(page) await page.waitForSelector('.article-table') const unique = '待删除-' + Date.now() await page.evaluate(async (t) => { const token = localStorage.getItem('token') const r = await fetch('/api/admin/articles', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ title: t, content: 'to delete' }) }) const j = await r.json() window.__delId = j.data.id window.__delSlug = j.data.slug }, unique) await page.reload() await page.waitForSelector('.filter-select') await page.selectOption('.filter-select', { label: '草稿' }) await page.waitForSelector('.article-table') await page.waitForTimeout(500) await page.once('dialog', (d) => d.accept()) const row = page.locator('.article-table tbody tr', { hasText: unique }).first() await row.locator('button', { hasText: '删除' }).click() await page.waitForTimeout(1500) const gone = await page.evaluate(async (slug) => { const r = await fetch('/api/articles/' + slug) const j = await r.json() return j.code }, await page.evaluate(() => window.__delSlug)) if (gone !== 3001 && gone !== 404) throw new Error('删除后公开可访问 code=' + gone) }) await step(browser, '2.5.9', '标签管理: 新增/编辑/删除', async (page) => { await login(page) await page.goto(BASE + '/admin/tags') await page.waitForSelector('.tag-table') const unique = '验收标签-' + Date.now() await page.click('text=新增标签') await page.waitForSelector('.tag-form input') await page.fill('.tag-form input:first-of-type', unique) await page.click('.tag-form button[type=submit]') await page.waitForTimeout(1000) let body = await page.locator('.tag-table').textContent() if (!body.includes(unique)) throw new Error('新增标签未显示') await page.locator('.tag-table tbody tr', { hasText: unique }).locator('button', { hasText: '编辑' }).click() await page.fill('.tag-form input:first-of-type', unique + '-改') await page.click('.tag-form button[type=submit]') await page.waitForTimeout(1000) body = await page.locator('.tag-table').textContent() if (!body.includes(unique + '-改')) throw new Error('编辑未生效') await page.once('dialog', (d) => d.accept()) await page.locator('.tag-table tbody tr', { hasText: unique + '-改' }).locator('button', { hasText: '删除' }).click() await page.waitForTimeout(1000) body = await page.locator('.tag-table').textContent() if (body.includes(unique)) throw new Error('删除未生效') }) await step(browser, '2.5.10', '表单校验: 空标题/内容拦截', async (page) => { await login(page) await page.goto(BASE + '/admin/articles/new') await page.waitForSelector('#title') await page.fill('#title', ' ') await page.fill('#content', ' ') await page.click('button[type=submit]') await page.waitForTimeout(500) const err = await page.locator('.error-text').textContent() if (!err || !err.includes('必填')) throw new Error('未提示必填: ' + err) const url = page.url() if (url.includes('admin/articles/new') === false) throw new Error('不应离开编辑器') }) await step(browser, '2.6.1', '封面上传', async (page) => { await login(page) await page.goto(BASE + '/admin/articles/new') await page.waitForSelector('#cover') const buf = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64') await page.setInputFiles('.upload-input', { name: 'cover.png', mimeType: 'image/png', buffer: buf }) await page.waitForFunction(() => document.querySelector('#cover')?.value.includes('/'), null, { timeout: 10000 }) const url = await page.locator('#cover').inputValue() if (!url) throw new Error('上传后 URL 未填入') const resp = await page.evaluate(async (u) => { const r = await fetch(u) return { status: r.status, ct: r.headers.get('content-type') } }, url) if (resp.status !== 200) throw new Error('上传文件不可访问 status=' + resp.status) }) await step(browser, '2.6.2', '上传反馈(上传中状态)', async (page) => { await login(page) await page.goto(BASE + '/admin/articles/new') await page.waitForSelector('#cover') const buf = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64') await page.setInputFiles('.upload-input', { name: 'cover2.png', mimeType: 'image/png', buffer: buf }) await page.waitForFunction(() => document.querySelector('.upload-btn')?.textContent.includes('上传中'), null, { timeout: 2000 }).catch(() => {}) await page.waitForFunction(() => !document.querySelector('.upload-btn')?.textContent.includes('上传中'), null, { timeout: 10000 }) const finalText = await page.locator('.upload-btn').textContent() if (!finalText.includes('上传')) throw new Error('上传完成后未恢复按钮文字') }) await browser.close() console.log(JSON.stringify(results, null, 2)) console.log(`PASS=${passed} FAIL=${failed}`) })().catch((e) => { console.error('FATAL', e) process.exit(1) })