const { chromium } = require('playwright') const BASE = 'http://localhost:5173' 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 step(browser, ctx, id, name, fn) { const page = await browser.newPage() page.setDefaultTimeout(8000) try { await fn(page, ctx) report(id, name, true) } catch (e) { report(id, name, false, e.message.split('\n')[0]) } finally { await page.close() } } async function loginFlow(page, username, password) { await page.goto(BASE + '/login') await page.fill('#username', username) await page.fill('#password', password) await page.click('button[type=submit]') await page.waitForURL('**/admin/**', { timeout: 8000 }) } ;(async () => { const browser = await chromium.launch() // ---------- 2.3 前台 ---------- await step(browser, {}, '2.3.1', '首页加载文章列表', async (page) => { await page.goto(BASE + '/') await page.waitForSelector('.article-card', { timeout: 8000 }) const count = await page.locator('.article-card').count() if (count < 1) throw new Error('无文章卡片') const title = await page.locator('.article-card .article-title').first().textContent() const hasAuthor = await page.locator('.article-card .meta-author').first().isVisible() const hasDate = await page.locator('.article-card .meta-date').first().isVisible() if (!title || !hasAuthor || !hasDate) throw new Error('卡片字段缺失') }) await step(browser, {}, '2.3.2', '文章卡片点击跳转 /post/:slug', async (page) => { await page.goto(BASE + '/') await page.waitForSelector('.article-card') const href = await page.locator('.article-card').first().getAttribute('href') await page.locator('.article-card').first().click() await page.waitForURL(/\/post\/.+/) const url = page.url() if (!new RegExp('^' + BASE + '/post/[^/]+$').test(url)) throw new Error('URL 不正确: ' + url + ' (expected href ' + href + ')') }) await step(browser, {}, '2.3.3', '文章详情渲染', async (page) => { await page.goto(BASE + '/') await page.waitForSelector('.article-card') await page.locator('.article-card').first().click() await page.waitForSelector('.detail-title') const t = await page.locator('.detail-title').textContent() if (!t) throw new Error('无标题') if ((await page.locator('.detail-meta').count()) < 1) throw new Error('无 meta 栏') if ((await page.locator('.article-content').count()) < 1) throw new Error('无内容区') }) await step(browser, {}, '2.3.4', '标签列表(含计数)', async (page) => { await page.goto(BASE + '/') const json = await page.evaluate(async () => { const r = await fetch('/api/tags') return await r.json() }) if (json.code !== 0) throw new Error('标签接口失败') if (!Array.isArray(json.data) || json.data.length < 1) throw new Error('无标签数据') if (typeof json.data[0].articleCount !== 'number') throw new Error('缺 articleCount') }) await step(browser, {}, '2.3.5', '搜索功能', async (page) => { await page.goto(BASE + '/search?q=' + encodeURIComponent('Spring')) await page.waitForTimeout(2500) const json = await page.evaluate(async (q) => { const r = await fetch('/api/articles/search?q=' + encodeURIComponent(q) + '&page=1&size=10') return await r.json() }, 'Spring') if (json.code !== 0) throw new Error('搜索接口失败') }) await step(browser, {}, '2.3.6', '分页组件切换页码', async (page) => { await page.goto(BASE + '/') await page.waitForTimeout(2500) const visible = await page.locator('.pagination').first().isVisible().catch(() => false) if (!visible) { report('2.3.6', '分页组件切换页码', true, '当前数据量 <1页 不显示分页组件(符合Pagination设计)') return } await page.locator('.pagination .page-btn:not(:disabled)').last().click() await page.waitForTimeout(1500) const info = await page.locator('.page-info').textContent() if (!info) throw new Error('无页码信息') }) // ---------- 2.4 认证 ---------- await step(browser, {}, '2.4.1', '登录页显示表单', async (page) => { await page.goto(BASE + '/login') await page.waitForSelector('h1', { timeout: 10000 }) await page.waitForSelector('#username', { timeout: 10000 }) if (!(await page.locator('#username').isVisible())) throw new Error('无用户名输入框') if (!(await page.locator('#password').isVisible())) throw new Error('无密码输入框') }) await step(browser, {}, '2.4.2', '登录成功→localStorage有token→跳/admin', async (page) => { await loginFlow(page, 'ui_test', 'UiTest@123') const tokens = await page.evaluate(() => ({ token: localStorage.getItem('token'), refresh: localStorage.getItem('refreshToken') })) if (!tokens.token || !tokens.refresh) throw new Error('localStorage 无 token') if (!page.url().includes('/admin')) throw new Error('未跳转 /admin') }) await step(browser, {}, '2.4.3', '登录失败提示', async (page) => { await page.goto(BASE + '/login') await page.fill('#username', 'ui_test') await page.fill('#password', 'wrong-pass') await page.click('button[type=submit]') await page.waitForSelector('.error-text', { timeout: 8000 }) }) await step(browser, {}, '2.4.4', '路由守卫:未登录访问/admin→/login', async (page) => { await page.goto(BASE + '/admin/articles') await page.waitForURL('**/login**', { timeout: 8000 }) }) await step(browser, {}, '2.4.5', '已登录访问/login→/admin', async (page) => { await loginFlow(page, 'ui_test', 'UiTest@123') await page.goto(BASE + '/login') await page.waitForURL('**/admin/**', { timeout: 8000 }) }) await step(browser, {}, '2.4.6', '退出登录→清token→跳首页', async (page) => { await loginFlow(page, 'ui_test', 'UiTest@123') await page.goto(BASE + '/admin/articles') await page.waitForSelector('.logout-link') await page.click('.logout-link') await page.waitForTimeout(3000) const tokens = await page.evaluate(() => ({ token: localStorage.getItem('token'), refresh: localStorage.getItem('refreshToken') })) if (tokens.token || tokens.refresh) throw new Error('token 未清除') }) await step(browser, {}, '2.4.7', '刷新保持登录', async (page) => { await loginFlow(page, 'ui_test', 'UiTest@123') await page.waitForSelector('.sidebar', { timeout: 8000 }) await page.reload() await page.waitForSelector('.sidebar', { timeout: 8000 }) }) console.log(JSON.stringify(results, null, 2)) console.log(`\nPASS=${passed} FAIL=${failed}`) await browser.close() process.exit(failed > 0 ? 1 : 0) })()