@copilotkit/web-inspector 1.57.1 → 1.57.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -47,6 +47,15 @@ import {
47
47
  isValidDockMode,
48
48
  } from "./lib/persistence";
49
49
  import type { PersistedState } from "./lib/persistence";
50
+ import {
51
+ TELEMETRY_DOCS_URL,
52
+ ensureTelemetryDistinctId,
53
+ getTelemetryDistinctIdForUrl,
54
+ maybeShowDisclosure,
55
+ trackBannerClicked,
56
+ trackBannerViewed,
57
+ trackThreadsTabClicked,
58
+ } from "./lib/telemetry";
50
59
 
51
60
  export const WEB_INSPECTOR_TAG = "cpk-web-inspector" as const;
52
61
 
@@ -57,7 +66,8 @@ type MenuKey =
57
66
  | "agents"
58
67
  | "frontend-tools"
59
68
  | "agent-context"
60
- | "threads";
69
+ | "threads"
70
+ | "settings";
61
71
 
62
72
  type MenuItem = {
63
73
  key: MenuKey;
@@ -2408,11 +2418,32 @@ export class WebInspectorElement extends LitElement {
2408
2418
  private announcementHtml: string | null = null;
2409
2419
  private announcementTimestamp: string | null = null;
2410
2420
  private announcementPreviewText: string | null = null;
2421
+ // Forward-compat for an optional `cta_label` field on the announcement
2422
+ // CDN payload (e.g. "Try threads", "New feature"). The current schema
2423
+ // ({timestamp, previewText, announcement}) doesn't carry it, so this is
2424
+ // null in production today; we read it defensively in fetchAnnouncement
2425
+ // so a future CDN-side schema bump lights up `cta_label` on banner_clicked
2426
+ // without an inspector release.
2427
+ private announcementCtaLabel: string | null = null;
2411
2428
  private hasUnseenAnnouncement = false;
2412
2429
  private announcementLoaded = false;
2413
2430
  private announcementPromise: Promise<void> | null = null;
2414
2431
  private showAnnouncementPreview = true;
2415
2432
  private announcementExpanded = false;
2433
+ // Per-instance dedup for `oss.inspector.banner_viewed` so the event fires
2434
+ // at most once per announcement timestamp per inspector mount. Plan calls
2435
+ // for "de-dup per timestamp per session"; instance-scoping is closer
2436
+ // to per-mount than per-tab (sessionStorage), but for the inspector the
2437
+ // distinction is academic — inspector instances rarely outlive the page.
2438
+ private viewedBannerTimestamps: Set<string> = new Set();
2439
+ private pendingBannerViewed: {
2440
+ banner_id: string;
2441
+ cta_label?: string;
2442
+ } | null = null;
2443
+ // Per-instance dedup for `oss.inspector.banner_clicked` (keyed by
2444
+ // `${bannerId}:${cta}`) so copy-button retries and accidental multi-clicks
2445
+ // don't inflate funnel counts beyond one signal per intent type per banner.
2446
+ private clickedBannerIds: Set<string> = new Set();
2416
2447
 
2417
2448
  get core(): CopilotKitCore | null {
2418
2449
  return this._core;
@@ -2603,6 +2634,11 @@ export class WebInspectorElement extends LitElement {
2603
2634
  onRuntimeConnectionStatusChanged: ({ status }) => {
2604
2635
  this.runtimeStatus = status;
2605
2636
  if (status === "connected") {
2637
+ if (!core.telemetryDisabled) {
2638
+ ensureTelemetryDistinctId();
2639
+ maybeShowDisclosure();
2640
+ }
2641
+ this.flushPendingBannerViewed();
2606
2642
  for (const agentId of this._ownedThreadStores.keys()) {
2607
2643
  this.refreshOwnedThreadStore(agentId);
2608
2644
  }
@@ -3913,6 +3949,11 @@ ${argsString}</pre
3913
3949
  color: #010507;
3914
3950
  font-weight: 600;
3915
3951
  }
3952
+ .cpk-tab-icon {
3953
+ display: inline-flex;
3954
+ flex-shrink: 0;
3955
+ align-items: center;
3956
+ }
3916
3957
  .cpk-tab-active .cpk-tab-icon {
3917
3958
  color: #757cf2;
3918
3959
  }
@@ -4337,6 +4378,24 @@ ${argsString}</pre
4337
4378
  <div class="min-w-[160px] max-w-xs">${agentSelector}</div>
4338
4379
  <div class="flex items-center gap-1">
4339
4380
  ${this.renderDockControls()}
4381
+ <button
4382
+ class="flex h-8 w-8 items-center justify-center rounded-md transition hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 ${
4383
+ this.selectedMenu === "settings"
4384
+ ? "bg-gray-100 text-gray-700"
4385
+ : "text-gray-400 hover:text-gray-600"
4386
+ }"
4387
+ type="button"
4388
+ aria-label="Settings"
4389
+ aria-pressed=${this.selectedMenu === "settings"}
4390
+ @click=${() =>
4391
+ this.handleMenuSelect(
4392
+ this.selectedMenu === "settings"
4393
+ ? "ag-ui-events"
4394
+ : "settings",
4395
+ )}
4396
+ >
4397
+ ${this.renderIcon("Settings")}
4398
+ </button>
4340
4399
  <button
4341
4400
  class="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400"
4342
4401
  type="button"
@@ -5601,9 +5660,64 @@ ${argsString}</pre
5601
5660
  return this.renderThreadsView();
5602
5661
  }
5603
5662
 
5663
+ if (this.selectedMenu === "settings") {
5664
+ return this.renderSettingsPanel();
5665
+ }
5666
+
5604
5667
  return nothing;
5605
5668
  }
5606
5669
 
5670
+ private renderSettingsPanel() {
5671
+ const optedOut = this.core?.telemetryDisabled ?? false;
5672
+ return html`
5673
+ <div class="flex h-full flex-col overflow-hidden">
5674
+ <div class="overflow-auto p-4">
5675
+ <div class="space-y-3">
5676
+ <h2 class="text-sm font-semibold text-slate-900">Settings</h2>
5677
+
5678
+ <div class="space-y-2">
5679
+ <h3 class="text-sm text-slate-500">Privacy</h3>
5680
+ <div class="rounded-lg border border-slate-200 bg-white p-4 space-y-3">
5681
+ <p class="text-sm text-gray-600 flex items-start gap-2">
5682
+ <span>${optedOut ? "❌" : "✅"}</span>
5683
+ <span>
5684
+ ${
5685
+ optedOut
5686
+ ? "You have disabled anonymous interaction data collection."
5687
+ : "CopilotKit is currently collecting anonymous interaction data from the inspector so we know which features people use. We never collect message content, agent state, prompts, or completions."
5688
+ }
5689
+ </span>
5690
+ </p>
5691
+ <a
5692
+ class="inline-flex items-center gap-1 text-sm text-slate-700 underline hover:text-slate-900"
5693
+ href=${TELEMETRY_DOCS_URL}
5694
+ target="_blank"
5695
+ rel="noopener"
5696
+ >Learn more →</a>
5697
+ </div>
5698
+ </div>
5699
+ </div>
5700
+ </div>
5701
+ </div>
5702
+ `;
5703
+ }
5704
+
5705
+ // Fires `banner_clicked` at most once per `${bannerId}:${cta}` per mount so
5706
+ // copy-button retries and accidental multi-clicks don't inflate funnel counts.
5707
+ private trackBannerClickedOnce(opts: { cta: "body" | "dismiss" }): void {
5708
+ if (this.core?.telemetryDisabled) return;
5709
+ const id = this.announcementTimestamp;
5710
+ if (!id) return;
5711
+ const key = `${id}:${opts.cta}`;
5712
+ if (this.clickedBannerIds.has(key)) return;
5713
+ this.clickedBannerIds.add(key);
5714
+ trackBannerClicked({
5715
+ banner_id: id,
5716
+ cta: opts.cta,
5717
+ cta_label: this.announcementCtaLabel ?? undefined,
5718
+ });
5719
+ }
5720
+
5607
5721
  private handleThreadDividerPointerDown = (event: PointerEvent) => {
5608
5722
  this.threadDividerResizing = true;
5609
5723
  this.threadDividerPointerId = event.pointerId;
@@ -6371,7 +6485,10 @@ ${prettyEvent}</pre
6371
6485
  }
6372
6486
 
6373
6487
  private handleMenuSelect(key: MenuKey): void {
6374
- if (!this.menuItems.some((item) => item.key === key)) {
6488
+ if (
6489
+ key !== "settings" &&
6490
+ !this.menuItems.some((item) => item.key === key)
6491
+ ) {
6375
6492
  return;
6376
6493
  }
6377
6494
 
@@ -6410,6 +6527,9 @@ ${prettyEvent}</pre
6410
6527
  }
6411
6528
 
6412
6529
  if (key === "threads") {
6530
+ if (this.selectedMenu !== "threads" && !this.core?.telemetryDisabled) {
6531
+ trackThreadsTabClicked();
6532
+ }
6413
6533
  this.autoSelectLatestThread();
6414
6534
  }
6415
6535
 
@@ -7286,6 +7406,16 @@ ${prettyEvent}</pre
7286
7406
  </div>`;
7287
7407
  }
7288
7408
 
7409
+ private flushPendingBannerViewed(): void {
7410
+ if (!this.pendingBannerViewed || this.core?.telemetryDisabled) {
7411
+ this.pendingBannerViewed = null;
7412
+ return;
7413
+ }
7414
+ if (this.runtimeStatus !== "connected") return;
7415
+ trackBannerViewed(this.pendingBannerViewed);
7416
+ this.pendingBannerViewed = null;
7417
+ }
7418
+
7289
7419
  private ensureAnnouncementLoading(): void {
7290
7420
  if (
7291
7421
  this.announcementPromise ||
@@ -7326,6 +7456,7 @@ ${prettyEvent}</pre
7326
7456
  }
7327
7457
 
7328
7458
  private handleDismissAnnouncement = (): void => {
7459
+ this.trackBannerClickedOnce({ cta: "dismiss" });
7329
7460
  this.markAnnouncementSeen();
7330
7461
  };
7331
7462
 
@@ -7340,6 +7471,7 @@ ${prettyEvent}</pre
7340
7471
  timestamp?: unknown;
7341
7472
  previewText?: unknown;
7342
7473
  announcement?: unknown;
7474
+ cta_label?: unknown;
7343
7475
  };
7344
7476
 
7345
7477
  const timestamp =
@@ -7348,6 +7480,8 @@ ${prettyEvent}</pre
7348
7480
  typeof data?.previewText === "string" ? data.previewText : null;
7349
7481
  const markdown =
7350
7482
  typeof data?.announcement === "string" ? data.announcement : null;
7483
+ const ctaLabel =
7484
+ typeof data?.cta_label === "string" ? data.cta_label : null;
7351
7485
 
7352
7486
  if (!timestamp || !markdown) {
7353
7487
  throw new Error("Malformed announcement payload");
@@ -7357,6 +7491,7 @@ ${prettyEvent}</pre
7357
7491
 
7358
7492
  this.announcementTimestamp = timestamp;
7359
7493
  this.announcementPreviewText = previewText ?? "";
7494
+ this.announcementCtaLabel = ctaLabel;
7360
7495
  this.hasUnseenAnnouncement =
7361
7496
  (!storedTimestamp || storedTimestamp !== timestamp) &&
7362
7497
  !!this.announcementPreviewText;
@@ -7364,6 +7499,22 @@ ${prettyEvent}</pre
7364
7499
  this.announcementHtml = await this.convertMarkdownToHtml(markdown);
7365
7500
  this.announcementLoaded = true;
7366
7501
 
7502
+ // banner_viewed: gate on actual visibility and per-mount dedup.
7503
+ // Store as pending rather than firing immediately — telemetryDisabled
7504
+ // may not be known yet if /info hasn't returned. Flushed in
7505
+ // onRuntimeConnectionStatusChanged once the handshake completes.
7506
+ if (
7507
+ this.hasUnseenAnnouncement &&
7508
+ !this.viewedBannerTimestamps.has(timestamp)
7509
+ ) {
7510
+ this.viewedBannerTimestamps.add(timestamp);
7511
+ this.pendingBannerViewed = {
7512
+ banner_id: timestamp,
7513
+ cta_label: ctaLabel ?? undefined,
7514
+ };
7515
+ this.flushPendingBannerViewed();
7516
+ }
7517
+
7367
7518
  this.requestUpdate();
7368
7519
  } catch (error) {
7369
7520
  // Swallowing here would hide non-network failures (malformed JSON, the
@@ -7422,6 +7573,10 @@ ${prettyEvent}</pre
7422
7573
  const target = event.target instanceof HTMLElement ? event.target : null;
7423
7574
  const button = target?.closest(".announcement-code__copy");
7424
7575
  if (!(button instanceof HTMLButtonElement)) {
7576
+ // banner_clicked fires once per banner per cta-type per mount. Dedup
7577
+ // prevents accidental multi-clicks from inflating funnel counts beyond
7578
+ // one "body" signal and one "dismiss" signal per banner.
7579
+ this.trackBannerClickedOnce({ cta: "body" });
7425
7580
  return;
7426
7581
  }
7427
7582
  event.preventDefault();
@@ -7466,6 +7621,20 @@ ${prettyEvent}</pre
7466
7621
  if (!url.searchParams.has("ref")) {
7467
7622
  url.searchParams.append("ref", "cpk-inspector");
7468
7623
  }
7624
+ // Propagate the inspector's anonymous distinct-ID so the website /
7625
+ // Ops API can call posthog.alias(...) on signup-flow landing and
7626
+ // close the banner_viewed → banner_clicked → signup_attributed
7627
+ // funnel. Returns null when the user has opted out, so opt-out
7628
+ // suppresses cross-domain ID leaks too.
7629
+ if (
7630
+ !url.searchParams.has("posthog_distinct_id") &&
7631
+ !this.core?.telemetryDisabled
7632
+ ) {
7633
+ const distinctId = getTelemetryDistinctIdForUrl();
7634
+ if (distinctId) {
7635
+ url.searchParams.append("posthog_distinct_id", distinctId);
7636
+ }
7637
+ }
7469
7638
  return url.toString();
7470
7639
  } catch {
7471
7640
  return href;
@@ -0,0 +1,323 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { MockInstance } from "vitest";
3
+
4
+ import {
5
+ TELEMETRY_DOCS_URL,
6
+ TELEMETRY_EVENTS,
7
+ TELEMETRY_INGEST_URL,
8
+ getTelemetryDistinctIdForUrl,
9
+ maybeShowDisclosure,
10
+ track,
11
+ trackBannerClicked,
12
+ trackBannerViewed,
13
+ trackThreadsTabClicked,
14
+ } from "../telemetry";
15
+ import {
16
+ _resetTelemetryPersistenceForTesting,
17
+ getOrCreateTelemetryDistinctId,
18
+ hasTelemetryDisclosureBeenShown,
19
+ isTelemetryOptedOut,
20
+ markTelemetryDisclosureShown,
21
+ setTelemetryOptOut,
22
+ } from "../persistence";
23
+
24
+ // The wrapper short-circuits before any network call when opted out, but
25
+ // for the network-touching cases we mock fetch globally so we can read
26
+ // what would have been sent without making real HTTP requests.
27
+ let fetchMock: MockInstance<typeof fetch>;
28
+ let consoleInfoSpy: MockInstance<typeof console.info>;
29
+
30
+ beforeEach(() => {
31
+ // Each test starts from a clean localStorage so distinct-ID + opt-out
32
+ // + disclosure-shown flags don't leak across cases.
33
+ window.localStorage.clear();
34
+ _resetTelemetryPersistenceForTesting();
35
+
36
+ // The wrapper POSTs via globalThis.fetch with a 3s AbortController
37
+ // timeout. Stub it with a resolving Response so happy-path sends
38
+ // complete synchronously (the wrapper does `void` on the promise).
39
+ fetchMock = vi
40
+ .spyOn(globalThis, "fetch")
41
+ .mockResolvedValue(new Response(null, { status: 204 }));
42
+
43
+ consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ vi.unstubAllGlobals();
49
+ });
50
+
51
+ // ─── Wire body shape ────────────────────────────────────────────────────────
52
+
53
+ describe("track()", () => {
54
+ it("posts to telemetry.copilotkit.ai/ingest with confirmed IngestPayload shape", async () => {
55
+ track(TELEMETRY_EVENTS.bannerViewed, {
56
+ banner_id: "2025-05-01T00:00:00Z",
57
+ });
58
+
59
+ await Promise.resolve();
60
+
61
+ expect(fetchMock).toHaveBeenCalledTimes(1);
62
+ const [url, init] = fetchMock.mock.calls[0]!;
63
+ expect(url).toBe(TELEMETRY_INGEST_URL);
64
+ expect(init?.method).toBe("POST");
65
+ expect((init?.headers as Record<string, string>)["Content-Type"]).toBe(
66
+ "application/json",
67
+ );
68
+ expect(
69
+ (init?.headers as Record<string, string>)["X-CopilotKit-Telemetry-Id"],
70
+ ).toMatch(/^[0-9a-f-]{36}$/);
71
+
72
+ // Ben confirmed shape (telemetry-sink-ingest/index.ts:127-134):
73
+ // package is a top-level object { name, version? }, NOT inside properties.
74
+ const body = JSON.parse((init?.body as string) ?? "{}") as {
75
+ event: string;
76
+ properties: Record<string, unknown>;
77
+ package: { name: string; version?: string };
78
+ ts: number;
79
+ };
80
+ expect(body.event).toBe("oss.inspector.banner_viewed");
81
+ expect(body.properties.banner_id).toBe("2025-05-01T00:00:00Z");
82
+ expect(typeof body.properties.distinct_id).toBe("string");
83
+ // package is top-level object, not a string inside properties
84
+ expect(body.package).toEqual({ name: "@copilotkit/web-inspector" });
85
+ expect(body.properties).not.toHaveProperty("package");
86
+ expect(typeof body.ts).toBe("number");
87
+ });
88
+
89
+ it("sends regardless of localStorage opt-out — callers gate on core.telemetryDisabled", async () => {
90
+ setTelemetryOptOut(true);
91
+ expect(isTelemetryOptedOut()).toBe(true);
92
+
93
+ track(TELEMETRY_EVENTS.bannerClicked, {
94
+ banner_id: "x",
95
+ cta: "body",
96
+ });
97
+ await Promise.resolve();
98
+
99
+ // track() no longer short-circuits on localStorage; opt-out is enforced
100
+ // at the call site via core.telemetryDisabled before track*() is invoked.
101
+ expect(fetchMock).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it("swallows fetch failures (telemetry is best-effort)", async () => {
105
+ fetchMock.mockRejectedValueOnce(new Error("network down"));
106
+
107
+ expect(() => track(TELEMETRY_EVENTS.threadsTabClicked)).not.toThrow();
108
+
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ });
111
+
112
+ it("does not send when fetch is unavailable (SSR / pre-fetch environment)", async () => {
113
+ vi.stubGlobal("fetch", undefined);
114
+
115
+ expect(() =>
116
+ track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" }),
117
+ ).not.toThrow();
118
+
119
+ // No fetch call possible — restore happens in afterEach via unstubAllGlobals
120
+ expect(fetchMock).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("never includes message content or agent state in the payload", async () => {
124
+ track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" });
125
+ await Promise.resolve();
126
+
127
+ const [, init] = fetchMock.mock.calls[0]!;
128
+ const raw = (init?.body as string) ?? "{}";
129
+ // Forbidden content keys (privacy invariant — never send inspector content)
130
+ expect(raw).not.toMatch(
131
+ /messages|completion|prompt|state_snapshot|agent_state|content|user_id/i,
132
+ );
133
+ });
134
+ });
135
+
136
+ // ─── Typed per-event helpers ─────────────────────────────────────────────────
137
+
138
+ describe("typed helpers", () => {
139
+ it("trackBannerViewed sends banner_id and optional cta_label", async () => {
140
+ trackBannerViewed({ banner_id: "ts-2025", cta_label: "Try threads" });
141
+ await Promise.resolve();
142
+ const [, init] = fetchMock.mock.calls[0]!;
143
+ const body = JSON.parse((init?.body as string) ?? "{}") as {
144
+ event: string;
145
+ properties: Record<string, unknown>;
146
+ };
147
+ expect(body.event).toBe(TELEMETRY_EVENTS.bannerViewed);
148
+ expect(body.properties.banner_id).toBe("ts-2025");
149
+ expect(body.properties.cta_label).toBe("Try threads");
150
+ });
151
+
152
+ it("trackBannerViewed omits cta_label when undefined (JSON.stringify drops it)", async () => {
153
+ trackBannerViewed({ banner_id: "ts-2025" });
154
+ await Promise.resolve();
155
+ const [, init] = fetchMock.mock.calls[0]!;
156
+ const raw = (init?.body as string) ?? "{}";
157
+ expect(raw).not.toContain("cta_label");
158
+ });
159
+
160
+ it("trackBannerClicked sends banner_id, cta, and optional cta_label", async () => {
161
+ trackBannerClicked({ banner_id: "ts-2025", cta: "body" });
162
+ await Promise.resolve();
163
+ const [, init] = fetchMock.mock.calls[0]!;
164
+ const body = JSON.parse((init?.body as string) ?? "{}") as {
165
+ event: string;
166
+ properties: Record<string, unknown>;
167
+ };
168
+ expect(body.event).toBe(TELEMETRY_EVENTS.bannerClicked);
169
+ expect(body.properties.banner_id).toBe("ts-2025");
170
+ expect(body.properties.cta).toBe("body");
171
+ });
172
+
173
+ it("trackThreadsTabClicked sends no caller-supplied properties", async () => {
174
+ trackThreadsTabClicked();
175
+ await Promise.resolve();
176
+ const [, init] = fetchMock.mock.calls[0]!;
177
+ const body = JSON.parse((init?.body as string) ?? "{}") as {
178
+ event: string;
179
+ properties: Record<string, unknown>;
180
+ };
181
+ expect(body.event).toBe(TELEMETRY_EVENTS.threadsTabClicked);
182
+ // Only distinct_id should be in properties (no caller keys)
183
+ expect(Object.keys(body.properties)).toEqual(["distinct_id"]);
184
+ });
185
+ });
186
+
187
+ // ─── Distinct ID lifecycle ───────────────────────────────────────────────────
188
+
189
+ describe("distinct ID lifecycle", () => {
190
+ it("persists across calls within the same session", () => {
191
+ const first = getOrCreateTelemetryDistinctId();
192
+ const second = getOrCreateTelemetryDistinctId();
193
+ expect(first).toBe(second);
194
+ expect(
195
+ window.localStorage.getItem("cpk:inspector:telemetry:distinct_id"),
196
+ ).toBe(first);
197
+ });
198
+
199
+ it("generates a UUID-v4-shaped value", () => {
200
+ const id = getOrCreateTelemetryDistinctId();
201
+ expect(id).toMatch(
202
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
203
+ );
204
+ });
205
+
206
+ it("returns a UUID without throwing in SSR (window undefined)", () => {
207
+ vi.stubGlobal("window", undefined);
208
+ expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
209
+ const id = getOrCreateTelemetryDistinctId();
210
+ expect(id).toMatch(
211
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
212
+ );
213
+ });
214
+
215
+ it("returns a UUID without throwing when localStorage.getItem throws", () => {
216
+ vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
217
+ throw new DOMException("QuotaExceededError");
218
+ });
219
+ expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
220
+ const id = getOrCreateTelemetryDistinctId();
221
+ expect(id).toMatch(/^[0-9a-f]{8}-/);
222
+ });
223
+
224
+ it("returns the same UUID across calls when localStorage throws (funnel coherence)", () => {
225
+ vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
226
+ throw new DOMException("QuotaExceededError");
227
+ });
228
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
229
+ throw new DOMException("QuotaExceededError");
230
+ });
231
+ const first = getOrCreateTelemetryDistinctId();
232
+ const second = getOrCreateTelemetryDistinctId();
233
+ expect(first).toBe(second);
234
+ });
235
+ });
236
+
237
+ // ─── Persistence error-resilience ───────────────────────────────────────────
238
+
239
+ describe("persistence error-resilience", () => {
240
+ it("isTelemetryOptedOut returns false (not disabled) when localStorage throws", () => {
241
+ vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
242
+ throw new DOMException("SecurityError");
243
+ });
244
+ // Must fail to "not opted out" — if it returned true, all users in
245
+ // restricted-storage contexts would have telemetry silently disabled.
246
+ expect(isTelemetryOptedOut()).toBe(false);
247
+ });
248
+
249
+ it("setTelemetryOptOut does not throw when localStorage.setItem throws", () => {
250
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
251
+ throw new DOMException("QuotaExceededError");
252
+ });
253
+ expect(() => setTelemetryOptOut(true)).not.toThrow();
254
+ });
255
+
256
+ it("markTelemetryDisclosureShown does not throw when localStorage.setItem throws", () => {
257
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
258
+ throw new DOMException("QuotaExceededError");
259
+ });
260
+ // Failure means the disclosure fires on every mount instead of once —
261
+ // a UX regression, not a data leak. The important invariant is no throw.
262
+ expect(() => markTelemetryDisclosureShown()).not.toThrow();
263
+ });
264
+ });
265
+
266
+ // ─── maybeShowDisclosure() ───────────────────────────────────────────────────
267
+
268
+ describe("maybeShowDisclosure()", () => {
269
+ it("logs once and sets the disclosure-shown flag", () => {
270
+ maybeShowDisclosure();
271
+ expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
272
+ const [message] = consoleInfoSpy.mock.calls[0]!;
273
+ expect(message).toContain(TELEMETRY_DOCS_URL);
274
+ expect(hasTelemetryDisclosureBeenShown()).toBe(true);
275
+
276
+ maybeShowDisclosure();
277
+ // No second log — flag short-circuits.
278
+ expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
279
+ });
280
+
281
+ it("does not log when the user is already opted out", () => {
282
+ setTelemetryOptOut(true);
283
+
284
+ maybeShowDisclosure();
285
+
286
+ expect(consoleInfoSpy).not.toHaveBeenCalled();
287
+ // The flag stays unset so a future opt-in flips back to "first run"
288
+ // behavior — see the wrapper's design comment.
289
+ expect(hasTelemetryDisclosureBeenShown()).toBe(false);
290
+ });
291
+ });
292
+
293
+ // ─── getTelemetryDistinctIdForUrl() ─────────────────────────────────────────
294
+
295
+ describe("getTelemetryDistinctIdForUrl()", () => {
296
+ it("returns the persisted distinct-ID when not opted out", () => {
297
+ const id = getTelemetryDistinctIdForUrl();
298
+ expect(id).not.toBeNull();
299
+ expect(id).toMatch(
300
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
301
+ );
302
+ // Same ID across calls — ensures URL propagation matches the ID
303
+ // that goes on event sends.
304
+ expect(getTelemetryDistinctIdForUrl()).toBe(id);
305
+ });
306
+
307
+ it("returns null when the user is opted out (no cross-domain leak)", () => {
308
+ setTelemetryOptOut(true);
309
+ expect(getTelemetryDistinctIdForUrl()).toBeNull();
310
+ });
311
+ });
312
+
313
+ // ─── Opt-out round-trip ──────────────────────────────────────────────────────
314
+
315
+ describe("opt-out round-trip", () => {
316
+ it("setTelemetryOptOut(true) → isTelemetryOptedOut() is true", () => {
317
+ expect(isTelemetryOptedOut()).toBe(false);
318
+ setTelemetryOptOut(true);
319
+ expect(isTelemetryOptedOut()).toBe(true);
320
+ setTelemetryOptOut(false);
321
+ expect(isTelemetryOptedOut()).toBe(false);
322
+ });
323
+ });