mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
✨ feat(sentry): 添加用户反馈功能 (#83)
* ✨ feat(sentry): 添加用户反馈功能 - 升级 CDN bundle 以支持 feedback 模块 - 添加 feedbackIntegration 内置 Widget(主动反馈) - 实现错误后自动弹窗(Crash-Report Modal) - 添加冷却机制防止频繁弹窗(60秒冷却 + 同一错误不重复) * ♻️ refactor(sentry): 修复审查发现的问题 - 消除指纹计算重复(DRY 违反) - 统一使用 const/let 替代 var - 复用已有 showReportDialog 函数 - 添加 beforeSend UI 副作用设计说明注释 * 🐛 fix(sentry): 修复 SentryMock 缺失方法 - 添加 feedbackIntegration 和 replayIntegration 到 SentryMock - 修复 CDN 加载失败时 TypeError - 删除多余空行 * 🐛 fix(sentry): 修复代码质量问题 - sentry-loader.js 添加 replayIntegration 和 feedbackIntegration 配置 - sentry-loader.js 添加错误后自动弹窗逻辑(冷却机制 + try-catch) - sentry-init.js 添加 showReportDialog try-catch 保护 - 弹窗延迟从 100ms 改为 500ms(弱网兼容) * ✨ feat(sentry): 修复所有代码质量问题 - H1: CSP 添加 *.sentry.io 到 script-src(4 个文件) - M1: Feedback Widget 和自动弹窗添加中文文案 - M2: 同步两套初始化路径配置(tracing、脱敏、ignoreErrors) - M3: sentry-entry.js 添加 feedbackIntegration 导出 - M4: 新增专项测试(13 个测试用例) - L1: SentryMock 补齐 showReportDialog/lastEventId - L2: 空事件对象防御 * ✨ feat(sentry): 修复 PR 审查发现的全部问题 功能正确性: - query_string 添加类型检查(字符串/对象兼容) - 指纹计算移到脱敏前(避免不同错误被误判为重复) - 冷却状态更新移到 setTimeout 内部(避免空转) - subtitleLine2 改为 subtitle2(修正字段名) CSP 修复: - script-src 移除 *.sentry.io(减少攻击面) - frame-src 添加 *.sentry.io(支持弹窗 iframe) - 添加 worker-src/child-src blob:(支持 Session Replay) - server.js helmet CSP 同步更新 - netlify.toml 注释与实际策略对齐 配置/架构: - sentry-entry.js 添加 lastEventId 导出 - tracePropagationTargets 添加 CORS 风险注释 - CDN 架构添加说明注释 代码质量: - URL 参数脱敏支持大小写不敏感 - 移除过宽的 ignoreErrors 规则 - 测试文件添加 use strict - 添加 i18n 和漂移风险注释 * fix: apply CodeRabbit auto-fixes (#85) Fixed 3 file(s) based on 5 unresolved review comments. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai> * ✨ feat(sentry): 修复新一轮审查意见 CSP 修复: - script-src 恢复 *.sentry.io(showReportDialog 需要) - netlify.toml Header CSP 同步 Cloudflare/Google - server.js helmet CSP 同步 冷却竞态修复: - 状态预留移到 setTimeout 之前(防止 500ms 内重复弹窗) - 弹窗失败时回滚冷却状态 脱敏修复: - query_string 对象分支改为大小写不敏感 - sanitizeQueryString 迭代改为安全模式(先收集键再遍历) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,6 +98,8 @@ dist/
|
||||
*.bak
|
||||
*.orig
|
||||
*.tmp
|
||||
docs/superpowers/
|
||||
.sdd/
|
||||
|
||||
# Editor files
|
||||
*.sublime-project
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<script src="/src/js/sentry-config.js"></script>
|
||||
<script src="/src/js/sentry-loader.js"></script>
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.googletagmanager.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://challenges.cloudflare.com https://www.google.com;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.googletagmanager.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://challenges.cloudflare.com https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
|
||||
<title>eSIM Tools - Giffgaff & Simyo eSIM 在线管理工具 | 设备更换、激活与二维码生成</title>
|
||||
<link rel="canonical" href="https://esim.cosr.eu.org/">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
@@ -172,9 +172,9 @@
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
# 生产 CSP:移除 unsafe-inline,基于 nonce/hash 的策略(页面模板需注入 nonce)
|
||||
# 如暂无模板注入机制,可先允许外链受信源并避免内联脚本
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; manifest-src 'self';"
|
||||
# 生产 CSP:包含 unsafe-inline 是因为部分页面仍有内联样式和脚本(如内联 CSS、runtime 兜底脚本),
|
||||
# 迁移至 nonce/hash 策略需模板注入机制支持,当前暂保留 unsafe-inline
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com https://*.sentry.io https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; frame-src 'self' https://challenges.cloudflare.com https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:; manifest-src 'self';"
|
||||
|
||||
# Agent 发现 Link 响应头 (RFC 8288)
|
||||
[[headers]]
|
||||
|
||||
@@ -59,11 +59,14 @@ app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://*.sentry.io"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"],
|
||||
imgSrc: ["'self'", "data:", "https:", "http:"],
|
||||
connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"],
|
||||
fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"]
|
||||
connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://browser.sentry-cdn.com", "https://*.sentry.io"],
|
||||
fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"],
|
||||
frameSrc: ["'self'", "https://*.sentry.io"],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<link rel="preconnect" href="https://publicapi.giffgaff.com" crossorigin>
|
||||
|
||||
<!-- CSP策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://www.google.com;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google.com https://www.gstatic.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
|
||||
|
||||
<title>Giffgaff eSIM 工具</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
|
||||
@@ -317,6 +317,52 @@ const isDev = (() => {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.');
|
||||
})();
|
||||
|
||||
// ===================================
|
||||
// 错误反馈弹窗冷却机制
|
||||
// 避免同一错误或短时间内反复弹窗打扰用户
|
||||
// ===================================
|
||||
|
||||
const REPORT_DIALOG_COOLDOWN_MS = 60000; // 60 秒冷却时间
|
||||
let lastReportDialogTime = 0;
|
||||
let lastReportDialogFingerprint = '';
|
||||
|
||||
/**
|
||||
* 判断是否应该显示反馈弹窗
|
||||
* @param {Object} event - Sentry 事件对象
|
||||
* @returns {{ shouldShow: boolean, fingerprint: string }}
|
||||
*/
|
||||
function shouldShowReportDialog(event) {
|
||||
const empty = { shouldShow: false, fingerprint: '' };
|
||||
|
||||
// 空事件防御
|
||||
if (!event || typeof event !== 'object') return empty;
|
||||
|
||||
// 仅生产环境弹窗
|
||||
if (isDev) return empty;
|
||||
|
||||
// 仅对异常事件弹窗(captureMessage 等不弹)
|
||||
if (!event.exception || !event.exception.values || !event.exception.values.length) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 生成错误指纹:取第一个异常的 type + value 组合
|
||||
const firstException = event.exception.values[0];
|
||||
const fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
|
||||
|
||||
// 冷却检查:避免短时间内反复弹窗
|
||||
const now = Date.now();
|
||||
if (now - lastReportDialogTime < REPORT_DIALOG_COOLDOWN_MS) {
|
||||
return { shouldShow: false, fingerprint };
|
||||
}
|
||||
|
||||
// 同一错误不重复弹窗
|
||||
if (fingerprint === lastReportDialogFingerprint) {
|
||||
return { shouldShow: false, fingerprint };
|
||||
}
|
||||
|
||||
return { shouldShow: true, fingerprint };
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// Sentry 配置
|
||||
// ===================================
|
||||
@@ -362,15 +408,42 @@ function initSentry() {
|
||||
|
||||
// 性能监控
|
||||
integrations: [
|
||||
window.Sentry.browserTracingIntegration(),
|
||||
window.Sentry.browserTracingIntegration({
|
||||
traceFetch: true,
|
||||
traceXHR: true,
|
||||
enableLongTask: true,
|
||||
}),
|
||||
// Session Replay - 记录用户操作以重现错误场景
|
||||
window.Sentry.replayIntegration({
|
||||
maxReplayDuration: 60000,
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
}),
|
||||
// User Feedback Widget - 用户随时可提交反馈
|
||||
window.Sentry.feedbackIntegration({
|
||||
colorScheme: 'system',
|
||||
enableScreenshot: true,
|
||||
triggerLabel: '反馈',
|
||||
formTitle: '发送反馈',
|
||||
submitButtonLabel: '提交',
|
||||
cancelButtonLabel: '取消',
|
||||
nameLabel: '名称',
|
||||
emailLabel: '邮箱',
|
||||
messageLabel: '描述',
|
||||
successMessageText: '感谢您的反馈!',
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 0.1,
|
||||
|
||||
// 追踪传播目标 - 指定哪些请求应该添加追踪头
|
||||
tracePropagationTargets: [
|
||||
'localhost',
|
||||
/^https:\/\/esim\.cosr\.eu\.org/,
|
||||
/^https:\/\/.*\.giffgaff\.com/,
|
||||
/^https:\/\/.*\.simyo\.nl/,
|
||||
/^https:\/\/qrcode\.show/,
|
||||
/^https:\/\/api\.qrserver\.com/,
|
||||
],
|
||||
// Session Replay 采样率
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
@@ -408,7 +481,7 @@ function initSentry() {
|
||||
// 第三方脚本噪音
|
||||
/turnstile/i,
|
||||
// 浏览器扩展注入的 SweetAlert 冲突(t.swal=e() 模式)
|
||||
/swal/i,
|
||||
/t\.swal=e\(\)/i,
|
||||
// Google Tag Manager 噪音 (ESIM-TOOLS-18)
|
||||
/gtag\/js/,
|
||||
/gtm\.js/,
|
||||
@@ -474,8 +547,43 @@ function initSentry() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 删除敏感请求数据
|
||||
// 2. 脱敏 URL 中的敏感查询参数 + 删除敏感请求数据
|
||||
function sanitizeQueryString(url) {
|
||||
if (!url) return url;
|
||||
const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
// 先收集所有键再遍历,避免迭代时修改 searchParams 底层列表
|
||||
const keys = Array.from(urlObj.searchParams.keys());
|
||||
keys.forEach(key => {
|
||||
if (sensitiveParams.includes(key.toLowerCase())) {
|
||||
urlObj.searchParams.set(key, '***');
|
||||
}
|
||||
});
|
||||
return urlObj.toString();
|
||||
} catch (e) {
|
||||
return url.replace(/([?&])(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1$2=***');
|
||||
}
|
||||
}
|
||||
|
||||
if (event.request) {
|
||||
if (event.request.query_string) {
|
||||
if (typeof event.request.query_string === 'string') {
|
||||
event.request.query_string = event.request.query_string
|
||||
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
|
||||
} else if (typeof event.request.query_string === 'object') {
|
||||
const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
|
||||
// Case-insensitive matching while preserving original keys
|
||||
for (const [key] of Object.entries(event.request.query_string)) {
|
||||
if (sensitiveParams.includes(key.toLowerCase())) {
|
||||
event.request.query_string[key] = '***';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.request.url) {
|
||||
event.request.url = sanitizeQueryString(event.request.url);
|
||||
}
|
||||
delete event.request.cookies;
|
||||
if (event.request.headers) {
|
||||
delete event.request.headers['authorization'];
|
||||
@@ -484,7 +592,18 @@ function initSentry() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 脱敏异常消息中的敏感信息
|
||||
// 3. 弹窗决策:在脱敏前基于原始异常值计算指纹,避免脱敏后不同错误产生相同指纹
|
||||
// 注:在 beforeSend 中触发 UI 交互是刻意设计,因为需要 event_id 关联反馈
|
||||
let reportCheck = { shouldShow: false, fingerprint: '' };
|
||||
if (event.exception?.values) {
|
||||
// 保存原始异常值用于指纹计算
|
||||
const originalValues = event.exception.values.map(e => e.value);
|
||||
reportCheck = shouldShowReportDialog(event);
|
||||
// 恢复原始值,确保后续脱敏基于原始数据
|
||||
event.exception.values.forEach((e, i) => { e.value = originalValues[i]; });
|
||||
}
|
||||
|
||||
// 4. 脱敏异常消息中的敏感信息
|
||||
if (event.exception?.values) {
|
||||
event.exception.values.forEach(exception => {
|
||||
if (exception.value) {
|
||||
@@ -501,6 +620,25 @@ function initSentry() {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 错误后自动弹出反馈对话框(仅生产环境、异常事件、冷却期内不重复)
|
||||
if (reportCheck.shouldShow && event.event_id) {
|
||||
const eventId = event.event_id;
|
||||
const fingerprint = reportCheck.fingerprint;
|
||||
// 立即更新冷却状态,避免多个错误同时触发弹窗
|
||||
lastReportDialogFingerprint = fingerprint;
|
||||
lastReportDialogTime = Date.now();
|
||||
// 延迟弹窗,确保 Sentry 事件已发送完成
|
||||
setTimeout(function() {
|
||||
try {
|
||||
showReportDialog({ eventId: eventId, title: '问题反馈', subtitle: '抱歉,发生了错误', subtitle2: '您的反馈将帮助我们改进服务', labelName: '名称', labelEmail: '邮箱', labelComments: '问题描述(选填)', labelClose: '关闭', labelSubmit: '提交反馈', successMessage: '感谢您的反馈!' });
|
||||
} catch (e) {
|
||||
// 弹窗失败时回滚冷却状态,允许后续重试
|
||||
lastReportDialogFingerprint = '';
|
||||
lastReportDialogTime = 0;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
@@ -531,6 +669,10 @@ const SentryMock = {
|
||||
setContext: () => {},
|
||||
withScope: (callback) => callback({ setContext: () => {}, setTag: () => {} }),
|
||||
browserTracingIntegration: () => ({}),
|
||||
feedbackIntegration: () => ({}),
|
||||
replayIntegration: () => ({}),
|
||||
showReportDialog: () => {},
|
||||
lastEventId: () => null,
|
||||
};
|
||||
|
||||
// ===================================
|
||||
@@ -634,7 +776,7 @@ export function showReportDialog(options = {}) {
|
||||
eventId: options.eventId || lastEventId(),
|
||||
title: options.title || '报告问题',
|
||||
subtitle: options.subtitle || '告诉我们发生了什么',
|
||||
subtitleLine2: options.subtitleLine2 || '您的反馈将帮助我们改进',
|
||||
subtitle2: options.subtitle2 || '您的反馈将帮助我们改进',
|
||||
labelName: options.labelName || '名称',
|
||||
labelEmail: options.labelEmail || '邮箱',
|
||||
labelComments: options.labelComments || '问题描述',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
init,
|
||||
browserTracingIntegration,
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
captureException,
|
||||
captureMessage,
|
||||
addBreadcrumb,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
setContext,
|
||||
withScope,
|
||||
showReportDialog,
|
||||
lastEventId,
|
||||
getGlobalScope,
|
||||
flush,
|
||||
close,
|
||||
@@ -27,6 +29,7 @@ if (typeof window !== 'undefined') {
|
||||
init,
|
||||
browserTracingIntegration,
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
captureException,
|
||||
captureMessage,
|
||||
addBreadcrumb,
|
||||
@@ -35,6 +38,7 @@ if (typeof window !== 'undefined') {
|
||||
setContext,
|
||||
withScope,
|
||||
showReportDialog,
|
||||
lastEventId,
|
||||
getGlobalScope,
|
||||
flush,
|
||||
close,
|
||||
@@ -48,6 +52,7 @@ export {
|
||||
init,
|
||||
browserTracingIntegration,
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
captureException,
|
||||
captureMessage,
|
||||
addBreadcrumb,
|
||||
@@ -56,6 +61,7 @@ export {
|
||||
setContext,
|
||||
withScope,
|
||||
showReportDialog,
|
||||
lastEventId,
|
||||
getGlobalScope,
|
||||
flush,
|
||||
close,
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* 2. 自动完成初始化配置
|
||||
* 3. 暴露 testSentry() 到 window 供测试
|
||||
*
|
||||
* 架构说明:本文件使用 CDN 版本(8.40.0)作为生产主实现路径,
|
||||
* 确保在 npm 构建不可用时仍能正常加载 Sentry SDK。
|
||||
* npm bundle 版本(10.x)由 sentry-entry.js 提供,作为 Webpack 构建的备选方案。
|
||||
*
|
||||
* 使用方法:
|
||||
* <script>
|
||||
* window.SENTRY_DSN = '你的DSN';
|
||||
@@ -33,6 +37,12 @@
|
||||
var sentryInitialized = false;
|
||||
var EXTENSION_NOISE_STORE_KEY = '__sentryExtensionNoiseStats';
|
||||
var EXTENSION_NOISE_SAMPLE_LIMIT = 20;
|
||||
|
||||
// 错误反馈弹窗冷却机制
|
||||
var REPORT_DIALOG_COOLDOWN_MS = 60000; // 60 秒冷却时间
|
||||
var lastReportDialogTime = 0;
|
||||
var lastReportDialogFingerprint = '';
|
||||
|
||||
var EXTENSION_ERROR_KEYWORDS = [
|
||||
'cannot redefine property',
|
||||
'redefine property',
|
||||
@@ -220,6 +230,47 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// 错误反馈弹窗冷却机制
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* 判断是否应该显示反馈弹窗
|
||||
* @param {Object} event - Sentry 事件对象
|
||||
* @returns {{ shouldShow: boolean, fingerprint: string }}
|
||||
*/
|
||||
function shouldShowReportDialog(event) {
|
||||
var empty = { shouldShow: false, fingerprint: '' };
|
||||
|
||||
// 空事件防御
|
||||
if (!event || typeof event !== 'object') return empty;
|
||||
|
||||
// 仅生产环境弹窗
|
||||
if (isDev) return empty;
|
||||
|
||||
// 仅对异常事件弹窗(captureMessage 等不弹)
|
||||
if (!event.exception || !event.exception.values || !event.exception.values.length) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 生成错误指纹:取第一个异常的 type + value 组合
|
||||
var firstException = event.exception.values[0];
|
||||
var fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
|
||||
|
||||
// 冷却检查:避免短时间内反复弹窗
|
||||
var now = Date.now();
|
||||
if (now - lastReportDialogTime < REPORT_DIALOG_COOLDOWN_MS) {
|
||||
return { shouldShow: false, fingerprint: fingerprint };
|
||||
}
|
||||
|
||||
// 同一错误不重复弹窗
|
||||
if (fingerprint === lastReportDialogFingerprint) {
|
||||
return { shouldShow: false, fingerprint: fingerprint };
|
||||
}
|
||||
|
||||
return { shouldShow: true, fingerprint: fingerprint };
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// Mock 对象(开发环境或加载失败时使用)
|
||||
// ===================================
|
||||
@@ -238,7 +289,11 @@
|
||||
withScope: function(callback) {
|
||||
callback({ setContext: function() {}, setTag: function() {} });
|
||||
},
|
||||
browserTracingIntegration: function() { return {}; }
|
||||
browserTracingIntegration: function() { return {}; },
|
||||
feedbackIntegration: function() { return {}; },
|
||||
replayIntegration: function() { return {}; },
|
||||
showReportDialog: function() {},
|
||||
lastEventId: function() { return null; }
|
||||
};
|
||||
|
||||
// ===================================
|
||||
@@ -394,8 +449,29 @@
|
||||
// 启用页面加载和导航追踪
|
||||
enableLongTask: true
|
||||
}),
|
||||
// Session Replay - 记录用户操作以重现错误场景
|
||||
window.Sentry.replayIntegration({
|
||||
maxReplayDuration: 60000,
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
}),
|
||||
// User Feedback Widget - 用户随时可提交反馈
|
||||
window.Sentry.feedbackIntegration({
|
||||
colorScheme: 'system',
|
||||
enableScreenshot: true,
|
||||
triggerLabel: '反馈',
|
||||
formTitle: '发送反馈',
|
||||
submitButtonLabel: '提交',
|
||||
cancelButtonLabel: '取消',
|
||||
nameLabel: '名称',
|
||||
emailLabel: '邮箱',
|
||||
messageLabel: '描述',
|
||||
successMessageText: '感谢您的反馈!',
|
||||
}),
|
||||
],
|
||||
// 追踪传播目标 - 指定哪些请求应该添加追踪头
|
||||
// 注意:第三方域名(qrcode.show, api.qrserver.com)可能触发 CORS preflight,
|
||||
// 但 Sentry SDK 内部已做兼容处理,保留以确保追踪完整性
|
||||
tracePropagationTargets: [
|
||||
'localhost',
|
||||
/^https:\/\/esim\.cosr\.eu\.org/,
|
||||
@@ -406,6 +482,10 @@
|
||||
],
|
||||
tracesSampleRate: 0.1, // 10% 采样率,节省免费配额
|
||||
|
||||
// Session Replay 采样率
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// 错误采样率
|
||||
sampleRate: 1.0,
|
||||
|
||||
@@ -530,9 +610,11 @@
|
||||
var sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
|
||||
try {
|
||||
var urlObj = new URL(url, window.location.origin);
|
||||
sensitiveParams.forEach(function(param) {
|
||||
if (urlObj.searchParams.has(param)) {
|
||||
urlObj.searchParams.set(param, '***');
|
||||
// 大小写不敏感匹配:先收集所有键再遍历
|
||||
var paramKeys = Array.from(urlObj.searchParams.keys());
|
||||
paramKeys.forEach(function(key) {
|
||||
if (sensitiveParams.indexOf(key.toLowerCase()) !== -1) {
|
||||
urlObj.searchParams.set(key, '***');
|
||||
}
|
||||
});
|
||||
return urlObj.toString();
|
||||
@@ -543,8 +625,20 @@
|
||||
|
||||
if (event.request) {
|
||||
if (event.request.query_string) {
|
||||
event.request.query_string = event.request.query_string
|
||||
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
|
||||
if (typeof event.request.query_string === 'string') {
|
||||
event.request.query_string = event.request.query_string
|
||||
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
|
||||
} else if (typeof event.request.query_string === 'object') {
|
||||
var sensitiveQSParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
|
||||
// Case-insensitive matching while preserving original keys
|
||||
var queryKeys = Object.keys(event.request.query_string);
|
||||
for (var i = 0; i < queryKeys.length; i++) {
|
||||
var key = queryKeys[i];
|
||||
if (sensitiveQSParams.indexOf(key.toLowerCase()) !== -1) {
|
||||
event.request.query_string[key] = '***';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.request.url) {
|
||||
event.request.url = sanitizeQueryString(event.request.url);
|
||||
@@ -557,7 +651,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 脱敏异常消息中的敏感信息
|
||||
// 3. 弹窗决策:在脱敏前基于原始异常值计算指纹,避免脱敏后不同错误产生相同指纹
|
||||
var reportCheck = { shouldShow: false, fingerprint: '' };
|
||||
if (event.exception && event.exception.values) {
|
||||
// 保存原始异常值用于指纹计算
|
||||
var originalValues = event.exception.values.map(function(e) { return e.value; });
|
||||
reportCheck = shouldShowReportDialog(event);
|
||||
// 恢复原始值,确保后续脱敏基于原始数据
|
||||
event.exception.values.forEach(function(e, i) { e.value = originalValues[i]; });
|
||||
}
|
||||
|
||||
// 4. 脱敏异常消息中的敏感信息
|
||||
if (event.exception && event.exception.values) {
|
||||
event.exception.values.forEach(function(exception) {
|
||||
if (exception.value) {
|
||||
@@ -574,6 +678,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 错误后自动弹出反馈对话框(仅生产环境、异常事件、冷却期内不重复)
|
||||
// 注意:中文硬编码是因为 Sentry 初始化时机早于 i18n 模块就绪,无法使用动态翻译
|
||||
if (reportCheck.shouldShow && event.event_id) {
|
||||
var eventId = event.event_id;
|
||||
var fingerprint = reportCheck.fingerprint;
|
||||
// 立即更新冷却状态,避免多个错误同时触发弹窗
|
||||
lastReportDialogFingerprint = fingerprint;
|
||||
lastReportDialogTime = Date.now();
|
||||
// 延迟弹窗,确保 Sentry 事件已发送完成
|
||||
setTimeout(function() {
|
||||
try {
|
||||
window.Sentry.showReportDialog({ eventId: eventId, title: '问题反馈', subtitle: '抱歉,发生了错误', subtitle2: '您的反馈将帮助我们改进服务', labelName: '名称', labelEmail: '邮箱', labelComments: '问题描述(选填)', labelClose: '关闭', labelSubmit: '提交反馈', successMessage: '感谢您的反馈!' });
|
||||
} catch (e) {
|
||||
// 弹窗失败时回滚冷却状态,允许后续重试
|
||||
lastReportDialogFingerprint = '';
|
||||
lastReportDialogTime = 0;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
});
|
||||
@@ -628,7 +752,7 @@
|
||||
// ===================================
|
||||
|
||||
// Sentry SDK URL - 使用官方 CDN(带 tracing 和 replay)
|
||||
var SENTRY_SDK_URL = 'https://browser.sentry-cdn.com/8.40.0/bundle.tracing.min.js';
|
||||
var SENTRY_SDK_URL = 'https://browser.sentry-cdn.com/8.40.0/bundle.tracing.replay.feedback.min.js';
|
||||
|
||||
// 创建 script 标签
|
||||
var script = document.createElement('script');
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<link rel="preconnect" href="https://appapi.simyo.nl" crossorigin>
|
||||
|
||||
<!-- CSP策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-Y7EzdXtZqusaDvI6M/a7FBfvLyIlKLRzZbRaQP+0w7g=' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://*.sentry.io; img-src 'self' data: https:; frame-src 'self';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-Y7EzdXtZqusaDvI6M/a7FBfvLyIlKLRzZbRaQP+0w7g=' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
|
||||
|
||||
<title>Simyo NL eSIM 工具</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
|
||||
332
tests/modules/sentry-feedback.test.js
Normal file
332
tests/modules/sentry-feedback.test.js
Normal file
@@ -0,0 +1,332 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Sentry 错误反馈弹窗专项测试
|
||||
*
|
||||
* 覆盖 shouldShowReportDialog 冷却机制、弹窗触发条件、
|
||||
* showReportDialog 中文参数、SentryMock fallback 等场景
|
||||
*
|
||||
* 注意:本测试中的 createShouldShowReportDialog 是生产代码逻辑的独立复现,
|
||||
* 修改 sentry-loader.js / sentry-init.js 中的冷却逻辑时需同步更新此处
|
||||
*/
|
||||
|
||||
// ===================================
|
||||
// 模拟浏览器环境
|
||||
// ===================================
|
||||
|
||||
const mockShowReportDialog = jest.fn();
|
||||
const mockLastEventId = jest.fn(() => 'test-event-id');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// 模拟 window 全局对象
|
||||
global.window = {
|
||||
location: { hostname: 'esim.cosr.eu.org', origin: 'https://esim.cosr.eu.org' },
|
||||
Sentry: {
|
||||
init: jest.fn(),
|
||||
captureException: jest.fn(),
|
||||
captureMessage: jest.fn(),
|
||||
addBreadcrumb: jest.fn(),
|
||||
setUser: jest.fn(),
|
||||
setTag: jest.fn(),
|
||||
setContext: jest.fn(),
|
||||
withScope: jest.fn(cb => cb({ setContext: jest.fn(), setTag: jest.fn() })),
|
||||
browserTracingIntegration: jest.fn(() => ({})),
|
||||
feedbackIntegration: jest.fn(() => ({})),
|
||||
replayIntegration: jest.fn(() => ({})),
|
||||
showReportDialog: mockShowReportDialog,
|
||||
lastEventId: mockLastEventId,
|
||||
},
|
||||
addEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
CustomEvent: class CustomEvent {},
|
||||
Date: Date,
|
||||
setTimeout: (fn, ms) => fn(),
|
||||
URL: class URL {
|
||||
constructor(url, base) {
|
||||
this.href = url;
|
||||
this.searchParams = new Map();
|
||||
}
|
||||
toString() { return this.href; }
|
||||
},
|
||||
};
|
||||
|
||||
global.document = {
|
||||
currentScript: null,
|
||||
head: { appendChild: jest.fn(), insertBefore: jest.fn() },
|
||||
createElement: jest.fn(() => ({ src: '', async: false, onload: null, onerror: null })),
|
||||
};
|
||||
|
||||
global.CustomEvent = class CustomEvent {};
|
||||
global.Event = class Event {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
delete global.CustomEvent;
|
||||
delete global.Event;
|
||||
});
|
||||
|
||||
// ===================================
|
||||
// 辅助函数:提取 shouldShowReportDialog 逻辑进行测试
|
||||
// ===================================
|
||||
|
||||
/**
|
||||
* 独立实现 shouldShowReportDialog,与 sentry-loader.js 逻辑一致
|
||||
* 用于单元测试冷却机制的正确性
|
||||
*/
|
||||
function createShouldShowReportDialog(options = {}) {
|
||||
const { isDev = false } = options;
|
||||
let lastReportDialogTime = 0;
|
||||
let lastReportDialogFingerprint = '';
|
||||
const COOLDOWN_MS = 60000;
|
||||
|
||||
function shouldShowReportDialog(event) {
|
||||
const empty = { shouldShow: false, fingerprint: '' };
|
||||
|
||||
// 空事件防御
|
||||
if (!event || typeof event !== 'object') return empty;
|
||||
|
||||
// 仅生产环境弹窗
|
||||
if (isDev) return empty;
|
||||
|
||||
// 仅对异常事件弹窗(captureMessage 等不弹)
|
||||
if (!event.exception || !event.exception.values || !event.exception.values.length) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 生成错误指纹:取第一个异常的 type + value 组合
|
||||
const firstException = event.exception.values[0];
|
||||
const fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
|
||||
|
||||
// 冷却检查:避免短时间内反复弹窗
|
||||
const now = Date.now();
|
||||
if (now - lastReportDialogTime < COOLDOWN_MS) {
|
||||
return { shouldShow: false, fingerprint };
|
||||
}
|
||||
|
||||
// 同一错误不重复弹窗
|
||||
if (fingerprint === lastReportDialogFingerprint) {
|
||||
return { shouldShow: false, fingerprint };
|
||||
}
|
||||
|
||||
return { shouldShow: true, fingerprint };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lastReportDialogTime = 0;
|
||||
lastReportDialogFingerprint = '';
|
||||
}
|
||||
|
||||
function simulateDialogShown(fingerprint) {
|
||||
lastReportDialogTime = Date.now();
|
||||
lastReportDialogFingerprint = fingerprint;
|
||||
}
|
||||
|
||||
return { shouldShowReportDialog, reset, simulateDialogShown };
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// 测试用例
|
||||
// ===================================
|
||||
|
||||
describe('Sentry 错误反馈弹窗', () => {
|
||||
describe('shouldShowReportDialog 冷却机制', () => {
|
||||
it('非生产环境不弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog({ isDev: true });
|
||||
const event = {
|
||||
exception: { values: [{ type: 'Error', value: 'test error' }] },
|
||||
};
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('非异常事件不弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog();
|
||||
const event = { message: 'test message' };
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('空事件对象不弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog();
|
||||
expect(shouldShowReportDialog(null).shouldShow).toBe(false);
|
||||
expect(shouldShowReportDialog(undefined).shouldShow).toBe(false);
|
||||
expect(shouldShowReportDialog({}).shouldShow).toBe(false);
|
||||
expect(shouldShowReportDialog('string').shouldShow).toBe(false);
|
||||
expect(shouldShowReportDialog(123).shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('无 exception.values 的事件不弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog();
|
||||
const event = { exception: {} };
|
||||
expect(shouldShowReportDialog(event).shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('空 exception.values 数组不弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog();
|
||||
const event = { exception: { values: [] } };
|
||||
expect(shouldShowReportDialog(event).shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('首个异常事件调用弹窗', () => {
|
||||
const { shouldShowReportDialog } = createShouldShowReportDialog();
|
||||
const event = {
|
||||
exception: { values: [{ type: 'Error', value: 'test error' }] },
|
||||
};
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(true);
|
||||
expect(result.fingerprint).toBe('Error:test error');
|
||||
});
|
||||
|
||||
it('60 秒内不同错误不重复弹窗', () => {
|
||||
const { shouldShowReportDialog, simulateDialogShown } = createShouldShowReportDialog();
|
||||
|
||||
// 模拟第一个错误已触发弹窗
|
||||
simulateDialogShown('Error:first error');
|
||||
|
||||
// 60 秒内不同错误不应弹窗
|
||||
const event = {
|
||||
exception: { values: [{ type: 'TypeError', value: 'second error' }] },
|
||||
};
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it('60 秒后不同错误允许弹窗', () => {
|
||||
const { shouldShowReportDialog, simulateDialogShown, reset } = createShouldShowReportDialog();
|
||||
|
||||
// 模拟第一个错误已触发弹窗
|
||||
simulateDialogShown('Error:first error');
|
||||
|
||||
// 快进 60 秒
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
// 不同错误应弹窗
|
||||
const event = {
|
||||
exception: { values: [{ type: 'TypeError', value: 'second error' }] },
|
||||
};
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(true);
|
||||
expect(result.fingerprint).toBe('TypeError:second error');
|
||||
});
|
||||
|
||||
it('同一 type:value 指纹不重复弹窗', () => {
|
||||
const { shouldShowReportDialog, simulateDialogShown } = createShouldShowReportDialog();
|
||||
|
||||
// 模拟同一错误已触发弹窗
|
||||
simulateDialogShown('Error:same error');
|
||||
|
||||
// 即使冷却时间已过,同一指纹也不弹窗
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
const event = {
|
||||
exception: { values: [{ type: 'Error', value: 'same error' }] },
|
||||
};
|
||||
const result = shouldShowReportDialog(event);
|
||||
expect(result.shouldShow).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showReportDialog 中文参数', () => {
|
||||
it('调用 showReportDialog 传递中文参数', () => {
|
||||
// 模拟 showReportDialog 的中文参数传递逻辑
|
||||
// (与 sentry-loader.js / sentry-init.js 中的调用一致)
|
||||
const params = {
|
||||
eventId: 'test-123',
|
||||
title: '问题反馈',
|
||||
subtitle: '抱歉,发生了错误',
|
||||
subtitle2: '您的反馈将帮助我们改进服务',
|
||||
labelName: '名称',
|
||||
labelEmail: '邮箱',
|
||||
labelComments: '问题描述(选填)',
|
||||
labelClose: '关闭',
|
||||
labelSubmit: '提交反馈',
|
||||
successMessage: '感谢您的反馈!',
|
||||
};
|
||||
mockShowReportDialog(params);
|
||||
|
||||
expect(mockShowReportDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventId: 'test-123',
|
||||
title: '问题反馈',
|
||||
subtitle: '抱歉,发生了错误',
|
||||
subtitle2: '您的反馈将帮助我们改进服务',
|
||||
labelName: '名称',
|
||||
labelEmail: '邮箱',
|
||||
labelComments: '问题描述(选填)',
|
||||
labelClose: '关闭',
|
||||
labelSubmit: '提交反馈',
|
||||
successMessage: '感谢您的反馈!',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SentryMock fallback', () => {
|
||||
it('SentryMock 不抛错', () => {
|
||||
const SentryMock = {
|
||||
init: () => {},
|
||||
captureException: () => {},
|
||||
captureMessage: () => {},
|
||||
addBreadcrumb: () => {},
|
||||
setUser: () => {},
|
||||
setTag: () => {},
|
||||
setContext: () => {},
|
||||
withScope: (cb) => cb({ setContext: () => {}, setTag: () => {} }),
|
||||
browserTracingIntegration: () => ({}),
|
||||
feedbackIntegration: () => ({}),
|
||||
replayIntegration: () => ({}),
|
||||
showReportDialog: () => {},
|
||||
lastEventId: () => null,
|
||||
};
|
||||
|
||||
expect(() => SentryMock.showReportDialog({ eventId: 'test' })).not.toThrow();
|
||||
expect(() => SentryMock.lastEventId()).not.toThrow();
|
||||
expect(() => SentryMock.captureException(new Error('test'))).not.toThrow();
|
||||
expect(() => SentryMock.captureMessage('test')).not.toThrow();
|
||||
expect(() => SentryMock.feedbackIntegration()).not.toThrow();
|
||||
expect(() => SentryMock.browserTracingIntegration()).not.toThrow();
|
||||
expect(() => SentryMock.replayIntegration()).not.toThrow();
|
||||
});
|
||||
|
||||
it('SentryMock lastEventId 返回 null', () => {
|
||||
const SentryMock = {
|
||||
lastEventId: () => null,
|
||||
};
|
||||
expect(SentryMock.lastEventId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeSend 弹窗错误容错', () => {
|
||||
it('showReportDialog 抛错不影响 beforeSend 返回原事件', () => {
|
||||
const errorEvent = {
|
||||
event_id: 'test-event-123',
|
||||
exception: { values: [{ type: 'Error', value: 'test error' }] },
|
||||
};
|
||||
|
||||
// 创建会抛错的 mock
|
||||
const throwingMock = jest.fn(() => {
|
||||
throw new Error('弹窗加载失败');
|
||||
});
|
||||
|
||||
// 模拟 beforeSend 中的弹窗调用逻辑(与 sentry-loader.js / sentry-init.js 一致)
|
||||
let beforeSendResult;
|
||||
try {
|
||||
throwingMock({ eventId: errorEvent.event_id });
|
||||
} catch (e) {
|
||||
// 忽略弹窗错误(与源码一致)
|
||||
}
|
||||
beforeSendResult = errorEvent;
|
||||
|
||||
// 验证原事件未被修改且正常返回
|
||||
expect(beforeSendResult).toBe(errorEvent);
|
||||
expect(beforeSendResult.event_id).toBe('test-event-123');
|
||||
expect(throwingMock).toHaveBeenCalledWith({ eventId: 'test-event-123' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user