From 41e6ee1b1b9dd225fb7cb9eb3da56e22b6415fae Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sat, 27 Jun 2026 03:01:04 +0800 Subject: [PATCH] Add analytics status controls --- src/core/analytics/analytics.ts | 7 ++ src/core/analytics/preferences.ts | 21 ++++ src/core/i18n/translations/de.json | 18 +++- src/core/i18n/translations/en.json | 18 +++- src/core/i18n/translations/fr.json | 18 +++- src/core/i18n/translations/ja.json | 18 +++- src/core/i18n/translations/ko.json | 18 +++- src/core/i18n/translations/zh-CN.json | 18 +++- src/core/i18n/translations/zh-TW.json | 18 +++- src/features/privacy/local-data-controls.tsx | 100 ++++++++++++++++--- tests/component/privacy-page.test.tsx | 49 ++++++++- tests/unit/analytics-utils.test.ts | 41 +++++++- 12 files changed, 320 insertions(+), 24 deletions(-) create mode 100644 src/core/analytics/preferences.ts diff --git a/src/core/analytics/analytics.ts b/src/core/analytics/analytics.ts index 4c9eb95d..7b20eed1 100644 --- a/src/core/analytics/analytics.ts +++ b/src/core/analytics/analytics.ts @@ -8,6 +8,7 @@ */ import taxonomy from "./taxonomy.json" +import { isAnalyticsOptedOut } from "./preferences" type AnalyticsTaxonomy = typeof taxonomy type AnalyticsEventName = keyof AnalyticsTaxonomy["events"] @@ -101,6 +102,7 @@ function safeSourcePage(value?: string | null): string | undefined { } export const isAnalyticsEnabled = (): boolean => false +export { getAnalyticsPreference, isAnalyticsOptedOut, setAnalyticsOptOut } from "./preferences" export function detectInteractionAnalyticsAction( signal: string, @@ -132,7 +134,12 @@ export function buildAnalyticsPayload(event: AnalyticsEventName, params: Analyti ) as AnalyticsPayload } +export function shouldTrackAnalyticsEvent(enabled = isAnalyticsEnabled(), optedOut = isAnalyticsOptedOut()): boolean { + return enabled && !optedOut +} + export function trackAllowlistedEvent(_event: AnalyticsEventName, _params: AnalyticsPayload = {}): void { + if (!shouldTrackAnalyticsEvent()) return // No-op by design. Future providers must call buildAnalyticsPayload first. void _event void _params diff --git a/src/core/analytics/preferences.ts b/src/core/analytics/preferences.ts new file mode 100644 index 00000000..bc28ab0b --- /dev/null +++ b/src/core/analytics/preferences.ts @@ -0,0 +1,21 @@ +import { readStorageString, removeStorageKey, writeStorageString } from "@/core/storage/tool-persistence" + +export type AnalyticsPreference = "default" | "opted_out" + +const ANALYTICS_OPT_OUT_KEY = "byteflow:analytics:opt-out" + +export function getAnalyticsPreference(): AnalyticsPreference { + return readStorageString(ANALYTICS_OPT_OUT_KEY) === "1" ? "opted_out" : "default" +} + +export function setAnalyticsOptOut(optedOut: boolean): void { + if (optedOut) { + writeStorageString(ANALYTICS_OPT_OUT_KEY, "1") + } else { + removeStorageKey(ANALYTICS_OPT_OUT_KEY) + } +} + +export function isAnalyticsOptedOut(): boolean { + return getAnalyticsPreference() === "opted_out" +} diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 6e26fad3..b89b00eb 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -167,7 +167,23 @@ "clear_button": "Gespeicherte Tool-Daten löschen", "cleared_one_message": "{count} gespeichertes Byteflow-Element wurde gelöscht.", "cleared_many_message": "{count} gespeicherte Byteflow-Elemente wurden gelöscht.", - "none_found_message": "Es wurden keine gespeicherten Byteflow-Daten gefunden." + "none_found_message": "Es wurden keine gespeicherten Byteflow-Daten gefunden.", + "analytics_title": "Analytics-Status und Steuerung", + "analytics_description": "Analytics sind in dieser Laufzeit standardmaessig deaktiviert. Falls eine spaetere Bereitstellung einen Cookie-freien Aggregationsanbieter aktiviert, verhindert diese lokale Einstellung Analytics-Ereignisse aus diesem Browser.", + "analytics_status_label": "Aktueller Status", + "analytics_status_disabled": "Standardmaessig deaktiviert", + "analytics_status_enabled": "Fuer diese Bereitstellung aktiviert", + "analytics_status_opted_out": "Lokal abgemeldet", + "analytics_opt_out_label": "Analytics in diesem Browser abmelden", + "analytics_opted_out_message": "Analytics-Abmeldung in diesem Browser gespeichert.", + "analytics_default_message": "Analytics-Einstellung auf Bereitstellungsstandard zurueckgesetzt.", + "analytics_allowed_title": "Erlaubte Ereignistaxonomie", + "analytics_allowed_events": "Erlaubte Ereignisse: tool_loaded, tool_action, copy_output, download_output, search_performed, related_tool_click, pipeline_template_opened und pwa_installed.", + "analytics_allowed_fields": "Erlaubte Felder sind nur grobe Werte: Tool-ID, Related-Tool-ID, Aktionstyp, Sprache, Groessen-Bucket, Suchlaengen-Bucket, Ergebniszahl, Quellseite und Plattform.", + "analytics_never_title": "Nie Analytics-Felder", + "analytics_never_fields": "Tool-Eingaben, Ausgaben, Payloads, Tokens, JWTs, Geheimnisse, Dateinamen, Dateiinhalte, Bildinhalte, Logtexte, Hashwerte, Suchtext, vollstaendige URLs, Nutzer-IDs und Sitzungs-IDs sind verboten.", + "analytics_privacy_link": "Datenschutzrichtlinie", + "analytics_trust_link": "Vertrauenszentrum" }, "privacy_badge": { "label": "Browser-lokal", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index e5fce829..72aae53d 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -171,7 +171,23 @@ "clear_button": "Clear saved tool data", "cleared_one_message": "Cleared {count} saved Byteflow item.", "cleared_many_message": "Cleared {count} saved Byteflow items.", - "none_found_message": "No saved Byteflow data was found." + "none_found_message": "No saved Byteflow data was found.", + "analytics_title": "Analytics status and controls", + "analytics_description": "Analytics are disabled by default in this runtime. If a future deployment enables a cookie-free aggregate provider, this local preference prevents analytics events from being sent from this browser.", + "analytics_status_label": "Current status", + "analytics_status_disabled": "Disabled by default", + "analytics_status_enabled": "Enabled for this deployment", + "analytics_status_opted_out": "Locally opted out", + "analytics_opt_out_label": "Opt out of analytics on this browser", + "analytics_opted_out_message": "Analytics opt-out saved on this browser.", + "analytics_default_message": "Analytics preference reset to deployment default.", + "analytics_allowed_title": "Allowed event taxonomy", + "analytics_allowed_events": "Allowed events: tool_loaded, tool_action, copy_output, download_output, search_performed, related_tool_click, pipeline_template_opened, and pwa_installed.", + "analytics_allowed_fields": "Allowed fields are coarse values only: tool ID, related tool ID, action type, language, size bucket, query-length bucket, result count, source page, and platform.", + "analytics_never_title": "Never analytics fields", + "analytics_never_fields": "Tool inputs, outputs, payloads, tokens, JWTs, secrets, file names, file contents, image contents, log bodies, hash values, search text, full URLs, user IDs, and session IDs are forbidden.", + "analytics_privacy_link": "Privacy Policy", + "analytics_trust_link": "Trust Center" }, "privacy_badge": { "label": "Browser-local", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 5aa5be72..67a9d20e 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -171,7 +171,23 @@ "clear_button": "Effacer les données d’outils enregistrées", "cleared_one_message": "{count} élément Byteflow enregistré a été effacé.", "cleared_many_message": "{count} éléments Byteflow enregistrés ont été effacés.", - "none_found_message": "Aucune donnée Byteflow enregistrée n’a été trouvée." + "none_found_message": "Aucune donnée Byteflow enregistrée n’a été trouvée.", + "analytics_title": "Statut et controles analytics", + "analytics_description": "Les analytics sont desactives par defaut dans ce runtime. Si un futur deploiement active un fournisseur agrege sans cookie, cette preference locale empeche ce navigateur denvoyer des evenements analytics.", + "analytics_status_label": "Statut actuel", + "analytics_status_disabled": "Desactive par defaut", + "analytics_status_enabled": "Active pour ce deploiement", + "analytics_status_opted_out": "Desactive localement", + "analytics_opt_out_label": "Se desinscrire des analytics sur ce navigateur", + "analytics_opted_out_message": "Desinscription analytics enregistree sur ce navigateur.", + "analytics_default_message": "Preference analytics retablie au defaut du deploiement.", + "analytics_allowed_title": "Taxonomie devenements autorisee", + "analytics_allowed_events": "Evenements autorises : tool_loaded, tool_action, copy_output, download_output, search_performed, related_tool_click, pipeline_template_opened et pwa_installed.", + "analytics_allowed_fields": "Les champs autorises sont seulement grossiers : ID outil, ID outil lie, type daction, langue, bucket de taille, bucket de longueur de requete, nombre de resultats, page source et plateforme.", + "analytics_never_title": "Jamais dans les analytics", + "analytics_never_fields": "Entrees doutils, sorties, payloads, tokens, JWT, secrets, noms de fichiers, contenus de fichiers, contenus dimages, corps de logs, hashes, texte de recherche, URL completes, ID utilisateur et ID de session sont interdits.", + "analytics_privacy_link": "Politique de confidentialite", + "analytics_trust_link": "Centre de confiance" }, "privacy_badge": { "label": "Local au navigateur", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 6d9ba550..3a241ce9 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -171,7 +171,23 @@ "clear_button": "保存済みツールデータをクリア", "cleared_one_message": "保存済み Byteflow 項目を {count} 件クリアしました。", "cleared_many_message": "保存済み Byteflow 項目を {count} 件クリアしました。", - "none_found_message": "保存済み Byteflow データは見つかりませんでした。" + "none_found_message": "保存済み Byteflow データは見つかりませんでした。", + "analytics_title": "分析の状態とコントロール", + "analytics_description": "この実行環境では分析は既定で無効です。将来 Cookie なしの集計プロバイダーを有効にした場合でも、このローカル設定によりこのブラウザーから分析イベントを送信しません。", + "analytics_status_label": "現在の状態", + "analytics_status_disabled": "既定で無効", + "analytics_status_enabled": "このデプロイで有効", + "analytics_status_opted_out": "ローカルでオプトアウト済み", + "analytics_opt_out_label": "このブラウザーで分析をオプトアウト", + "analytics_opted_out_message": "このブラウザーに分析オプトアウトを保存しました。", + "analytics_default_message": "分析設定をデプロイ既定値に戻しました。", + "analytics_allowed_title": "許可されるイベント分類", + "analytics_allowed_events": "許可イベント: tool_loaded、tool_action、copy_output、download_output、search_performed、related_tool_click、pipeline_template_opened、pwa_installed。", + "analytics_allowed_fields": "許可フィールドは粗い値のみです: tool ID、related tool ID、action type、language、size bucket、query-length bucket、result count、source page、platform。", + "analytics_never_title": "分析に含めないフィールド", + "analytics_never_fields": "ツール入力、出力、payload、token、JWT、secret、ファイル名、ファイル内容、画像内容、ログ本文、ハッシュ値、検索テキスト、完全な URL、ユーザー ID、セッション ID は禁止です。", + "analytics_privacy_link": "プライバシーポリシー", + "analytics_trust_link": "信頼センター" }, "privacy_badge": { "label": "ブラウザローカル", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 36c9a5b8..f88cbe10 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -171,7 +171,23 @@ "clear_button": "저장된 도구 데이터 지우기", "cleared_one_message": "저장된 Byteflow 항목 {count}개를 지웠습니다.", "cleared_many_message": "저장된 Byteflow 항목 {count}개를 지웠습니다.", - "none_found_message": "저장된 Byteflow 데이터를 찾을 수 없습니다." + "none_found_message": "저장된 Byteflow 데이터를 찾을 수 없습니다.", + "analytics_title": "분석 상태 및 제어", + "analytics_description": "이 런타임에서는 분석이 기본적으로 비활성화되어 있습니다. 향후 쿠키 없는 집계 제공자가 활성화되더라도 이 로컬 설정은 이 브라우저에서 분석 이벤트 전송을 막습니다.", + "analytics_status_label": "현재 상태", + "analytics_status_disabled": "기본적으로 비활성화", + "analytics_status_enabled": "이 배포에서 활성화됨", + "analytics_status_opted_out": "로컬에서 옵트아웃됨", + "analytics_opt_out_label": "이 브라우저에서 분석 옵트아웃", + "analytics_opted_out_message": "이 브라우저에 분석 옵트아웃을 저장했습니다.", + "analytics_default_message": "분석 설정을 배포 기본값으로 되돌렸습니다.", + "analytics_allowed_title": "허용 이벤트 분류", + "analytics_allowed_events": "허용 이벤트: tool_loaded, tool_action, copy_output, download_output, search_performed, related_tool_click, pipeline_template_opened, pwa_installed.", + "analytics_allowed_fields": "허용 필드는 거친 값뿐입니다: tool ID, related tool ID, action type, language, size bucket, query-length bucket, result count, source page, platform.", + "analytics_never_title": "분석에 포함하지 않는 필드", + "analytics_never_fields": "도구 입력, 출력, payload, token, JWT, secret, 파일 이름, 파일 내용, 이미지 내용, 로그 본문, 해시값, 검색 텍스트, 전체 URL, 사용자 ID, 세션 ID는 금지됩니다.", + "analytics_privacy_link": "개인정보 처리방침", + "analytics_trust_link": "신뢰 센터" }, "privacy_badge": { "label": "브라우저 로컬", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index eac08f87..51e558cb 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -171,7 +171,23 @@ "clear_button": "清除已保存的工具数据", "cleared_one_message": "已清除 {count} 条保存的 Byteflow 项目。", "cleared_many_message": "已清除 {count} 条保存的 Byteflow 项目。", - "none_found_message": "未找到已保存的 Byteflow 数据。" + "none_found_message": "未找到已保存的 Byteflow 数据。", + "analytics_title": "分析状态和控制", + "analytics_description": "当前运行时默认禁用分析。如果未来部署启用无 Cookie 聚合提供方,此本地偏好会阻止此浏览器发送分析事件。", + "analytics_status_label": "当前状态", + "analytics_status_disabled": "默认禁用", + "analytics_status_enabled": "此部署已启用", + "analytics_status_opted_out": "已在本地退出", + "analytics_opt_out_label": "在此浏览器退出分析", + "analytics_opted_out_message": "已在此浏览器保存分析退出设置。", + "analytics_default_message": "分析偏好已恢复为部署默认值。", + "analytics_allowed_title": "允许的事件分类", + "analytics_allowed_events": "允许事件:tool_loaded、tool_action、copy_output、download_output、search_performed、related_tool_click、pipeline_template_opened 和 pwa_installed。", + "analytics_allowed_fields": "允许字段仅为粗粒度值:工具 ID、相关工具 ID、操作类型、语言、大小分桶、查询长度分桶、结果数、来源页面和平台。", + "analytics_never_title": "永不进入分析的字段", + "analytics_never_fields": "工具输入、输出、payload、token、JWT、密钥、文件名、文件内容、图片内容、日志正文、哈希值、搜索文本、完整 URL、用户 ID 和会话 ID 均被禁止。", + "analytics_privacy_link": "隐私政策", + "analytics_trust_link": "信任中心" }, "privacy_badge": { "label": "浏览器本地", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index b751ab9b..4f125e37 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -171,7 +171,23 @@ "clear_button": "清除已保存的工具資料", "cleared_one_message": "已清除 {count} 筆保存的 Byteflow 項目。", "cleared_many_message": "已清除 {count} 筆保存的 Byteflow 項目。", - "none_found_message": "未找到已保存的 Byteflow 資料。" + "none_found_message": "未找到已保存的 Byteflow 資料。", + "analytics_title": "分析狀態和控制", + "analytics_description": "目前執行階段預設停用分析。如果未來部署啟用無 Cookie 聚合提供者,此本機偏好會阻止此瀏覽器傳送分析事件。", + "analytics_status_label": "目前狀態", + "analytics_status_disabled": "預設停用", + "analytics_status_enabled": "此部署已啟用", + "analytics_status_opted_out": "已在本機退出", + "analytics_opt_out_label": "在此瀏覽器退出分析", + "analytics_opted_out_message": "已在此瀏覽器儲存分析退出設定。", + "analytics_default_message": "分析偏好已恢復為部署預設值。", + "analytics_allowed_title": "允許的事件分類", + "analytics_allowed_events": "允許事件:tool_loaded、tool_action、copy_output、download_output、search_performed、related_tool_click、pipeline_template_opened 和 pwa_installed。", + "analytics_allowed_fields": "允許欄位僅為粗粒度值:工具 ID、相關工具 ID、操作類型、語言、大小分桶、查詢長度分桶、結果數、來源頁面和平台。", + "analytics_never_title": "永不進入分析的欄位", + "analytics_never_fields": "工具輸入、輸出、payload、token、JWT、密鑰、檔名、檔案內容、圖片內容、日誌本文、雜湊值、搜尋文字、完整 URL、使用者 ID 和工作階段 ID 均被禁止。", + "analytics_privacy_link": "隱私權政策", + "analytics_trust_link": "信任中心" }, "privacy_badge": { "label": "瀏覽器本地", diff --git a/src/features/privacy/local-data-controls.tsx b/src/features/privacy/local-data-controls.tsx index d73cadec..7454e452 100644 --- a/src/features/privacy/local-data-controls.tsx +++ b/src/features/privacy/local-data-controls.tsx @@ -1,19 +1,28 @@ "use client" import * as React from "react" -import { Trash2 } from "lucide-react" +import Link from "next/link" +import { BarChart3, ShieldCheck, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { isAnalyticsEnabled, isAnalyticsOptedOut, setAnalyticsOptOut } from "@/core/analytics/analytics" import { requireTranslationValue } from "@/core/i18n/i18n" import { useLang } from "@/core/i18n/lang-provider" import { clearByteflowLocalData } from "@/core/storage/tool-persistence-policy" export function LocalDataControls() { - const { t } = useLang() + const { t, lang } = useLang() const [message, setMessage] = React.useState(null) + const [analyticsOptedOut, setAnalyticsOptedOut] = React.useState(false) const labels = t.common.local_data_controls + React.useEffect(() => { + setAnalyticsOptedOut(isAnalyticsOptedOut()) + }, []) + const clearData = () => { const removed = clearByteflowLocalData() + setAnalyticsOptedOut(isAnalyticsOptedOut()) if (removed === 0) { setMessage(requireTranslationValue(labels.none_found_message, "common.local_data_controls.none_found_message")) return @@ -26,23 +35,88 @@ export function LocalDataControls() { setMessage(messageTemplate.replace("{count}", String(removed))) } + const updateAnalyticsOptOut = (checked: boolean | "indeterminate") => { + const optedOut = checked === true + setAnalyticsOptOut(optedOut) + setAnalyticsOptedOut(optedOut) + setMessage(optedOut + ? requireTranslationValue(labels.analytics_opted_out_message, "common.local_data_controls.analytics_opted_out_message") + : requireTranslationValue(labels.analytics_default_message, "common.local_data_controls.analytics_default_message")) + } + + const analyticsEnabled = isAnalyticsEnabled() + const analyticsStatus = analyticsEnabled && !analyticsOptedOut + ? requireTranslationValue(labels.analytics_status_enabled, "common.local_data_controls.analytics_status_enabled") + : analyticsOptedOut + ? requireTranslationValue(labels.analytics_status_opted_out, "common.local_data_controls.analytics_status_opted_out") + : requireTranslationValue(labels.analytics_status_disabled, "common.local_data_controls.analytics_status_disabled") + return ( -
-
+
+
+
+
+

+ {requireTranslationValue(labels.title, "common.local_data_controls.title")} +

+

+ {requireTranslationValue(labels.description, "common.local_data_controls.description")} +

+
+ +
+ {message ?

{message}

: null} +
+
-

- {requireTranslationValue(labels.title, "common.local_data_controls.title")} +

+

- {requireTranslationValue(labels.description, "common.local_data_controls.description")} + {requireTranslationValue(labels.analytics_description, "common.local_data_controls.analytics_description")}

- -
- {message ?

{message}

: null} +
+

+ {requireTranslationValue(labels.analytics_status_label, "common.local_data_controls.analytics_status_label")}:{" "} + {analyticsStatus} +

+ +
+
+
+

{requireTranslationValue(labels.analytics_allowed_title, "common.local_data_controls.analytics_allowed_title")}

+

{requireTranslationValue(labels.analytics_allowed_events, "common.local_data_controls.analytics_allowed_events")}

+

{requireTranslationValue(labels.analytics_allowed_fields, "common.local_data_controls.analytics_allowed_fields")}

+
+
+

+

+

{requireTranslationValue(labels.analytics_never_fields, "common.local_data_controls.analytics_never_fields")}

+
+
+
+ + {requireTranslationValue(labels.analytics_privacy_link, "common.local_data_controls.analytics_privacy_link")} + + + {requireTranslationValue(labels.analytics_trust_link, "common.local_data_controls.analytics_trust_link")} + +
+
) } diff --git a/tests/component/privacy-page.test.tsx b/tests/component/privacy-page.test.tsx index 6aad997a..49615c53 100644 --- a/tests/component/privacy-page.test.tsx +++ b/tests/component/privacy-page.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" import PrivacyPage from "@/app/[lang]/privacy/page" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" @@ -12,7 +12,31 @@ vi.mock("next/link", () => ({ ), })) +function installMemoryStorage() { + const store = new Map() + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size + }, + }, + }) +} + describe("PrivacyPage", () => { + beforeEach(() => { + installMemoryStorage() + }) + it("renders the manifest-generated external request tool list", () => { render( @@ -36,4 +60,25 @@ describe("PrivacyPage", () => { expect(screen.getByText("The URL you provide may be requested by your browser.")).toBeInTheDocument() expect(screen.getAllByText("Network access starts only after you choose the external-request action.").length).toBeGreaterThan(0) }) + + it("shows analytics status and stores a local opt-out preference", () => { + render( + + + , + ) + + expect(screen.getByRole("heading", { name: "Analytics status and controls" })).toBeInTheDocument() + expect(screen.getByText("Disabled by default")).toBeInTheDocument() + expect(screen.getByText(/Allowed events: tool_loaded/)).toBeInTheDocument() + expect(screen.getByText(/Tool inputs, outputs, payloads, tokens/)).toBeInTheDocument() + expect(screen.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/en/privacy") + expect(screen.getAllByRole("link", { name: "Trust Center" })[0]).toHaveAttribute("href", "/en/trust-center") + + fireEvent.click(screen.getByLabelText("Opt out of analytics on this browser")) + + expect(window.localStorage.getItem("byteflow:analytics:opt-out")).toBe("1") + expect(screen.getByText("Locally opted out")).toBeInTheDocument() + expect(screen.getByText("Analytics opt-out saved on this browser.")).toBeInTheDocument() + }) }) diff --git a/tests/unit/analytics-utils.test.ts b/tests/unit/analytics-utils.test.ts index b04a73e7..24fbb0db 100644 --- a/tests/unit/analytics-utils.test.ts +++ b/tests/unit/analytics-utils.test.ts @@ -1,7 +1,27 @@ -import { describe, expect, it } from "vitest" -import { buildAnalyticsPayload, detectInteractionAnalyticsAction } from "@/core/analytics/analytics" +import { beforeEach, describe, expect, it } from "vitest" +import { buildAnalyticsPayload, detectInteractionAnalyticsAction, getAnalyticsPreference, isAnalyticsOptedOut, setAnalyticsOptOut, shouldTrackAnalyticsEvent } from "@/core/analytics/analytics" + +function installMemoryStorage() { + const store = new Map() + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + }, + }) +} describe("analytics interaction detection", () => { + beforeEach(() => { + installMemoryStorage() + }) + it("detects copy interactions across localized labels", () => { expect(detectInteractionAnalyticsAction("Copy output")).toBe("copy_output") expect(detectInteractionAnalyticsAction("复制输出")).toBe("copy_output") @@ -33,4 +53,21 @@ describe("analytics interaction detection", () => { query_length_bucket: "medium", }) }) + + it("stores a local opt-out preference and blocks future provider dispatch", () => { + expect(getAnalyticsPreference()).toBe("default") + expect(isAnalyticsOptedOut()).toBe(false) + expect(shouldTrackAnalyticsEvent(true, false)).toBe(true) + + setAnalyticsOptOut(true) + + expect(getAnalyticsPreference()).toBe("opted_out") + expect(isAnalyticsOptedOut()).toBe(true) + expect(shouldTrackAnalyticsEvent(true, true)).toBe(false) + + setAnalyticsOptOut(false) + + expect(getAnalyticsPreference()).toBe("default") + expect(isAnalyticsOptedOut()).toBe(false) + }) })