用户反馈三点: 1. 公告里"红字"/"蓝字"工具按钮硬编码颜色 #ff4d4f / #00b8e6 在浅色下糊 2. 消息中心列表里公告直接渲染完整 HTML,行被撑得很大 3. 点行就自动 navigate(link_url),用户希望"只看不跳",看完主动决定要不要跳转 改动: a) SettingsPage 公告编辑器颜色按钮(2 个): - 红字: <span style="color:#ff4d4f"> → var(--color-danger) - 蓝字: <span style="color:#00b8e6"> → var(--color-primary) - 分割线 border-top 颜色 #333 → var(--color-border-card) - 三个都改成 CSS var,自动适配浅/深主题 - title 文案加"(自适应主题)"提示超管 b) NotificationsPage accordion 模式: - 加 expandedId: number | null state,始终最多 1 条展开 - 折叠态:chip + title + 时间 + 一行剥 HTML 后的纯文本预览(stripAndTruncate, 60 字 '…') - 展开态:头部 + 下方完整内容(announcement 用 DOMPurify+HTML / 其他 plain) + link_url 非空时显示【前往查看】按钮(蓝底白字,带箭头 icon) - chevron icon 旋转 0deg/180deg 视觉指示折叠/展开 - 同 id 再点 → 收起;不同 id → 切换(前一个自动收起) - 切页时自动重置 expandedId 为 null c) 点击行为: - handleRowClick(自动跳) → handleToggle(只 markRead + 切 expandedId) - 新加 handleJump(url):用户主动点【前往查看】才触发 navigate / window.open(http url) - 展开区域 onClick 加 stopPropagation 防误触收起 d) smoke test 更新: - 测试公告内容做长用 EXPANDED-ONLY-MARKER 末尾标记,preview 截断后看不到 - 7.2.0 折叠态 preview 截断验证(marker 不可见) - 7.2 展开后 marker 可见 - 7.3 再点收起 marker 不再可见 - 用 chip [公告] 文字作为稳定点击锚点(只在头部出现不在展开内容里) 验证: - typecheck 0 error - announcement-integration-smoke 13/13(从 10 项扩到 13,加 accordion 路径) - v0.20.1-smoke 11/11 + v2-smoke 25/25 + modal-interaction 8/8 全过 - vitest 71 fail / 162 pass 与基线一致 GlobalAnnouncementGate 强弹 modal 行为不变(plan §一 7 — 公告强制阅读语义保留)。 重看路径走 sidebar 大铃铛 → 消息中心 → accordion 展开看全文 → 可点【前往查看】跳。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
/**
|
|
* 通知 / 公告整合 smoke test —
|
|
*
|
|
* 覆盖:
|
|
* 1. admin POST /admin/announcement/publish (空内容 → 400)
|
|
* 2. admin POST /admin/announcement/publish (HTML 内容 → 200 sent_to=N)
|
|
* 3. 后端 DB:fan-out 数等于 User 总数(active+inactive 都收到)
|
|
* 4. tudou GET /announcement → 拿到那条 HTML
|
|
* 5. tudou 浏览器进 /app 应自动强弹 modal(GlobalAnnouncementGate)
|
|
* 6. 关闭 modal → POST /announcement/read → 再开页面不弹
|
|
* 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染)
|
|
*
|
|
* 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/tudoupass123 可登录。
|
|
* 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。
|
|
*/
|
|
import { chromium } from '@playwright/test';
|
|
|
|
const BASE = 'http://localhost:5173';
|
|
const API = 'http://localhost:8000';
|
|
|
|
const results = [];
|
|
function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); }
|
|
function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); }
|
|
|
|
async function login(page, username, password) {
|
|
const res = await page.request.post(`${API}/api/v1/auth/login`, {
|
|
data: { username, password },
|
|
});
|
|
if (!res.ok()) throw new Error(`登录失败 ${username}: ${res.status()}`);
|
|
const body = await res.json();
|
|
return { token: body?.tokens?.access, user: body?.user };
|
|
}
|
|
|
|
async function setStorage(page, { token, refresh, user }) {
|
|
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(({ access, r, u }) => {
|
|
localStorage.setItem('access_token', access);
|
|
if (r) localStorage.setItem('refresh_token', r);
|
|
if (u) localStorage.setItem('user', JSON.stringify(u));
|
|
}, { access: token, r: refresh, u: user });
|
|
}
|
|
|
|
async function clearAllUnreadAnnouncements(adminToken) {
|
|
// 没有专用清理 endpoint,用 admin 自己的 read-all 跑一遍(只清自己的);
|
|
// 其他用户的未读靠 tudou 的 read-all 单独清。这里只清 admin 自己。
|
|
await fetch(`${API}/api/v1/announcement/read`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${adminToken}` },
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
const consoleErrors = [];
|
|
page.on('console', (m) => {
|
|
if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) {
|
|
consoleErrors.push(m.text());
|
|
}
|
|
});
|
|
// 接受 confirm 弹窗(发送公告二次确认)
|
|
page.on('dialog', (d) => d.accept());
|
|
|
|
console.log('\n════ 公告整合 smoke ════');
|
|
|
|
// 前置:登录拿 token
|
|
let adminTok, tudouTok;
|
|
try {
|
|
const a = await login(page, 'admin', 'admin123');
|
|
const t = await login(page, 'tudou', 'tudoupass123');
|
|
adminTok = a.token; tudouTok = t.token;
|
|
if (!adminTok || !tudouTok) throw new Error('token 空');
|
|
} catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; }
|
|
|
|
// 先清掉 tudou 可能的旧未读公告
|
|
await fetch(`${API}/api/v1/announcement/read`, {
|
|
method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` },
|
|
});
|
|
await fetch(`${API}/api/v1/announcement/read`, {
|
|
method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` },
|
|
});
|
|
|
|
// ── 1. 空内容 400 ──
|
|
try {
|
|
const r = await fetch(`${API}/api/v1/admin/announcement/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' },
|
|
body: JSON.stringify({ content: '' }),
|
|
});
|
|
if (r.status === 400) pass('1. 空内容发送返回 400');
|
|
else fail('1. 空内容', new Error(`期望 400 实际 ${r.status}`));
|
|
} catch (e) { fail('1. 空内容', e); }
|
|
|
|
// ── 2. HTML 发送返回 200 + sent_to ──
|
|
// 内容做长一点 — 短公告 preview 不会截断,折叠态/展开态文字完全一样,
|
|
// 测试 7.3 收起没法用 text 区分;长内容让 preview '…' 截断,
|
|
// 用末尾独有 marker 字串作为"只在展开态才看得到"的探针。
|
|
const uniqueMarker = `EXPANDED-ONLY-MARKER-${Date.now()}`;
|
|
const testContent = `<p>smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。占位文字继续:用户/团队/超管角色矩阵 → 团队管理/消费记录/系统设置。结尾标记 <b>${uniqueMarker}</b></p>`;
|
|
let sentTo = 0;
|
|
try {
|
|
const r = await fetch(`${API}/api/v1/admin/announcement/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' },
|
|
body: JSON.stringify({ content: testContent }),
|
|
});
|
|
const data = await r.json();
|
|
sentTo = data.sent_to;
|
|
if (r.status === 200 && sentTo > 0) pass(`2. HTML 发送成功 sent_to=${sentTo}`);
|
|
else fail('2. HTML 发送', new Error(`期望 200+sent_to>0 实际 ${r.status} ${JSON.stringify(data)}`));
|
|
} catch (e) { fail('2. HTML 发送', e); }
|
|
|
|
// ── 3. fan-out 数 = User 总数(用 admin 计:GET /admin/users total 应 ≈ sent_to) ──
|
|
try {
|
|
const r = await fetch(`${API}/api/v1/admin/users?page_size=1`, {
|
|
headers: { 'Authorization': `Bearer ${adminTok}` },
|
|
});
|
|
const data = await r.json();
|
|
if (data.total === sentTo) pass(`3. fan-out 数 (${sentTo}) = User 总数 (${data.total})`);
|
|
else fail('3. fan-out 数', new Error(`sent_to=${sentTo} vs admin/users.total=${data.total}`));
|
|
} catch (e) { fail('3. fan-out 数', e); }
|
|
|
|
// ── 4. tudou GET /announcement 拿到那条 ──
|
|
try {
|
|
const r = await fetch(`${API}/api/v1/announcement`, {
|
|
headers: { 'Authorization': `Bearer ${tudouTok}` },
|
|
});
|
|
const data = await r.json();
|
|
if (data.enabled && data.announcement.includes('smoke 测试公告')) {
|
|
pass('4. tudou 拿到未读公告');
|
|
} else {
|
|
fail('4. tudou 未读公告', new Error(`enabled=${data.enabled} content=${data.announcement.slice(0, 40)}`));
|
|
}
|
|
} catch (e) { fail('4. tudou GET /announcement', e); }
|
|
|
|
// ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ──
|
|
const tudouLogin = await login(page, 'tudou', 'tudoupass123');
|
|
await setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user });
|
|
await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染
|
|
try {
|
|
const modalVisible = await page.locator('text=我知道了').isVisible({ timeout: 3000 });
|
|
if (modalVisible) pass('5. tudou 进 /app 自动强弹公告 modal');
|
|
else fail('5. 自动强弹', new Error('未找到"我知道了"按钮 — modal 没弹'));
|
|
} catch (e) { fail('5. 自动强弹', e); }
|
|
|
|
// ── 6. 关闭 modal → 标已读 → 再开页面不弹 ──
|
|
try {
|
|
await page.locator('button:has-text("我知道了")').click({ timeout: 3000 });
|
|
await page.waitForTimeout(1500); // 等 POST /announcement/read 完成
|
|
// 验证后端已标读
|
|
const r = await fetch(`${API}/api/v1/announcement`, {
|
|
headers: { 'Authorization': `Bearer ${tudouTok}` },
|
|
});
|
|
const data = await r.json();
|
|
if (!data.enabled) pass('6. 关闭 modal 后 GET /announcement 返回 enabled=false (已读)');
|
|
else fail('6. 关闭后未读取', new Error(`期望 enabled=false 实际 ${data.enabled}`));
|
|
|
|
// 再开 /app 不该弹
|
|
await page.goto(`${BASE}/admin/dashboard`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(2000);
|
|
const stillVisible = await page.locator('button:has-text("我知道了")').isVisible({ timeout: 1500 }).catch(() => false);
|
|
if (!stillVisible) pass('6.1 再开页面不再弹 modal');
|
|
else fail('6.1 再次弹出', new Error('已读状态下还在弹'));
|
|
} catch (e) { fail('6. 关闭流程', e); }
|
|
|
|
// ── 7. 消息中心:accordion 列表 ──
|
|
try {
|
|
await page.goto(`${BASE}/notifications`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(2000);
|
|
// 找带 [公告] chip 的行
|
|
const chip = await page.locator('text=/^公告$/').first().isVisible({ timeout: 3000 });
|
|
if (chip) pass('7. 消息中心显示 [公告] chip');
|
|
else fail('7. chip 缺失', new Error('未找到公告 chip'));
|
|
// 折叠态:公告文字应该在 title 行(标题"系统公告")就能看到
|
|
const titleVisible = await page.locator('text=系统公告').first().isVisible({ timeout: 2000 });
|
|
if (titleVisible) pass('7.1 折叠态显示公告标题');
|
|
else fail('7.1 折叠态', new Error('看不到公告标题'));
|
|
|
|
// ── 7.2 点击行展开 → 看到末尾独有 marker(只在展开态才能看到,折叠 preview 被 60 字 '…' 截断) ──
|
|
// 用 chip [公告] 作为稳定点击锚点(chip 只在头部出现,不会跳到展开内容里)
|
|
const chipLocator = page.locator('text=/^公告$/').first();
|
|
// 先验证折叠态看不到 marker
|
|
const markerInCollapsed = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false);
|
|
if (!markerInCollapsed) pass('7.2.0 折叠态 preview 截断,看不到末尾 marker');
|
|
else fail('7.2.0 折叠态泄漏', new Error('折叠状态下却看得到 marker,preview 没截断'));
|
|
|
|
await chipLocator.click({ force: true, timeout: 3000 });
|
|
await page.waitForTimeout(800);
|
|
const expandedMarker = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 2000 });
|
|
if (expandedMarker) pass('7.2 点击行展开后显示完整 HTML 内容(看到末尾 marker)');
|
|
else fail('7.2 展开内容', new Error('展开后看不到 marker'));
|
|
|
|
// ── 7.3 再点 chip → 收起(marker 不再可见) ──
|
|
await chipLocator.click({ force: true, timeout: 3000 });
|
|
await page.waitForTimeout(800);
|
|
const stillExpanded = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false);
|
|
if (!stillExpanded) pass('7.3 再点同一行收起 (accordion,marker 不再可见)');
|
|
else fail('7.3 收起', new Error('再点没收起,marker 还可见'));
|
|
} catch (e) { fail('7. 消息中心 accordion', e); }
|
|
|
|
// 清场:把测试造的公告标已读,避免污染下一次 smoke
|
|
await fetch(`${API}/api/v1/announcement/read`, {
|
|
method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` },
|
|
});
|
|
await fetch(`${API}/api/v1/announcement/read`, {
|
|
method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` },
|
|
});
|
|
|
|
if (consoleErrors.length > 0) {
|
|
fail('console errors', new Error(consoleErrors.slice(0, 3).join(' | ')));
|
|
} else {
|
|
pass('全程无 console.error');
|
|
}
|
|
|
|
console.log('\n────────────── 汇总 ──────────────');
|
|
const passed = results.filter(r => r.ok).length;
|
|
const failed = results.filter(r => !r.ok);
|
|
console.log(`通过: ${passed} / ${results.length}`);
|
|
if (failed.length > 0) {
|
|
console.log(`失败 ${failed.length} 项:`);
|
|
failed.forEach(r => console.log(` - ${r.name}: ${r.err}`));
|
|
}
|
|
|
|
await browser.close();
|
|
process.exit(failed.length === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|